@nurix/etna 0.20.47 → 0.20.48

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,208 @@
1
+ // harness/code/hooks/lib/protection-gate.mjs — the Protection walls: frozen records and protected files,
2
+ // on the file tools and on the shell write screen. The one module the fail-closed `protection.mjs`
3
+ // loads, so its import graph stays narrow (frozen, repo, session-log, walls, graph, node built-ins): a
4
+ // partial mount missing a compliance-only module cannot crash the hook that blocks on a crash.
5
+ // `protectionVerdict` is the predicate and writes nothing; `protectionGate` adds the logged deny.
6
+ // Compliance runs the same predicate first and defers when `protectionHandled` (docs/harness/code/hooks.md).
7
+
8
+ import fs from "node:fs";
9
+ import path from "node:path";
10
+ import { frozenStatusAtHead } from "./frozen.mjs";
11
+ import { readSafe, relIfInside, commandRoot, backtickTokens, docsRoot, resolveProjectDir } from "./repo.mjs";
12
+ import { appendEvent } from "./session-log.mjs";
13
+ import { wallEnabled } from "./walls.mjs";
14
+ import { readGraph } from "./graph.mjs";
15
+
16
+ // The per-call ceiling of one git call, and the default when a caller carries no deadline.
17
+ const GIT_TIMEOUT_MS = 5000;
18
+
19
+ // Milliseconds a git call may take: the time left before `deadlineAt`, capped at GIT_TIMEOUT_MS.
20
+ function budgetMs(deadlineAt) {
21
+ if (!Number.isFinite(deadlineAt)) return GIT_TIMEOUT_MS;
22
+ return Math.max(0, Math.min(GIT_TIMEOUT_MS, Math.floor(deadlineAt - Date.now())));
23
+ }
24
+
25
+ export function decision(permissionDecision, reason) {
26
+ return {
27
+ hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision, permissionDecisionReason: reason },
28
+ };
29
+ }
30
+
31
+ // A deny is also an event in the session gate log — the record that makes going silent after a
32
+ // block impossible. The log is a dated per-session record (never drained; bookkeeping stays a
33
+ // queue); the Stop and commit walls block on `denied` lines with no later `resolved` pair.
34
+ export function deny(projectDir, sessionId, gate, target, reason) {
35
+ appendEvent(projectDir, sessionId, "denied", gate, target);
36
+ return decision("deny", reason);
37
+ }
38
+
39
+ // --- the build graph's switch ------------------------------------------------------------
40
+ // A run handed a build graph (docs/harness/code/build-graph.md) may declare `mode.walls`, an
41
+ // allowlist over lib/walls.mjs. Every wall it does not name is off for that session — because
42
+ // the compliance set assumes a session that can dispatch a bookkeeper and a user who can answer
43
+ // an ask, and a generation box has neither: its walls would be unresolvable blocks, not guard
44
+ // rails. No graph, or a graph with no `mode` block, leaves every wall exactly as it was. With no graph
45
+ // declaring one, an exit set (docs/harness/code/exit-check.md) may carry the same block.
46
+ // Read here rather than through lib/graph-store.mjs and lib/exit-store.mjs, which import the state
47
+ // module; the reading is theirs (`wallsAllowlist`, `exitWallsAllowlist`), field for field.
48
+ function graphWalls(projectDir) {
49
+ let text;
50
+ try {
51
+ text = fs.readFileSync(path.join(projectDir, ".claude", "state", "graph.json"), "utf8");
52
+ } catch (e) {
53
+ return null; // absent — dormancy
54
+ }
55
+ const parsed = readGraph(text, projectDir);
56
+ if (!parsed.ok || !parsed.graph.mode) return null; // a refused graph never switches a wall off
57
+ return parsed.graph.mode.walls;
58
+ }
59
+
60
+ function exitWalls(projectDir) {
61
+ try {
62
+ const set = JSON.parse(fs.readFileSync(path.join(projectDir, ".claude", "state", "exit.json"), "utf8"));
63
+ const attempts = set?.attempts;
64
+ const isExitSet = set && set.v === 1 && Array.isArray(set.owed) && Number.isInteger(attempts?.used) && Number.isInteger(attempts?.max);
65
+ return isExitSet && Array.isArray(set?.mode?.walls) ? set.mode.walls : null;
66
+ } catch (e) {
67
+ return null; // absent or unreadable — no allowlist
68
+ }
69
+ }
70
+
71
+ // Keyed by projectDir, so one importer serving two repositories never reads the other's switch.
72
+ const allowlistCache = new Map();
73
+ export function wallOn(projectDir, name) {
74
+ if (!allowlistCache.has(projectDir)) {
75
+ let allowlist;
76
+ try {
77
+ allowlist = graphWalls(projectDir) ?? exitWalls(projectDir);
78
+ } catch (e) {
79
+ allowlist = null; // a broken graph never switches a wall off
80
+ }
81
+ allowlistCache.set(projectDir, allowlist);
82
+ }
83
+ return wallEnabled(allowlistCache.get(projectDir), name);
84
+ }
85
+
86
+ // The protected-list reader: AGENTS.md, the list itself, and every backticked path in
87
+ // <docs>/operations/protected-files.md. Read once per projectDir per process.
88
+ const protectedCache = new Map();
89
+ export function protectedPaths(projectDir) {
90
+ if (protectedCache.has(projectDir)) return protectedCache.get(projectDir);
91
+ const root = docsRoot(projectDir);
92
+ const text = readSafe(path.join(projectDir, root, "operations", "protected-files.md"));
93
+ const list = ["AGENTS.md", `${root}/operations/protected-files.md`, ...backtickTokens(text)];
94
+ protectedCache.set(projectDir, list);
95
+ return list;
96
+ }
97
+
98
+ // A file write arriving by shell hits the same wall the Edit tool does. Only tokens in a
99
+ // WRITING position count — redirect targets, `tee`/`mv`/`cp` destinations, and any path on an
100
+ // in-place editor line (`sed -i`, `perl -i`) — so reading a frozen record into a pipe stays
101
+ // legal. A token that isn't an existing repo path falls through frozenStatus/protected as a
102
+ // no-op, which keeps sed expressions and flags harmless without shell-parsing them.
103
+ export function bashWriteTargets(command) {
104
+ const targets = [];
105
+ let m;
106
+ const redirect = />{1,2}\s*([^\s;|&<>]+)/g;
107
+ while ((m = redirect.exec(command))) targets.push(m[1]);
108
+ const tee = /\btee\s+(?:-\S+\s+)*([^\s;|&<>]+)/g;
109
+ while ((m = tee.exec(command))) targets.push(m[1]);
110
+ const moveCopy = /\b(?:mv|cp)\s+(?:-\S+\s+)*\S+\s+([^\s;|&<>]+)/g;
111
+ while ((m = moveCopy.exec(command))) targets.push(m[1]);
112
+ if (/\b(?:sed|perl)\s+(?:-\S*\s+)*-\S*i/.test(command) || /\bsed\s+--in-place\b/.test(command)) {
113
+ for (const t of command.split(/[\s"']+/)) {
114
+ if (t && !t.startsWith("-") && /[/.]/.test(t)) targets.push(t);
115
+ }
116
+ }
117
+ return targets;
118
+ }
119
+
120
+ // The two walls on one repo-relative path. frozenStatusAtHead, not frozenStatus: a record frozen
121
+ // only in the WORKTREE is a freeze born in the current edit — a plan legitimately flipping to
122
+ // Complete — and denying that walls the completion ceremony itself. A spent budget reads as not
123
+ // frozen: a timeout is never a gate. Returns { wall, rel, message, projectDir } or null.
124
+ export function pathVerdict(projectDir, rel, { deadlineAt, shell = false } = {}) {
125
+ if (wallOn(projectDir, "frozen-record")) {
126
+ const timeoutMs = budgetMs(deadlineAt);
127
+ const frozen = timeoutMs > 0 ? frozenStatusAtHead(projectDir, rel, { timeoutMs }) : { frozen: false };
128
+ if (frozen.frozen) {
129
+ return {
130
+ wall: "frozen-record",
131
+ rel,
132
+ projectDir,
133
+ message: shell
134
+ ? `BLOCKED — frozen record. \`${rel}\` is committed history and is never edited (${frozen.reason}) — a shell write is still a write. A correction is a new dated entry that links back.`
135
+ : `BLOCKED — frozen record. \`${rel}\` is committed history and is never edited (${frozen.reason}). A correction is a new dated entry that links back.`,
136
+ };
137
+ }
138
+ }
139
+ if (wallOn(projectDir, "protected-file") && protectedPaths(projectDir).includes(rel)) {
140
+ return {
141
+ wall: "protected-file",
142
+ rel,
143
+ projectDir,
144
+ message: shell
145
+ ? `BLOCKED — protected file. \`${rel}\` is on the protected list — a shell write is still a write.`
146
+ : `BLOCKED — protected file. \`${rel}\` is on the protected list.`,
147
+ };
148
+ }
149
+ return null;
150
+ }
151
+
152
+ // The two walls on every write target of a shell command run in `root`; the first denial wins.
153
+ export function shellVerdict(root, command, { deadlineAt } = {}) {
154
+ for (const target of bashWriteTargets(command || "")) {
155
+ const rel = relIfInside(root, target);
156
+ if (!rel) continue;
157
+ const verdict = pathVerdict(root, rel, { deadlineAt, shell: true });
158
+ if (verdict) return verdict;
159
+ }
160
+ return null;
161
+ }
162
+
163
+ // The predicate over a PreToolUse payload: { wall, rel, message, projectDir } or null. Writes nothing.
164
+ // A Bash command is judged in the repo it acts on (lib/repo.mjs#commandRoot).
165
+ export function protectionVerdict(projectDir, payload, { deadlineAt } = {}) {
166
+ const input = (payload && payload.tool_input) || {};
167
+ if (payload && payload.tool_name === "Bash") {
168
+ if (!input.command) return null;
169
+ const timeoutMs = budgetMs(deadlineAt);
170
+ const root = timeoutMs > 0 ? commandRoot(projectDir, payload, input.command, { timeoutMs }) : projectDir;
171
+ return shellVerdict(root, input.command, { deadlineAt });
172
+ }
173
+ const rel = relIfInside(projectDir, input.file_path);
174
+ return rel ? pathVerdict(projectDir, rel, { deadlineAt }) : null;
175
+ }
176
+
177
+ // The predicate with the logged deny: the hook output object, or null.
178
+ export function protectionGate(projectDir, payload, { deadlineAt } = {}) {
179
+ const verdict = protectionVerdict(projectDir, payload, { deadlineAt });
180
+ if (!verdict) return null;
181
+ return deny(verdict.projectDir, payload.session_id, verdict.wall, verdict.rel, verdict.message);
182
+ }
183
+
184
+ // The dispatcher protection.mjs calls.
185
+ export function protectionPreToolUse(payload, { deadlineAt } = {}) {
186
+ if (!payload || payload.hook_event_name !== "PreToolUse") return null;
187
+ return protectionGate(resolveProjectDir(payload), payload, { deadlineAt });
188
+ }
189
+
190
+ // True when the project's committed .claude/settings.json registers a PreToolUse handler whose
191
+ // command names .claude/hooks/protection.mjs, and that file exists. A mount rewrites .claude/hooks/**
192
+ // without touching the settings, so either half can be missing; compliance holds both walls then.
193
+ export function protectionHandled(projectDir) {
194
+ if (!fs.existsSync(path.join(projectDir, ".claude", "hooks", "protection.mjs"))) return false;
195
+ let settings;
196
+ try {
197
+ settings = JSON.parse(fs.readFileSync(path.join(projectDir, ".claude", "settings.json"), "utf8"));
198
+ } catch (e) {
199
+ return false;
200
+ }
201
+ const groups = settings?.hooks?.PreToolUse;
202
+ if (!Array.isArray(groups)) return false;
203
+ return groups.some(
204
+ (group) =>
205
+ Array.isArray(group?.hooks) &&
206
+ group.hooks.some((handler) => typeof handler?.command === "string" && handler.command.includes(".claude/hooks/protection.mjs"))
207
+ );
208
+ }
@@ -1,12 +1,12 @@
1
1
  // harness/code/hooks/lib/records-door.mjs — the hooks' side of the records door. A hook never holds a
2
- // credential: it hands rows to `nustack records emit`, which climbs the door's rungs, and when the tool
3
- // is absent or its call fails it appends them to .claude/state/records/<kind>.jsonl, where the next
4
- // session finds them. Never throws. Fuller account: docs/harness/code/hooks.md § lib/records-door.mjs.
2
+ // credential: it hands rows to `nustack records emit` as a bare JSON array on stdin, and the CLI climbs
3
+ // the door's rungs. There is no fallback file: with the tool absent or its call failing, the caller
4
+ // keeps the rows and offers them again (lib/backlog-detector.mjs). Never throws.
5
+ // Fuller account: docs/harness/code/hooks.md § lib/records-door.mjs.
5
6
 
6
7
  import fs from "node:fs";
7
8
  import path from "node:path";
8
9
  import { spawnSync } from "node:child_process";
9
- import { stateDir } from "./state.mjs";
10
10
 
11
11
  // The first executable named `name` on the PATH, or null.
12
12
  export function onPath(name, pathEnv = process.env.PATH || "") {
@@ -22,33 +22,24 @@ export function onPath(name, pathEnv = process.env.PATH || "") {
22
22
  return null;
23
23
  }
24
24
 
25
- function appendLocal(projectDir, kind, rows) {
26
- const dir = path.join(stateDir(projectDir), "records");
27
- fs.mkdirSync(dir, { recursive: true });
28
- const at = new Date().toISOString();
29
- fs.appendFileSync(path.join(dir, `${kind}.jsonl`), rows.map((row) => JSON.stringify({ kind, row, at }) + "\n").join(""));
30
- }
31
-
32
- // Emits rows of one kind: through `nustack records emit --kind <kind> --json` when the tool is on the
33
- // PATH and answers 0, else to the rung-4 file. Returns "door" or "file" (or "none" when nothing landed).
25
+ // Emits rows of one kind through `nustack records emit --kind <kind> --json`, the rows as a bare array
26
+ // on stdin — the shape every published CLI's parseRows accepts. Returns "door" (exit 0), "absent" (no
27
+ // `nustack` on the PATH), "failed" (a non-zero exit, a spawn error or a timeout) or "none" (no rows).
34
28
  export function emitRecord(kind, rows, { projectDir, env = process.env, timeoutMs = 5000 } = {}) {
35
29
  const list = (Array.isArray(rows) ? rows : [rows]).filter(Boolean);
36
30
  if (list.length === 0) return "none";
37
31
  const binary = onPath("nustack", env.PATH || "");
38
- if (binary) {
32
+ if (!binary) return "absent";
33
+ try {
39
34
  const res = spawnSync(binary, ["records", "emit", "--kind", kind, "--json"], {
40
35
  cwd: projectDir,
41
- input: JSON.stringify({ kind, rows: list }),
36
+ input: JSON.stringify(list),
42
37
  encoding: "utf8",
43
38
  env,
44
39
  timeout: timeoutMs,
45
40
  });
46
- if (!res.error && res.status === 0) return "door";
47
- }
48
- try {
49
- appendLocal(projectDir, kind, list);
50
- return "file";
41
+ return !res.error && res.status === 0 ? "door" : "failed";
51
42
  } catch (e) {
52
- return "none";
43
+ return "failed";
53
44
  }
54
45
  }
@@ -1,11 +1,36 @@
1
- // harness/code/hooks/lib/repo.mjs — the shared floor every hook module stands on: safe reads,
2
- // path normalization, git, session ids, and the one definition of "source file". Each of these
3
- // existed as 2–5 diverging private copies before being hoisted here.
1
+ // harness/code/hooks/lib/repo.mjs — the shared floor every hook module stands on: stdin, the project
2
+ // root, the state folder, safe reads, path normalization, git, session ids, and the one definition of
3
+ // "source file". Each of these existed as 2–5 diverging private copies before being hoisted here. It
4
+ // imports nothing but node built-ins, which keeps the Protection hook's import graph narrow.
4
5
 
5
6
  import fs from "node:fs";
6
7
  import path from "node:path";
7
8
  import { spawnSync } from "node:child_process";
8
9
 
10
+ // Read all of stdin synchronously; returns "" if nothing is piped.
11
+ export function readStdin() {
12
+ try {
13
+ return fs.readFileSync(0, "utf8");
14
+ } catch (e) {
15
+ return "";
16
+ }
17
+ }
18
+
19
+ // Resolve the project root: CLAUDE_PROJECT_DIR env, else payload cwd, else process cwd.
20
+ export function resolveProjectDir(payload) {
21
+ return process.env.CLAUDE_PROJECT_DIR || (payload && payload.cwd) || process.cwd();
22
+ }
23
+
24
+ // Ensure .claude/state/ exists and is self-ignoring (a `*` .gitignore inside it),
25
+ // so consumers never see machine-local state as untracked noise. Returns the dir.
26
+ export function stateDir(projectDir) {
27
+ const dir = path.join(projectDir, ".claude", "state");
28
+ fs.mkdirSync(dir, { recursive: true });
29
+ const ignore = path.join(dir, ".gitignore");
30
+ if (!fs.existsSync(ignore)) fs.writeFileSync(ignore, "*\n");
31
+ return dir;
32
+ }
33
+
9
34
  export function readSafe(file) {
10
35
  try {
11
36
  return fs.readFileSync(file, "utf8");
@@ -44,8 +69,8 @@ export function relIfInside(projectDir, filePath) {
44
69
  return rel.split(path.sep).join("/");
45
70
  }
46
71
 
47
- export function git(cwd, args) {
48
- const res = spawnSync("git", args, { cwd, encoding: "utf8", timeout: 5000 });
72
+ export function git(cwd, args, { timeoutMs = 5000 } = {}) {
73
+ const res = spawnSync("git", args, { cwd, encoding: "utf8", timeout: timeoutMs });
49
74
  return res.status === 0 ? (res.stdout || "").trim() : null;
50
75
  }
51
76
 
@@ -75,7 +100,8 @@ export function backtickTokens(text) {
75
100
  // A candidate is normalized to its git toplevel so a subdirectory cwd still resolves to the
76
101
  // repo root. Claim and release both route here — two private copies of this rule once
77
102
  // disagreed, and the divergence leaked stale claims.
78
- export function commandRoot(projectDir, payload, command) {
103
+ // `timeoutMs` bounds the toplevel lookup, for a caller spending a wall-clock budget.
104
+ export function commandRoot(projectDir, payload, command, { timeoutMs = 5000 } = {}) {
79
105
  const cdMatch = /(?:^|\n|&&|;)\s*cd\s+"?([^"\s;&]+)"?/.exec(command || "");
80
106
  for (const candidate of [cdMatch && cdMatch[1], payload && payload.cwd]) {
81
107
  if (!candidate || !path.isAbsolute(candidate)) continue;
@@ -84,7 +110,7 @@ export function commandRoot(projectDir, payload, command) {
84
110
  } catch (e) {
85
111
  continue;
86
112
  }
87
- return git(candidate, ["rev-parse", "--show-toplevel"]) || candidate;
113
+ return git(candidate, ["rev-parse", "--show-toplevel"], { timeoutMs }) || candidate;
88
114
  }
89
115
  return projectDir;
90
116
  }
@@ -9,8 +9,7 @@
9
9
 
10
10
  import fs from "node:fs";
11
11
  import path from "node:path";
12
- import { stateDir } from "./state.mjs";
13
- import { sid8 } from "./repo.mjs";
12
+ import { stateDir, sid8 } from "./repo.mjs";
14
13
 
15
14
  export function sessionLogPath(projectDir, sessionId) {
16
15
  const dir = path.join(stateDir(projectDir), "sessions");
@@ -17,7 +17,7 @@
17
17
  import fs from "node:fs";
18
18
  import path from "node:path";
19
19
  import { globToRegExp } from "./ownership.mjs";
20
- import { docsRoot } from "./repo.mjs";
20
+ import { docsRoot, stateDir } from "./repo.mjs";
21
21
 
22
22
  // Relative to the docs root (lib/repo.mjs#docsRoot) — never a bare "docs/..." literal.
23
23
  export const QUEUE_PATH = ["operations", "bookkeeping.md"];
@@ -326,15 +326,9 @@ export function waiverCovers(rows, kind, subject, today) {
326
326
  return unexpiredWaivers(rows, kind, today).some((w) => w.body.includes(`\`${subject}\``));
327
327
  }
328
328
 
329
- // Ensure .claude/state/ exists and is self-ignoring (a `*` .gitignore inside it),
330
- // so consumers never see machine-local state as untracked noise. Returns the dir.
331
- export function stateDir(projectDir) {
332
- const dir = path.join(projectDir, ".claude", "state");
333
- fs.mkdirSync(dir, { recursive: true });
334
- const ignore = path.join(dir, ".gitignore");
335
- if (!fs.existsSync(ignore)) fs.writeFileSync(ignore, "*\n");
336
- return dir;
337
- }
329
+ // stateDir lives in lib/repo.mjs, so the Protection hook's narrow import graph reaches it without
330
+ // this module; it is re-exported here for every existing importer.
331
+ export { stateDir };
338
332
 
339
333
  // The build graph's two machine-local files. Named here so every reader — the walker's adapters,
340
334
  // the compliance gates, a composer writing a graph before any session has run — spells them once.
@@ -0,0 +1,53 @@
1
+ // harness/code/hooks/protection.mjs — the Protection hook: the fail-closed PreToolUse entry over
2
+ // lib/protection-gate.mjs. It denies an edit or a shell write to a frozen record or a protected file,
3
+ // and it is the one hook registered with `onFailure: "block"`: an exception in the gate, or a module
4
+ // that cannot load, exits 1 after a line in .claude/state/hook-failures.log, and Claude Code then
5
+ // blocks the call. A timeout is never a gate: every git call spends one wall-clock budget, a spent
6
+ // budget allows, and a backstop timer exits 0 at the deadline if an asynchronous wait outlives it.
7
+ // A repository with no git, no protected list or no docs root is no failure: nothing is frozen or
8
+ // protected there. Fuller account: docs/harness/code/hooks.md.
9
+
10
+ import fs from "node:fs";
11
+ import path from "node:path";
12
+
13
+ // The wall-clock budget, under the handler's 10 s timeout so process start fits in the gap.
14
+ const PROTECTION_DEADLINE_MS = 7000;
15
+
16
+ const deadlineAt = Date.now() + PROTECTION_DEADLINE_MS;
17
+ setTimeout(() => process.exit(0), PROTECTION_DEADLINE_MS).unref();
18
+
19
+ async function main() {
20
+ const { protectionPreToolUse } = await import("./lib/protection-gate.mjs");
21
+ let raw = "";
22
+ try {
23
+ raw = fs.readFileSync(0, "utf8");
24
+ } catch (e) {
25
+ raw = "";
26
+ }
27
+ let payload = {};
28
+ try {
29
+ payload = raw ? JSON.parse(raw) : {};
30
+ } catch (e) {
31
+ payload = {}; // no payload names nothing to protect
32
+ }
33
+ const out = protectionPreToolUse(payload, { deadlineAt });
34
+ if (out) process.stdout.write(JSON.stringify(out));
35
+ }
36
+
37
+ let code = 0;
38
+ try {
39
+ await main();
40
+ } catch (e) {
41
+ // Fail closed: a Protection wall that cannot run must not let the write through.
42
+ code = 1;
43
+ try {
44
+ const dir = path.join(process.env.CLAUDE_PROJECT_DIR || process.cwd(), ".claude", "state");
45
+ fs.mkdirSync(dir, { recursive: true });
46
+ fs.appendFileSync(path.join(dir, "hook-failures.log"), `${new Date().toISOString()} protection.mjs ${e?.message || e}\n`);
47
+ } catch (e2) {
48
+ /* nowhere to write — the exit status still blocks */
49
+ }
50
+ process.stderr.write(`protection.mjs failed closed: ${e?.message || e}\n`);
51
+ } finally {
52
+ process.exit(code);
53
+ }
@@ -128,6 +128,8 @@ function freshSettings(bundleDir) {
128
128
  // `.codex/hooks.json` — the Claude hooks.json filtered to type:"command", the only kind Codex
129
129
  // executes, with each command prefixed so `$CLAUDE_PROJECT_DIR` resolves under a runtime that does
130
130
  // not export it. The translation itself is `toCodexCommand`, imported rather than restated.
131
+ // `onFailure` is dropped from every handler: Codex's hook schema has no such field, and the
132
+ // fail-closed claim it carries holds on Claude Code only.
131
133
  function codexHooks(bundleDir) {
132
134
  const hooksJson = join(bundleDir, 'hooks', 'hooks.json');
133
135
  if (!existsSync(hooksJson)) return null;
@@ -143,7 +145,7 @@ function codexHooks(bundleDir) {
143
145
  for (const group of groups) {
144
146
  const hooks = (group.hooks || [])
145
147
  .filter((h) => h.type === 'command')
146
- .map((h) => ({ ...h, command: toCodexCommand(h.command) }));
148
+ .map(({ onFailure, ...h }) => ({ ...h, command: toCodexCommand(h.command) }));
147
149
  if (hooks.length) kept.push({ ...group, hooks });
148
150
  }
149
151
  if (kept.length) out[event] = kept;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nurix/etna",
3
- "version": "0.20.47",
3
+ "version": "0.20.48",
4
4
  "description": "Bootstrap a project with a Nurix harness — writes the committed contract (CLAUDE.md, AGENTS.md, .claude/harness.json, settings.json, the mount hook) and mounts the harness itself, gitignored, from a bundled payload; sessions keep it current from the CDN.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -46,5 +46,5 @@
46
46
  "agent",
47
47
  "plugin"
48
48
  ],
49
- "etnaFingerprint": "7097a106c017"
49
+ "etnaFingerprint": "7050b3489eda"
50
50
  }