Skip to content

Commit 73a508e

Browse files
mpstatonclaude
andcommitted
feat(org-workbench, corpus-kinds): kind input becomes autocomplete — vocabulary converges without being enforced
Human-gate finding from the org-relations walk-through: the Corpus items adder's free-text kind breeds near-duplicates (report vs reports). The kind inputs (add AND edit) now autocomplete against the kinds already in use across the client's orgs, while a non-match still creates exactly what the operator typed — open vocabulary, converging by suggestion. New read organization.corpus.kinds (resolver.ts listCorpusKinds): distinct org_corpus kinds across the client's visible orgs, flattened in JS rather than a version-sensitive [*].kind projection. Handler in handlers.ts, verb + timeout in capabilities.ts. First live fire: 19 kinds in reach-edu. AdditiveList grows an optional kindSuggestions datalist (per-instance id, $derived); OrgCard fetches the vocabulary per card load and feeds it to the Corpus items list only (links/streams kinds stay auto-detected). Verified: tsc clean both services, svelte-check 0/0, services rebuilt, live NATS fire returns the real vocabulary. Refs #57. Files changed: - services/record-surrealdb-resolver/src/resolver.ts - services/record-surrealdb-resolver/src/handlers.ts - services/workspace/src/capabilities.ts - apps/org-workbench/src/AdditiveList.svelte - apps/org-workbench/src/OrgCard.svelte - apps/org-workbench/src/lib/org-client.ts - changelog/2026-07-27_01_Organizations-Learn-Their-Family-Tree-Parent-Child-Peer-Relations-Plus-Org-Tags.md Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RW28dw3kQAKXr2ZNefCukE
1 parent f66116e commit 73a508e

7 files changed

Lines changed: 101 additions & 0 deletions

File tree

apps/org-workbench/src/AdditiveList.svelte

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,12 +26,17 @@
2626
onremove,
2727
removenote,
2828
entryaction,
29+
kindSuggestions,
2930
}: {
3031
title: string;
3132
entries: Entry[];
3233
kindHint?: string;
3334
// Show a name input on the ➕ form (streams: "Today's Credentials").
3435
nameable?: boolean;
36+
// Optional datalist for the kind inputs (add + edit) — autocomplete
37+
// against kinds already in use; a non-match still creates whatever the
38+
// operator typed (gh #57).
39+
kindSuggestions?: string[];
3540
onadd: (url: string, kind?: string, name?: string) => Promise<void>;
3641
// Optional 🔍 — launches search-and-add pre-scoped to this list (Phase 3).
3742
onsearch?: () => void;
@@ -51,6 +56,9 @@
5156
entryaction?: { label: string; fn: (entry: Entry) => void };
5257
} = $props();
5358
59+
// One datalist per list instance — the id must be unique in the page.
60+
const kindListId = $derived(`ow-kinds-${title.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`);
61+
5462
let adding = $state(false);
5563
let open = $state(false);
5664
let url = $state('');
@@ -205,6 +213,7 @@
205213
class="ow-add-kind"
206214
type="text"
207215
placeholder={kindHint}
216+
list={kindSuggestions?.length ? kindListId : undefined}
208217
bind:value={kind}
209218
disabled={adding}
210219
/>
@@ -242,6 +251,7 @@
242251
class="ow-add-kind"
243252
type="text"
244253
placeholder="kind"
254+
list={kindSuggestions?.length ? kindListId : undefined}
245255
bind:value={editKind}
246256
onkeydown={onEditKey}
247257
disabled={editBusy}
@@ -327,6 +337,12 @@
327337
{/each}
328338
</ul>
329339
{/if}
340+
341+
{#if kindSuggestions?.length}
342+
<datalist id={kindListId}>
343+
{#each kindSuggestions as k (k)}<option value={k}></option>{/each}
344+
</datalist>
345+
{/if}
330346
</section>
331347

332348
<style>

apps/org-workbench/src/OrgCard.svelte

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
addOrgTag,
2323
removeOrgTag,
2424
suggestTags,
25+
fetchCorpusKinds,
2526
} from './lib/org-client';
2627
import { requestSearch } from './lib/search-request';
2728
import { submitCrawl } from './lib/search-queue';
@@ -107,6 +108,18 @@
107108
// Tags — has_tag observations per client (Initiative / Program / Funder…).
108109
// Datalist rides the shared per-client tag_vocab via tag.suggest; fetched
109110
// lazily when the add input opens.
111+
// Corpus-kind vocabulary for the datalist (gh #57) — client-wide, refreshed
112+
// per card load so a kind minted on one org suggests on the next.
113+
let corpusKinds = $state<string[]>([]);
114+
$effect(() => {
115+
void org.slug;
116+
fetchCorpusKinds(client)
117+
.then((k) => (corpusKinds = k))
118+
.catch(() => {
119+
/* datalist is a convenience — the input works without it */
120+
});
121+
});
122+
110123
let addingTag = $state(false);
111124
let tagDraft = $state('');
112125
let tagVocab = $state<string[]>([]);
@@ -360,6 +373,7 @@
360373
title="Corpus items"
361374
entries={org.org_corpus}
362375
kindHint="kind (optional)"
376+
kindSuggestions={corpusKinds}
363377
onadd={makeAdd(addOrgCorpus)}
364378
onedit={makeEdit(updateOrgCorpus)}
365379
onremove={makeRemove(removeOrgCorpus)}

apps/org-workbench/src/lib/org-client.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,18 @@ export async function updateOrgIdentity(args: {
199199
if (!r.ok) throw new Error(r.error || 'resolver.update_org failed');
200200
}
201201

202+
// Distinct corpus kinds across the client's orgs — feeds the kind input's
203+
// datalist so vocabulary converges without being enforced (gh #57).
204+
export async function fetchCorpusKinds(client: string): Promise<string[]> {
205+
const r = (await workspace.invoke('organization.corpus.kinds', { client })) as {
206+
ok: boolean;
207+
kinds?: string[];
208+
error?: string;
209+
};
210+
if (!r.ok) throw new Error(r.error || 'organization.corpus.kinds failed');
211+
return r.kinds ?? [];
212+
}
213+
202214
export async function addOrgCorpus(args: AddArgs): Promise<ShapedLink> {
203215
const r = (await workspace.invoke('organization.corpus.add', args)) as {
204216
ok: boolean;

changelog/2026-07-27_01_Organizations-Learn-Their-Family-Tree-Parent-Child-Peer-Relations-Plus-Org-Tags.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,3 +179,15 @@ triage SKILL.md's parent-child open decision flips to **MODEL LANDED**,
179179
and the `initiative_hub` stream kind sheds its "while parent/child
180180
modeling is unresolved" caveat — it's now only for initiatives that don't
181181
merit their own org row.
182+
183+
### Human-gate finding: corpus kinds stop drifting (#57)
184+
185+
The operator's walk-through surfaced it in minutes: the Corpus items
186+
adder's free-text `kind` breeds near-duplicates (`report` vs `reports`).
187+
Fix: `organization.corpus.kinds` returns the distinct kinds already in use
188+
across the client's orgs — 19 in reach-edu on first fire (`annual_report`,
189+
`article`, `blog_post`, …) — and `AdditiveList` grew an optional
190+
`kindSuggestions` datalist on both the add and edit kind inputs.
191+
Autocomplete, never enforcement: a non-match creates exactly what the
192+
operator typed, the same open-vocabulary philosophy as relation kinds.
193+
Refreshed per card load, so a kind minted on one org suggests on the next.

services/record-surrealdb-resolver/src/handlers.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import {
3131
setClientBrief,
3232
type BriefSetInput,
3333
getOrgDetail,
34+
listCorpusKinds,
3435
checkContentUrls,
3536
type NormRecord,
3637
type ApplyInput,
@@ -320,4 +321,21 @@ export function registerHandlers(nc: NatsConnection): void {
320321
}
321322
}
322323
})();
324+
325+
// organization.corpus.kinds — distinct corpus kinds for the client's
326+
// datalist (gh #57, the org-relations human-gate finding).
327+
(async () => {
328+
const sub = nc.subscribe('organization.corpus.kinds.requested');
329+
for await (const msg of sub) {
330+
const args = msg.json() as { client: string };
331+
try {
332+
const db = await getDb();
333+
const result = await listCorpusKinds(db, args.client);
334+
if (msg.reply) msg.respond(JSON.stringify(result));
335+
} catch (err: unknown) {
336+
const error = err instanceof Error ? err.message : String(err);
337+
if (msg.reply) msg.respond(JSON.stringify({ ok: false, error }));
338+
}
339+
}
340+
})();
323341
}

