@nurix/etna 0.19.90 → 0.19.91

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.
@@ -7,8 +7,10 @@
7
7
 
8
8
  import fs from "node:fs";
9
9
  import path from "node:path";
10
- import { spawnSync } from "node:child_process";
11
- import { readStdin, resolveProjectDir, frontmatter } from "./lib/ownership.mjs";
10
+ import { readStdin, resolveProjectDir } from "./lib/ownership.mjs";
11
+ import { readSafe, relIfInside, git, commandRoot, backtickTokens, SOURCE_EXT } from "./lib/repo.mjs";
12
+ import { openGatedPlans, inPlanScope } from "./lib/plans.mjs";
13
+ import { stagedExempt } from "./lib/exempt.mjs";
12
14
  import { readQueue, countRows, askedThisSession, recordAsked } from "./lib/state.mjs";
13
15
  import { frozenStatus } from "./lib/frozen.mjs";
14
16
  import { undischargedRows } from "./lib/ledger.mjs";
@@ -16,26 +18,6 @@ import { appendEvent, unresolvedDenials, sessionLogPath, malformedEventLines } f
16
18
  import { approvedPackages, rejectedPackages } from "./lib/approved.mjs";
17
19
  import { claimActivity, releaseActivity, recentForeignTouches } from "./lib/claims.mjs";
18
20
 
