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