@nurix/etna 0.19.43 → 0.19.45

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/bin/etna.js +33 -6
  2. package/data/harness-index.json +1 -1
  3. package/data/harnesses/apollo/agents/bookkeeper.md +27 -0
  4. package/data/harnesses/apollo/agents/maintenance.md +3 -1
  5. package/data/harnesses/apollo/harness.json +2 -2
  6. package/data/harnesses/apollo/hooks/awareness.mjs +370 -0
  7. package/data/harnesses/apollo/hooks/check-bookkeeping-clean.mjs +19 -0
  8. package/data/harnesses/apollo/hooks/check-changelog-presence.mjs +48 -0
  9. package/data/harnesses/apollo/hooks/check-completion-integrity.mjs +68 -0
  10. package/data/harnesses/apollo/hooks/check-ownership.mjs +22 -0
  11. package/data/harnesses/apollo/hooks/compliance.mjs +353 -0
  12. package/data/harnesses/apollo/hooks/hooks.json +16 -10
  13. package/data/harnesses/apollo/hooks/lib/frozen.mjs +58 -0
  14. package/data/harnesses/apollo/hooks/lib/ownership.mjs +223 -0
  15. package/data/harnesses/apollo/hooks/lib/state.mjs +156 -0
  16. package/data/harnesses/apollo/skills/boundary/SKILL.md +3 -1
  17. package/data/harnesses/apollo/skills/documentation/SKILL.md +2 -0
  18. package/data/harnesses/apollo/skills/documentation/templates/Readme.md +2 -0
  19. package/data/harnesses/apollo/skills/documentation/templates/bookkeeping.md +18 -0
  20. package/data/harnesses/apollo/skills/documentation/templates/journal.md +13 -0
  21. package/data/harnesses/apollo/skills/documentation/templates/plan.md +3 -0
  22. package/data/harnesses/apollo/skills/test-plan/SKILL.md +2 -0
  23. package/data/harnesses/code/agents/bookkeeper.md +27 -0
  24. package/data/harnesses/code/agents/maintenance.md +3 -1
  25. package/data/harnesses/code/harness.json +2 -2
  26. package/data/harnesses/code/hooks/awareness.mjs +370 -0
  27. package/data/harnesses/code/hooks/check-bookkeeping-clean.mjs +19 -0
  28. package/data/harnesses/code/hooks/check-changelog-presence.mjs +48 -0
  29. package/data/harnesses/code/hooks/check-completion-integrity.mjs +68 -0
  30. package/data/harnesses/code/hooks/check-ownership.mjs +22 -0
  31. package/data/harnesses/code/hooks/compliance.mjs +353 -0
  32. package/data/harnesses/code/hooks/hooks.json +16 -10
  33. package/data/harnesses/code/hooks/lib/frozen.mjs +58 -0
  34. package/data/harnesses/code/hooks/lib/ownership.mjs +223 -0
  35. package/data/harnesses/code/hooks/lib/state.mjs +156 -0
  36. package/data/harnesses/code/skills/boundary/SKILL.md +3 -1
  37. package/data/harnesses/code/skills/documentation/SKILL.md +2 -0
  38. package/data/harnesses/code/skills/documentation/templates/Readme.md +2 -0
  39. package/data/harnesses/code/skills/documentation/templates/bookkeeping.md +18 -0
  40. package/data/harnesses/code/skills/documentation/templates/journal.md +13 -0
  41. package/data/harnesses/code/skills/documentation/templates/plan.md +3 -0
  42. package/data/harnesses/code/skills/test-plan/SKILL.md +2 -0
  43. package/data/harnesses/delivery/agents/maintenance.md +3 -1
  44. package/data/harnesses/delivery/harness.json +2 -2
  45. package/data/harnesses/delivery/skills/documentation/SKILL.md +2 -0
  46. package/data/harnesses/delivery/skills/documentation/templates/Readme.md +2 -0
  47. package/data/harnesses/delivery/skills/documentation/templates/bookkeeping.md +18 -0
  48. package/data/harnesses/delivery/skills/documentation/templates/journal.md +13 -0
  49. package/data/harnesses/delivery/skills/documentation/templates/plan.md +3 -0
  50. package/data/harnesses/etna/agents/maintenance.md +3 -1
  51. package/data/harnesses/etna/harness.json +2 -2
  52. package/data/harnesses/etna/skills/boundary/SKILL.md +3 -1
  53. package/data/harnesses/etna/skills/documentation/SKILL.md +2 -0
  54. package/data/harnesses/etna/skills/documentation/templates/Readme.md +2 -0
  55. package/data/harnesses/etna/skills/documentation/templates/bookkeeping.md +18 -0
  56. package/data/harnesses/etna/skills/documentation/templates/journal.md +13 -0
  57. package/data/harnesses/etna/skills/documentation/templates/plan.md +3 -0
  58. package/package.json +2 -2
  59. package/data/harnesses/apollo/hooks/generate-ownership-index.mjs +0 -148
  60. package/data/harnesses/code/hooks/generate-ownership-index.mjs +0 -148
