@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,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
+ }
@@ -0,0 +1,223 @@
1
+ // harness/code/hooks/lib/ownership.mjs — frontmatter scan + owns:-glob resolution, shared by the
2
+ // hook entrypoints (awareness, compliance) and the CI checks so gates and pipeline cannot disagree.
3
+ // Everything here is read-only over docs/features/ and the source tree; no network, no deps.
4
+
5
+ import fs from "node:fs";
6
+ import path from "node:path";
7
+
8
+ // Read all of stdin synchronously; returns "" if nothing is piped.
9
+ export function readStdin() {
10
+ try {
11
+ return fs.readFileSync(0, "utf8");
12
+ } catch (e) {
13
+ return "";
14
+ }
15
+ }
16
+
17
+ // Resolve the project root: CLAUDE_PROJECT_DIR env, else payload cwd, else process cwd.
18
+ export function resolveProjectDir(payload) {
19
+ return process.env.CLAUDE_PROJECT_DIR || (payload && payload.cwd) || process.cwd();
20
+ }
21
+
22
+ // Recursively collect every .md file under dir (returns [] if the dir is absent).
23
+ export function listMarkdown(dir) {
24
+ const out = [];
25
+ let entries;
26
+ try {
27
+ entries = fs.readdirSync(dir, { withFileTypes: true });
28
+ } catch (e) {
29
+ return out;
30
+ }
31
+ for (const entry of entries) {
32
+ const full = path.join(dir, entry.name);
33
+ if (entry.isDirectory()) out.push(...listMarkdown(full));
34
+ else if (entry.isFile() && entry.name.endsWith(".md")) out.push(full);
35
+ }
36
+ return out;
37
+ }
38
+
39
+ // Pull the leading `--- … ---` frontmatter block; "" if the file has none.
40
+ export function frontmatter(text) {
41
+ const m = /^?---\r?\n([\s\S]*?)\r?\n---/.exec(text);
42
+ return m ? m[1] : "";
43
+ }
44
+
45
+ // Trim surrounding whitespace, then strip one layer of matching quotes.
46
+ export function unquote(s) {
47
+ return String(s).trim().replace(/^["']|["']$/g, "").trim();
48
+ }
49
+
50
+ // One YAML list/array item → clean value: a quoted item ends at its closing quote
51
+ // (anything after — trailing comments — drops); an unquoted item ends at ` #`.
52
+ export function cleanItem(s) {
53
+ const t = String(s).trim();
54
+ const q = t[0];
55
+ if (q === '"' || q === "'") {
56
+ const end = t.indexOf(q, 1);
57
+ if (end > 0) return t.slice(1, end).trim();
58
+ }
59
+ const hash = t.search(/\s#/);
60
+ return unquote(hash >= 0 ? t.slice(0, hash) : t);
61
+ }
62
+
63
+ // `[a, b] # comment` → items; content ends at the closing bracket, not the line end.
64
+ function parseInlineArray(raw) {
65
+ const end = raw.indexOf("]");
66
+ const body = (end >= 0 ? raw.slice(0, end) : raw).replace(/^\[/, "");
67
+ return body.split(",").map((s) => cleanItem(s)).filter(Boolean);
68
+ }
69
+
70
+ // Read one frontmatter key: scalar (`k: v`), inline array (`k: [a, b]`), or a following `- item` block.
71
+ // Returns a string for scalars, an array for lists, or undefined when the key is absent.
72
+ export function field(block, key) {
73
+ const lines = block.split(/\r?\n/);
74
+ const head = new RegExp(`^${key}\\s*:\\s*(.*)$`);
75
+ for (let i = 0; i < lines.length; i++) {
76
+ const m = head.exec(lines[i]);
77
+ if (!m) continue;
78
+ const raw = m[1].trim();
79
+ if (raw === "") {
80
+ // A bare key may continue as an indented inline array on the next line, or a `- item` block.
81
+ const next = (lines[i + 1] || "").trim();
82
+ if (next.startsWith("[")) {
83
+ return parseInlineArray(next);
84
+ }
85
+ const items = [];
86
+ for (let j = i + 1; j < lines.length; j++) {
87
+ const li = /^\s*-\s+(.*)$/.exec(lines[j]);
88
+ if (!li) break;
89
+ items.push(cleanItem(li[1]));
90
+ }
91
+ return items;
92
+ }
93
+ if (raw.startsWith("[")) {
94
+ return parseInlineArray(raw);
95
+ }
96
+ return unquote(raw);
97
+ }
98
+ return undefined;
99
+ }
100
+
101
+ // Coerce a field that may be scalar/array/undefined into a clean string array.
102
+ export function asList(v) {
103
+ if (v == null) return [];
104
+ return (Array.isArray(v) ? v : [v]).map((s) => String(s).trim()).filter(Boolean);
105
+ }
106
+
107
+ // Scan docs/features/**/*.md and return one entry per feature doc that declares owns:.
108
+ // Shape: { doc (repo-relative, /-separated), owns[], module, keywords[], status }.
109
+ export function scanFeatureDocs(projectDir) {
110
+ const featuresDir = path.join(projectDir, "docs", "features");
111
+ const files = listMarkdown(featuresDir);
112
+ const docs = [];
113
+ for (const file of files) {
114
+ let text;
115
+ try {
116
+ text = fs.readFileSync(file, "utf8");
117
+ } catch (e) {
118
+ continue;
119
+ }
120
+ const block = frontmatter(text);
121
+ if (!block) continue;
122
+ const owns = asList(field(block, "owns"));
123
+ if (owns.length === 0) continue;
124
+ docs.push({
125
+ doc: path.relative(projectDir, file).split(path.sep).join("/"),
126
+ owns,
127
+ module: (field(block, "module") || "—").toString(),
128
+ keywords: asList(field(block, "keywords")),
129
+ status: (field(block, "status") || "active").toString().toLowerCase(),
130
+ });
131
+ }
132
+ return docs;
133
+ }
134
+
135
+ // Build the ownership index text from every feature doc's frontmatter.
136
+ export function buildIndex(projectDir) {
137
+ const docs = scanFeatureDocs(projectDir);
138
+ const lines = [];
139
+ let deprecated = 0;
140
+ for (const entry of docs) {
141
+ if (entry.status === "deprecated") {
142
+ deprecated += 1;
143
+ continue; // keep deprecated docs out of the routing lines
144
+ }
145
+ const kw = entry.keywords.length ? `[${entry.keywords.join(", ")}]` : "[]";
146
+ for (const glob of entry.owns) {
147
+ lines.push(`${glob} → ${entry.doc} · ${entry.module} · ${kw} · ${entry.status}`);
148
+ }
149
+ }
150
+ if (lines.length === 0) return "";
151
+ lines.sort();
152
+ const header =
153
+ "# Ownership index (generated) — code-path glob → owning feature doc · module · keywords · status\n" +
154
+ "# Match the file or topic you're about to touch, then read the owning doc before changing it.\n";
155
+ const footer = deprecated > 0 ? `\n# (${deprecated} deprecated feature doc(s) omitted)` : "";
156
+ return header + lines.join("\n") + footer;
157
+ }
158
+
159
+ // Compile one owns:-style glob (`**` any depth, `*` within a segment) to a RegExp
160
+ // over /-separated repo-relative paths. `a/**` also matches `a` itself.
161
+ export function globToRegExp(glob) {
162
+ const g = glob.replace(/\\/g, "/").replace(/\/+$/, "");
163
+ const escaped = g.replace(/[\\^$.|?+()[\]{}]/g, "\\$&");
164
+ const re = escaped
165
+ .replace(/\/\*\*$/, "\0TAIL\0") // `x/**` → x plus optionally anything below it
166
+ .replace(/\*\*\//g, "\0LEAD\0") // `**/x` → any (possibly empty) directory prefix
167
+ .replace(/\*\*/g, "\0ANY\0")
168
+ .replace(/\*/g, "[^/]*")
169
+ .replace(/\0TAIL\0/g, "(?:/.*)?")
170
+ .replace(/\0LEAD\0/g, "(?:.*/)?")
171
+ .replace(/\0ANY\0/g, ".*");
172
+ return new RegExp(`^${re}$`);
173
+ }
174
+
175
+ // Feature docs owning the given repo-relative path (deprecated docs excluded).
176
+ export function owningDocs(projectDir, relPath) {
177
+ const rel = relPath.split(path.sep).join("/");
178
+ const out = [];
179
+ for (const entry of scanFeatureDocs(projectDir)) {
180
+ if (entry.status === "deprecated") continue;
181
+ if (entry.owns.some((g) => globToRegExp(g).test(rel))) out.push(entry);
182
+ }
183
+ return out;
184
+ }
185
+
186
+ const SKIP_DIRS = new Set(["node_modules", ".git", "dist", "build", ".claude", ".codex"]);
187
+
188
+ // Walk the repo once and return every tracked-ish file path (repo-relative, /-separated).
189
+ // Used to detect dead globs; heavy dirs are skipped, depth is bounded by the tree itself.
190
+ export function listRepoFiles(projectDir) {
191
+ const out = [];
192
+ function walk(dir) {
193
+ let entries;
194
+ try {
195
+ entries = fs.readdirSync(dir, { withFileTypes: true });
196
+ } catch (e) {
197
+ return;
198
+ }
199
+ for (const entry of entries) {
200
+ if (SKIP_DIRS.has(entry.name)) continue;
201
+ const full = path.join(dir, entry.name);
202
+ if (entry.isDirectory()) walk(full);
203
+ else if (entry.isFile()) out.push(path.relative(projectDir, full).split(path.sep).join("/"));
204
+ }
205
+ }
206
+ walk(projectDir);
207
+ return out;
208
+ }
209
+
210
+ // Globs across all feature docs that match zero files on disk.
211
+ // Returns [{ glob, doc }]; pass a precomputed file list to amortize the walk.
212
+ export function deadGlobs(projectDir, repoFiles) {
213
+ const files = repoFiles || listRepoFiles(projectDir);
214
+ const dead = [];
215
+ for (const entry of scanFeatureDocs(projectDir)) {
216
+ if (entry.status === "deprecated") continue;
217
+ for (const glob of entry.owns) {
218
+ const re = globToRegExp(glob);
219
+ if (!files.some((f) => re.test(f))) dead.push({ glob, doc: entry.doc });
220
+ }
221
+ }
222
+ return dead;
223
+ }