@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.
@@ -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.10",
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": "552c88cf99bf"
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 = /\b(?:pnpm|npm|yarn|bun)\s+(?:add|install|i)\s+(.+)/.exec(command);
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 (/\bgit\s+commit\b/.test(command)) {
231
+ if (statement(String.raw`git\s+commit\b`).test(command)) {
211
232
  return commitGate(projectDir);
212
233
  }
213
234
 
214
- if (/\bgit\s+(checkout\s+-b|switch\s+-c|branch\s+\S+|worktree\s+add)\b/.test(command)) {
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
- /\bwrangler\b.*(\bd1\s+execute\b.*--remote|\bsecret\s+(put|bulk)\b|\bdeploy\b)/.test(command) &&
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
- const counts = countRows(readQueue(projectDir));
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} tp={transcript_path} ts={ISO}
5
- // - note: {YYYY-MM-DD} · {area} · {context} → lands-in: {owning doc | backlog | memory} | sid=… tp=… ts=…
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/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 || "-"}`;
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.44",
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": "0140c843638d"
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
- }