@@ -0,0 +1,22 @@
1
+ // harness/code/hooks/check-ownership.mjs — CI backstop: every owns: glob must match at least one
2
+ // file, unless an unexpired `waived:` dead-glob row in docs/operations/bookkeeping.md covers it
3
+ // (the waiver names the glob verbatim). Exit 1 on any unwaived dead glob.
4
+
5
+ import { deadGlobs } from "./lib/ownership.mjs";
6
+ import { readQueue, unexpiredWaivers } from "./lib/state.mjs";
7
+
8
+ const projectDir = process.env.CLAUDE_PROJECT_DIR || process.cwd();
9
+ const today = new Date().toISOString().slice(0, 10);
10
+
11
+ const dead = deadGlobs(projectDir);
12
+ const waivers = unexpiredWaivers(readQueue(projectDir), "dead-glob", today);
13
+ const unwaived = dead.filter((d) => !waivers.some((w) => w.body.includes(d.glob)));
14
+
15
+ if (unwaived.length === 0) {
16
+ console.log(`check-ownership: OK (${dead.length} dead glob(s), all waived or none)`);
17
+ process.exit(0);
18
+ }
19
+ console.error(`check-ownership: ${unwaived.length} unwaived dead owns: glob(s):`);
20
+ for (const d of unwaived) console.error(` ${d.glob} → ${d.doc}`);
21
+ console.error("Fix the owning doc's owns: frontmatter, or add a `waived:` dead-glob row with reason + expires=.");
22
+ process.exit(1);
@@ -0,0 +1,353 @@
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, test files mid-plan, off-map doc paths). 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.
7
+
8
+ import fs from "node:fs";
9
+ import path from "node:path";
10
+ import { spawnSync } from "node:child_process";
11
+ import { readStdin, resolveProjectDir, frontmatter } from "./lib/ownership.mjs";
12
+ import { readQueue, countRows, askedThisSession, recordAsked } from "./lib/state.mjs";
13
+ import { frozenStatus } from "./lib/frozen.mjs";
14
+
15
+ function readSafe(file) {
16
+ try {
17
+ return fs.readFileSync(file, "utf8");
18
+ } catch (e) {
19
+ return "";
20
+ }
21
+ }
22
+
23
+ function relIfInside(projectDir, filePath) {
24
+ if (!filePath) return null;
25
+ const rel = path.relative(projectDir, path.resolve(projectDir, filePath));
26
+ if (rel.startsWith("..") || path.isAbsolute(rel)) return null;
27
+ return rel.split(path.sep).join("/");
28
+ }
29
+
30
+ function git(projectDir, args) {
31
+ const res = spawnSync("git", args, { cwd: projectDir, encoding: "utf8", timeout: 5000 });
32
+ return res.status === 0 ? (res.stdout || "").trim() : null;
33
+ }
34
+
35
+ function decision(permissionDecision, reason) {
36
+ return {
37
+ hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision, permissionDecisionReason: reason },
38
+ };
39
+ }
40
+
41
+ // ---------------------------------------------------------------------------
42
+ // PreToolUse — file gates
43
+ // ---------------------------------------------------------------------------
44
+
45
+ function protectedPaths(projectDir) {
46
+ const listFile = path.join(projectDir, "docs", "operations", "protected-files.md");
47
+ const paths = ["AGENTS.md", "docs/operations/protected-files.md"];
48
+ const text = readSafe(listFile);
49
+ for (const m of text.matchAll(/`([^`\n]+)`/g)) {
50
+ const candidate = m[1].trim();
51
+ if (candidate && !candidate.includes(" ")) paths.push(candidate.replace(/^\.\//, ""));
52
+ }
53
+ return paths;
54
+ }
55
+
56
+ // Open plans in the new format only (a `Gates:` line present). Plans without one are
57
+ // legacy and pass every plan gate — otherwise every open plan bricks on harness upgrade.
58
+ function openGatedPlans(projectDir) {
59
+ const dir = path.join(projectDir, "docs", "history", "plans");
60
+ let entries;
61
+ try {
62
+ entries = fs.readdirSync(dir).filter((f) => f.endsWith(".md"));
63
+ } catch (e) {
64
+ return [];
65
+ }
66
+ const plans = [];
67
+ for (const file of entries) {
68
+ const text = readSafe(path.join(dir, file));
69
+ const status = (/^\*\*Status:\*\*\s*(.+)$/m.exec(text) || [])[1]?.trim();
70
+ if (!status || /Complete|Abandoned/.test(status)) continue;
71
+ const gates = (/^\*\*Gates:\*\*\s*(.+)$/m.exec(text) || [])[1]?.trim();
72
+ if (!gates) continue; // legacy tolerance
73
+ plans.push({ file, status, gates, hasLedger: /^##\s+Test Obligations\b/m.test(text) });
74
+ }
75
+ return plans;
76
+ }
77
+
78
+ const TEST_FILE_RE = /(^|\/)tests?\/|\.test\.|\.spec\./;
79
+ const SOURCE_LIKE = /\.(m?[jt]sx?|py|rs|go|rb|java|kt|swift|c|h|cpp|hpp|cs|sh|sql|vue|svelte)$/;
80
+
81
+ function fileGate(projectDir, rel, toolName) {
82
+ const frozen = frozenStatus(projectDir, rel);
83
+ if (frozen.frozen) {
84
+ return decision(
85
+ "deny",
86
+ `BLOCKED — frozen record. \`${rel}\` is committed history and is never edited (${frozen.reason}). A correction is a new dated entry that links back.`
87
+ );
88
+ }
89
+
90
+ if (protectedPaths(projectDir).includes(rel)) {
91
+ return decision("deny", `BLOCKED — protected file. \`${rel}\` is on the protected list.`);
92
+ }
93
+
94
+ const inDocs = rel.startsWith("docs/") || rel.startsWith(".claude/") || rel.endsWith(".md");
95
+ if (!inDocs && SOURCE_LIKE.test(rel)) {
96
+ for (const plan of openGatedPlans(projectDir)) {
97
+ if (/Boundary:\s*pending/i.test(plan.gates)) {
98
+ return decision(
99
+ "deny",
100
+ `BLOCKED — plan gate. \`${plan.file}\` is open but its Gates line reads 'Boundary: pending'. Run the \`boundary\` skill and record its verdict in the plan before implementation edits.`
101
+ );
102
+ }
103
+ if (!plan.hasLedger) {
104
+ return decision(
105
+ "deny",
106
+ `BLOCKED — plan gate. \`${plan.file}\` has no \`## Test Obligations\` section. Run the \`test-plan\` skill; its ledger is what the verification gate discharges.`
107
+ );
108
+ }
109
+ }
110
+ }
111
+
112
+ if (toolName === "Write" && TEST_FILE_RE.test(rel)) {
113
+ const midPlan = openGatedPlans(projectDir).some((p) => /In Progress/i.test(p.status));
114
+ if (midPlan) {
115
+ return decision(
116
+ "ask",
117
+ "Create a test file mid-plan? Plan builds defer tests to the verification gate — proceed only if this is a bug-fix regression test (fails before the fix, passes after)."
118
+ );
119
+ }
120
+ }
121
+
122
+ // Doc placement is never an interruption: an off-map docs/ folder is detected silently by
123
+ // the awareness module (a `doc-placement` auto: row) and resolved by the bookkeeper.
124
+ return null;
125
+ }
126
+
127
+ // ---------------------------------------------------------------------------
128
+ // PreToolUse — Bash gates
129
+ // ---------------------------------------------------------------------------
130
+
131
+ const LOCKFILES = /^(pnpm-lock\.yaml|package-lock\.json|yarn\.lock|bun\.lockb?|Cargo\.lock)$/;
132
+
133
+ // Exempt per the changelog rule's closed list, judged mechanically: lockfile-only,
134
+ // harness-file-only (.claude/, AGENTS.md, CLAUDE.md), or changelog housekeeping.
135
+ function stagedExempt(files) {
136
+ return files.every(
137
+ (f) =>
138
+ LOCKFILES.test(path.basename(f)) ||
139
+ f.startsWith(".claude/") ||
140
+ f === "AGENTS.md" ||
141
+ f === "CLAUDE.md" ||
142
+ f.startsWith("docs/history/changelog/") ||
143
+ f === "docs/history/CHANGELOG.md"
144
+ );
145
+ }
146
+
147
+ function commitGate(projectDir) {
148
+ const staged = (git(projectDir, ["diff", "--cached", "--name-only"]) || "")
149
+ .split("\n")
150
+ .map((s) => s.trim())
151
+ .filter(Boolean);
152
+ if (staged.length > 0 && !stagedExempt(staged)) {
153
+ if (!staged.some((f) => f.startsWith("docs/history/changelog/"))) {
154
+ return decision(
155
+ "deny",
156
+ "BLOCKED — changelog. This commit stages no `docs/history/changelog/` entry. Every non-exempt commit carries its entry inside it (exempt: release/bot, lockfile-only, pure renames, changelog housekeeping — state which if claiming one, then stage accordingly or re-run)."
157
+ );
158
+ }
159
+ }
160
+ const counts = countRows(readQueue(projectDir));
161
+ if (counts.unresolved > 0) {
162
+ return decision(
163
+ "deny",
164
+ `BLOCKED — bookkeeping. ${counts.unresolved} unresolved row(s) in docs/operations/bookkeeping.md. Dispatch the bookkeeper, resolve, or convert to \`waived:\` with a reason and expiry.`
165
+ );
166
+ }
167
+ return null;
168
+ }
169
+
170
+ // Package names approved anywhere in the rules (general.md table + testing.md's frameworks).
171
+ function approvedPackages(projectDir) {
172
+ const names = new Set();
173
+ for (const rule of ["general.md", "testing.md"]) {
174
+ const text = readSafe(path.join(projectDir, ".claude", "rules", rule));
175
+ for (const m of text.matchAll(/`(@?[a-z0-9][\w./-]*)`/g)) names.add(m[1]);
176
+ }
177
+ return names;
178
+ }
179
+
180
+ function packageGate(projectDir, command) {
181
+ const m = new RegExp(String.raw`(?:^|&&|;|\|)\s*(?:sudo\s+)?(?:pnpm|npm|yarn|bun)\s+(?:add|install|i)\s+(.+)`).exec(command);
182
+ if (!m) return null;
183
+ const approved = approvedPackages(projectDir);
184
+ if (approved.size === 0) return null; // no rules file installed — nothing to enforce
185
+ const args = m[1].split(/\s+/).filter((a) => a && !a.startsWith("-"));
186
+ for (const arg of args) {
187
+ const pkg = arg.startsWith("@") ? arg.split("@").slice(0, 2).join("@") : arg.split("@")[0];
188
+ if (!pkg || pkg === "." || pkg.includes("/") && !pkg.startsWith("@")) continue;
189
+ if (!approved.has(pkg)) {
190
+ return decision(
191
+ "deny",
192
+ `BLOCKED — package table. \`${pkg}\` is not in the approved table (.claude/rules/general.md; test packages: .claude/rules/testing.md). The table changes only by explicit user decision.`
193
+ );
194
+ }
195
+ }
196
+ return null;
197
+ }
198
+
199
+ // The repo a Bash command acts on: an explicit `cd <dir>` in the command wins, then the
200
+ // payload's cwd, then the session's home project — a session may operate across repos
201
+ // (forge + consumer), and gating a commit against the wrong repo blocks or passes falsely.
202
+ function commandRoot(projectDir, payload, command) {
203
+ const cdMatch = /(?:^|&&|;)\s*cd\s+"?([^"\s;&]+)"?/.exec(command || "");
204
+ for (const candidate of [cdMatch && cdMatch[1], payload.cwd]) {
205
+ if (!candidate || !path.isAbsolute(candidate)) continue;
206
+ try {
207
+ if (fs.statSync(candidate).isDirectory()) return candidate;
208
+ } catch (e) {
209
+ /* fall through */
210
+ }
211
+ }
212
+ return projectDir;
213
+ }
214
+
215
+ // An ask class fires once per session: the first occurrence asks, later ones pass silently —
216
+ // the user has already exercised judgment on this class this session.
217
+ function askOnce(projectDir, sessionId, key, message) {
218
+ if (askedThisSession(projectDir, sessionId, key)) return null;
219
+ recordAsked(projectDir, sessionId, key);
220
+ return decision("ask", message);
221
+ }
222
+
223
+ // Command matchers anchor at statement boundaries (start, &&, ;, |) so quoted text — an
224
+ // echoed payload, a commit message naming a command — can never trigger a gate.
225
+ const AT_STATEMENT = String.raw`(?:^|&&|;|\|)\s*(?:sudo\s+)?(?:npx\s+(?:--yes\s+)?)?`;
226
+ const statement = (body) => new RegExp(AT_STATEMENT + body);
227
+
228
+ function bashGate(projectDir, command, sessionId) {
229
+ if (!command) return null;
230
+
231
+ if (statement(String.raw`git\s+commit\b`).test(command)) {
232
+ return commitGate(projectDir);
233
+ }
234
+
235
+ if (statement(String.raw`git\s+(checkout\s+-b|switch\s+-c|branch\s+\S+|worktree\s+add)\b`).test(command)) {
236
+ return askOnce(
237
+ projectDir,
238
+ sessionId,
239
+ "branch-worktree",
240
+ "Create a branch/worktree? Policy allows this only when you asked for a PR or branch."
241
+ );
242
+ }
243
+
244
+ if (
245
+ statement(String.raw`wrangler(?:@\S+)?\b.*(\bd1\s+execute\b.*--remote|\bsecret\s+(put|bulk)\b|\bdeploy\b)`).test(command) &&
246
+ !/--dry-run\b|--local\b/.test(command)
247
+ ) {
248
+ return askOnce(
249
+ projectDir,
250
+ sessionId,
251
+ "live-command",
252
+ "Run this live-system command (deploy / remote DB / secrets)? If it's a one-shot migration, its runbook should exist in docs/history/migrations/ first. Asked once per session."
253
+ );
254
+ }
255
+
256
+ return packageGate(projectDir, command);
257
+ }
258
+
259
+ // ---------------------------------------------------------------------------
260
+ // Stop
261
+ // ---------------------------------------------------------------------------
262
+
263
+ // True when the transcript's recent tail shows a bookkeeper dispatch this turn — best-effort:
264
+ // absent or unreadable transcript reads as "not dispatched".
265
+ function bookkeeperDispatched(transcriptPath) {
266
+ const text = readSafe(transcriptPath || "");
267
+ if (!text) return false;
268
+ const tail = text.slice(-200000);
269
+ return /bookkeeper/i.test(tail) && /"(Task|Agent)"/.test(tail);
270
+ }
271
+
272
+ // Plans this session flipped to Complete: Status is Complete AND the file appears in this
273
+ // machine's touched.tsv (written by the awareness detector as edits happen).
274
+ function plansCompletedThisSession(projectDir) {
275
+ const touched = readSafe(path.join(projectDir, ".claude", "state", "touched.tsv"));
276
+ const touchedPlans = new Set(
277
+ touched
278
+ .split("\n")
279
+ .map((line) => line.split("\t")[1])
280
+ .filter((p) => p && p.startsWith("docs/history/plans/"))
281
+ );
282
+ const out = [];
283
+ for (const rel of touchedPlans) {
284
+ const text = readSafe(path.join(projectDir, rel));
285
+ if (/^\*\*Status:\*\*\s*Complete\b/m.test(text)) out.push(rel);
286
+ }
287
+ return out;
288
+ }
289
+
290
+ function stopGate(projectDir, payload) {
291
+ if (payload.stop_hook_active) return null; // the runtime's infinite-loop guard — never re-block
292
+
293
+ const counts = countRows(readQueue(projectDir));
294
+ if (counts.unresolved > 0 && !bookkeeperDispatched(payload.transcript_path)) {
295
+ return {
296
+ decision: "block",
297
+ reason:
298
+ `${counts.unresolved} unresolved bookkeeping rows (${counts.auto} auto, ${counts.note} note, ${counts.needsSession} needs-session) in docs/operations/bookkeeping.md. ` +
299
+ "Dispatch the `bookkeeper` agent to drain them, resolve them now, or convert to `waived:` rows with reason + expiry. " +
300
+ "If implementation of the open plan is complete, also report it and offer the verification gate — never run it unbidden.",
301
+ };
302
+ }
303
+
304
+ const completed = plansCompletedThisSession(projectDir);
305
+ if (completed.length > 0) {
306
+ const drafts = git(projectDir, ["status", "--porcelain", "--", "docs/history/changelog/"]) || "";
307
+ if (!drafts.trim()) {
308
+ return {
309
+ decision: "block",
310
+ reason: `Plan \`${completed[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.`,
311
+ };
312
+ }
313
+ }
314
+
315
+ return null;
316
+ }
317
+
318
+ // ---------------------------------------------------------------------------
319
+
320
+ function main() {
321
+ const raw = readStdin();
322
+ let payload = {};
323
+ try {
324
+ payload = raw ? JSON.parse(raw) : {};
325
+ } catch (e) {
326
+ payload = {};
327
+ }
328
+ const projectDir = resolveProjectDir(payload);
329
+ const event = payload.hook_event_name;
330
+
331
+ let out = null;
332
+ if (event === "PreToolUse") {
333
+ const input = payload.tool_input || {};
334
+ if (payload.tool_name === "Bash") {
335
+ out = bashGate(commandRoot(projectDir, payload, input.command), input.command, payload.session_id);
336
+ } else {
337
+ const rel = relIfInside(projectDir, input.file_path || input.notebook_path);
338
+ if (rel) out = fileGate(projectDir, rel, payload.tool_name);
339
+ }
340
+ } else if (event === "Stop") {
341
+ out = stopGate(projectDir, payload);
342
+ }
343
+
344
+ if (out) process.stdout.write(JSON.stringify(out));
345
+ }
346
+
347
+ try {
348
+ main();
349
+ } catch (e) {
350
+ // Fail open: a broken gate must never become an outage.
351
+ } finally {
352
+ process.exit(0);
353
+ }
@@ -1,16 +1,22 @@
1
1
  {
2
+ "permissions": {
3
+ "allow": [
4
+ "Edit(docs/**)",
5
+ "Write(docs/**)"
6
+ ]
7
+ },
2
8
  "hooks": {
3
9
  "SessionStart": [
4
- {
5
- "matcher": "",
6
- "hooks": [
7
- {
8
- "type": "command",
9
- "command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/generate-ownership-index.mjs\"",
10
- "timeout": 10
11
- }
12
- ]
13
- }
10
+ { "matcher": "", "hooks": [ { "type": "command", "command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/awareness.mjs\"", "timeout": 10 } ] }
11
+ ],
12
+ "PostToolUse": [
13
+ { "matcher": "Edit|Write|MultiEdit|Bash", "hooks": [ { "type": "command", "command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/awareness.mjs\"", "timeout": 10 } ] }
14
+ ],
15
+ "PreToolUse": [
16
+ { "matcher": "Edit|Write|MultiEdit|Bash", "hooks": [ { "type": "command", "command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/compliance.mjs\"", "timeout": 10 } ] }
17
+ ],
18
+ "Stop": [
19
+ { "matcher": "", "hooks": [ { "type": "command", "command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/compliance.mjs\"", "timeout": 15 } ] }
14
20
  ]
15
21
  }
16
22
  }
