@nurix/etna 0.19.44 → 0.19.46
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/data/harness-index.json +1 -1
- package/data/harnesses/apollo/agents/bookkeeper.md +27 -0
- package/data/harnesses/apollo/harness.json +2 -2
- package/data/harnesses/apollo/hooks/awareness.mjs +380 -0
- package/data/harnesses/apollo/hooks/check-bookkeeping-clean.mjs +19 -0
- package/data/harnesses/apollo/hooks/check-changelog-presence.mjs +48 -0
- package/data/harnesses/apollo/hooks/check-completion-integrity.mjs +68 -0
- package/data/harnesses/apollo/hooks/check-ownership.mjs +22 -0
- package/data/harnesses/apollo/hooks/compliance.mjs +359 -0
- package/data/harnesses/apollo/hooks/hooks.json +16 -10
- package/data/harnesses/apollo/hooks/lib/frozen.mjs +58 -0
- package/data/harnesses/apollo/hooks/lib/ownership.mjs +223 -0
- package/data/harnesses/apollo/hooks/lib/state.mjs +157 -0
- package/data/harnesses/code/harness.json +2 -2
- package/data/harnesses/code/hooks/awareness.mjs +10 -0
- package/data/harnesses/code/hooks/compliance.mjs +34 -7
- package/data/harnesses/code/hooks/lib/state.mjs +6 -5
- package/package.json +3 -2
- package/data/harnesses/apollo/hooks/generate-ownership-index.mjs +0 -148
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// harness/code/hooks/lib/state.mjs — the bookkeeping queue (docs/operations/bookkeeping.md) and the
|
|
2
|
+
// machine-local state dir (.claude/state/). The row grammar is the contract between the awareness
|
|
3
|
+
// detector (writer), the bookkeeper agent (drain), the compliance gates (walls), and CI (backstop):
|
|
4
|
+
// - auto: {YYYY-MM-DD} · {finding-kind} · {description} → {action} | sid={session_id}
|
|
5
|
+
// - note: {YYYY-MM-DD} · {area} · {context} → lands-in: {owning doc | backlog | memory} | sid=…
|
|
6
|
+
// - needs-session: {YYYY-MM-DD} · {what could not be grounded} → main session resolves | refs={ts}
|
|
7
|
+
// - waived: {YYYY-MM-DD} · {what is deferred} · reason: {why} → expires={YYYY-MM-DD}
|
|
8
|
+
// Unresolved = auto + note + needs-session. waived: rows are resolved history until expires= passes.
|
|
9
|
+
|
|
10
|
+
import fs from "node:fs";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
|
|
13
|
+
export const QUEUE_PATH = ["docs", "operations", "bookkeeping.md"];
|
|
14
|
+
|
|
15
|
+
export const QUEUE_HEADER = `# Bookkeeping
|
|
16
|
+
|
|
17
|
+
The shared work queue keeping documentation and code in sync — rows are appended mechanically (auto:) and by working sessions (note:), drained by the bookkeeper agent, and walled at commit: a commit is possible only when every row is resolved or waived. Row grammar and lifecycle: the behavioral-hooks feature doc. A \`waived:\` row carries \`expires=\` and is PR-reviewable history.
|
|
18
|
+
`;
|
|
19
|
+
|
|
20
|
+
export function queueFile(projectDir) {
|
|
21
|
+
return path.join(projectDir, ...QUEUE_PATH);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const ROW_RE = /^-\s+(auto|note|needs-session|waived):\s+(.*)$/;
|
|
25
|
+
|
|
26
|
+
// Parse the queue text into rows: { kind, date, body, raw }. Non-row lines are ignored.
|
|
27
|
+
export function parseQueue(text) {
|
|
28
|
+
const rows = [];
|
|
29
|
+
for (const line of String(text || "").split(/\r?\n/)) {
|
|
30
|
+
const m = ROW_RE.exec(line.trim());
|
|
31
|
+
if (!m) continue;
|
|
32
|
+
const body = m[2].trim();
|
|
33
|
+
const date = (/^(\d{4}-\d{2}-\d{2})/.exec(body) || [])[1] || "";
|
|
34
|
+
rows.push({ kind: m[1], date, body, raw: line });
|
|
35
|
+
}
|
|
36
|
+
return rows;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function readQueue(projectDir) {
|
|
40
|
+
try {
|
|
41
|
+
return parseQueue(fs.readFileSync(queueFile(projectDir), "utf8"));
|
|
42
|
+
} catch (e) {
|
|
43
|
+
return [];
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Counts by kind; `unresolved` is the number the walls act on.
|
|
48
|
+
export function countRows(rows) {
|
|
49
|
+
const c = { auto: 0, note: 0, needsSession: 0, waived: 0 };
|
|
50
|
+
for (const row of rows) {
|
|
51
|
+
if (row.kind === "auto") c.auto += 1;
|
|
52
|
+
else if (row.kind === "note") c.note += 1;
|
|
53
|
+
else if (row.kind === "needs-session") c.needsSession += 1;
|
|
54
|
+
else if (row.kind === "waived") c.waived += 1;
|
|
55
|
+
}
|
|
56
|
+
c.unresolved = c.auto + c.note + c.needsSession;
|
|
57
|
+
return c;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Format one auto: row per the grammar; the sid tail is the only provenance kept —
|
|
61
|
+
// a transcript path or timestamp is machine noise in a PR-reviewed queue.
|
|
62
|
+
export function formatAutoRow({ date, kind, description, action, sessionId }) {
|
|
63
|
+
return `- auto: ${date} · ${kind} · ${description} → ${action} | sid=${sessionId || "-"}`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Append rows to the queue, creating the file with its genre header when absent.
|
|
67
|
+
// Dedupes on the row text before the `| sid=` tail, so a re-fired detector never stacks duplicates.
|
|
68
|
+
export function appendRows(projectDir, rowLines) {
|
|
69
|
+
if (!rowLines || rowLines.length === 0) return 0;
|
|
70
|
+
const file = queueFile(projectDir);
|
|
71
|
+
let text = "";
|
|
72
|
+
try {
|
|
73
|
+
text = fs.readFileSync(file, "utf8");
|
|
74
|
+
} catch (e) {
|
|
75
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
76
|
+
text = QUEUE_HEADER + "\n";
|
|
77
|
+
}
|
|
78
|
+
const key = (line) => line.split("| sid=")[0].trim();
|
|
79
|
+
const existing = new Set(parseQueue(text).map((r) => key(r.raw)));
|
|
80
|
+
const fresh = rowLines.filter((line) => !existing.has(key(line)));
|
|
81
|
+
if (fresh.length === 0) return 0;
|
|
82
|
+
if (!text.endsWith("\n")) text += "\n";
|
|
83
|
+
fs.writeFileSync(file, text + fresh.join("\n") + "\n");
|
|
84
|
+
return fresh.length;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Delete queue rows matching the predicate (used by the detector to auto-resolve
|
|
88
|
+
// rows whose finding the session has since addressed). Returns the count removed.
|
|
89
|
+
export function resolveRows(projectDir, predicate) {
|
|
90
|
+
const file = queueFile(projectDir);
|
|
91
|
+
let text;
|
|
92
|
+
try {
|
|
93
|
+
text = fs.readFileSync(file, "utf8");
|
|
94
|
+
} catch (e) {
|
|
95
|
+
return 0;
|
|
96
|
+
}
|
|
97
|
+
let removed = 0;
|
|
98
|
+
const kept = text.split(/\r?\n/).filter((line) => {
|
|
99
|
+
const m = ROW_RE.exec(line.trim());
|
|
100
|
+
if (!m) return true;
|
|
101
|
+
const row = { kind: m[1], body: m[2].trim(), raw: line };
|
|
102
|
+
if (predicate(row)) {
|
|
103
|
+
removed += 1;
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
return true;
|
|
107
|
+
});
|
|
108
|
+
if (removed > 0) fs.writeFileSync(file, kept.join("\n"));
|
|
109
|
+
return removed;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Unexpired waived rows for a finding kind (the dead-glob allowlist CI honors).
|
|
113
|
+
// A waived row covers a glob when its body names it verbatim.
|
|
114
|
+
export function unexpiredWaivers(rows, kind, today) {
|
|
115
|
+
return rows.filter((row) => {
|
|
116
|
+
if (row.kind !== "waived") return false;
|
|
117
|
+
if (kind && !row.body.includes(kind)) return false;
|
|
118
|
+
const expires = (/expires=(\d{4}-\d{2}-\d{2})/.exec(row.body) || [])[1];
|
|
119
|
+
if (!expires) return false;
|
|
120
|
+
return expires >= today;
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Ensure .claude/state/ exists and is self-ignoring (a `*` .gitignore inside it),
|
|
125
|
+
// so consumers never see machine-local state as untracked noise. Returns the dir.
|
|
126
|
+
export function stateDir(projectDir) {
|
|
127
|
+
const dir = path.join(projectDir, ".claude", "state");
|
|
128
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
129
|
+
const ignore = path.join(dir, ".gitignore");
|
|
130
|
+
if (!fs.existsSync(ignore)) fs.writeFileSync(ignore, "*\n");
|
|
131
|
+
return dir;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Session-scoped ask memory: an `ask` gate fires once per (session, class); after the user has
|
|
135
|
+
// answered it once, repeats in the same session pass silently (the runtime's own permission
|
|
136
|
+
// prompts still apply). Records live in .claude/state/asked.tsv — machine-local, self-ignored.
|
|
137
|
+
export function askedThisSession(projectDir, sessionId, key) {
|
|
138
|
+
if (!sessionId) return false;
|
|
139
|
+
try {
|
|
140
|
+
const text = fs.readFileSync(path.join(projectDir, ".claude", "state", "asked.tsv"), "utf8");
|
|
141
|
+
return text.split("\n").includes(`${sessionId}\t${key}`);
|
|
142
|
+
} catch (e) {
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function recordAsked(projectDir, sessionId, key) {
|
|
148
|
+
if (!sessionId) return;
|
|
149
|
+
fs.appendFileSync(path.join(stateDir(projectDir), "asked.tsv"), `${sessionId}\t${key}\n`);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Append one tab-separated line to .claude/state/touched.tsv (file → owning doc map for the session).
|
|
153
|
+
export function recordTouched(projectDir, relPath, docs) {
|
|
154
|
+
const dir = stateDir(projectDir);
|
|
155
|
+
const line = `${new Date().toISOString()}\t${relPath}\t${docs.join(",") || "-"}\n`;
|
|
156
|
+
fs.appendFileSync(path.join(dir, "touched.tsv"), line);
|
|
157
|
+
}
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"harness": "code",
|
|
3
3
|
"name": "code",
|
|
4
|
-
"version": "3.5.
|
|
4
|
+
"version": "3.5.11",
|
|
5
5
|
"files": [
|
|
6
6
|
".prettierignore",
|
|
7
7
|
".prettierrc.json",
|
|
8
8
|
".vscode/settings.json"
|
|
9
9
|
],
|
|
10
10
|
"seeds": [],
|
|
11
|
-
"digest": "
|
|
11
|
+
"digest": "9074f75982ad"
|
|
12
12
|
}
|
|
@@ -123,6 +123,14 @@ function driftBanner(projectDir) {
|
|
|
123
123
|
const waivers = unexpiredWaivers(queueRows, "dead-glob", today());
|
|
124
124
|
const unwaivedDead = dead.filter((d) => !waivers.some((w) => w.body.includes(d.glob)));
|
|
125
125
|
|
|
126
|
+
// A queued dead-glob row covered by an unexpired waiver is resolved history — drop it,
|
|
127
|
+
// so waiving a finding class actually empties the rows it supersedes.
|
|
128
|
+
resolveRows(projectDir, (row) => {
|
|
129
|
+
if (row.kind !== "auto" || !row.body.includes("dead-glob")) return false;
|
|
130
|
+
const m = /`([^`]+)`/.exec(row.body);
|
|
131
|
+
return !!m && waivers.some((w) => w.body.includes(`\`${m[1]}\``));
|
|
132
|
+
});
|
|
133
|
+
|
|
126
134
|
// Docs older than the code they own (doc mtime < newest owned-file mtime).
|
|
127
135
|
const staleDocs = [];
|
|
128
136
|
for (const entry of scanFeatureDocs(projectDir)) {
|
|
@@ -313,7 +321,9 @@ function detectorForFile(projectDir, rel, meta, rows) {
|
|
|
313
321
|
|
|
314
322
|
function detectRenames(projectDir, command, meta, rows) {
|
|
315
323
|
if (!/\bgit mv\b|\bmv\b/.test(command || "")) return;
|
|
324
|
+
const waivers = unexpiredWaivers(readQueue(projectDir), "dead-glob", today());
|
|
316
325
|
for (const { glob, doc } of deadGlobs(projectDir)) {
|
|
326
|
+
if (waivers.some((w) => w.body.includes(`\`${glob}\``))) continue;
|
|
317
327
|
rows.push(
|
|
318
328
|
formatAutoRow({
|
|
319
329
|
...meta,
|
|
@@ -178,7 +178,7 @@ function approvedPackages(projectDir) {
|
|
|
178
178
|
}
|
|
179
179
|
|
|
180
180
|
function packageGate(projectDir, command) {
|
|
181
|
-
const m =
|
|
181
|
+
const m = new RegExp(String.raw`(?:^|&&|;|\|)\s*(?:sudo\s+)?(?:pnpm|npm|yarn|bun)\s+(?:add|install|i)\s+(.+)`).exec(command);
|
|
182
182
|
if (!m) return null;
|
|
183
183
|
const approved = approvedPackages(projectDir);
|
|
184
184
|
if (approved.size === 0) return null; // no rules file installed — nothing to enforce
|
|
@@ -196,6 +196,22 @@ function packageGate(projectDir, command) {
|
|
|
196
196
|
return null;
|
|
197
197
|
}
|
|
198
198
|
|
|
199
|
+
// The repo a Bash command acts on: an explicit `cd <dir>` in the command wins, then the
|
|
200
|
+
// payload's cwd, then the session's home project — a session may operate across repos
|
|
201
|
+
// (forge + consumer), and gating a commit against the wrong repo blocks or passes falsely.
|
|
202
|
+
function commandRoot(projectDir, payload, command) {
|
|
203
|
+
const cdMatch = /(?:^|&&|;)\s*cd\s+"?([^"\s;&]+)"?/.exec(command || "");
|
|
204
|
+
for (const candidate of [cdMatch && cdMatch[1], payload.cwd]) {
|
|
205
|
+
if (!candidate || !path.isAbsolute(candidate)) continue;
|
|
206
|
+
try {
|
|
207
|
+
if (fs.statSync(candidate).isDirectory()) return candidate;
|
|
208
|
+
} catch (e) {
|
|
209
|
+
/* fall through */
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return projectDir;
|
|
213
|
+
}
|
|
214
|
+
|
|
199
215
|
// An ask class fires once per session: the first occurrence asks, later ones pass silently —
|
|
200
216
|
// the user has already exercised judgment on this class this session.
|
|
201
217
|
function askOnce(projectDir, sessionId, key, message) {
|
|
@@ -204,14 +220,20 @@ function askOnce(projectDir, sessionId, key, message) {
|
|
|
204
220
|
return decision("ask", message);
|
|
205
221
|
}
|
|
206
222
|
|
|
223
|
+
// Command matchers anchor at statement boundaries (start, &&, ;, |) so quoted text — an
|
|
224
|
+
// echoed payload, a commit message naming a command — can never trigger a gate.
|
|
225
|
+
const AT_STATEMENT = String.raw`(?:^|&&|;|\|)\s*(?:sudo\s+)?(?:npx\s+(?:--yes\s+)?)?`;
|
|
226
|
+
const statement = (body) => new RegExp(AT_STATEMENT + body);
|
|
227
|
+
|
|
207
228
|
function bashGate(projectDir, command, sessionId) {
|
|
208
229
|
if (!command) return null;
|
|
209
230
|
|
|
210
|
-
if (
|
|
231
|
+
if (statement(String.raw`git\s+commit\b`).test(command)) {
|
|
211
232
|
return commitGate(projectDir);
|
|
212
233
|
}
|
|
213
234
|
|
|
214
|
-
|
|
235
|
+
// `git branch <name>` creates; flag-led forms (--show-current, --list, -d, -vv) query or manage
|
|
236
|
+
if (statement(String.raw`git\s+(checkout\s+-b|switch\s+-c|branch\s+(?!-)\S+|worktree\s+add)`).test(command)) {
|
|
215
237
|
return askOnce(
|
|
216
238
|
projectDir,
|
|
217
239
|
sessionId,
|
|
@@ -221,7 +243,7 @@ function bashGate(projectDir, command, sessionId) {
|
|
|
221
243
|
}
|
|
222
244
|
|
|
223
245
|
if (
|
|
224
|
-
|
|
246
|
+
statement(String.raw`wrangler(?:@\S+)?\b.*(\bd1\s+execute\b.*--remote|\bsecret\s+(put|bulk)\b|\bdeploy\b)`).test(command) &&
|
|
225
247
|
!/--dry-run\b|--local\b/.test(command)
|
|
226
248
|
) {
|
|
227
249
|
return askOnce(
|
|
@@ -269,12 +291,17 @@ function plansCompletedThisSession(projectDir) {
|
|
|
269
291
|
function stopGate(projectDir, payload) {
|
|
270
292
|
if (payload.stop_hook_active) return null; // the runtime's infinite-loop guard — never re-block
|
|
271
293
|
|
|
272
|
-
|
|
294
|
+
// The Stop wall is scoped to this session's own rows (plus sid-less ones) — a concurrent
|
|
295
|
+
// session's open work must not block this one's stop. The commit wall stays global.
|
|
296
|
+
const mine = readQueue(projectDir).filter(
|
|
297
|
+
(row) => !/\bsid=/.test(row.body) || row.body.includes(`sid=${payload.session_id}`)
|
|
298
|
+
);
|
|
299
|
+
const counts = countRows(mine);
|
|
273
300
|
if (counts.unresolved > 0 && !bookkeeperDispatched(payload.transcript_path)) {
|
|
274
301
|
return {
|
|
275
302
|
decision: "block",
|
|
276
303
|
reason:
|
|
277
|
-
`${counts.unresolved} unresolved bookkeeping rows (${counts.auto} auto, ${counts.note} note, ${counts.needsSession} needs-session) in docs/operations/bookkeeping.md. ` +
|
|
304
|
+
`${counts.unresolved} unresolved bookkeeping rows from this session (${counts.auto} auto, ${counts.note} note, ${counts.needsSession} needs-session) in docs/operations/bookkeeping.md. ` +
|
|
278
305
|
"Dispatch the `bookkeeper` agent to drain them, resolve them now, or convert to `waived:` rows with reason + expiry. " +
|
|
279
306
|
"If implementation of the open plan is complete, also report it and offer the verification gate — never run it unbidden.",
|
|
280
307
|
};
|
|
@@ -311,7 +338,7 @@ function main() {
|
|
|
311
338
|
if (event === "PreToolUse") {
|
|
312
339
|
const input = payload.tool_input || {};
|
|
313
340
|
if (payload.tool_name === "Bash") {
|
|
314
|
-
out = bashGate(projectDir, input.command, payload.session_id);
|
|
341
|
+
out = bashGate(commandRoot(projectDir, payload, input.command), input.command, payload.session_id);
|
|
315
342
|
} else {
|
|
316
343
|
const rel = relIfInside(projectDir, input.file_path || input.notebook_path);
|
|
317
344
|
if (rel) out = fileGate(projectDir, rel, payload.tool_name);
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// harness/code/hooks/lib/state.mjs — the bookkeeping queue (docs/operations/bookkeeping.md) and the
|
|
2
2
|
// machine-local state dir (.claude/state/). The row grammar is the contract between the awareness
|
|
3
3
|
// detector (writer), the bookkeeper agent (drain), the compliance gates (walls), and CI (backstop):
|
|
4
|
-
// - auto: {YYYY-MM-DD} · {finding-kind} · {description} → {action} | sid={session_id}
|
|
5
|
-
// - note: {YYYY-MM-DD} · {area} · {context} → lands-in: {owning doc | backlog | memory} | sid=…
|
|
4
|
+
// - auto: {YYYY-MM-DD} · {finding-kind} · {description} → {action} | sid={session_id}
|
|
5
|
+
// - note: {YYYY-MM-DD} · {area} · {context} → lands-in: {owning doc | backlog | memory} | sid=…
|
|
6
6
|
// - needs-session: {YYYY-MM-DD} · {what could not be grounded} → main session resolves | refs={ts}
|
|
7
7
|
// - waived: {YYYY-MM-DD} · {what is deferred} · reason: {why} → expires={YYYY-MM-DD}
|
|
8
8
|
// Unresolved = auto + note + needs-session. waived: rows are resolved history until expires= passes.
|
|
@@ -57,9 +57,10 @@ export function countRows(rows) {
|
|
|
57
57
|
return c;
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
-
// Format one auto: row per the grammar; sid
|
|
61
|
-
|
|
62
|
-
|
|
60
|
+
// Format one auto: row per the grammar; the sid tail is the only provenance kept —
|
|
61
|
+
// a transcript path or timestamp is machine noise in a PR-reviewed queue.
|
|
62
|
+
export function formatAutoRow({ date, kind, description, action, sessionId }) {
|
|
63
|
+
return `- auto: ${date} · ${kind} · ${description} → ${action} | sid=${sessionId || "-"}`;
|
|
63
64
|
}
|
|
64
65
|
|
|
65
66
|
// Append rows to the queue, creating the file with its genre header when absent.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nurix/etna",
|
|
3
|
-
"version": "0.19.
|
|
3
|
+
"version": "0.19.46",
|
|
4
4
|
"description": "Bootstrap a project with a Nurix harness — copies the harness's CLAUDE.md, skills, agents, rules, and hooks directly into the project from a bundled payload (no marketplace fetch, no network).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
"node": ">=18.0.0"
|
|
20
20
|
},
|
|
21
21
|
"scripts": {
|
|
22
|
+
"test": "node --test 'tests/**/*.test.mjs'",
|
|
22
23
|
"build:data": "node scripts/build-data.js",
|
|
23
24
|
"prepack": "node scripts/build-data.js",
|
|
24
25
|
"validate:codex-agents": "node lib/codex/validate-codex-agents.mjs"
|
|
@@ -41,5 +42,5 @@
|
|
|
41
42
|
"agent",
|
|
42
43
|
"plugin"
|
|
43
44
|
],
|
|
44
|
-
"etnaFingerprint": "
|
|
45
|
+
"etnaFingerprint": "efebb48c460d"
|
|
45
46
|
}
|
|
@@ -1,148 +0,0 @@
|
|
|
1
|
-
// harness/apollo/hooks/generate-ownership-index.mjs — SessionStart hook: scan docs/features/**/*.md,
|
|
2
|
-
// extract the focal frontmatter (owns / module / keywords / status), and inject a compact
|
|
3
|
-
// code-path → owning-doc index via additionalContext. Fires on startup/resume/clear/compact so the
|
|
4
|
-
// map survives /clear and /compact and is regenerated fresh every time. Writes no file.
|
|
5
|
-
|
|
6
|
-
import fs from "node:fs";
|
|
7
|
-
import path from "node:path";
|
|
8
|
-
|
|
9
|
-
// Read all of stdin synchronously; returns "" if nothing is piped.
|
|
10
|
-
function readStdin() {
|
|
11
|
-
try {
|
|
12
|
-
return fs.readFileSync(0, "utf8");
|
|
13
|
-
} catch (e) {
|
|
14
|
-
return "";
|
|
15
|
-
}
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
// Resolve the project root: CLAUDE_PROJECT_DIR env, else payload cwd, else process cwd.
|
|
19
|
-
function resolveProjectDir(payload) {
|
|
20
|
-
return process.env.CLAUDE_PROJECT_DIR || (payload && payload.cwd) || process.cwd();
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
// Recursively collect every .md file under dir (returns [] if the dir is absent).
|
|
24
|
-
function listMarkdown(dir) {
|
|
25
|
-
const out = [];
|
|
26
|
-
let entries;
|
|
27
|
-
try {
|
|
28
|
-
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
29
|
-
} catch (e) {
|
|
30
|
-
return out;
|
|
31
|
-
}
|
|
32
|
-
for (const entry of entries) {
|
|
33
|
-
const full = path.join(dir, entry.name);
|
|
34
|
-
if (entry.isDirectory()) out.push(...listMarkdown(full));
|
|
35
|
-
else if (entry.isFile() && entry.name.endsWith(".md")) out.push(full);
|
|
36
|
-
}
|
|
37
|
-
return out;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
// Pull the leading `--- … ---` frontmatter block; "" if the file has none.
|
|
41
|
-
function frontmatter(text) {
|
|
42
|
-
const m = /^?---\r?\n([\s\S]*?)\r?\n---/.exec(text);
|
|
43
|
-
return m ? m[1] : "";
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
// Trim surrounding whitespace, then strip one layer of matching quotes.
|
|
47
|
-
function unquote(s) {
|
|
48
|
-
return String(s).trim().replace(/^["']|["']$/g, "").trim();
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
// Read one frontmatter key: scalar (`k: v`), inline array (`k: [a, b]`), or a following `- item` block.
|
|
52
|
-
// Returns a string for scalars, an array for lists, or undefined when the key is absent.
|
|
53
|
-
function field(block, key) {
|
|
54
|
-
const lines = block.split(/\r?\n/);
|
|
55
|
-
const head = new RegExp(`^${key}\\s*:\\s*(.*)$`);
|
|
56
|
-
for (let i = 0; i < lines.length; i++) {
|
|
57
|
-
const m = head.exec(lines[i]);
|
|
58
|
-
if (!m) continue;
|
|
59
|
-
const raw = m[1].trim();
|
|
60
|
-
if (raw === "") {
|
|
61
|
-
const items = [];
|
|
62
|
-
for (let j = i + 1; j < lines.length; j++) {
|
|
63
|
-
const li = /^\s*-\s+(.*)$/.exec(lines[j]);
|
|
64
|
-
if (!li) break;
|
|
65
|
-
items.push(unquote(li[1]));
|
|
66
|
-
}
|
|
67
|
-
return items;
|
|
68
|
-
}
|
|
69
|
-
if (raw.startsWith("[")) {
|
|
70
|
-
return raw.replace(/^\[|\]$/g, "").split(",").map((s) => unquote(s)).filter(Boolean);
|
|
71
|
-
}
|
|
72
|
-
return unquote(raw);
|
|
73
|
-
}
|
|
74
|
-
return undefined;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
// Coerce a field that may be scalar/array/undefined into a clean string array.
|
|
78
|
-
function asList(v) {
|
|
79
|
-
if (v == null) return [];
|
|
80
|
-
return (Array.isArray(v) ? v : [v]).map((s) => String(s).trim()).filter(Boolean);
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
// Build the ownership index text from every feature doc's frontmatter.
|
|
84
|
-
function buildIndex(projectDir) {
|
|
85
|
-
const featuresDir = path.join(projectDir, "docs", "features");
|
|
86
|
-
const files = listMarkdown(featuresDir);
|
|
87
|
-
const lines = [];
|
|
88
|
-
let deprecated = 0;
|
|
89
|
-
for (const file of files) {
|
|
90
|
-
let text;
|
|
91
|
-
try {
|
|
92
|
-
text = fs.readFileSync(file, "utf8");
|
|
93
|
-
} catch (e) {
|
|
94
|
-
continue;
|
|
95
|
-
}
|
|
96
|
-
const block = frontmatter(text);
|
|
97
|
-
if (!block) continue;
|
|
98
|
-
const owns = asList(field(block, "owns"));
|
|
99
|
-
if (owns.length === 0) continue;
|
|
100
|
-
const status = (field(block, "status") || "active").toString().toLowerCase();
|
|
101
|
-
if (status === "deprecated") {
|
|
102
|
-
deprecated += 1;
|
|
103
|
-
continue; // keep deprecated docs out of the routing lines
|
|
104
|
-
}
|
|
105
|
-
const moduleLabel = (field(block, "module") || "—").toString();
|
|
106
|
-
const keywords = asList(field(block, "keywords"));
|
|
107
|
-
const doc = path.relative(projectDir, file).split(path.sep).join("/");
|
|
108
|
-
const kw = keywords.length ? `[${keywords.join(", ")}]` : "[]";
|
|
109
|
-
for (const glob of owns) {
|
|
110
|
-
lines.push(`${glob} → ${doc} · ${moduleLabel} · ${kw} · ${status}`);
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
if (lines.length === 0) return "";
|
|
114
|
-
lines.sort();
|
|
115
|
-
const header =
|
|
116
|
-
"# Ownership index (generated) — code-path glob → owning feature doc · module · keywords · status\n" +
|
|
117
|
-
"# Match the file or topic you're about to touch, then read the owning doc before changing it.\n";
|
|
118
|
-
const footer = deprecated > 0 ? `\n# (${deprecated} deprecated feature doc(s) omitted)` : "";
|
|
119
|
-
return header + lines.join("\n") + footer;
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
function main() {
|
|
123
|
-
const raw = readStdin();
|
|
124
|
-
let payload = {};
|
|
125
|
-
try {
|
|
126
|
-
payload = raw ? JSON.parse(raw) : {};
|
|
127
|
-
} catch (e) {
|
|
128
|
-
payload = {};
|
|
129
|
-
}
|
|
130
|
-
const index = buildIndex(resolveProjectDir(payload));
|
|
131
|
-
if (!index) return; // no feature docs yet → inject nothing
|
|
132
|
-
process.stdout.write(
|
|
133
|
-
JSON.stringify({
|
|
134
|
-
hookSpecificOutput: {
|
|
135
|
-
hookEventName: "SessionStart",
|
|
136
|
-
additionalContext: index,
|
|
137
|
-
},
|
|
138
|
-
})
|
|
139
|
-
);
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
try {
|
|
143
|
-
main();
|
|
144
|
-
} catch (e) {
|
|
145
|
-
// Best-effort: a routing index is a convenience, never a gate. Inject nothing on failure.
|
|
146
|
-
} finally {
|
|
147
|
-
process.exit(0);
|
|
148
|
-
}
|