SLOPSHOPPER

glossary-browser

Browse a repository's ubiquitous language (GLOSSARY-MAP.md, each context's GLOSSARY.md, the ADRs) in a band above the prompt and a pane, and steer you and the…

newpanebandguardcommandtoast
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · glossary-browser
│ ┃ glossary-browser ✕ › fix the failing auth test and add an audit log call │ ┃ No GLOSSARY-MAP.md or GLOSSARY.md in this │ ┃ project. ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /glossary │ ⎿ glossary-browser: No GLOSSARY-MAP.md or GLOSSARY.md in this proj │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · glossary-browser
No GLOSSARY-MAP.md or GLOSSARY.md in this project.
README

Glossary Browser

A Claude Code plugin that keeps a repository's ubiquitous language in view while you work. It reads the glossary the repository already has (GLOSSARY-MAP.md, each context's GLOSSARY.md, the ADRs), shows it in a band above the prompt and a pane you can explore, and its Term Check steers you and the model away from the words the glossary says to avoid.

It is a mod: a plugin of function hooks that runs inside Claude Code (terminal or desktop).

Install

/plugin marketplace add chicio/chicio-labs
/plugin install glossary-browser@chicio-labs
/reload-plugins

Works with Matt Pocock's domain-modeling skills

The Glossary Browser reads exactly the files that Matt Pocock's domain-modeling skill writes, so it pairs with that skill and with grill-with-docs, the grilling session that runs domain-modeling and records the terms and decisions as they settle. Grill a plan, and the terms the session resolves show up in the band and the pane, and the Term Check starts steering toward them. Install the skills with:

npx skills add mattpocock/skills -s domain-modeling grill-with-docs grilling

It follows the skill's current convention, GLOSSARY.md and GLOSSARY-MAP.md (GLOSSARY-FORMAT.md). Versions of the skill before 2026-08-15 wrote CONTEXT.md and CONTEXT-MAP.md: rename them to adopt it.

What your repository needs

Either a single GLOSSARY.md at the repository root (one context), or a GLOSSARY-MAP.md there that links each context's glossary:

# Glossary Map

## Contexts

- [Ordering](./src/ordering/GLOSSARY.md): receives and tracks customer orders
- [Billing](./src/billing/GLOSSARY.md): generates invoices

## Relationships

- **Ordering → Billing**: Ordering emits OrderPlaced; Billing invoices it

Each GLOSSARY.md lists its terms under ## Language, optionally grouped by ### sections:

**Invoice**:
A request for payment sent to a customer after delivery.
_Avoid_: bill, payment request