@@ -0,0 +1,58 @@
1
+ // harness/code/hooks/lib/frozen.mjs — the frozen-record predicate, shared by the compliance gate
2
+ // and CI. Frozen: a committed docs/history/changelog/ entry, a plan whose Status is Complete or
3
+ // Abandoned, an outcome-stamped migration runbook. Immutability begins at commit for changelog
4
+ // entries (a draft in the working tree is meant to be edited), at the status flip for plans, and
5
+ // at the Executed stamp for runbooks. Every check fails open: no git, unreadable file ⇒ not frozen.
6
+
7
+ import fs from "node:fs";
8
+ import path from "node:path";
9
+ import { spawnSync } from "node:child_process";
10
+
11
+ function relPosix(projectDir, filePath) {
12
+ return path.relative(projectDir, path.resolve(projectDir, filePath)).split(path.sep).join("/");
13
+ }
14
+
15
+ // True when git tracks the file at HEAD (i.e. it has been committed at least once).
16
+ export function isCommitted(projectDir, filePath) {
17
+ const rel = relPosix(projectDir, filePath);
18
+ const res = spawnSync("git", ["cat-file", "-e", `HEAD:${rel}`], { cwd: projectDir, stdio: "ignore" });
19
+ return res.status === 0;
20
+ }
21
+
22
+ function readSafe(projectDir, filePath) {
23
+ try {
24
+ return fs.readFileSync(path.resolve(projectDir, filePath), "utf8");
25
+ } catch (e) {
26
+ return "";
27
+ }
28
+ }
29
+
30
+ // The predicate. Returns { frozen: boolean, reason?: string }.
31
+ export function frozenStatus(projectDir, filePath) {
32
+ const rel = relPosix(projectDir, filePath);
33
+ if (rel.startsWith("docs/history/changelog/") && rel.endsWith(".md")) {
34
+ if (isCommitted(projectDir, rel)) {
35
+ return { frozen: true, reason: "committed changelog entry — immutability begins at commit" };
36
+ }
37
+ return { frozen: false };
38
+ }
39
+ if (rel.startsWith("docs/history/plans/") && rel.endsWith(".md")) {
40
+ const text = readSafe(projectDir, rel);
41
+ if (/^\*\*Status:\*\*\s*(Complete|Abandoned)\b/m.test(text)) {
42
+ return { frozen: true, reason: "plan with Status: Complete or Abandoned" };
43
+ }
44
+ return { frozen: false };
45
+ }
46
+ if (rel.startsWith("docs/history/migrations/") && rel.endsWith(".md")) {
47
+ const text = readSafe(projectDir, rel);
48
+ if (/^\*\*Executed:\*\*\s*\d{4}-\d{2}-\d{2}/m.test(text)) {
49
+ return { frozen: true, reason: "outcome-stamped migration runbook" };
50
+ }
51
+ return { frozen: false };
52
+ }
53
+ return { frozen: false };
54
+ }
55
+
56
+ export function isFrozen(projectDir, filePath) {
57
+ return frozenStatus(projectDir, filePath).frozen;
58
+ }