@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.
@@ -1,712 +1,33 @@
1
- // harness/code/hooks/compliance.mjs — the gate-always-waivable hook module.
2
- // PreToolUse (Edit|Write|MultiEdit|Bash): deny what is never legitimate (frozen records, protected
3
- // files, plan-gate skips, changelog-less commits, off-catalog packages), ask where intent matters
4
- // (branches, live commands). Stop: block an arc that tries
5
- // to end with unresolved bookkeeping or an unrecorded plan completion. Every gate fails open — a
6
- // crash or unreadable repo allows the action; enforcement is never allowed to become an outage.
1
+ // harness/code/hooks/compliance.mjs — the gate-always-waivable hook module: the thin entry over
2
+ // lib/gates.mjs. PreToolUse (Edit|Write|MultiEdit|Bash) and Stop: read the payload, dispatch, write the
3
+ // decision. Every gate fails open — a crash or unreadable repo allows the action; enforcement is never
4
+ // allowed to become an outage. The one fail-closed exception is Protection (frozen records, protected
5
+ // files), which runs as its own hook, protection.mjs; this module defers to it where it is handled.
7
6
 
8
7
  import fs from "node:fs";
9
8
  import path from "node:path";
10
- import { readStdin, resolveProjectDir } from "./lib/ownership.mjs";
11
- import { readSafe, relIfInside, git, commandRoot, backtickTokens, SOURCE_EXT, docsRoot } from "./lib/repo.mjs";
12
- import { openGatedPlans, inPlanScope } from "./lib/plans.mjs";
13
- import { stagedExempt } from "./lib/exempt.mjs";
14
- import { readQueue, countRows, rowsBlockingCommit, askedThisSession, recordAsked } from "./lib/state.mjs";
15
- import { liveRows } from "./lib/sweep.mjs";
16
- // frozenStatusAtHead, not frozenStatus: a record frozen only in the WORKTREE is a freeze born in the
17
- // current edit — a plan legitimately flipping to Complete — and denying that walls the completion
18
- // ceremony itself, stranding the `Completed:` field the very same edit was meant to fill. Only an edit
19
- // to something HEAD already froze is an evasion. frozen.mjs says exactly this above frozenStatusAtHead;
20
- // this call site disagreed with it until 2026-08-20, when a verification gate flipped Status, was frozen
21
- // mid-ceremony, and could not write its own completion date.
22
- import { frozenStatusAtHead } from "./lib/frozen.mjs";
23
- import { undischargedRows } from "./lib/ledger.mjs";
24
- import { appendEvent, unresolvedDenials, sessionLogPath, malformedEventLines } from "./lib/session-log.mjs";
25
- import { approvedPackages, rejectedPackages } from "./lib/approved.mjs";
26
- import { claimActivity, releaseActivity, recentForeignTouches } from "./lib/claims.mjs";
27
- import { atStop, graphDenial, wallsAllowlist } from "./lib/graph-store.mjs";
28
- import { exitAtStop, exitWallsAllowlist, recordUnreadable } from "./lib/exit-store.mjs";
29
- import { WALLS, wallEnabled } from "./lib/walls.mjs";
30
9
 
