Skip to content

Commit e0abad8

Browse files
withkynamclaude
andcommitted
feat(context): frontmatter-driven keyword routing + drift-proof index
Make context discovery deterministic instead of agent-judgment-dependent: - Add `keywords` (required) and `related` (optional cross-links) to the context-file frontmatter contract (vc-context-discovery SKILL.md + seeds). - discover-context.mjs: `--match` ranks docs by keyword overlap and follows `related` siblings; `--emit-routing` regenerates the Root-Entry-Points and Context-Groups tables from frontmatter (between GENERATED:routing markers), killing router drift; `--check-routing` fails when the block is stale. - validate-context-discovery.mjs: enforce non-empty keywords, resolve every `related` slug (no dangling links), and fail on a stale generated block. - Wire the lifecycle: context-maintenance.md and generate-context.md now edit frontmatter + --emit-routing rather than hand-editing the router tables. Task-Routing table stays hand-authored (editorial task-type -> file mapping). Bare-kit mode unaffected; kit validators still pass clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent f968bc8 commit e0abad8

7 files changed

Lines changed: 278 additions & 46 deletions

File tree

.claude/skills/vc-audit-context/scripts/validate-context-discovery.mjs

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,54 @@ if (!bareKitMode) {
188188
}
189189
}
190190

