@nurix/etna 0.19.44 → 0.19.45
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 +370 -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 +353 -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 +156 -0
- package/data/harnesses/code/harness.json +2 -2
- package/data/harnesses/code/hooks/compliance.mjs +26 -5
- package/package.json +2 -2
- package/data/harnesses/apollo/hooks/generate-ownership-index.mjs +0 -148
|
@@ -0,0 +1,156 @@
|
|
|
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} tp={transcript_path} ts={ISO}
|
|
5
|
+
// - note: {YYYY-MM-DD} · {area} · {context} → lands-in: {owning doc | backlog | memory} | sid=… tp=… ts=…
|
|
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; sid/tp/ts come from the hook payload.
|
|
61
|
+
export function formatAutoRow({ date, kind, description, action, sessionId, transcriptPath, ts }) {
|
|
62
|
+
return `- auto: ${date} · ${kind} · ${description} → ${action} | sid=${sessionId || "-"} tp=${transcriptPath || "-"} ts=${ts || "-"}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Append rows to the queue, creating the file with its genre header when absent.
|
|
66
|
+
// Dedupes on the row text before the `| sid=` tail, so a re-fired detector never stacks duplicates.
|
|
67
|
+
export function appendRows(projectDir, rowLines) {
|
|
68
|
+
if (!rowLines || rowLines.length === 0) return 0;
|
|
69
|
+
const file = queueFile(projectDir);
|
|
70
|
+
let text = "";
|
|
71
|
+
try {
|
|
72
|
+
text = fs.readFileSync(file, "utf8");
|
|
73
|
+
} catch (e) {
|
|
74
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
75
|
+
text = QUEUE_HEADER + "\n";
|
|
76
|
+
}
|
|
77
|
+
const key = (line) => line.split("| sid=")[0].trim();
|
|
78
|
+
const existing = new Set(parseQueue(text).map((r) => key(r.raw)));
|
|
79
|
+
const fresh = rowLines.filter((line) => !existing.has(key(line)));
|
|
80
|
+
if (fresh.length === 0) return 0;
|
|
81
|
+
if (!text.endsWith("\n")) text += "\n";
|
|
82
|
+
fs.writeFileSync(file, text + fresh.join("\n") + "\n");
|
|
83
|
+
return fresh.length;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Delete queue rows matching the predicate (used by the detector to auto-resolve
|
|
87
|
+
// rows whose finding the session has since addressed). Returns the count removed.
|
|
88
|
+
export function resolveRows(projectDir, predicate) {
|
|
89
|
+
const file = queueFile(projectDir);
|
|
90
|
+
let text;
|
|
91
|
+
try {
|
|
92
|
+
text = fs.readFileSync(file, "utf8");
|
|
93
|
+
} catch (e) {
|
|
94
|
+
return 0;
|
|
95
|
+
}
|
|
96
|
+
let removed = 0;
|
|
97
|
+
const kept = text.split(/\r?\n/).filter((line) => {
|
|
98
|
+
const m = ROW_RE.exec(line.trim());
|
|
99
|
+
if (!m) return true;
|
|
100
|
+
const row = { kind: m[1], body: m[2].trim(), raw: line };
|
|
101
|
+
if (predicate(row)) {
|
|
102
|
+
removed += 1;
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
return true;
|
|
106
|
+
});
|
|
107
|
+
if (removed > 0) fs.writeFileSync(file, kept.join("\n"));
|
|
108
|
+
return removed;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Unexpired waived rows for a finding kind (the dead-glob allowlist CI honors).
|
|
112
|
+
// A waived row covers a glob when its body names it verbatim.
|
|
113
|
+
export function unexpiredWaivers(rows, kind, today) {
|
|
114
|
+
return rows.filter((row) => {
|
|
115
|
+
if (row.kind !== "waived") return false;
|
|
116
|
+
if (kind && !row.body.includes(kind)) return false;
|
|
117
|
+
const expires = (/expires=(\d{4}-\d{2}-\d{2})/.exec(row.body) || [])[1];
|
|
118
|
+
if (!expires) return false;
|
|
119
|
+
return expires >= today;
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Ensure .claude/state/ exists and is self-ignoring (a `*` .gitignore inside it),
|
|
124
|
+
// so consumers never see machine-local state as untracked noise. Returns the dir.
|
|
125
|
+
export function stateDir(projectDir) {
|
|
126
|
+
const dir = path.join(projectDir, ".claude", "state");
|
|
127
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
128
|
+
const ignore = path.join(dir, ".gitignore");
|
|
129
|
+
if (!fs.existsSync(ignore)) fs.writeFileSync(ignore, "*\n");
|
|
130
|
+
return dir;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Session-scoped ask memory: an `ask` gate fires once per (session, class); after the user has
|
|
134
|
+
// answered it once, repeats in the same session pass silently (the runtime's own permission
|
|
135
|
+
// prompts still apply). Records live in .claude/state/asked.tsv — machine-local, self-ignored.
|
|
136
|
+
export function askedThisSession(projectDir, sessionId, key) {
|
|
137
|
+
if (!sessionId) return false;
|
|
138
|
+
try {
|
|
139
|
+
const text = fs.readFileSync(path.join(projectDir, ".claude", "state", "asked.tsv"), "utf8");
|
|
140
|
+
return text.split("\n").includes(`${sessionId}\t${key}`);
|
|
141
|
+
} catch (e) {
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function recordAsked(projectDir, sessionId, key) {
|
|
147
|
+
if (!sessionId) return;
|
|
148
|
+
fs.appendFileSync(path.join(stateDir(projectDir), "asked.tsv"), `${sessionId}\t${key}\n`);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Append one tab-separated line to .claude/state/touched.tsv (file → owning doc map for the session).
|
|
152
|
+
export function recordTouched(projectDir, relPath, docs) {
|
|
153
|
+
const dir = stateDir(projectDir);
|
|
154
|
+
const line = `${new Date().toISOString()}\t${relPath}\t${docs.join(",") || "-"}\n`;
|
|
155
|
+
fs.appendFileSync(path.join(dir, "touched.tsv"), line);
|
|
156
|
+
}
|
|
@@ -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": "132494301fd0"
|
|
12
12
|
}
|
|
@@ -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,19 @@ 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
|
-
if (
|
|
235
|
+
if (statement(String.raw`git\s+(checkout\s+-b|switch\s+-c|branch\s+\S+|worktree\s+add)\b`).test(command)) {
|
|
215
236
|
return askOnce(
|
|
216
237
|
projectDir,
|
|
217
238
|
sessionId,
|
|
@@ -221,7 +242,7 @@ function bashGate(projectDir, command, sessionId) {
|
|
|
221
242
|
}
|
|
222
243
|
|
|
223
244
|
if (
|
|
224
|
-
|
|
245
|
+
statement(String.raw`wrangler(?:@\S+)?\b.*(\bd1\s+execute\b.*--remote|\bsecret\s+(put|bulk)\b|\bdeploy\b)`).test(command) &&
|
|
225
246
|
!/--dry-run\b|--local\b/.test(command)
|
|
226
247
|
) {
|
|
227
248
|
return askOnce(
|
|
@@ -311,7 +332,7 @@ function main() {
|
|
|
311
332
|
if (event === "PreToolUse") {
|
|
312
333
|
const input = payload.tool_input || {};
|
|
313
334
|
if (payload.tool_name === "Bash") {
|
|
314
|
-
out = bashGate(projectDir, input.command, payload.session_id);
|
|
335
|
+
out = bashGate(commandRoot(projectDir, payload, input.command), input.command, payload.session_id);
|
|
315
336
|
} else {
|
|
316
337
|
const rel = relIfInside(projectDir, input.file_path || input.notebook_path);
|
|
317
338
|
if (rel) out = fileGate(projectDir, rel, payload.tool_name);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nurix/etna",
|
|
3
|
-
"version": "0.19.
|
|
3
|
+
"version": "0.19.45",
|
|
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": {
|
|
@@ -41,5 +41,5 @@
|
|
|
41
41
|
"agent",
|
|
42
42
|
"plugin"
|
|
43
43
|
],
|
|
44
|
-
"etnaFingerprint": "
|
|
44
|
+
"etnaFingerprint": "8a30d3130f63"
|
|
45
45
|
}
|
|
@@ -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
|
-
}
|