31
- // --- the build graph's switch ------------------------------------------------------------
32
- // A run handed a build graph (docs/harness/code/build-graph.md) may declare `mode.walls`, an
33
- // allowlist over lib/walls.mjs. Every wall it does not name is off for that session — because
34
- // the compliance set assumes a session that can dispatch a bookkeeper and a user who can answer
35
- // an ask, and a generation box has neither: its walls would be unresolvable blocks, not guard
36
- // rails. No graph, or a graph with no `mode` block, leaves every wall exactly as it was. With no graph
37
- // declaring one, an exit set (docs/harness/code/exit-check.md) may carry the same block.
38
- let allowlistCache;
39
- function wallOn(projectDir, name) {
40
- if (allowlistCache === undefined) {
41
- try {
42
- allowlistCache = wallsAllowlist(projectDir) ?? exitWallsAllowlist(projectDir);
43
- } catch (e) {
44
- allowlistCache = null; // a broken graph never switches a wall off
45
- }
46
- }
47
- return wallEnabled(allowlistCache, name);
48
- }
49
-
50
- // The graph's own wall: a landed node is written once, and the graph and its state are machinery.
51
- // It answers with decision("deny", …) and NEVER deny() — a steer is not a failure, and a logged
52
- // denial in a generation box would need a `resolved` line nobody is there to write.
53
- function graphGate(projectDir, rel) {
10
+ // The library loads inside the trap, so a gate module that cannot load fails open like any crash.
11
+ async function main() {
12
+ const { compliancePreToolUse, complianceStop } = await import("./lib/gates.mjs");
13
+ let raw = "";
54
14
  try {
55
- const reason = graphDenial(projectDir, rel);
56
- return reason ? decision("deny", reason) : null;
15
+ raw = fs.readFileSync(0, "utf8");
57
16
  } catch (e) {
58
- return null; // the walker never becomes an outage
59
- }
60
- }
61
-
62
- function decision(permissionDecision, reason) {
63
- return {
64
- hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision, permissionDecisionReason: reason },
65
- };
66
- }
67
-
68
- // A deny is also an event in the session gate log — the record that makes going silent after a
69
- // block impossible. The log is a dated per-session record (never drained; bookkeeping stays a
70
- // queue); the Stop and commit walls block on `denied` lines with no later `resolved` pair.
71
- function deny(projectDir, sessionId, gate, target, reason) {
72
- appendEvent(projectDir, sessionId, "denied", gate, target);
73
- return decision("deny", reason);
74
- }
75
-
76
- // The mechanical half of resolution: when the once-denied operation later succeeds through the
77
- // gate, machinery writes `retried-ok` itself — the session hand-writes only the judgment claims
78
- // (`waived → {ref}`, `disclosed → {entry}`).
79
- function autoResolve(projectDir, sessionId, gate, target) {
80
- if (unresolvedDenials(projectDir, sessionId).includes(`${gate} · ${target}`)) {
81
- appendEvent(projectDir, sessionId, "resolved", gate, target, "retried-ok");
82
- }
83
- }
84
-
85
- // ---------------------------------------------------------------------------
86
- // PreToolUse — file gates
87
- // ---------------------------------------------------------------------------
88
-
89
- // Re-read at most once per hook process — the list is consulted per gated path.
90
- let protectedCache = null;
91
- function protectedPaths(projectDir) {
92
- if (protectedCache) return protectedCache;
93
- const root = docsRoot(projectDir);
94
- const text = readSafe(path.join(projectDir, root, "operations", "protected-files.md"));
95
- protectedCache = ["AGENTS.md", `${root}/operations/protected-files.md`, ...backtickTokens(text)];
96
- return protectedCache;
97
- }
98
-
99
- // Names the genre of a file the write would create in a standards folder, or null.
100
- function newStandardDoc(projectDir, rel) {
101
- const match = new RegExp(`^${docsRoot(projectDir)}/architecture/(doctrine|playbooks)/[^/]+\\.md$`).exec(rel);
102
- if (!match || /\/Readme\.md$/i.test(rel) || fs.existsSync(path.join(projectDir, rel))) return null;
103
- return match[1] === "doctrine" ? "doctrine" : "playbook";
104
- }
105
-
106
- function fileGate(projectDir, rel, sessionId) {
107
- const frozen = wallOn(projectDir, "frozen-record") ? frozenStatusAtHead(projectDir, rel) : { frozen: false };
108
- if (frozen.frozen) {
109
- return deny(
110
- projectDir,
111
- sessionId,
112
- "frozen-record",
113
- rel,
114
- `BLOCKED — frozen record. \`${rel}\` is committed history and is never edited (${frozen.reason}). A correction is a new dated entry that links back.`
115
- );
116
- }
117
-
118
- if (wallOn(projectDir, "protected-file") && protectedPaths(projectDir).includes(rel)) {
119
- return deny(projectDir, sessionId, "protected-file", rel, `BLOCKED — protected file. \`${rel}\` is on the protected list.`);
120
- }
121
-
122
- // A doctrine or playbook document is a standard every product reads, so a new one waits for the
123
- // user (the documentation skill § Doctrine and playbooks); an edit to an existing one passes.
124
- const standard = newStandardDoc(projectDir, rel);
125
- if (standard && wallOn(projectDir, "new-standard-doc")) {
126
- const asked = askOnce(
127
- projectDir,
128
- sessionId,
129
- "new-standard-doc:" + rel,
130
- `\`${rel}\` would be a new ${standard} document. A ${standard} is created only with the user's approval, after extending an existing one is ruled out: name the existing documents it was checked against and the distinction (the documentation skill § Doctrine and playbooks). Create it?`
131
- );
132
- if (asked) return asked;
133
- }
134
-
135
- // Before the claim ask and outside the plan gate's `inDocs` skip — the graph's own files live
136
- // under .claude/, which that branch deliberately excludes.
137
- const graphWall = graphGate(projectDir, rel);
138
- if (graphWall) return graphWall;
139
-
140
- // The file half of the claim board: a path another LIVE session touched minutes ago belongs to
141
- // someone else right now. Advisory by design — one ask per sibling per session, never a wall —
142
- // this is the presence protocol ("git status archaeology") moved into the gate that fires anyway.
143
- try {
144
- const holder = wallOn(projectDir, "foreign-claim")
145
- ? recentForeignTouches(projectDir, sessionId).find((f) => f.paths.some((p) => p.rel === rel))
146
- : null;
147
- if (holder) {
148
- const recent = holder.paths.find((p) => p.rel === rel);
149
- appendEvent(projectDir, sessionId, "coord", "file-claim", rel, `touched ${recent.ageMin}m ago by sid=${holder.sid8}`);
150
- const asked = askOnce(
151
- projectDir,
152
- sessionId,
153
- `foreign-claim:${holder.sid8}`,
154
- `Another live session (sid=${holder.sid8}, active ${holder.lastActiveMin}m ago) touched \`${rel}\` ${recent.ageMin}m ago and may still be working there. Edit anyway? (Asked once per session for that sibling; scope commits with explicit pathspecs either way.)`
155
- );
156
- if (asked) return asked;
157
- }
158
- } catch (e) {
159
- /* presence is a convenience — never let it block an edit on failure */
160
- }
161
-
162
- const inDocs = rel.startsWith(`${docsRoot(projectDir)}/`) || rel.startsWith(".claude/") || rel.endsWith(".md");
163
- if (!inDocs && SOURCE_EXT.test(rel) && wallOn(projectDir, "plan-gate")) {
164
- for (const plan of openGatedPlans(projectDir)) {
165
- // The gate binds the plan's own paths, so disjoint plans run in parallel.
166
- // A scopeless plan stays repo-wide — fail closed.
167
- if (plan.scope.length && !inPlanScope(rel, plan.scope)) continue;
168
- // Only a passed verdict opens the gate: `pending` has no verdict yet, and `blocked` is a verdict against coding.
169
- const boundary = /Boundary:\s*(\w+)/i.exec(plan.gates);
170
- if (boundary && !/^passed$/i.test(boundary[1])) {
171
- return deny(
172
- projectDir,
173
- sessionId,
174
- "plan-gate",
175
- plan.file,
176
- `BLOCKED — plan gate. \`${plan.file}\` is open but its Gates line reads 'Boundary: ${boundary[1]}'. ${
177
- /^blocked$/i.test(boundary[1])
178
- ? "Fold the `boundary` skill's amendments into the plan and re-run it until it records `passed`."
179
- : "Run the `boundary` skill and record its verdict in the plan before implementation edits."
180
- }`
181
- );
182
- }
183
- if (!plan.hasLedger) {
184
- return deny(
185
- projectDir,
186
- sessionId,
187
- "plan-gate",
188
- plan.file,
189
- `BLOCKED — plan gate. \`${plan.file}\` has no \`## Test Obligations\` section. Run the \`testing\` skill (plan-gate mode); its ledger is what the verification gate discharges.`
190
- );
191
- }
192
- if (!plan.hasDocLedger) {
193
- return deny(
194
- projectDir,
195
- sessionId,
196
- "plan-gate",
197
- plan.file,
198
- `BLOCKED — plan gate. \`${plan.file}\` has no \`## Doc Obligations\` section. Resolve the Proposed Changes paths against the ownership index and fill the table yourself — it is a lookup, not a skill run. "None" is a valid row when the plan touches only unowned paths.`
199
- );
200
- }
201
- // The gates pass now — a plan-gate denial recorded earlier is a successful retry.
202
- autoResolve(projectDir, sessionId, "plan-gate", plan.file);
203
- }
204
- }
205
-
206
- // Writing a test file is never an interruption. Deferring tests to the verification gate stays
207
- // doctrine (`testing.md` § During the loop), but it needs no gate here: what the ledger
208
- // actually guarantees is enforced at both ends already — a plan may not take implementation
209
- // edits until its `## Test Obligations` section exists (above), and it may not reach
210
- // `Status: Complete` until the gate discharges every row (the Stop gate). Policing the *moment*
211
- // a file appears adds no guarantee those two do not, and it fires hardest at the gate itself,
212
- // where the ledger's tests are precisely what should be getting written.
213
-
214
- // Doc placement is never an interruption: docs/structure.md is the authority a session
215
- // consults; no hook polices it.
216
- return null;
217
- }
218
-
219
- // ---------------------------------------------------------------------------
220
- // PreToolUse — Bash gates
221
- // ---------------------------------------------------------------------------
222
-
223
- function commitGate(projectDir, sessionId) {
224
- const staged = (git(projectDir, ["diff", "--cached", "--name-only"]) || "")
225
- .split("\n")
226
- .map((s) => s.trim())
227
- .filter(Boolean);
228
- // A rename-only staged set is on the changelog rule's exemption list; the gate can now honor it
229
- // the way CI's caller already does. At least one line, and every line a rename — one edit joins
230
- // the set and the wall returns.
231
- const nameStatus = (git(projectDir, ["diff", "--cached", "--name-status", "-M"]) || "")
232
- .split("\n")
233
- .map((s) => s.trim())
234
- .filter(Boolean);
235
- const allRenames = nameStatus.length > 0 && nameStatus.every((line) => line.startsWith("R"));
236
- const root = docsRoot(projectDir);
237
- if (wallOn(projectDir, "changelog") && staged.length > 0 && !stagedExempt(projectDir, staged) && !allRenames) {
238
- if (!staged.some((f) => f.startsWith(`${root}/history/changelog/`))) {
239
- return deny(
240
- projectDir,
241
- sessionId,
242
- "changelog",
243
- "git commit",
244
- `BLOCKED — changelog. This commit stages no \`${root}/history/changelog/\` entry. Every non-exempt commit carries its entry inside it (exempt: release/bot, harness installs and bumps, lockfile-only, pure renames, changelog housekeeping — state which if claiming one, then stage accordingly or re-run).`
245
- );
246
- }
247
- }
248
- // The bookkeeping wall is scoped to the staged set — the grammar and its fail-closed cases are
249
- // `lib/state.mjs` § What a row implicates. Rows about files this commit does not touch are
250
- // somebody else's, and holding a commit behind rows its session may not drain is a wait with no
251
- // action that clears it. The read goes through `liveRows` first: a gate tests conditions at gate
252
- // time, never a cached record of them — a row whose condition died since the last sweep must not
253
- // wall anything. Pure by design; the detector's next sweep deletes what this filtered out.
254
- const { blocking, unrelated } = wallOn(projectDir, "bookkeeping")
255
- ? rowsBlockingCommit(liveRows(projectDir, readQueue(projectDir)), staged)
256
- : { blocking: [], unrelated: [] };
257
- if (blocking.length > 0) {
258
- const shown = blocking.slice(0, 8);
259
- const lines = shown.map(({ row, hits }) =>
260
- hits.length > 0
261
- ? ` - ${row.kind}: ${row.body}\n ↳ implicates staged ${hits.join(", ")}`
262
- : ` - ${row.kind}: ${row.body}\n ↳ names no staged path — a row like this blocks every commit`
263
- );
264
- if (blocking.length > shown.length) lines.push(` - …and ${blocking.length - shown.length} more`);
265
- return deny(
266
- projectDir,
267
- sessionId,
268
- "bookkeeping",
269
- "git commit",
270
- `BLOCKED — bookkeeping. ${blocking.length} unresolved row(s) in ${root}/operations/bookkeeping.md implicate this commit (each re-verified against the repo just now — these are live):\n${lines.join("\n")}\n` +
271
- "Dispatch the bookkeeper to drain them (never drain auto:/note: rows in your own loop; needs-session: rows are yours), or convert to `waived:` with a reason and expiry; a successful retry re-tests everything again." +
272
- (unrelated > 0
273
- ? ` (${unrelated} further unresolved row(s) implicate nothing staged here — another session's work, and not yours to drain.)`
274
- : "")
275
- );
276
- }
277
-
278
- // All commit gates pass — a changelog/bookkeeping denial recorded earlier is a successful retry.
279
- autoResolve(projectDir, sessionId, "changelog", "git commit");
280
- autoResolve(projectDir, sessionId, "bookkeeping", "git commit");
281
- return null;
282
- }
283
-
284
-
285
- // A token must parse as an npm package spec — name or @scope/name, optional @range tail —
286
- // before it may be judged against the table. Everything else on an install line (redirections
287
- // like 2>&1, chained commands, flags, paths, URLs) is shell, not a package. A non-candidate is
288
- // skipped, never blocked: a miss is caught downstream by the lockfile, while a false deny
289
- // teaches the session the gate is noise exactly where the gate matters most.
290
- const PKG_SPEC = /^(@[a-z0-9~-][\w.-]*\/)?[a-z0-9~-][\w.-]*(@[\w.^~*<>=|x-]+)?$/;
291
-
292
- function packageGate(projectDir, command, sessionId) {
293
- if (!wallOn(projectDir, "package-table")) return null;
294
- const m = new RegExp(
295
- String.raw`(?:^|\n|&&|;|\|)\s*(?:sudo\s+)?(?:pnpm|npm|yarn|bun)\s+(?:add|install|i)(?=\s|$)([^&;|\n]*)`
296
- ).exec(command);
297
- if (!m) return null;
298
- const approved = approvedPackages(projectDir);
299
- if (approved.size === 0) return null; // no rules file installed — nothing to enforce
300
- const rejected = rejectedPackages(projectDir);
301
- const raw = m[1] || "";
302
- // Test packages install at `latest`, outside the table's pin set (.claude/rules/testing.md).
303
- // Two dev-only installs are a decision already taken rather than a new one: a companion in an
304
- // approved scope (`@testing-library/react` is approved, so `-D @testing-library/user-event` is
305
- // not a fresh choice), and `@types/*`, which is the typing of a package already decided. Scoped
306
- // and dev only — an unscoped or runtime install is still judged against the table.
307
- const isDev = /(?:^|\s)(?:-D|--save-dev|--dev)(?:\s|$)/.test(raw);
308
- const approvedScopes = new Set(
309
- [...approved].filter((n) => n.startsWith("@")).map((n) => n.split("/")[0])
310
- );
311
- approvedScopes.add("@types");
312
- const pkgs = raw
313
- .split(/\s+/)
314
- .filter((a) => a && !a.startsWith("-") && PKG_SPEC.test(a))
315
- .map((arg) => (arg.startsWith("@") ? arg.split("@").slice(0, 2).join("@") : arg.split("@")[0]));
316
- for (const pkg of pkgs) {
317
- if (approved.has(pkg)) continue;
318
- if (isDev && pkg.startsWith("@") && approvedScopes.has(pkg.split("/")[0])) continue;
319
- // Approved wins over rejected: `jest` is banned for web and Express and prescribed for Expo,
320
- // so a name in both is a prescription somewhere and must not be denied.
321
- const no = rejected.get(pkg);
322
- if (no) {
323
- const instead = no.use
324
- ? `Use \`${no.use}\`${no.version ? ` ${no.version}` : ""}${no.purpose ? ` — ${no.purpose}` : ""}.`
325
- : `The prescribed choice for your stack is in ${no.rule}.`;
326
- return deny(
327
- projectDir,
328
- sessionId,
329
- "package-table",
330
- pkg,
331
- `BLOCKED — \`${pkg}\` is rejected here. ${instead} Rejections are recorded in .claude/hooks/approved-packages.md; changing one is an explicit user decision.`
332
- );
333
- }
334
- // No decision exists for this package — not approved, not rejected. Allowed, and harvested
335
- // centrally by the fleet package census rather than blocked here.
336
- }
337
- // Every named package passed — a package-table denial for one of them is a successful retry.
338
- for (const pkg of pkgs) autoResolve(projectDir, sessionId, "package-table", pkg);
339
- return null;
340
- }
341
-
342
- // An ask class fires once per session: the first occurrence asks, later ones pass silently —
343
- // the user has already exercised judgment on this class this session.
344
- function askOnce(projectDir, sessionId, key, message) {
345
- if (askedThisSession(projectDir, sessionId, key)) return null;
346
- recordAsked(projectDir, sessionId, key);
347
- return decision("ask", message);
348
- }
349
-
350
- // Command matchers anchor at statement boundaries (start, newline, &&, ;, |) so quoted text — an
351
- // echoed payload, a commit message naming a command — can never trigger a gate, while a command
352
- // on its own line cannot slip past one. (Heredoc bodies still look like statements; that false
353
- // positive is accepted over the false negative.)
354
- const AT_STATEMENT = String.raw`(?:^|\n|&&|;|\|)\s*(?:sudo\s+)?(?:npx\s+(?:--yes\s+)?)?`;
355
- const statement = (body) => new RegExp(AT_STATEMENT + body);
356
-
357
- function bashGate(projectDir, command, sessionId) {
358
- if (!command) return null;
359
-
360
- if (statement(String.raw`git\s+commit\b`).test(command)) {
361
- if (!wallOn(projectDir, "commit-claim")) return commitGate(projectDir, sessionId);
362
- // The claim board serializes the commit sequence: disjoint files do not make disjoint
363
- // commits — every session writes the same branch through the same index, so one committer
364
- // at a time. A held claim is a plain wait (no denied/resolved pair to resolve — the retry
365
- // clears it); a stale claim (holder dead or past TTL) is stolen loudly on the record.
366
- const claim = claimActivity(projectDir, "commit", sessionId);
367
- if (claim.held) {
368
- appendEvent(projectDir, sessionId, "coord", "commit-claim", `waited — held by sid=${claim.held.sid8}`, `${claim.held.ageMin}m old`);
369
- return decision(
370
- "deny",
371
- `WAIT — another session (sid=${claim.held.sid8}, claimed ${claim.held.ageMin}m ago) is mid-commit in this checkout. Re-run the commit in a moment; its completion (or staleness) releases the claim automatically. Do not delete .git/index.lock.`
372
- );
373
- }
374
- if (claim.stole) {
375
- appendEvent(projectDir, sessionId, "coord", "commit-claim", `stale claim by sid=${claim.stole.sid8} evicted`);
376
- }
377
- const gate = commitGate(projectDir, sessionId);
378
- if (gate) {
379
- // The commit is not happening — release immediately so a walled commit never holds the board.
380
- releaseActivity(projectDir, "commit", sessionId);
381
- return gate;
382
- }
383
- return null; // allowed — the awareness PostToolUse releases the claim when the command completes
384
- }
385
-
386
- // `git branch <name>` creates; flag-led forms (--show-current, --list, -d, -vv) query or manage
387
- if (wallOn(projectDir, "branch-worktree") && statement(String.raw`git\s+(checkout\s+-b|switch\s+-c|branch\s+(?!-)\S+|worktree\s+add)`).test(command)) {
388
- return askOnce(
389
- projectDir,
390
- sessionId,
391
- "branch-worktree",
392
- "Create a branch/worktree? Policy allows this only when you asked for a PR or branch."
393
- );
17
+ raw = "";
394
18
  }
395
-
396
- if (
397
- wallOn(projectDir, "live-command") &&
398
- statement(String.raw`wrangler(?:@\S+)?\b.*(\bd1\s+execute\b.*--remote|\bsecret\s+(put|bulk)\b|\bdeploy\b)`).test(command) &&
399
- !/--dry-run\b|--local\b/.test(command)
400
- ) {
401
- return askOnce(
402
- projectDir,
403
- sessionId,
404
- "live-command",
405
- `Run this live-system command (deploy / remote DB / secrets)? If it's a one-shot migration, its runbook should exist in ${docsRoot(projectDir)}/history/migrations/ first. Asked once per session.`
406
- );
407
- }
408
-
409
- const shellWrite = bashWriteScreen(projectDir, command, sessionId);
410
- if (shellWrite) return shellWrite;
411
-
412
- return packageGate(projectDir, command, sessionId);
413
- }
414
-
415
- // A file write arriving by shell hits the same wall the Edit tool does. Only tokens in a
416
- // WRITING position count — redirect targets, `tee`/`mv`/`cp` destinations, and any path on an
417
- // in-place editor line (`sed -i`, `perl -i`) — so reading a frozen record into a pipe stays
418
- // legal. A token that isn't an existing repo path falls through frozenStatus/protected as a
419
- // no-op, which keeps sed expressions and flags harmless without shell-parsing them.
420
- function bashWriteTargets(command) {
421
- const targets = [];
422
- let m;
423
- const redirect = />{1,2}\s*([^\s;|&<>]+)/g;
424
- while ((m = redirect.exec(command))) targets.push(m[1]);
425
- const tee = /\btee\s+(?:-\S+\s+)*([^\s;|&<>]+)/g;
426
- while ((m = tee.exec(command))) targets.push(m[1]);
427
- const moveCopy = /\b(?:mv|cp)\s+(?:-\S+\s+)*\S+\s+([^\s;|&<>]+)/g;
428
- while ((m = moveCopy.exec(command))) targets.push(m[1]);
429
- if (/\b(?:sed|perl)\s+(?:-\S*\s+)*-\S*i/.test(command) || /\bsed\s+--in-place\b/.test(command)) {
430
- for (const t of command.split(/[\s"']+/)) {
431
- if (t && !t.startsWith("-") && /[/.]/.test(t)) targets.push(t);
432
- }
433
- }
434
- return targets;
435
- }
436
-
437
- function bashWriteScreen(projectDir, command, sessionId) {
438
- for (const target of bashWriteTargets(command)) {
439
- const rel = relIfInside(projectDir, target);
440
- if (!rel) continue;
441
- // A shell write is still a write: the build graph's wall runs here too, or `cat > docs/prd.md`
442
- // over a landed node ships green while the Edit tool is refused.
443
- const graphWall = graphGate(projectDir, rel);
444
- if (graphWall) return graphWall;
445
- const frozen = wallOn(projectDir, "frozen-record") ? frozenStatusAtHead(projectDir, rel) : { frozen: false };
446
- if (frozen.frozen) {
447
- return deny(
448
- projectDir,
449
- sessionId,
450
- "frozen-record",
451
- rel,
452
- `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.`
453
- );
454
- }
455
- if (wallOn(projectDir, "protected-file") && protectedPaths(projectDir).includes(rel)) {
456
- return deny(
457
- projectDir,
458
- sessionId,
459
- "protected-file",
460
- rel,
461
- `BLOCKED — protected file. \`${rel}\` is on the protected list — a shell write is still a write.`
462
- );
463
- }
464
- }
465
- return null;
466
- }
467
-
468
- // ---------------------------------------------------------------------------
469
- // Stop
470
- // ---------------------------------------------------------------------------
471
-
472
- // True when the transcript's recent tail shows a REAL bookkeeper dispatch — a tool record naming
473
- // the agent type, not the word appearing in prose (policy text quoting "bookkeeper" rides in most
474
- // transcripts, and a prose match silently suppressed this wall). Best-effort: absent or
475
- // unreadable transcript reads as "not dispatched".
476
- function bookkeeperDispatched(transcriptPath) {
477
- const tail = readSafe(transcriptPath || "").slice(-200000);
478
- return /"subagent_type"\s*:\s*"bookkeeper"/.test(tail);
479
- }
480
-
481
- // Plans this session flipped to Complete: Status is Complete AND the file appears in this
482
- // machine's touched.tsv (written by the awareness detector as edits happen).
483
- function plansCompletedThisSession(projectDir, sessionId) {
484
- const touched = readSafe(path.join(projectDir, ".claude", "state", "touched.tsv"));
485
- const touchedPlans = new Set(
486
- touched
487
- .split("\n")
488
- .map((line) => line.split("\t"))
489
- // THIS session's rows only. touched.tsv is shared by every session in the checkout, so an
490
- // unfiltered read blocks one session's Stop for a plan another session completed — whose Ask
491
- // quotes and iterations it does not have and cannot invent. Rows written before the id column
492
- // existed carry no session and belong to none, so they are skipped rather than assumed.
493
- .filter((cols) => sessionId && cols[3] === sessionId)
494
- .map((cols) => cols[1])
495
- .filter((p) => p && p.startsWith(`${docsRoot(projectDir)}/history/plans/`))
496
- );
497
- const out = [];
498
- for (const rel of touchedPlans) {
499
- const text = readSafe(path.join(projectDir, rel));
500
- if (/^\*\*Status:\*\*\s*Complete\b/m.test(text)) out.push(rel);
501
- }
502
- return out;
503
- }
504
-
505
- // Whether some changelog entry already accounts for this plan. An entry names its plan by slug
506
- // (the template links `../plans/<slug>.md`), so the slug is what is searched for — over every
507
- // entry on disk, committed or draft. A dirty-but-unrelated file in the changelog directory does
508
- // NOT count: that shortcut made the wall satisfiable by `touch docs/history/changelog/x.md`,
509
- // for every plan at once.
510
- function planHasChangelogEntry(projectDir, planRel) {
511
- const slug = path.basename(planRel, ".md");
512
- const dir = path.join(projectDir, docsRoot(projectDir), "history", "changelog");
513
- let names = [];
514
- try {
515
- names = fs.readdirSync(dir);
516
- } catch {
517
- return false;
518
- }
519
- return names.some((name) => name.endsWith(".md") && readSafe(path.join(dir, name)).includes(slug));
520
- }
521
-
522
- // The Stop half of the build-graph walker. Blocks with the next injection line while the graph
523
- // can still advance, allows the moment it is terminal, and answers null when no graph exists —
524
- // which is every session in every repo that was never handed one.
525
- function graphStop(projectDir, stopHookActive) {
526
- let step;
527
- try {
528
- step = atStop(projectDir);
529
- } catch (e) {
530
- return null; // the walker never becomes an outage
531
- }
532
- if (!step) return null;
533
- if (step.refusal) {
534
- // A refusal the model cannot repair (the wall denies writes to the graph) must not become an
535
- // unbreakable Stop loop: say it once, loudly, and let the second Stop through. Fixing the
536
- // graph is the run owner's job, not the model's — so this branch, alone in this function,
537
- // honours the runtime's loop guard.
538
- if (stopHookActive) return null;
539
- return {
540
- decision: "block",
541
- reason:
542
- `BUILD GRAPH REFUSED — \`.claude/state/graph.json\` exists but ${step.refusal}. Nothing is being steered. ` +
543
- "Fix the graph or remove it: the walker is dormant only when that file is ABSENT, never when it is wrong. (Said once — the next Stop passes.)",
544
- };
545
- }
546
- if (!step.line) return null;
547
- return {
548
- decision: "block",
549
- reason:
550
- `${step.line} The build graph decides this run's order and completion — ${step.terminal.summary} ` +
551
- "Write the file the line names; the run ends by itself when the graph is complete, blocked, or out of attempts.",
552
- };
553
- }
554
-
555
- // The exit check (docs/harness/code/exit-check.md): authority, so a crash blocks rather than passes. It
556
- // blocks once — the box walls `.claude/state`, so the model cannot repair the file — and the second stop
557
- // records `blocked`, so the node still ends failed instead of met.
558
- function exitStop(projectDir, stopHookActive) {
559
- let step;
560
- try {
561
- step = exitAtStop(projectDir);
562
- } catch (e) {
563
- const reason = `EXIT CHECK FAILED — ${e?.message || e}`;
564
- if (!stopHookActive) {
565
- return { decision: "block", reason: `${reason}. This node's exit set cannot be read, so it is not finished. (Said once — the next Stop ends the node blocked.)` };
566
- }
567
- try {
568
- recordUnreadable(projectDir, reason);
569
- } catch (e2) {
570
- /* nowhere to record it; the box seals without a verdict and the run owner's own check decides */
571
- }
572
- return null;
573
- }
574
- if (!step || step.allow) return null;
575
- return { decision: "block", reason: step.block };
576
- }
577
-
578
- function stopGate(projectDir, payload) {
579
- // The build graph is asked BEFORE the runtime's loop guard, and deliberately ignores it. The
580
- // guard exists because an unbounded Stop hook is a hang; the walker is bounded instead — every
581
- // block charges an attempt on the node it names, so a model that answers "done" spends that
582
- // node's budget and the run ends `exhausted`. Honouring the flag would leave the walker able to
583
- // steer exactly once per turn chain and then go quiet with documents still owed, which is the
584
- // liveness hole the whole design exists to close.
585
- //
586
- // Consequence, and it is deliberate: while a graph is live the first block flips
587
- // `stop_hook_active` for the rest of the chain, so the four sub-walls below are suspended until
588
- // the graph is terminal. A generation box wants exactly that; a human session running a
589
- // hand-written graph should know it.
590
- const graph = graphStop(projectDir, payload.stop_hook_active);
591
- if (graph) return graph;
592
- // The exit check is asked before the loop guard for the walker's reason: it is bounded by its own attempts.
593
- const exit = exitStop(projectDir, payload.stop_hook_active);
594
- if (exit) return exit;
595
-
596
- if (payload.stop_hook_active) return null; // the runtime's infinite-loop guard — never re-block
597
-
598
- // The Stop wall is scoped to this session's own rows (plus sid-less ones) — a concurrent
599
- // session's open work must not block this one's stop. End-of-session accountability is per
600
- // session and stays that way; the commit wall scopes by staged path instead, and CI by nothing.
601
- // Filtered through `liveRows` (sid first — fewer verifier runs): the wall blocks on conditions
602
- // that hold NOW, not on rows the last sweep happened to leave written down.
603
- const mine = liveRows(
604
- projectDir,
605
- readQueue(projectDir).filter((row) => !/\bsid=/.test(row.body) || row.body.includes(`sid=${payload.session_id}`))
606
- );
607
- const counts = countRows(mine);
608
- if (wallOn(projectDir, "queue") && counts.unresolved > 0 && !bookkeeperDispatched(payload.transcript_path)) {
609
- return {
610
- decision: "block",
611
- reason:
612
- `${counts.unresolved} unresolved bookkeeping rows from this session (${counts.auto} auto, ${counts.note} note, ${counts.needsSession} needs-session, ${counts.expiredWaived} expired-waived) in ${docsRoot(projectDir)}/operations/bookkeeping.md. ` +
613
- "Dispatch the `bookkeeper` agent to drain them — inline resolution is for needs-session: rows only — or convert to `waived:` rows with reason + expiry. " +
614
- (counts.expiredWaived > 0
615
- ? "An expired waiver is a deferral that ran out — re-judge whether its reason still holds rather than re-waiving by default. "
616
- : "") +
617
- "If the open plan's code is written, its gate is the build's tail — drive, fix, pin, discharge before stopping; the freeze and the commit are the user's.",
618
- };
619
- }
620
-
621
- // Open gate denials wall the stop: silence after a block is the one ending the log exists to
622
- // make impossible. Resolution appends to the log — retried-ok is machinery-written on a
623
- // successful retry; waived/disclosed are the session's hand-written, auditable claims.
624
- const openDenials = wallOn(projectDir, "open-denials") ? unresolvedDenials(projectDir, payload.session_id) : [];
625
- if (openDenials.length > 0) {
626
- const rel = path.relative(projectDir, sessionLogPath(projectDir, payload.session_id));
627
- // A resolution that doesn't parse reads to the session as "my resolution didn't count"
628
- // with no explanation — name the offending lines and the grammar instead of staying mute.
629
- const bad = malformedEventLines(projectDir, payload.session_id);
630
- const badNote =
631
- bad.length > 0
632
- ? ` NOTE: ${bad.length} line(s) look like events but don't parse — the grammar is \`- HH:MM:SS denied|resolved · gate · target[ · detail]\` (a resolved line missing its timestamp is invisible to the wall): ${bad.join(" | ")}.`
633
- : "";
634
- return {
635
- decision: "block",
636
- reason:
637
- `${openDenials.length} gate denial(s) this session have no resolved line in ${rel}: ${openDenials.join("; ")}. ` +
638
- "For each, either retry the operation properly (a success appends `retried-ok` automatically), convert it to a waiver and append " +
639
- "`resolved · {gate} · {target} · waived → {ref}`, or record the failure honestly in the changelog entry and append " +
640
- "`resolved · {gate} · {target} · disclosed → {entry file}`. Never delete log lines — the log is a record, not a queue." +
641
- badNote,
642
- };
643
- }
644
-
645
- // An undischarged Doc Obligations ledger is not walled here — the detector raises it as a
646
- // `needs-session:` row, which the queue wall above already blocks on, and which outlives this
647
- // stop through the commit gate and CI. A second wall keyed to one checkbox would add a special
648
- // case beside a general rule that already covers it.
649
- const completed = plansCompletedThisSession(projectDir, payload.session_id);
650
-
651
- // The Test Obligations ledger IS walled per row: the Outcome cell lives in the plan file, so the
652
- // only way past this block is reading the file — a context-held copy of the ledger is exactly
653
- // how rows get missed. (Tables without an Outcome column are legacy and pass; see lib/ledger.)
654
- for (const rel of wallOn(projectDir, "ledger") ? completed : []) {
655
- const rows = undischargedRows(readSafe(path.join(projectDir, rel)));
656
- if (rows.length > 0) {
657
- return {
658
- decision: "block",
659
- reason:
660
- `Plan \`${rel}\` is Status: Complete with ${rows.length} Test Obligations row(s) missing a discharge outcome (#${rows.join(", #")}). ` +
661
- "Re-read the ledger from the plan file and discharge every row at the verification gate — satisfied, owes nothing (reason), or blocked → backlog — before completing.",
662
- };
663
- }
664
- }
665
-
666
- const undocumented = wallOn(projectDir, "changelog-entry")
667
- ? completed.filter((rel) => !planHasChangelogEntry(projectDir, rel))
668
- : [];
669
- if (undocumented.length > 0) {
670
- return {
671
- decision: "block",
672
- reason: `Plan \`${undocumented[0]}\` reached Status: Complete this session with no changelog entry drafted. Write the draft now (documentation skill → templates/changelog-entry.md) — the Ask quotes, rejected iterations, and skill verdicts that fill it exist only in this session and die with it.`,
673
- };
674
- }
675
-
676
- return null;
677
- }
678
-
679
- // ---------------------------------------------------------------------------
680
-
681
- function main() {
682
- const raw = readStdin();
683
19
  let payload = {};
684
20
  try {
685
21
  payload = raw ? JSON.parse(raw) : {};
686
22
  } catch (e) {
687
23
  payload = {};
688
24
  }
689
- const projectDir = resolveProjectDir(payload);
690
- const event = payload.hook_event_name;
691
-
692
- let out = null;
693
- if (event === "PreToolUse") {
694
- const input = payload.tool_input || {};
695
- if (payload.tool_name === "Bash") {
696
- out = bashGate(commandRoot(projectDir, payload, input.command), input.command, payload.session_id);
697
- } else {
698
- const rel = relIfInside(projectDir, input.file_path);
699
- if (rel) out = fileGate(projectDir, rel, payload.session_id);
700
- }
701
- } else if (event === "Stop") {
702
- out = stopGate(projectDir, payload);
703
- }
704
-
25
+ const out = payload.hook_event_name === "Stop" ? complianceStop(payload) : compliancePreToolUse(payload);
705
26
  if (out) process.stdout.write(JSON.stringify(out));
706
27
  }
707
28
 
708
29
  try {
709
- main();
30
+ await main();
710
31
  } catch (e) {
711
32
  // Fail open: a broken gate must never become an outage. But a gate that crashes silently is a
712
33
  // gate nobody knows stopped guarding — leave a trace where the next SessionStart can find it.