services/record-surrealdb-resolver/src/resolver.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1213,3 +1213,29 @@ export async function getOrgDetail(
12131213
},
12141214
};
12151215
}
1216+
1217+
// organization.corpus.kinds — the distinct corpus-item kinds already in use
1218+
// across every org this client can see. Feeds the kind input's datalist so
1219+
// vocabulary converges (report vs reports) without ever being enforced —
1220+
// a non-match creates whatever the operator typed. Flattened in JS rather
1221+
// than a [*].kind projection (version-sensitive SurrealQL, same caution as
1222+
// D4's domains clause).
1223+
export async function listCorpusKinds(
1224+
db: Surreal,
1225+
client: string,
1226+
): Promise<{ ok: true; kinds: string[] }> {
1227+
const r = await db.query(
1228+
`SELECT VALUE org_corpus FROM organizations WHERE client_access CONTAINS $client;`,
1229+
{ client },
1230+
);
1231+
const lists = (r?.[0] as { kind?: unknown }[][]) ?? [];
1232+
const kinds = Array.from(
1233+
new Set(
1234+
lists
1235+
.flat()
1236+
.map((e) => (typeof e?.kind === 'string' ? e.kind.trim() : ''))
1237+
.filter(Boolean),
1238+
),
1239+
).sort((a, b) => a.localeCompare(b));
1240+
return { ok: true, kinds };
1241+
}

services/workspace/src/capabilities.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,8 @@ const CAPABILITY_TO_SUBJECT: Record<string, string> = {
226226
'organization.relation.update': 'organization.relation.update.requested',
227227
'organization.tag.add': 'organization.tag.add.requested',
228228
'organization.tag.remove': 'organization.tag.remove.requested',
229+
// Distinct corpus kinds for the kind-input datalist (gh #57).
230+
'organization.corpus.kinds': 'organization.corpus.kinds.requested',
229231

230232
// Domain catalog — the canonical typed-grouping graph behind apps/strategy-curator
231233
// (which is the type='strategy' view). Served by record-surrealdb-resolver
@@ -353,6 +355,7 @@ const CAPABILITY_TIMEOUTS_MS: Record<string, number> = {
353355
'organization.relation.update': 30_000,
354356
'organization.tag.add': 30_000,
355357
'organization.tag.remove': 30_000,
358+
'organization.corpus.kinds': 30_000,
356359
'client.brief.get': 30_000,
357360
'client.brief.set': 30_000,
358361
// A crawl is one model turn with multiple server-side web searches (plus

0 commit comments

Comments
 (0)