ADRs are read from docs/adr/*.md at the root (system-wide) and inside each context's folder. Without a glossary the plugin stays quiet.

Using it

  • The band above the prompt names the contexts and counts the terms and ADRs. Focus it (ctrl+x tab, or click) and press g to open the pane. When the Term Check has flagged words, they appear in the band: press 1 to 3 to open a flagged term, x to clear the flags.
  • /glossary [term] opens the pane, on that term when one is given.
  • The pane lists the contexts on the left, each expandable to its terms (by section) and its ADRs, plus the system-wide ADRs and the map's Relationships. The right side shows the selection: a term's definition, its Avoid entry, the words the Term Check flags for it, and where the same word means something in another context; an ADR rendered as Markdown. Type in the filter to search every term, definition and ADR title. Esc closes it.

The glossary is read live, and re-read when a GLOSSARY.md, the map or an ADR is edited in the session. The plugin never writes it.

The Term Check

It looks for Avoid words in two places:

  • Your prompts. The prompt goes through exactly as typed, with a note only the model reads: each word, its context and the canonical term ('task' is an Avoid word in Agentic Delivery: when it means Work Unit, say Work Unit). The model uses the canonical terms in its reply and its work, and ignores a line when the word was meant in another sense. Every context's list applies, since a prompt has no path.
  • The model's edits to Markdown (.md, .mdx). An edit to a file inside a context's folder is checked against that context's list. If it uses an Avoid word, the edit is refused with the canonical terms, so the model rewrites it; when the word is meant (a quote, code, another sense), sending the same edit again unchanged lets it through. An edit to a file outside every context is checked against all of them and only flagged; with a single root GLOSSARY.md, its one context owns every file. GLOSSARY.md and GLOSSARY-MAP.md themselves are never checked.

What it does not count as a hit: code blocks and inline code, HTML or JSX tags, link targets, an Avoid word that is part of a canonical name (the glossary's "design" inside "Design System"), and Avoid entries that are guidance rather than words ("using it for a block inside a page").

Configuration

OptionDefaultEffect
denyEditstrueRefuse Markdown edits that use the owning context's Avoid words. Off, they are only flagged.

Set it with claude plugin configure glossary-browser@chicio-labs.

Limits

The Term Check is a convenience, not enforcement: it runs only in Claude Code, and the retry lets any word through by design. A rule every contributor and every tool must follow belongs in lint or CI.

Development

From the repository root:

claude plugin validate claude-plugins/glossary-browser
claude plugin test claude-plugins/glossary-browser

CI runs both on every push. tsc -p claude-plugins/glossary-browser type-checks the mod once a Claude Code session has loaded it, since the engine writes the type declarations it extends at load time. Releases go through the release-plugin.yml workflow, which bumps version in .claude-plugin/plugin.json, writes CHANGELOG.md and tags glossary-browser--v<version>.

Source 3 files
hooks/register.tsx 442 lines
1import { atom, read, update } from "claude-code";
2import type { EngineInterface, Register } from "claude-code";
3
4import type { Adr, Flag, Glossary } from "../types";
5import {
6    RELATIONSHIPS_ID,
7    SYSTEM_ID,
8    adrId,
9    adrTitle,
10    allAdrs,
11    check,
12    contextId,
13    contextMarkdown,
14    contextsForPath,
15    denyReason,
16    findTermId,
17    glossaryFile,
18    isOwnedPath,
19    parseContext,
20    parseContextMap,
21    promptNote,
22    search,
23    singleContextEntry,
24    termId,
25    termMarkdown,
26} from "./glossary";
27
28const PLUGIN = "glossary-browser";
29const PANE = "glossary-browser";
30const TITLE = "Glossary Browser";
31const LIST_WIDTH = 34;
32const MARKDOWN_LIMIT = 9800;
33const BAND_FLAGS = 3;
34
35const glossaryAtom = atom({ plugin: "glossary-browser", key: "glossary" } as const, null);
36const selectedAtom = atom({ plugin: "glossary-browser", key: "selected" } as const, null);
37const expandedAtom = atom({ plugin: "glossary-browser", key: "expanded" } as const, null);
38const filterAtom = atom({ plugin: "glossary-browser", key: "filter" } as const, "");
39const pageAtom = atom({ plugin: "glossary-browser", key: "page" } as const, 0);
40const flagsAtom = atom({ plugin: "glossary-browser", key: "flags" } as const, []);
41
42type Engine = EngineInterface;
43
44const listAdrs = async ($: Engine, root: string, dir: string): Promise<Adr[]> => {
45    const relative = dir === "" ? "docs/adr" : `${dir}/docs/adr`;
46    if (!(await $.fs.exists(`${root}/${relative}`))) {
47        return [];
48    }
49    const entries = await $.fs.list(`${root}/${relative}`);
50    const files = entries
51        .filter((entry) => entry.kind === "file" && entry.name.endsWith(".md"))
52        .map((entry) => entry.name)
53        .sort();
54
55    return Promise.all(
56        files.map(async (name) => adrTitle(`${relative}/${name}`, await $.fs.read(`${root}/${relative}/${name}`))),
57    );
58};
59
60const loadGlossary = async ($: Engine): Promise<Glossary | null> => {
61    const root = await $.session.root();
62    if (await $.fs.exists(`${root}/GLOSSARY-MAP.md`)) {
63        const map = parseContextMap(await $.fs.read(`${root}/GLOSSARY-MAP.md`));
64        const contexts = await Promise.all(
65            map.entries.map(async (entry) => {
66                const path = `${root}/${glossaryFile(entry.dir)}`;
67                const text = (await $.fs.exists(path)) ? await $.fs.read(path) : "";
68
69                return parseContext(text, entry, await listAdrs($, root, entry.dir));
70            }),
71        );
72
73        return { contexts, systemAdrs: await listAdrs($, root, ""), relationships: map.relationships };
74    }
75    if (await $.fs.exists(`${root}/GLOSSARY.md`)) {
76        const text = await $.fs.read(`${root}/GLOSSARY.md`);
77        const context = parseContext(text, singleContextEntry(text), await listAdrs($, root, ""));
78
79        return { contexts: [context], systemAdrs: [], relationships: "" };
80    }
81
82    return null;
83};
84
85const refresh = async ($: Engine): Promise<Glossary | null> => {
86    const glossary = await loadGlossary($).catch((error: unknown) => {
87        $.ui.log(`${PLUGIN}: could not read the glossary (${String(error)})`, { to: "debug" });
88
89        return null;
90    });
91    await update($, glossaryAtom, () => glossary);
92
93    return glossary;
94};
95
96const openPane = async ($: Engine) => {
97    await refresh($);
98    const opened = await $.ui.open({ id: PANE, title: TITLE, focus: true, closeOnEscape: true });
99    if (!opened.isPlaced) {
100        $.ui.toast(`${TITLE}: widen the terminal to see it`);
101    }
102};
103
104const select = async ($: Engine, id: string) => {
105    await update($, selectedAtom, () => id);
106};
107
108const truncate = (text: string): string =>
109    text.length <= MARKDOWN_LIMIT ? text : `${text.slice(0, MARKDOWN_LIMIT)}\n\n_…truncated_`;
110
111const detailOf = async ($: Engine, glossary: Glossary, id: string | null): Promise<string> => {
112    if (id === null) {
113        return [
114            `## ${TITLE}`,
115            "The project's ubiquitous language, read live from `GLOSSARY-MAP.md` and each context's `GLOSSARY.md` (or a single root `GLOSSARY.md`).",
116            "Pick a context on the left, or type in the filter to search every term, definition and ADR.",
117            "The **Term Check** flags _Avoid_ words in your prompts and in the model's edits to Markdown files; " +
118                "its flags show in the band above the prompt.",
119        ].join("\n\n");
120    }
121    if (id === RELATIONSHIPS_ID) {
122        return `## Relationships\n\n${glossary.relationships}`;
123    }
124    if (id.startsWith("ctx:")) {
125        const context = glossary.contexts[Number(id.slice(4))];
126
127        return context ? contextMarkdown(context) : "";
128    }
129    if (id.startsWith("term:")) {
130        const [, index, ...name] = id.split(":");
131        const contextIndex = Number(index);
132        const term = glossary.contexts[contextIndex]?.terms.find((one) => one.name === name.join(":"));
133
134        return term ? termMarkdown(glossary, contextIndex, term) : "";
135    }
136    if (id.startsWith("adr:")) {
137        const root = await $.session.root();
138        const path = id.slice(4);
139        const text = await $.fs.read(`${root}/${path}`).catch(() => `_Could not read \`${path}\`._`);
140
141        return truncate(`_\`${path}\`_\n\n${text}`);
142    }
143
144    return "";
145};
146
147type Row = { kind: "label"; text: string } | { kind: "item"; id: string; text: string };
148
149const rowsOf = (glossary: Glossary, expanded: string | null, filter: string): Row[] => {
150    if (filter.trim() !== "") {
151        const matches = search(glossary, filter);
152
153        return matches.length === 0
154            ? [{ kind: "label", text: "No match." }]
155            : matches.map((match) => ({ kind: "item", id: match.id, text: match.label }));
156    }
157    const rows: Row[] = [];
158    glossary.contexts.forEach((context, index) => {
159        const id = contextId(index);
160        const isOpen = expanded === id;
161        rows.push({ kind: "item", id, text: `${isOpen ? "▾" : "▸"} ${context.name} (${context.terms.length})` });
162        if (isOpen) {
163            let section = "";
164            for (const term of context.terms) {
165                if (term.section !== section && term.section !== "") {
166                    section = term.section;
167                    rows.push({ kind: "label", text: `  ${section}` });
168                }
169                rows.push({ kind: "item", id: termId(index, term.name), text: `    ${term.name}` });
170            }
171            if (context.adrs.length > 0) {
172                rows.push({ kind: "label", text: "  ADRs" });
173                for (const adr of context.adrs) {
174                    rows.push({ kind: "item", id: adrId(adr.path), text: `    ${adr.title}` });
175                }
176            }
177        }
178    });
179    const isSystemOpen = expanded === SYSTEM_ID;
180    if (glossary.systemAdrs.length > 0) {
181        rows.push({
182            kind: "item",
183            id: SYSTEM_ID,
184            text: `${isSystemOpen ? "▾" : "▸"} System-wide ADRs (${glossary.systemAdrs.length})`,
185        });
186    }
187    if (isSystemOpen) {
188        for (const adr of glossary.systemAdrs) {
189            rows.push({ kind: "item", id: adrId(adr.path), text: `    ${adr.title}` });
190        }
191    }
192    if (glossary.relationships !== "") {
193        rows.push({ kind: "item", id: RELATIONSHIPS_ID, text: "• Relationships" });
194    }
195
196    return rows;
197};
198
199const isGroup = (id: string): boolean => id.startsWith("ctx:") || id === SYSTEM_ID;
200
201const GLOSSARY_SOURCE = /(^|\/)(GLOSSARY|GLOSSARY-MAP)\.md$|(^|\/)docs\/adr\//;
202
203const addFlags = async ($: Engine, found: Flag[]) => {
204    await update($, flagsAtom, (flags) => {
205        const known = new Set(flags.map((flag) => `${flag.context}|${flag.word}|${flag.where}`));
206
207        return [...flags, ...found.filter((flag) => !known.has(`${flag.context}|${flag.word}|${flag.where}`))];
208    });
209};
210
211const describeFlags = (flags: Flag[]): string => flags.map((flag) => `'${flag.word}' → ${flag.term}`).join(" · ");
212
213export const register: Register = (on, options) => {
214    const denyEdits = options.denyEdits !== false;
215    const retried = new Set<string>();
216
217    on("session.start", async ($, e, next) => {
218        await $.command.register({
219            name: "glossary",
220            description: "Open the Glossary Browser, optionally on a term: /glossary [term]",
221        });
222        await refresh($);
223
224        return next(e);
225    });
226
227    on("command.run", { command: "glossary" }, async ($, e) => {
228        const glossary = await refresh($);
229        if (glossary === null) {
230            return { text: "No GLOSSARY-MAP.md or GLOSSARY.md in this project: nothing to browse." };
231        }
232        const query = e.args.trim();
233        if (query !== "") {
234            const id = findTermId(glossary, query);
235            if (id !== null) {
236                await select($, id);
237                const contextIndex = id.startsWith("term:") ? id.split(":")[1] : null;
238                if (contextIndex !== null) {
239                    await update($, expandedAtom, () => contextId(Number(contextIndex)));
240                }
241                await update($, filterAtom, () => "");
242            } else {
243                await update($, filterAtom, () => query);
244            }
245        }
246        await openPane($);
247
248        return { text: `${TITLE} opened${query !== "" ? ` on "${query}"` : ""}.` };
249    });
250
251    on("prompt.submit", async ($, e, next) => {
252        const glossary = await read($, glossaryAtom);
253        if (glossary === null || e.text.trimStart().startsWith("/")) {
254            return next(e);
255        }
256        const found = check(e.text, glossary.contexts, "prompt");
257        await update($, flagsAtom, () => found);
258
259        return found.length === 0 ? next(e) : next({ ...e, context: [...(e.context ?? []), promptNote(found)] });
260    });
261
262    on("tool.call", async ($, e, next) => {
263        if (e.tool !== "Edit" && e.tool !== "Write") {
264            return next(e);
265        }
266        const input = e as unknown as { file_path: string; new_string?: string; content?: string };
267        const root = await $.session.root();
268        if (!input.file_path.startsWith(`${root}/`)) {
269            return next(e);
270        }
271        const relative = input.file_path.slice(root.length + 1);
272        const glossary = await read($, glossaryAtom);
273        const contexts = glossary === null ? null : contextsForPath(relative, glossary);
274        const text = (e.tool === "Edit" ? input.new_string : input.content) ?? "";
275        const found = glossary === null || contexts === null ? [] : check(text, contexts, relative, glossary.contexts);
276        if (found.length > 0) {
277            await addFlags($, found);
278        }
279        const retry = `${relative}\n${text}`;
280        const isRefused =
281            found.length > 0 &&
282            denyEdits &&
283            glossary !== null &&
284            isOwnedPath(relative, glossary) &&
285            !retried.has(retry);
286        if (isRefused) {
287            retried.add(retry);
288
289            return { deny: denyReason(found, relative) };
290        }
291        const ran = await next(e);
292        if (ran.deny === undefined && ran.isError !== true && GLOSSARY_SOURCE.test(relative)) {
293            await refresh($);
294        }
295
296        return ran;
297    });
298
299    on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
300        const glossary = await read($, glossaryAtom);
301        if (e.props.hasSurvey || glossary === null) {
302            return next(e);
303        }
304        const { Box, Button, Text } = $.ui.resolve(e);
305        const flags = await read($, flagsAtom);
306        const terms = glossary.contexts.reduce((sum, context) => sum + context.terms.length, 0);
307        const adrs = allAdrs(glossary).length;
308        const openOn = (flag: Flag) => async () => {
309            const index = glossary.contexts.findIndex((context) => context.name === flag.context);
310            await select($, termId(index, flag.term));
311            await update($, expandedAtom, () => contextId(index));
312            await update($, filterAtom, () => "");
313            await openPane($);
314        };
315
316        return (
317            <Box flexDirection="column">
318                <Box gap={1}>
319                    <Button key="open" label="Glossary" hotkey="g" onPress={() => openPane($)} />
320                    <Text dimColor wrap="truncate">
321                        {glossary.contexts.map((context) => context.name).join(" · ")} — {terms} terms · {adrs} ADRs
322                    </Text>
323                </Box>
324                {flags.length > 0 && (
325                    <Box gap={1}>
326                        <Text color="yellow">⚠ Term Check</Text>
327                        {flags.slice(0, BAND_FLAGS).map((flag, index) => (
328                            <Button
329                                key={`flag-${index}`}
330                                label={`'${flag.word}' → ${flag.term}`}
331                                hotkey={String(index + 1)}
332                                plain
333                                onPress={openOn(flag)}
334                            />
335                        ))}
336                        {flags.length > BAND_FLAGS && <Text dimColor>+{flags.length - BAND_FLAGS} more</Text>}
337                        <Button
338                            key="clear"
339                            label="clear"
340                            hotkey="x"
341                            plain
342                            dimColor
343                            onPress={() => update($, flagsAtom, () => [])}
344                        />
345                    </Box>
346                )}
347            </Box>
348        );
349    });
350
351    on("ui.render", { component: "Pane", requestId: PANE }, async ($, e) => {
352        const elements = $.ui.resolve(e);
353        const { Box, Button, Markdown, Text } = elements;
354        const Input = "Input" in elements ? elements.Input : null;
355        const glossary = await read($, glossaryAtom);
356        if (glossary === null) {
357            return <Text dimColor>No GLOSSARY-MAP.md or GLOSSARY.md in this project.</Text>;
358        }
359        const selected = await read($, selectedAtom);
360        const expanded = await read($, expandedAtom);
361        const filter = await read($, filterAtom);
362        const flags = await read($, flagsAtom);
363        const rows = rowsOf(glossary, expanded, filter);
364        const room = Math.max(4, (e.viewport?.rows ?? 24) - 4);
365        const pages = Math.max(1, Math.ceil(rows.length / room));
366        const page = Math.min(await read($, pageAtom), pages - 1);
367        const visible = rows.slice(page * room, page * room + room);
368        const detail = await detailOf($, glossary, selected);
369        const press = (id: string) => async () => {
370            if (isGroup(id)) {
371                await update($, expandedAtom, (current) => (current === id ? null : id));
372                await update($, pageAtom, () => 0);
373            }
374            await select($, id);
375        };
376        const flagNote = flags.length > 0 ? `\n\n---\n\n**Term Check**: ${describeFlags(flags)}` : "";
377
378        return (
379            <Box flexDirection="row" gap={2}>
380                <Box flexDirection="column" width={LIST_WIDTH} flexShrink={0}>
381                    {Input !== null && (
382                        <Input
383                            key="filter"
384                            placeholder="filter terms and ADRs"
385                            value={filter}
386                            submitLabel="open"
387                            onInput={(value: string) =>
388                                update($, filterAtom, () => value).then(() => update($, pageAtom, () => 0))
389                            }
390                            onSubmit={(value: string) => {
391                                const first = search(glossary, value)[0];
392                                return first ? select($, first.id) : undefined;
393                            }}
394                        />
395                    )}
396                    {visible.map((row, index) =>
397                        row.kind === "label" ? (
398                            <Text key={`label-${page}-${index}`} dimColor wrap="truncate">
399                                {row.text}
400                            </Text>
401                        ) : (
402                            <Button
403                                key={row.id}
404                                label={`${row.id === selected ? "›" : " "}${row.text}`}
405                                plain
406                                dimColor={row.id !== selected}
407                                onPress={press(row.id)}
408                            />
409                        ),
410                    )}
411                    {pages > 1 && (
412                        <Box gap={1}>
413                            {page > 0 && (
414                                <Button
415                                    key="prev"
416                                    label="▲ prev"
417                                    plain
418                                    onPress={() => update($, pageAtom, () => page - 1)}
419                                />
420                            )}
421                            <Text dimColor>
422                                {page + 1}/{pages}
423                            </Text>
424                            {page < pages - 1 && (
425                                <Button
426                                    key="next"
427                                    label="▼ next"
428                                    plain
429                                    onPress={() => update($, pageAtom, () => page + 1)}
430                                />
431                            )}
432                        </Box>
433                    )}
434                </Box>
435                <Box flexDirection="column" flexGrow={1}>
436                    <Markdown key="detail" text={truncate(`${detail}${flagNote}`)} />
437                </Box>
438            </Box>
439        );
440    });
441};
442
hooks/glossary.ts 324 lines
1import type { Adr, Flag, Glossary, GlossaryContext, Term } from "../types";
2
3const PROSE_LEADS = /^(using|calling|counting|bare)\b/i;
4const CLAUSE_LEAD = /^\s*(which|when|that)\b/i;
5
6const untilClause = (parts: string[]): string[] => {
7    const clause = parts.findIndex((part) => CLAUSE_LEAD.test(part));
8
9    return clause < 0 ? parts : parts.slice(0, clause);
10};
11
12export const avoidWordsOf = (avoid: string, canonical: Set<string>): string[] =>
13    untilClause(splitTopLevel(avoid))
14        .map((entry) => entry.replace(/\([^)]*\)/g, "").trim())
15        .filter((entry) => entry.length > 0 && !PROSE_LEADS.test(entry))
16        .filter((entry) => /^[A-Za-z][A-Za-z -]*$/.test(entry) && entry.split(/\s+/).length <= 3)
17        .filter((entry) => !canonical.has(entry.toLowerCase()));
18
19const splitTopLevel = (text: string): string[] => {
20    const parts: string[] = [];
21    let depth = 0;
22    let quoted = false;
23    let current = "";
24    for (const char of text) {
25        if (char === "(") {
26            depth += 1;
27        } else if (char === ")") {
28            depth = Math.max(0, depth - 1);
29        } else if (char === '"') {
30            quoted = !quoted;
31        }
32        if (char === "," && depth === 0 && !quoted) {
33            parts.push(current);
34            current = "";
35        } else {
36            current += char;
37        }
38    }
39    parts.push(current);
40
41    return parts;
42};
43
44export type MapEntry = { name: string; dir: string; summary: string };
45
46export const glossaryFile = (dir: string): string => (dir === "" ? "GLOSSARY.md" : `${dir}/GLOSSARY.md`);
47
48export const singleContextEntry = (text: string): MapEntry => {
49    const title = /^# (.+)$/m.exec(text)?.[1]?.trim() ?? "Glossary";
50    const summary =
51        text
52            .split("\n")
53            .find((line) => line.trim() !== "" && !line.startsWith("#") && !line.startsWith("**"))
54            ?.trim() ?? "";
55
56    return { name: title, dir: "", summary };
57};
58
59export const parseContextMap = (text: string): { entries: MapEntry[]; relationships: string } => {
60    const contexts = sectionOf(text, "Contexts");
61    const entries: MapEntry[] = [];
62    for (const bullet of contexts.split(/\n(?=- )/)) {
63        const match = /^- \[([^\]]+)\]\(([^)]+)\):\s*([\s\S]*)$/.exec(bullet.trim());
64        const [, name, link, summary] = match ?? [];
65        if (name !== undefined && link !== undefined && summary !== undefined) {
66            const dir = link.replace(/^\.\//, "").replace(/\/?GLOSSARY\.md$/, "");
67            entries.push({ name, dir, summary: summary.replace(/\s+/g, " ").trim() });
68        }
69    }
70
71    return { entries, relationships: sectionOf(text, "Relationships").trim() };
72};
73
74const sectionOf = (text: string, heading: string): string => {
75    const start = text.search(new RegExp(`^## ${heading}\\s*$`, "m"));
76    if (start < 0) {
77        return "";
78    }
79    const body = text.slice(start).replace(/^.*\n/, "");
80    const end = body.search(/^## /m);
81
82    return end < 0 ? body : body.slice(0, end);
83};
84
85export const parseContext = (text: string, entry: MapEntry, adrs: Adr[]): GlossaryContext => {
86    const lines = text.split("\n");
87    const terms: Term[] = [];
88    const intro: string[] = [];
89    let section = "";
90    let current: Term | null = null;
91    let isLanguage = false;
92    for (const line of lines) {
93        if (/^## /.test(line)) {
94            isLanguage = /^## Language\s*$/.test(line);
95            current = null;
96        } else if (/^### /.test(line)) {
97            section = line.replace(/^### /, "").trim();
98            current = null;
99        } else if (!isLanguage && terms.length === 0 && !/^# /.test(line) && line.trim() !== "") {
100            intro.push(line);
101        } else if (isLanguage) {
102            const head = /^\*\*(.+?)\*\*:\s*(.*)$/.exec(line);
103            if (head) {
104                const term: Term = {
105                    name: head[1] ?? "",
106                    section,
107                    definition: head[2] ?? "",
108                    avoid: "",
109                    avoidWords: [],
110                };
111                terms.push(term);
112                current = term;
113            } else if (current && /^_Avoid_:/.test(line)) {
114                current.avoid = line.replace(/^_Avoid_:\s*/, "").trim();
115            } else if (current && line.trim() !== "") {
116                current.definition = `${current.definition} ${line.trim()}`.trim();
117            } else if (line.trim() === "") {
118                current = null;
119            }
120        }
121    }
122    const canonical = new Set(terms.map((term) => term.name.toLowerCase()));
123    for (const term of terms) {
124        term.avoidWords = avoidWordsOf(term.avoid, canonical);
125    }
126
127    return { name: entry.name, dir: entry.dir, summary: entry.summary, intro: intro.join("\n"), terms, adrs };
128};
129
130export const adrTitle = (path: string, text: string): Adr => {
131    const heading = /^# (.+)$/m.exec(text);
132
133    return { path, title: heading?.[1]?.trim() ?? path.split("/").pop() ?? path };
134};
135
136const proseOnly = (text: string): string =>
137    text
138        .replace(/```[\s\S]*?```/g, " ")
139        .replace(/`[^`\n]*`/g, " ")
140        .replace(/<[^>\n]+>/g, " ")
141        .replace(/\]\([^)]*\)/g, "]")
142        .replace(/https?:\/\/\S+/g, " ");
143
144const escape = (word: string): string => word.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
145
146const canonicalNames = (contexts: GlossaryContext[]): string[] => {
147    const names = contexts.flatMap((context) => [context.name, ...context.terms.map((term) => term.name)]);
148    const tails = names
149        .map((name) => name.split(/\s+/).slice(1))
150        .filter((words) => words.length >= 2)
151        .map((words) => words.join(" "));
152
153    return [...new Set([...names, ...tails])].sort((a, b) => b.length - a.length);
154};
155
156const withoutCanonical = (prose: string, names: string[]): string =>
157    names.reduce(
158        (text, name) => text.replace(new RegExp(`\\b${escape(name).replace(/\s+/g, "[\\s-]+")}s?\\b`, "gi"), " "),
159        prose,
160    );
161
162export const check = (text: string, contexts: GlossaryContext[], where: string, all = contexts): Flag[] => {
163    const prose = withoutCanonical(proseOnly(text), canonicalNames(all));
164    const flags: Flag[] = [];
165    const seen = new Set<string>();
166    for (const context of contexts) {
167        for (const term of context.terms) {
168            for (const word of term.avoidWords) {
169                const pattern = new RegExp(`\\b${escape(word).replace(/\s+/g, "\\s+")}(s|es)?\\b`, "i");
170                const key = `${context.name}|${word.toLowerCase()}`;
171                if (!seen.has(key) && pattern.test(prose)) {
172                    seen.add(key);
173                    flags.push({ word, term: term.name, context: context.name, where });
174                }
175            }
176        }
177    }
178
179    return flags;
180};
181
182const EXEMPT = /(^|\/)(GLOSSARY|GLOSSARY-MAP)\.md$/;
183
184const owns = (dir: string, relative: string): boolean =>
185    dir === "" || relative === dir || relative.startsWith(`${dir}/`);
186
187export const contextsForPath = (relative: string, glossary: Glossary): GlossaryContext[] | null => {
188    if (!/\.(md|mdx)$/.test(relative) || EXEMPT.test(relative)) {
189        return null;
190    }
191    const owners = glossary.contexts
192        .filter((context) => owns(context.dir, relative))
193        .sort((a, b) => b.dir.length - a.dir.length);
194
195    return owners.length > 0 ? owners.slice(0, 1) : glossary.contexts;
196};
197
198export const isOwnedPath = (relative: string, glossary: Glossary): boolean =>
199    glossary.contexts.some((context) => owns(context.dir, relative));
200
201const flagLine = (flag: Flag): string =>
202    `- '${flag.word}' is an Avoid word in ${flag.context}: when it means ${flag.term}, say ${flag.term}.`;
203
204export const promptNote = (flags: Flag[]): string =>
205    [
206        "Glossary Term Check: the prompt uses Avoid words from this repository's glossary (GLOSSARY.md).",
207        ...flags.map(flagLine),
208        "Use the canonical terms in your reply and your work. If a word is meant in another sense, ignore its line.",
209    ].join("\n");
210
211export const denyReason = (flags: Flag[], relative: string): string =>
212    [
213        `Glossary Term Check: this edit to ${relative} uses Avoid words from the ${flags[0]?.context ?? ""} glossary.`,
214        ...flags.map(flagLine),
215        "Rewrite the text with the canonical terms. If a word is meant (a quote, code, another sense), " +
216            "send the same edit again unchanged and it will pass.",
217    ].join("\n");
218
219export const elsewhere = (glossary: Glossary, contextName: string, term: Term): string[] => {
220    const name = term.name.toLowerCase();
221    const found: string[] = [];
222    for (const context of glossary.contexts) {
223        for (const other of context.terms) {
224            const isSelf = context.name === contextName && other.name === term.name;
225            if (isSelf) {
226                continue;
227            }
228            if (other.name.toLowerCase() === name) {
229                found.push(`**${other.name}** in ${context.name}: ${other.definition}`);
230            } else if (other.avoidWords.some((word) => word.toLowerCase() === name)) {
231                found.push(`${context.name} avoids it for **${other.name}**`);
232            }
233        }
234    }
235
236    return found;
237};
238
239export type Match = { id: string; label: string };
240
241export const search = (glossary: Glossary, query: string): Match[] => {
242    const needle = query.trim().toLowerCase();
243    if (needle === "") {
244        return [];
245    }
246    const byName: Match[] = [];
247    const byText: Match[] = [];
248    glossary.contexts.forEach((context, index) => {
249        for (const term of context.terms) {
250            const match = { id: termId(index, term.name), label: `${term.name} · ${context.name}` };
251            if (term.name.toLowerCase().includes(needle)) {
252                byName.push(match);
253            } else if (`${term.definition} ${term.avoid}`.toLowerCase().includes(needle)) {
254                byText.push(match);
255            }
256        }
257    });
258    const adrs = allAdrs(glossary)
259        .filter((adr) => adr.title.toLowerCase().includes(needle))
260        .map((adr) => ({ id: adrId(adr.path), label: `ADR · ${adr.title}` }));
261
262    return [...byName, ...byText, ...adrs];
263};
264
265export const allAdrs = (glossary: Glossary): Adr[] => [
266    ...glossary.systemAdrs,
267    ...glossary.contexts.flatMap((context) => context.adrs),
268];
269
270export const termId = (contextIndex: number, name: string): string => `term:${contextIndex}:${name}`;
271export const adrId = (path: string): string => `adr:${path}`;
272export const contextId = (contextIndex: number): string => `ctx:${contextIndex}`;
273export const RELATIONSHIPS_ID = "rel";
274export const SYSTEM_ID = "system";
275
276export const findTermId = (glossary: Glossary, name: string): string | null => {
277    const needle = name.trim().toLowerCase();
278    for (const [index, context] of glossary.contexts.entries()) {
279        const term = context.terms.find((one) => one.name.toLowerCase() === needle);
280        if (term) {
281            return termId(index, term.name);
282        }
283    }
284    const fuzzy = search(glossary, name)[0];
285
286    return fuzzy ? fuzzy.id : null;
287};
288
289export const termMarkdown = (glossary: Glossary, contextIndex: number, term: Term): string => {
290    const context = glossary.contexts[contextIndex];
291    if (context === undefined) {
292        return "";
293    }
294    const parts = [`## ${term.name}`, `_${context.name}${term.section ? ` · ${term.section}` : ""}_`, term.definition];
295    if (term.avoid !== "") {
296        parts.push(`**Avoid**: ${term.avoid}`);
297        parts.push(
298            term.avoidWords.length > 0
299                ? `**Term Check flags**: ${term.avoidWords.map((word) => `\`${word}\``).join(", ")}`
300                : "_The Term Check flags nothing for this term: its Avoid entries are guidance, not words._",
301        );
302    }
303    const others = elsewhere(glossary, context.name, term);
304    if (others.length > 0) {
305        parts.push(`**Same word elsewhere**\n\n${others.map((line) => `- ${line}`).join("\n")}`);
306    }
307
308    return parts.join("\n\n");
309};
310
311export const contextMarkdown = (context: GlossaryContext): string => {
312    const sections = [...new Set(context.terms.map((term) => term.section))].filter((one) => one !== "");
313    const parts = [`## ${context.name}`, context.summary];
314    if (context.intro.trim() !== "" && context.intro.trim() !== context.summary) {
315        parts.push(context.intro.trim());
316    }
317    parts.push(`\`${glossaryFile(context.dir)}\` · ${context.terms.length} terms · ${context.adrs.length} ADRs`);
318    if (sections.length > 0) {
319        parts.push(`**Sections**: ${sections.join(", ")}`);
320    }
321
322    return parts.join("\n\n");
323};
324
types/index.d.ts 48 lines
1export type Term = {
2    name: string;
3    section: string;
4    definition: string;
5    avoid: string;
6    avoidWords: string[];
7};
8
9export type Adr = {
10    path: string;
11    title: string;
12};
13
14export type GlossaryContext = {
15    name: string;
16    dir: string;
17    summary: string;
18    intro: string;
19    terms: Term[];
20    adrs: Adr[];
21};
22
23export type Glossary = {
24    contexts: GlossaryContext[];
25    systemAdrs: Adr[];
26    relationships: string;
27};
28
29export type Flag = {
30    word: string;
31    term: string;
32    context: string;
33    where: string;
34};
35
36declare module "claude-code" {
37    interface PluginState {
38        "glossary-browser": {
39            glossary: Glossary | null;
40            selected: string | null;
41            expanded: string | null;
42            filter: string;
43            page: number;
44            flags: Flag[];
45        };
46    }
47}
48