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…

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).
/plugin marketplace add chicio/chicio-labs
/plugin install glossary-browser@chicio-labs
/reload-plugins
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.
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.
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 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.
It looks for Avoid words in two places:
'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..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").
| Option | Default | Effect |
|---|---|---|
denyEdits | true | Refuse 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.
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.
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>.
hooks/register.tsx 442 lines1import { 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};
442hooks/glossary.ts 324 lines1import 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};
324types/index.d.ts 48 lines1export 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