191+
// --- frontmatter routing contract: keyword coverage + related-link integrity ---
192+
// Build a slug registry (context:{slug}) across all context docs first.
193+
const contextDocNames = new Set();
194+
for (const doc of contextDocs) {
195+
if (doc === router) continue;
196+
const name = parseFrontmatter(doc).name;
197+
if (name) contextDocNames.add(name);
198+
}
199+
for (const doc of contextDocs) {
200+
if (doc === router) continue;
201+
const fm = parseFrontmatter(doc);
202+
// keywords: required, non-empty — this is the --match routing surface.
203+
const keywords = (fm.keywords || "")
204+
.split(",")
205+
.map((k) => k.trim())
206+
.filter(Boolean);
207+
if (keywords.length === 0) {
208+
// Warn, not fail: pre-existing context docs created before the keyword-routing
209+
// contract should not break a project's lint on sync. New docs get keywords from
210+
// the seeds; backfill old ones at UPDATE-PROCESS. Dangling `related` and routing
211+
// drift below remain hard failures (real breakage, opt-in only).
212+
warn(`${doc} has empty/missing 'keywords' frontmatter (recommended for keyword routing; backfill at UPDATE-PROCESS — see vc-context-discovery)`);
213+
}
214+
// related: every listed slug must resolve to a real context: doc (no dangling cross-links).
215+
const related = (fm.related || "")
216+
.replace(/^\[|\]$/g, "")
217+
.split(",")
218+
.map((s) => s.trim().replace(/^["']|["']$/g, ""))
219+
.filter(Boolean);
220+
for (const slug of related) {
221+
if (!contextDocNames.has(slug)) {
222+
fail(`${doc} 'related' references ${slug} which resolves to no context doc (dangling cross-link)`);
223+
}
224+
}
225+
}
226+
227+
// --- generated routing block must be in sync with frontmatter on disk ---
228+
if (routerText.includes("<!-- GENERATED:routing -->")) {
229+
try {
230+
execSync(
231+
`node ${JSON.stringify(path.join(root, ".claude/skills/vc-context-discovery/scripts/discover-context.mjs"))} --check-routing`,
232+
{ cwd: root, stdio: ["pipe", "pipe", "pipe"] },
233+
);
234+
} catch {
235+
fail(`${router} GENERATED:routing block is stale — run discover-context.mjs --emit-routing to rebuild`);
236+
}
237+
}
238+
191239
for (const dir of fs.readdirSync(path.join(root, "process/context"), { withFileTypes: true })) {
192240
if (dir.isDirectory() && !getGroupEntrypoint(dir.name)) {
193241
fail(`process/context/${dir.name}/ is missing all-${dir.name}.md`);

.claude/skills/vc-context-discovery/SKILL.md

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,21 @@ Document the canonical schemas for three file types used across the repo. These
2727
```yaml
2828
name: context:{slug}
2929
description: "one-line scope — used by vc-context-discovery for routing"
30+
keywords: comma, separated, task, vocabulary, terms # drives grep-first keyword routing
31+
related: [context:{other-slug}, context:{another-slug}] # sibling/cross-group links (optional)
3032
date: dd-mm-yy
3133
```
3234
35+
- `keywords` — strongly recommended, non-empty. Comma-separated task-vocabulary terms an
36+
agent would use to describe a task this doc serves (e.g. `session, token, refresh, jwt,
37+
login`). This is the match surface for `discover-context.mjs --match`; weak/absent
38+
keywords are why a relevant doc never gets routed to. Lint WARNS when empty (so existing
39+
projects don't break on sync) — backfill at UPDATE-PROCESS.
40+
- `related` — OPTIONAL list of `context:{slug}` values for sibling docs that are usually
41+
needed together (the markdown-native equivalent of cross-links). Every slug listed MUST
42+
resolve to a real `context:` doc; dangling links fail lint. Discovery follows these after
43+
the primary match so a task touching two domains loads both.
44+
3345
**Plan file frontmatter schema:**
3446
```yaml
3547
name: plan:{slug}
@@ -68,6 +80,29 @@ never throws on a missing root and exits 0 unless given a bad flag. Use `--json`
6880
machine-readable object. Prefer this over manually reading each file — it is deterministic
6981
and avoids loading huge files into context.
7082

83+
**Keyword-first routing (deterministic, not judgment).** When the task vocabulary does not
84+
obviously map to a routing-table row, do not guess — let the index do the matching:
85+
86+
```bash
87+
node .claude/skills/vc-context-discovery/scripts/discover-context.mjs --match "update the user ORM model"
88+
```
89+
90+
This tokenizes the task and ranks context docs by overlap with their frontmatter `keywords`,
91+
then appends any `related:` siblings of the top hits. Read the ranked docs in order. This is
92+
the fallback that fixes "the right doc existed but the agent walked past it."
93+
94+
**Routing-table generation (drift-proof index).** The "Current Root Entry Points" and
95+
"Current Context Groups" tables in `all-context.md` are GENERATED from frontmatter, not
96+
hand-authored — they live between `<!-- GENERATED:routing -->` / `<!-- /GENERATED:routing -->`
97+
markers. Rebuild them after any context-org change:
98+
99+
```bash
100+
node .claude/skills/vc-context-discovery/scripts/discover-context.mjs --emit-routing # rewrites the block
101+
node .claude/skills/vc-context-discovery/scripts/discover-context.mjs --check-routing # lint: block in sync?
102+
```
103+
104+
The hand-authored Task Routing Table (task-type → file) stays editorial and is NOT generated.
105+
71106
Per **task-folder artefact colocation**, the script surfaces each task's plan, spec,
72107
reports, and references INSIDE its own `{slug}_{date}/` task folder; the sibling
73108
`reports/`/`references/` dirs are deprecated and only hold legacy artefacts.
@@ -86,9 +121,9 @@ Use these steps only if the script above fails or is unavailable.
86121

87122
**Step 5.** If no feature name was provided, run `find process/general-plans/active/ -type f | sort` to surface any active plans relevant to the current task. Note: plan files are inside `{slug}_{date}/` task subfolders — look one level deep for `*_PLAN_*.md` files.
88123

89-
**Step 6.** From the routing table in `all-context.md`, identify the context group files relevant to the current task domain (e.g. `tests`, `container`, `infra`, `skills`, `uxui`, `workflows`). Do NOT read every context file — only the ones the routing table says apply to this domain.
124+
**Step 6.** From the routing table in `all-context.md`, identify the context group files relevant to the current task domain (e.g. `tests`, `container`, `infra`, `skills`, `uxui`, `workflows`). Do NOT read every context file — only the ones the routing table says apply to this domain. If no row obviously matches, run `discover-context.mjs --match "<task>"` and use its ranked keyword hits instead of guessing.
90125

91-
**Step 7.** Read the domain context file(s) identified in step 6. Each `all-{group}.md` is a router — after reading it, follow its routing table to load the deeper domain file(s) for the task.
126+
**Step 7.** Read the domain context file(s) identified in step 6. Each `all-{group}.md` is a router — after reading it, follow its routing table to load the deeper domain file(s) for the task. Then load any `related:` siblings of the docs you read — a task spanning two domains (e.g. auth + tests) needs both, and `related` is how the cross-domain link is declared.
92127

93128
**Step 8.** Frontmatter extraction: For each file in the discovered set, if the file has YAML frontmatter (a `---` block at top), extract the `name`, `description`, and `date` fields. Surface in output as `"[path] (name: X — description: Y)"`. Files without frontmatter: surface path only (no error, do not skip them).
94129

@@ -142,9 +177,9 @@ emitted or run. See `03-session-start.md` for the matching field table.
142177
143178
After collecting file paths, read YAML frontmatter from each file where present.
144179
145-
Surface the following fields alongside path: `name`, `description`, `date`, `type`, `feature`, `phase`.
180+
Surface the following fields alongside path: `name`, `description`, `keywords`, `related`, `date`, `type`, `feature`, `phase`.
146181
147-
Use the `description` field for routing decisions instead of filename inference.
182+
Use the `description` and `keywords` fields for routing decisions instead of filename inference.
148183
149184
Group plan files by their `feature` field value.
150185

.claude/skills/vc-context-discovery/scripts/discover-context.mjs

Lines changed: 157 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,23 @@ const root = execSync("git rev-parse --show-toplevel").toString().trim();
1212
const args = process.argv.slice(2);
1313
let feature = null;
1414
let asJson = false;
15+
let matchQuery = null;
16+
let emitRouting = false;
17+
let checkRouting = false;
1518
for (let i = 0; i < args.length; i++) {
1619
const a = args[i];
1720
if (a === "--json") asJson = true;
18-
else if (a === "--feature") {
21+
else if (a === "--emit-routing") emitRouting = true;
22+
else if (a === "--check-routing") checkRouting = true;
23+
else if (a === "--match") {
24+
matchQuery = args[++i];
25+
if (!matchQuery) {
26+
console.error("--match requires a value");
27+
process.exit(2);
28+
}
29+
} else if (a.startsWith("--match=")) {
30+
matchQuery = a.slice("--match=".length);
31+
} else if (a === "--feature") {
1932
feature = args[++i];
2033
if (!feature) {
2134
console.error("--feature requires a value");
@@ -97,9 +110,23 @@ function readFrontmatter(relPath) {
97110
}
98111
}
99112

113+
// keywords: comma-separated scalar -> array of lowercase terms
114+
const keywords = (top.keywords ?? "")
115+
.split(",")
116+
.map((k) => k.trim().toLowerCase())
117+
.filter(Boolean);
118+
// related: inline YAML list `[context:a, context:b]` -> array of slug strings
119+
const related = (top.related ?? "")
120+
.replace(/^\[|\]$/g, "")
121+
.split(",")
122+
.map((s) => s.trim().replace(/^["']|["']$/g, ""))
123+
.filter(Boolean);
124+
100125
return {
101126
name: top.name ?? null,
102127
description: top.description ?? null,
128+
keywords,
129+
related,
103130
date: top.date ?? null,
104131
type: top.type ?? metadata.type ?? null,
105132
feature: top.feature ?? metadata.feature ?? null,
@@ -126,6 +153,135 @@ if (feature) {
126153
featureFiles = walk(`process/features/${feature}`).map(describe);
127154
}
128155

156+
// --- keyword matching + routing generation ---------------------------------
157+
const ROUTER = "process/context/all-context.md";
158+
const ROUTING_START = "<!-- GENERATED:routing -->";
159+
const ROUTING_END = "<!-- /GENERATED:routing -->";
160+
161+
function contextDocsWithFm() {
162+
return contextFiles.filter((d) => d.path.endsWith(".md") && d.fm && d.fm.hasFrontmatter);
163+
}
164+
165+
function groupOf(relPath) {
166+
// process/context/{group}/all-{group}.md -> {group}; root all-context.md -> null
167+
const rest = relPath.replace(/^process\/context\//, "");
168+
const parts = rest.split(path.sep);
169+
return parts.length > 1 ? parts[0] : null;
170+
}
171+
172+
function groupEntrypoints() {
173+
return contextDocsWithFm()
174+
.filter((d) => /(^|\/)all-[^/]+\.md$/.test(d.path) && groupOf(d.path))
175+
.sort((a, b) => a.path.localeCompare(b.path));
176+
}
177+
178+
function buildRoutingBlock() {
179+
const eps = groupEntrypoints();
180+
const rootRows = [
181+
"| `process/context/all-context.md` | any substantial planning, research, review, or implementation task |",
182+
...eps.map((d) => `| \`${d.path}\` | ${d.fm.readWhen || d.fm.description || "—"} |`),
183+
];
184+
const groupRows = eps.length
185+
? eps.map((d) => `| \`${groupOf(d.path)}/\` | \`${d.path}\` | ${d.fm.description || "—"} |`)
186+
: ["| (no groups yet — populated during STUDY phase, then regenerated by `--emit-routing`) | | |"];
187+
188+
return [
189+
ROUTING_START,
190+
"| File | Read when |",
191+
"|---|---|",
192+
...rootRows,
193+
"",
194+
"## Current Context Groups",
195+
"",
196+
"| Group | Entry point | Scope |",
197+
"|---|---|---|",
198+
...groupRows,
199+
ROUTING_END,
200+
].join("\n");
201+
}
202+
203+
function currentRoutingBlock(text) {
204+
const s = text.indexOf(ROUTING_START);
205+
const e = text.indexOf(ROUTING_END);
206+
if (s === -1 || e === -1 || e < s) return null;
207+
return text.slice(s, e + ROUTING_END.length);
208+
}
209+
210+
if (emitRouting || checkRouting) {
211+
if (!fs.existsSync(path.join(root, ROUTER))) {
212+
process.stderr.write(`[bare-kit mode] ${ROUTER} absent — nothing to ${emitRouting ? "emit" : "check"}.\n`);
213+
process.exit(0);
214+
}
215+
const text = fs.readFileSync(path.join(root, ROUTER), "utf8");
216+
const existing = currentRoutingBlock(text);
217+
if (existing === null) {
218+
console.error(`${ROUTER} has no <!-- GENERATED:routing --> markers; cannot manage routing block.`);
219+
process.exit(2);
220+
}
221+
const fresh = buildRoutingBlock();
222+
if (checkRouting) {
223+
if (existing.trim() !== fresh.trim()) {
224+
console.error(`${ROUTER} routing block is STALE — run discover-context.mjs --emit-routing to rebuild.`);
225+
process.exit(1);
226+
}
227+
console.log(`${ROUTER} routing block is in sync.`);
228+
process.exit(0);
229+
}
230+
fs.writeFileSync(path.join(root, ROUTER), text.replace(existing, fresh));
231+
console.log(`Rewrote routing block in ${ROUTER} (${groupEntrypoints().length} group entrypoint(s)).`);
232+
process.exit(0);
233+
}
234+
235+
if (matchQuery) {
236+
const tokens = (matchQuery.toLowerCase().match(/[a-z0-9]+/g) || []).filter((t) => t.length > 2);
237+
const scored = contextDocsWithFm()
238+
.map((d) => {
239+
const kws = d.fm.keywords || [];
240+
let score = 0;
241+
const hits = [];
242+
for (const t of tokens) {
243+
if (kws.some((k) => k === t || k.includes(t) || t.includes(k))) { score += 1; hits.push(t); }
244+
}
245+
return { d, score, hits };
246+
})
247+
.filter((x) => x.score > 0)
248+
.sort((a, b) => b.score - a.score || a.d.path.localeCompare(b.d.path));
249+
250+
// append related siblings of the top hits (deduped, not already matched)
251+
const matchedPaths = new Set(scored.map((x) => x.d.path));
252+
const byName = new Map(contextDocsWithFm().map((d) => [d.fm.name, d]));
253+
const siblings = [];
254+
for (const { d } of scored) {
255+
for (const rel of d.fm.related || []) {
256+
const sib = byName.get(rel);
257+
if (sib && !matchedPaths.has(sib.path)) {
258+
matchedPaths.add(sib.path);
259+
siblings.push({ from: d.fm.name, sib });
260+
}
261+
}
262+
}
263+
264+
if (asJson) {
265+
console.log(JSON.stringify({
266+
query: matchQuery,
267+
matches: scored.map((x) => ({ path: x.d.path, score: x.score, hits: x.hits, name: x.d.fm.name, description: x.d.fm.description })),
268+
related: siblings.map((s) => ({ path: s.sib.path, name: s.sib.fm.name, via: s.from })),
269+
}, null, 2));
270+
process.exit(0);
271+
}
272+
273+
const out = [`Keyword matches for: "${matchQuery}" (read in order)`];
274+
if (scored.length === 0) out.push(" (no keyword matches — fall back to all-context.md routing table)");
275+
for (const x of scored) out.push(` [${x.score}] ${x.d.path}${x.d.fm.description ?? "—"} (hits: ${x.hits.join(", ")})`);
276+
if (siblings.length) {
277+
out.push("");
278+
out.push("Related siblings (load alongside the above):");
279+
for (const s of siblings) out.push(` ${s.sib.path}${s.sib.fm.description ?? "—"} (related via ${s.from})`);
280+
}
281+
console.log(out.join("\n"));
282+
process.exit(0);
283+
}
284+
129285
// --- output ----------------------------------------------------------------
130286
function line(d) {
131287
if (d.fm && (d.fm.name || d.fm.description)) {

.claude/skills/vc-generate-context/references/generate-context.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -87,9 +87,10 @@ Use this table to determine which context groups to create during `standalone-fu
8787

8888
## Validation
8989

90-
After updating `process/context/all-context.md`, run:
90+
After updating `process/context/all-context.md`, regenerate the routing tables from frontmatter, then validate:
9191

9292
```bash
93+
node .claude/skills/vc-context-discovery/scripts/discover-context.mjs --emit-routing
9394
node .claude/skills/vc-generate-context/scripts/validate-all-context.mjs
9495
```
9596

@@ -147,9 +148,8 @@ When populating `all-context.md` from seed templates or from a fresh scan, write
147148
1. **Title**: Replace the project name placeholder with the actual project name.
148149
2. **Project description**: Use the user's own words when available (from vc-setup's ASK step). Supplement with what the code scan reveals. The user's description should be prominent, not buried.
149150
3. **Quick Start section**: Keep the generic routing instructions from the template.
150-
4. **Current Root Entry Points table**: Populate with actual context files that were created.
151-
5. **Current Context Groups table**: Populate with groups created by the context group detection step (those groups whose `all-{group}.md` files were just written).
152-
6. **Task Routing table**: Fill based on what context groups exist, mapping task types to the relevant entry points.
151+
4. **Current Root Entry Points table** and **Current Context Groups table**: do NOT hand-fill these. They live between `<!-- GENERATED:routing -->` markers and are generated from each group doc's frontmatter. First ensure every `all-{group}.md` you wrote has complete frontmatter (`name: context:all-{group}`, `description`, a non-empty `keywords` list of task-vocabulary terms, optional `related: [context:{slug}]` siblings), then run `node .claude/skills/vc-context-discovery/scripts/discover-context.mjs --emit-routing` to populate both tables.
152+
6. **Task Routing table**: hand-author this (it maps editorial task types → entry points). Fill based on what context groups exist.
153153
7. **Repository Structure**: Write actual directory tree output (2-3 levels deep, showing key directories and files).
154154
8. **Technology Stack**: Write specific framework names, versions, and combinations discovered during analysis.
155155
9. **Key Patterns and Conventions**: Document actual patterns found in the codebase (error handling, state management, API patterns, naming conventions, import aliases). Include conventions the user mentioned.

process/_seeds/context/_all-group-template.md.seed

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,11 @@
1+
---
2+
name: context:all-{{group}}
3+
description: "{{group_scope_short}} — the {{group_name}} group entrypoint/router"
4+
keywords: {{group}}, (add the task-vocabulary terms an agent would use for this domain)
5+
related: []
6+
date: (dd-mm-yy)
7+
---
8+
19
# {{group_name}} Context
210

311
This file is the canonical {{group_name}} context entrypoint for {{project_name}}.
@@ -15,8 +23,9 @@ To use: copy this file to `process/context/{group}/all-{group}.md`, then replace
1523
- `{{group_scope_short}}` with a brief scope phrase (e.g., "schema changes, migrations, or query patterns")
1624
- `{{project_name}}` with the project name
1725
- Fill in each section with real project-specific content
26+
- Fill in the YAML frontmatter at the top: a non-empty `keywords` list (task-vocabulary terms — this is what `discover-context.mjs --match` ranks against) and any `related: [context:{slug}]` siblings usually needed alongside this group. Lint warns on empty keywords and fails on dangling related slugs.
1827

19-
Every `all-{group}.md` entrypoint MUST have these sections: Scope, Read When, Quick Routing, Source Paths, Update Triggers.
28+
Every `all-{group}.md` entrypoint MUST have frontmatter (`name`, `description`, `keywords`, `date`) plus these sections: Scope, Read When, Quick Routing, Source Paths, Update Triggers.
2029

2130
---
2231

0 commit comments

Comments
 (0)