19
- function readSafe(file) {
20
- try {
21
- return fs.readFileSync(file, "utf8");
22
- } catch (e) {
23
- return "";
24
- }
25
- }
26
-
27
- function relIfInside(projectDir, filePath) {
28
- if (!filePath) return null;
29
- const rel = path.relative(projectDir, path.resolve(projectDir, filePath));
30
- if (rel.startsWith("..") || path.isAbsolute(rel)) return null;
31
- return rel.split(path.sep).join("/");
32
- }
33
-
34
- function git(projectDir, args) {
35
- const res = spawnSync("git", args, { cwd: projectDir, encoding: "utf8", timeout: 5000 });
36
- return res.status === 0 ? (res.stdout || "").trim() : null;
37
- }
38
-
39
21
  function decision(permissionDecision, reason) {
40
22
  return {
41
23
  hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision, permissionDecisionReason: reason },
@@ -63,48 +45,16 @@ function autoResolve(projectDir, sessionId, gate, target) {
63
45
  // PreToolUse — file gates
64
46
  // ---------------------------------------------------------------------------
65
47
 
48
+ // Re-read at most once per hook process — the list is consulted per gated path.
49
+ let protectedCache = null;
66
50
  function protectedPaths(projectDir) {
67
- const listFile = path.join(projectDir, "docs", "operations", "protected-files.md");
68
- const paths = ["AGENTS.md", "docs/operations/protected-files.md"];
69
- const text = readSafe(listFile);
70
- for (const m of text.matchAll(/`([^`\n]+)`/g)) {
71
- const candidate = m[1].trim();
72
- if (candidate && !candidate.includes(" ")) paths.push(candidate.replace(/^\.\//, ""));
73
- }
74
- return paths;
51
+ if (protectedCache) return protectedCache;
52
+ const text = readSafe(path.join(projectDir, "docs", "operations", "protected-files.md"));
53
+ protectedCache = ["AGENTS.md", "docs/operations/protected-files.md", ...backtickTokens(text)];
54
+ return protectedCache;
75
55
  }
76
56
 
77
- // Open plans in the new format only (a `Gates:` line present). Plans without one are
78
- // legacy and pass every plan gate — otherwise every open plan bricks on harness upgrade.
79
- function openGatedPlans(projectDir) {
80
- const dir = path.join(projectDir, "docs", "history", "plans");
81
- let entries;
82
- try {
83
- entries = fs.readdirSync(dir).filter((f) => f.endsWith(".md"));
84
- } catch (e) {
85
- return [];
86
- }
87
- const plans = [];
88
- for (const file of entries) {
89
- const text = readSafe(path.join(dir, file));
90
- const status = (/^\*\*Status:\*\*\s*(.+)$/m.exec(text) || [])[1]?.trim();
91
- if (!status || /Complete|Abandoned/.test(status)) continue;
92
- const gates = (/^\*\*Gates:\*\*\s*(.+)$/m.exec(text) || [])[1]?.trim();
93
- if (!gates) continue; // legacy tolerance
94
- plans.push({
95
- file,
96
- status,
97
- gates,
98
- hasLedger: /^##\s+Test Obligations\b/m.test(text),
99
- hasDocLedger: /^##\s+Doc Obligations\b/m.test(text),
100
- });
101
- }
102
- return plans;
103
- }
104
-
105
- const SOURCE_LIKE = /\.(m?[jt]sx?|py|rs|go|rb|java|kt|swift|c|h|cpp|hpp|cs|sh|sql|vue|svelte)$/;
106
-
107
- function fileGate(projectDir, rel, toolName, sessionId) {
57
+ function fileGate(projectDir, rel, sessionId) {
108
58
  const frozen = frozenStatus(projectDir, rel);
109
59
  if (frozen.frozen) {
110
60
  return deny(
@@ -141,8 +91,11 @@ function fileGate(projectDir, rel, toolName, sessionId) {
141
91
  }
142
92
 
143
93
  const inDocs = rel.startsWith("docs/") || rel.startsWith(".claude/") || rel.endsWith(".md");
144
- if (!inDocs && SOURCE_LIKE.test(rel)) {
94
+ if (!inDocs && SOURCE_EXT.test(rel)) {
145
95
  for (const plan of openGatedPlans(projectDir)) {
96
+ // The gate binds the plan's own paths, so disjoint plans run in parallel.
97
+ // A scopeless plan stays repo-wide — fail closed.
98
+ if (plan.scope.length && !inPlanScope(rel, plan.scope)) continue;
146
99
  if (/Boundary:\s*pending/i.test(plan.gates)) {
147
100
  return deny(
148
101
  projectDir,
@@ -183,8 +136,8 @@ function fileGate(projectDir, rel, toolName, sessionId) {
183
136
  // a file appears adds no guarantee those two do not, and it fires hardest at the gate itself,
184
137
  // where the ledger's tests are precisely what should be getting written.
185
138
 
186
- // Doc placement is never an interruption: an off-map docs/ folder is detected silently by
187
- // the awareness module (a `doc-placement` auto: row) and resolved by the bookkeeper.
139
+ // Doc placement is never an interruption: docs/structure.md is the authority a session
140
+ // consults; no hook polices it.
188
141
  return null;
189
142
  }
190
143
 
@@ -192,32 +145,6 @@ function fileGate(projectDir, rel, toolName, sessionId) {
192
145
  // PreToolUse — Bash gates
193
146
  // ---------------------------------------------------------------------------
194
147
 
195
- const LOCKFILES = /^(pnpm-lock\.yaml|package-lock\.json|yarn\.lock|bun\.lockb?|Cargo\.lock)$/;
196
-
197
- // Exempt per the changelog rule's closed list, judged mechanically: lockfile-only,
198
- // harness-file-only (.claude/, AGENTS.md, CLAUDE.md), or changelog housekeeping.
199
- const HARNESS_PREFIXES = [".claude/", ".agents/", ".codex/"];
200
- const HARNESS_FILES = [
201
- "AGENTS.md",
202
- "CLAUDE.md",
203
- ".mcp.json",
204
- ".vscode/settings.json",
205
- ".prettierignore",
206
- ".prettierrc.json",
207
- ];
208
-
209
- function stagedExempt(files) {
210
- return files.every(
211
- (f) =>
212
- LOCKFILES.test(path.basename(f)) ||
213
- HARNESS_PREFIXES.some((p) => f.startsWith(p)) ||
214
- HARNESS_FILES.includes(f) ||
215
- f.startsWith("docs/history/changelog/") ||
216
- f.includes(".bkp.") ||
217
- f === "docs/history/CHANGELOG.md"
218
- );
219
- }
220
-
221
148
  function commitGate(projectDir, sessionId) {
222
149
  const staged = (git(projectDir, ["diff", "--cached", "--name-only"]) || "")
223
150
  .split("\n")
@@ -271,7 +198,7 @@ const PKG_SPEC = /^(@[a-z0-9~-][\w.-]*\/)?[a-z0-9~-][\w.-]*(@[\w.^~*<>=|x-]+)?$/
271
198
 
272
199
  function packageGate(projectDir, command, sessionId) {
273
200
  const m = new RegExp(
274
- String.raw`(?:^|&&|;|\|)\s*(?:sudo\s+)?(?:pnpm|npm|yarn|bun)\s+(?:add|install|i)(?=\s|$)([^&;|]*)`
201
+ String.raw`(?:^|\n|&&|;|\|)\s*(?:sudo\s+)?(?:pnpm|npm|yarn|bun)\s+(?:add|install|i)(?=\s|$)([^&;|\n]*)`
275
202
  ).exec(command);
276
203
  if (!m) return null;
277
204
  const approved = approvedPackages(projectDir);
@@ -288,9 +215,11 @@ function packageGate(projectDir, command, sessionId) {
288
215
  [...approved].filter((n) => n.startsWith("@")).map((n) => n.split("/")[0])
289
216
  );
290
217
  approvedScopes.add("@types");
291
- const args = raw.split(/\s+/).filter((a) => a && !a.startsWith("-") && PKG_SPEC.test(a));
292
- for (const arg of args) {
293
- const pkg = arg.startsWith("@") ? arg.split("@").slice(0, 2).join("@") : arg.split("@")[0];
218
+ const pkgs = raw
219
+ .split(/\s+/)
220
+ .filter((a) => a && !a.startsWith("-") && PKG_SPEC.test(a))
221
+ .map((arg) => (arg.startsWith("@") ? arg.split("@").slice(0, 2).join("@") : arg.split("@")[0]));
222
+ for (const pkg of pkgs) {
294
223
  if (approved.has(pkg)) continue;
295
224
  if (isDev && pkg.startsWith("@") && approvedScopes.has(pkg.split("/")[0])) continue;
296
225
  // Approved wins over rejected: `jest` is banned for web and Express and prescribed for Expo,
@@ -311,33 +240,11 @@ function packageGate(projectDir, command, sessionId) {
311
240
  // No decision exists for this package — not approved, not rejected. Allowed, and harvested
312
241
  // centrally by the fleet package census rather than blocked here.
313
242
  }
314
- // Every named package is approved — a package-table denial for one of them is a successful retry.
315
- for (const arg of args) {
316
- const pkg = arg.startsWith("@") ? arg.split("@").slice(0, 2).join("@") : arg.split("@")[0];
317
- autoResolve(projectDir, sessionId, "package-table", pkg);
318
- }
243
+ // Every named package passed — a package-table denial for one of them is a successful retry.
244
+ for (const pkg of pkgs) autoResolve(projectDir, sessionId, "package-table", pkg);
319
245
  return null;
320
246
  }
321
247
 
322
- // The repo a Bash command acts on: an explicit `cd <dir>` in the command wins, then the
323
- // payload's cwd, then the session's home project — a session may operate across repos
324
- // (forge + consumer), and gating a commit against the wrong repo blocks or passes falsely.
325
- // A candidate is normalized to its git toplevel: a cwd inside the repo (packages/studio)
326
- // still gates — and writes the session log — against the repo root, not the subdirectory.
327
- function commandRoot(projectDir, payload, command) {
328
- const cdMatch = /(?:^|&&|;)\s*cd\s+"?([^"\s;&]+)"?/.exec(command || "");
329
- for (const candidate of [cdMatch && cdMatch[1], payload.cwd]) {
330
- if (!candidate || !path.isAbsolute(candidate)) continue;
331
- try {
332
- if (!fs.statSync(candidate).isDirectory()) continue;
333
- } catch (e) {
334
- continue;
335
- }
336
- return git(candidate, ["rev-parse", "--show-toplevel"]) || candidate;
337
- }
338
- return projectDir;
339
- }
340
-
341
248
  // An ask class fires once per session: the first occurrence asks, later ones pass silently —
342
249
  // the user has already exercised judgment on this class this session.
343
250
  function askOnce(projectDir, sessionId, key, message) {
@@ -346,9 +253,11 @@ function askOnce(projectDir, sessionId, key, message) {
346
253
  return decision("ask", message);
347
254
  }
348
255
 
349
- // Command matchers anchor at statement boundaries (start, &&, ;, |) so quoted text — an
350
- // echoed payload, a commit message naming a command — can never trigger a gate.
351
- const AT_STATEMENT = String.raw`(?:^|&&|;|\|)\s*(?:sudo\s+)?(?:npx\s+(?:--yes\s+)?)?`;
256
+ // Command matchers anchor at statement boundaries (start, newline, &&, ;, |) so quoted text — an
257
+ // echoed payload, a commit message naming a command — can never trigger a gate, while a command
258
+ // on its own line cannot slip past one. (Heredoc bodies still look like statements; that false
259
+ // positive is accepted over the false negative.)
260
+ const AT_STATEMENT = String.raw`(?:^|\n|&&|;|\|)\s*(?:sudo\s+)?(?:npx\s+(?:--yes\s+)?)?`;
352
261
  const statement = (body) => new RegExp(AT_STATEMENT + body);
353
262
 
354
263
  function bashGate(projectDir, command, sessionId) {
@@ -460,13 +369,13 @@ function bashWriteScreen(projectDir, command, sessionId) {
460
369
  // Stop
461
370
  // ---------------------------------------------------------------------------
462
371
 
463
- // True when the transcript's recent tail shows a bookkeeper dispatch this turn — best-effort:
464
- // absent or unreadable transcript reads as "not dispatched".
372
+ // True when the transcript's recent tail shows a REAL bookkeeper dispatch — a tool record naming
373
+ // the agent type, not the word appearing in prose (policy text quoting "bookkeeper" rides in most
374
+ // transcripts, and a prose match silently suppressed this wall). Best-effort: absent or
375
+ // unreadable transcript reads as "not dispatched".
465
376
  function bookkeeperDispatched(transcriptPath) {
466
- const text = readSafe(transcriptPath || "");
467
- if (!text) return false;
468
- const tail = text.slice(-200000);
469
- return /bookkeeper/i.test(tail) && /"(Task|Agent)"/.test(tail);
377
+ const tail = readSafe(transcriptPath || "").slice(-200000);
378
+ return /"subagent_type"\s*:\s*"bookkeeper"/.test(tail);
470
379
  }
471
380
 
472
381
  // Plans this session flipped to Complete: Status is Complete AND the file appears in this
@@ -493,19 +402,12 @@ function plansCompletedThisSession(projectDir, sessionId) {
493
402
  return out;
494
403
  }
495
404
 
496
- // Whether some changelog entry already accounts for this plan.
497
- //
498
- // COMMITTED COUNTS. The test used to be `git status --porcelain docs/history/changelog/` — i.e. "is
499
- // there a DIRTY file there" — which a properly committed entry can never satisfy: committing cleans
500
- // the tree, so the gate re-fired immediately after the one action that discharged it, and the only
501
- // way to silence it was to leave an uncommitted file lying in the changelog directory.
502
- //
503
- // An entry names its plan by slug (the template links `../plans/<slug>.md`), so the slug is what is
504
- // searched for. A dirty file still counts too: a draft being written this moment has not necessarily
505
- // named its plan yet, and that was the original check's one real job.
405
+ // Whether some changelog entry already accounts for this plan. An entry names its plan by slug
406
+ // (the template links `../plans/<slug>.md`), so the slug is what is searched for — over every
407
+ // entry on disk, committed or draft. A dirty-but-unrelated file in the changelog directory does
408
+ // NOT count: that shortcut made the wall satisfiable by `touch docs/history/changelog/x.md`,
409
+ // for every plan at once.
506
410
  function planHasChangelogEntry(projectDir, planRel) {
507
- if ((git(projectDir, ["status", "--porcelain", "--", "docs/history/changelog/"]) || "").trim()) return true;
508
-
509
411
  const slug = path.basename(planRel, ".md");
510
412
  const dir = path.join(projectDir, "docs", "history", "changelog");
511
413
  let names = [];
@@ -517,43 +419,6 @@ function planHasChangelogEntry(projectDir, planRel) {
517
419
  return names.some((name) => name.endsWith(".md") && readSafe(path.join(dir, name)).includes(slug));
518
420
  }
519
421
 
520
- // Approximate the context the next turn will re-read, from the transcript's last recorded usage.
521
- // Best-effort: an absent or unparseable transcript reports nothing.
522
- function sessionContextTokens(transcriptPath) {
523
- const text = readSafe(transcriptPath || "");
524
- if (!text) return 0;
525
- const lines = text.slice(-400000).split("\n");
526
- for (let i = lines.length - 1; i >= 0; i--) {
527
- if (!lines[i].includes('"usage"')) continue;
528
- try {
529
- const u = JSON.parse(lines[i]).message.usage;
530
- return (
531
- (u.input_tokens || 0) + (u.cache_read_input_tokens || 0) + (u.cache_creation_input_tokens || 0)
532
- );
533
- } catch (e) {
534
- // the slice cut this line in half — keep scanning upward
535
- }
536
- }
537
- return 0;
538
- }
539
-
540
- // Context re-reads in full on every turn, so its size IS the standing per-turn floor — the one
541
- // number that argues for ending a session rather than carrying it into unrelated work. The agent
542
- // cannot act on this (it can neither end a session nor start one), so it is addressed to the user
543
- // as a systemMessage and never blocks. Kept out of the model's context deliberately: a running
544
- // token count in-context produces context anxiety, not shorter sessions.
545
- const CONTEXT_ADVISORY_TOKENS = 250000;
546
-
547
- function sessionSizeNote(payload) {
548
- const tokens = sessionContextTokens(payload.transcript_path);
549
- if (tokens < CONTEXT_ADVISORY_TOKENS) return null;
550
- return (
551
- `Session context is roughly ${Math.round(tokens / 1000)}K tokens, and every turn re-reads all of it — ` +
552
- "that is the per-turn floor before anything new is produced. A fresh session starts near zero; " +
553
- "worth ending this one if the next task is unrelated."
554
- );
555
- }
556
-
557
422
  function stopGate(projectDir, payload) {
558
423
  if (payload.stop_hook_active) return null; // the runtime's infinite-loop guard — never re-block
559
424
 
@@ -651,16 +516,11 @@ function main() {
651
516
  if (payload.tool_name === "Bash") {
652
517
  out = bashGate(commandRoot(projectDir, payload, input.command), input.command, payload.session_id);
653
518
  } else {
654
- const rel = relIfInside(projectDir, input.file_path || input.notebook_path);
655
- if (rel) out = fileGate(projectDir, rel, payload.tool_name, payload.session_id);
519
+ const rel = relIfInside(projectDir, input.file_path);
520
+ if (rel) out = fileGate(projectDir, rel, payload.session_id);
656
521
  }
657
522
  } else if (event === "Stop") {
658
523
  out = stopGate(projectDir, payload);
659
- // Advisory rides alongside a block rather than replacing it — both are true at once.
660
- if (!payload.stop_hook_active) {
661
- const note = sessionSizeNote(payload);
662
- if (note) out = { ...(out || {}), systemMessage: note };
663
- }
664
524
  }
665
525
 
666
526
  if (out) process.stdout.write(JSON.stringify(out));
@@ -2,16 +2,8 @@
2
2
  // PreToolUse install gate (compliance) and the PostToolUse outcome detector (awareness), so
3
3
  // "blocked at install" and "flagged in the manifest" can never disagree about the table.
4
4
 
5
- import fs from "node:fs";
6
5
  import path from "node:path";
7
-
8
- function readSafe(file) {
9
- try {
10
- return fs.readFileSync(file, "utf8");
11
- } catch (e) {
12
- return "";
13
- }
14
- }
6
+ import { readSafe } from "./repo.mjs";
15
7
 
16
8
  // A rule names its rejected alternatives in backticks — instruction-style § Negative space requires
17
9
  // it — so harvesting every backticked token adds the ban list to the approved set. Two shapes carry
@@ -12,15 +12,12 @@
12
12
  import fs from "node:fs";
13
13
  import path from "node:path";
14
14
  import { stateDir, withFileLock, atomicWrite } from "./state.mjs";
15
+ import { sid8 } from "./repo.mjs";
15
16
 
16
17
  const LIVE_WINDOW_MIN = 30; // transcript modified within this window ⇒ the session is live
17
18
  const CLAIM_TTL_MIN = 20; // an activity claim older than this is stale even if the holder lives
18
19
  const RETENTION_DAYS = 14; // registry + touched.tsv rows older than this are pruned
19
20
 
20
- function sid8(sessionId) {
21
- return String(sessionId || "unknown").replace(/[^a-zA-Z0-9-]/g, "").slice(0, 8) || "unknown";
22
- }
23
-
24
21
  function minutesSince(iso) {
25
22
  const t = Date.parse(iso);
26
23
  return Number.isFinite(t) ? (Date.now() - t) / 60000 : Infinity;
@@ -0,0 +1,32 @@
1
+ // harness/code/hooks/lib/exempt.mjs — the changelog rule's closed exemption list, judged
2
+ // mechanically. One predicate shared by the commit gate and the CI backstop: two private
3
+ // copies once drifted (`.bkp.` exempt at the gate, not in CI) and could disagree about
4
+ // whether the same commit owed an entry.
5
+
6
+ import path from "node:path";
7
+
8
+ const LOCKFILES = /^(pnpm-lock\.yaml|package-lock\.json|yarn\.lock|bun\.lockb?|Cargo\.lock)$/;
9
+ const HARNESS_PREFIXES = [".claude/", ".agents/", ".codex/"];
10
+ const HARNESS_FILES = [
11
+ "AGENTS.md",
12
+ "CLAUDE.md",
13
+ ".mcp.json",
14
+ ".vscode/settings.json",
15
+ ".prettierignore",
16
+ ".prettierrc.json",
17
+ ];
18
+
19
+ export function changelogExemptFile(f) {
20
+ return (
21
+ LOCKFILES.test(path.basename(f)) ||
22
+ HARNESS_PREFIXES.some((p) => f.startsWith(p)) ||
23
+ HARNESS_FILES.includes(f) ||
24
+ f.startsWith("docs/history/changelog/") ||
25
+ f.includes(".bkp.") ||
26
+ f === "docs/history/CHANGELOG.md"
27
+ );
28
+ }
29
+
30
+ export function stagedExempt(files) {
31
+ return files.every(changelogExemptFile);
32
+ }
@@ -29,10 +29,10 @@ function readSafe(projectDir, filePath) {
29
29
 
30
30
  // The predicate. Returns { frozen: boolean, reason?: string }.
31
31
  export function frozenStatus(projectDir, filePath) {
32
+ const rel = relPosix(projectDir, filePath);
32
33
  // *.bkp.* siblings are install-time backups, not records — the maintenance fold edits and
33
34
  // deletes them, which a frozen verdict would forbid.
34
- if (relPosix(projectDir, filePath).includes(".bkp.")) return { frozen: false };
35
- const rel = relPosix(projectDir, filePath);
35
+ if (rel.includes(".bkp.")) return { frozen: false };
36
36
  if (rel.startsWith("docs/history/changelog/") && rel.endsWith(".md")) {
37
37
  if (isCommitted(projectDir, rel)) {
38
38
  return { frozen: true, reason: "committed changelog entry — immutability begins at commit" };
@@ -56,6 +56,26 @@ export function frozenStatus(projectDir, filePath) {
56
56
  return { frozen: false };
57
57
  }
58
58
 
59
- export function isFrozen(projectDir, filePath) {
60
- return frozenStatus(projectDir, filePath).frozen;
59
+ // Frozen as of HEAD — the copy git holds, not the working tree. This is the outcome detector's
60
+ // predicate: a record frozen only in the worktree is a freeze born in the current edit (a plan
61
+ // legitimately flipping to Complete), and flagging it walls the completion ceremony itself.
62
+ // Only an edit to something HEAD already froze is an evasion.
63
+ export function frozenStatusAtHead(projectDir, filePath) {
64
+ const rel = relPosix(projectDir, filePath);
65
+ if (rel.includes(".bkp.")) return { frozen: false };
66
+ if (rel.startsWith("docs/history/changelog/") && rel.endsWith(".md")) {
67
+ return frozenStatus(projectDir, filePath); // already keyed on isCommitted
68
+ }
69
+ const inRepo = spawnSync("git", ["rev-parse", "--git-dir"], { cwd: projectDir, stdio: "ignore", timeout: 5000 });
70
+ if (inRepo.status !== 0) return frozenStatus(projectDir, filePath); // gitless — the worktree is all there is
71
+ const res = spawnSync("git", ["show", `HEAD:${rel}`], { cwd: projectDir, encoding: "utf8", timeout: 5000 });
72
+ const headText = res.status === 0 ? res.stdout || "" : "";
73
+ if (!headText) return { frozen: false }; // never committed — a freeze born in this worktree is a ceremony, not an evasion
74
+ if (rel.startsWith("docs/history/plans/") && /^\*\*Status:\*\*\s*(Complete|Abandoned)\b/m.test(headText)) {
75
+ return { frozen: true, reason: "plan committed with Status: Complete or Abandoned" };
76
+ }
77
+ if (rel.startsWith("docs/history/migrations/") && /^\*\*Executed:\*\*\s*\d{4}-\d{2}-\d{2}/m.test(headText)) {
78
+ return { frozen: true, reason: "outcome-stamped migration runbook" };
79
+ }
80
+ return { frozen: false };
61
81
  }
@@ -194,10 +194,10 @@ export function globToRegExp(glob) {
194
194
  }
195
195
 
196
196
  // Feature docs owning the given repo-relative path (deprecated docs excluded).
197
- export function owningDocs(projectDir, relPath) {
197
+ export function owningDocs(projectDir, relPath, docs) {
198
198
  const rel = relPath.split(path.sep).join("/");
199
199
  const out = [];
200
- for (const entry of scanFeatureDocs(projectDir)) {
200
+ for (const entry of docs || scanFeatureDocs(projectDir)) {
201
201
  if (entry.status === "deprecated") continue;
202
202
  if (entry.owns.some((g) => globToRegExp(g).test(rel))) out.push(entry);
203
203
  }
@@ -229,11 +229,11 @@ export function listRepoFiles(projectDir) {
229
229
  }
230
230
 
231
231
  // Globs across all feature docs that match zero files on disk.
232
- // Returns [{ glob, doc }]; pass a precomputed file list to amortize the walk.
233
- export function deadGlobs(projectDir, repoFiles) {
232
+ // Returns [{ glob, doc }]; pass precomputed file/doc lists to amortize the walks.
233
+ export function deadGlobs(projectDir, repoFiles, docs) {
234
234
  const files = repoFiles || listRepoFiles(projectDir);
235
235
  const dead = [];
236
- for (const entry of scanFeatureDocs(projectDir)) {
236
+ for (const entry of docs || scanFeatureDocs(projectDir)) {
237
237
  if (entry.status === "deprecated") continue;
238
238
  for (const glob of entry.owns) {
239
239
  const re = globToRegExp(glob);
@@ -258,43 +258,39 @@ export function specificity(glob) {
258
258
  return { literal, wildcard };
259
259
  }
260
260
 
261
- // Two globs are equally specific when both tuple components match.
262
- export function sameSpecificity(a, b) {
263
- return a.literal === b.literal && a.wildcard === b.wildcard;
264
- }
265
-
266
261
  // Cross-doc equal-specificity ownership conflicts: two globs from DIFFERENT feature docs that
267
262
  // match a common file at the same specificity, so "most specific wins" cannot pick an owner and
268
- // the path is doubly owned. Returns [{ a: {doc, glob}, b: {doc, glob}, example }].
263
+ // the path is doubly owned. Returns [{ a: {doc, glob}, b: {doc, glob}, example }] — one pass
264
+ // over the files, owners grouped per file, instead of a quadratic glob-pair scan.
269
265
  //
270
266
  // This is the mechanical half of a rule the harness has always stated and never checked — the
271
267
  // `documentation` skill's frontmatter reference calls an equal-specificity overlap "an ownership
272
268
  // conflict to resolve", and the `maintenance` agent is told to flag one. Without this, both had
273
269
  // to be enforced by eye.
274
- export function overlaps(projectDir, repoFiles) {
270
+ export function overlaps(projectDir, repoFiles, docs) {
275
271
  const files = repoFiles || listRepoFiles(projectDir);
276
272
  const owners = [];
277
- for (const entry of scanFeatureDocs(projectDir)) {
273
+ for (const entry of docs || scanFeatureDocs(projectDir)) {
278
274
  if (entry.status === "deprecated") continue;
279
275
  for (const glob of entry.owns) {
280
- const re = globToRegExp(glob);
281
- owners.push({ doc: entry.doc, glob, spec: specificity(glob), files: files.filter((f) => re.test(f)) });
276
+ const s = specificity(glob);
277
+ owners.push({ doc: entry.doc, glob, key: `${s.literal}/${s.wildcard}`, re: globToRegExp(glob) });
282
278
  }
283
279
  }
284
- return pairOverlaps(owners);
285
- }
286
-
287
- // The pure core of `overlaps()`, split out so tests can feed synthetic owners without a repo tree.
288
- export function pairOverlaps(owners) {
289
280
  const found = [];
290
- for (let i = 0; i < owners.length; i++) {
291
- for (let j = i + 1; j < owners.length; j++) {
292
- const a = owners[i];
293
- const b = owners[j];
294
- if (a.doc === b.doc) continue;
295
- if (!sameSpecificity(a.spec, b.spec)) continue;
296
- const example = a.files.find((f) => b.files.includes(f));
297
- if (example) found.push({ a: { doc: a.doc, glob: a.glob }, b: { doc: b.doc, glob: b.glob }, example });
281
+ const seen = new Set(); // one example per glob pair
282
+ for (const file of files) {
283
+ const here = owners.filter((o) => o.re.test(file));
284
+ for (let i = 0; i < here.length; i++) {
285
+ for (let j = i + 1; j < here.length; j++) {
286
+ const a = here[i];
287
+ const b = here[j];
288
+ if (a.doc === b.doc || a.key !== b.key) continue;
289
+ const pair = `${a.doc}·${a.glob}·${b.doc}·${b.glob}`;
290
+ if (seen.has(pair)) continue;
291
+ seen.add(pair);
292
+ found.push({ a: { doc: a.doc, glob: a.glob }, b: { doc: b.doc, glob: b.glob }, example: file });
293
+ }
298
294
  }
299
295
  }
300
296
  return found;
@@ -0,0 +1,63 @@
1
+ // harness/code/hooks/lib/plans.mjs — the one reader of docs/history/plans/. The gate, the
2
+ // SessionStart banner, and the CI completion check all consume this parse; three private
3
+ // regex triplets once disagreed about what a plan's Status line was.
4
+
5
+ import fs from "node:fs";
6
+ import path from "node:path";
7
+ import { readSafe, backtickTokens } from "./repo.mjs";
8
+ import { globToRegExp } from "./ownership.mjs";
9
+
10
+ // A plan's scope: path-shaped backticked tokens in its `## Proposed Changes` section.
11
+ // Empty ⇒ unparseable ⇒ the caller treats the plan as repo-wide (fail closed — the
12
+ // pressure lands on the scopeless plan, not its neighbors).
13
+ export function planScopePaths(text) {
14
+ const section = /^##\s+Proposed Changes\s*$([\s\S]*?)(?=^##\s|(?![\s\S]))/m.exec(text);
15
+ if (!section) return [];
16
+ return backtickTokens(section[1]).filter((t) => t.includes("/"));
17
+ }
18
+
19
+ // True when `rel` falls inside a scope entry. Wildcarded entries compile through the same glob
20
+ // engine the ownership index uses — a mid-pattern glob (`tests/*.test.mjs`) once fell through
21
+ // the trailing-`/**` special case and silently matched nothing, narrowing the gate. A bare
22
+ // directory entry covers its subtree; a bare file matches exactly.
23
+ export function inPlanScope(rel, scope) {
24
+ return scope.some((entry) => {
25
+ if (entry.includes("*")) return globToRegExp(entry).test(rel);
26
+ if (entry.endsWith("/")) return rel === entry.slice(0, -1) || rel.startsWith(entry);
27
+ return rel === entry || rel.startsWith(entry + "/");
28
+ });
29
+ }
30
+
31
+ // Every plan in the folder, parsed once: { file, status, gates, scope, hasLedger,
32
+ // hasDocLedger, ledgerRows }. `gates` is null on legacy plans (pre-Gates format).
33
+ export function listPlans(projectDir) {
34
+ const dir = path.join(projectDir, "docs", "history", "plans");
35
+ let entries;
36
+ try {
37
+ entries = fs.readdirSync(dir).filter((f) => f.endsWith(".md"));
38
+ } catch (e) {
39
+ return [];
40
+ }
41
+ const plans = [];
42
+ for (const file of entries) {
43
+ const text = readSafe(path.join(dir, file));
44
+ const status = (/^\*\*Status:\*\*\s*(.+)$/m.exec(text) || [])[1]?.trim() || null;
45
+ if (!status) continue;
46
+ plans.push({
47
+ file,
48
+ status,
49
+ gates: (/^\*\*Gates:\*\*\s*(.+)$/m.exec(text) || [])[1]?.trim() || null,
50
+ scope: planScopePaths(text),
51
+ hasLedger: /^##\s+Test Obligations\b/m.test(text),
52
+ hasDocLedger: /^##\s+Doc Obligations\b/m.test(text),
53
+ ledgerRows: (text.match(/^\|\s*\d+\s*\|/gm) || []).length,
54
+ });
55
+ }
56
+ return plans;
57
+ }
58
+
59
+ // Open plans in the new format only (a `Gates:` line present). Plans without one are
60
+ // legacy and pass every plan gate — otherwise every open plan bricks on harness upgrade.
61
+ export function openGatedPlans(projectDir) {
62
+ return listPlans(projectDir).filter((p) => !/Complete|Abandoned/.test(p.status) && p.gates);
63
+ }