@nurix/etna 0.19.89 → 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.
- package/data/harness-index.json +1 -1
- package/data/harnesses/code/CLAUDE.md +1 -1
- package/data/harnesses/code/harness.json +2 -2
- package/data/harnesses/code/hooks/awareness.mjs +183 -111
- package/data/harnesses/code/hooks/check-bookkeeping-clean.mjs +7 -2
- package/data/harnesses/code/hooks/check-changelog-presence.mjs +4 -25
- package/data/harnesses/code/hooks/check-completion-integrity.mjs +1 -8
- package/data/harnesses/code/hooks/check-ownership.mjs +6 -8
- package/data/harnesses/code/hooks/compliance.mjs +47 -184
- package/data/harnesses/code/hooks/lib/approved.mjs +1 -9
- package/data/harnesses/code/hooks/lib/claims.mjs +1 -4
- package/data/harnesses/code/hooks/lib/exempt.mjs +32 -0
- package/data/harnesses/code/hooks/lib/frozen.mjs +24 -4
- package/data/harnesses/code/hooks/lib/ownership.mjs +24 -28
- package/data/harnesses/code/hooks/lib/plans.mjs +63 -0
- package/data/harnesses/code/hooks/lib/repo.mjs +68 -0
- package/data/harnesses/code/hooks/lib/session-log.mjs +2 -5
- package/data/harnesses/code/hooks/lib/state.mjs +36 -10
- package/data/harnesses/code/skills/documentation/templates/bookkeeping.md +1 -1
- package/data/harnesses/delivery/harness.json +2 -2
- package/data/harnesses/delivery/skills/documentation/templates/bookkeeping.md +1 -1
- package/data/harnesses/etna/harness.json +2 -2
- package/data/harnesses/etna/skills/documentation/templates/bookkeeping.md +1 -1
- package/package.json +2 -2
|
@@ -7,8 +7,10 @@
|
|
|
7
7
|
|
|
8
8
|
import fs from "node:fs";
|
|
9
9
|
import path from "node:path";
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
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
|
-
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
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 &&
|
|
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:
|
|
187
|
-
//
|
|
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`(
|
|
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
|
|
292
|
-
|
|
293
|
-
|
|
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
|
|
315
|
-
for (const
|
|
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
|
-
|
|
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
|
|
464
|
-
//
|
|
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
|
|
467
|
-
|
|
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
|
-
//
|
|
499
|
-
//
|
|
500
|
-
//
|
|
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
|
|
|
@@ -567,8 +432,11 @@ function stopGate(projectDir, payload) {
|
|
|
567
432
|
return {
|
|
568
433
|
decision: "block",
|
|
569
434
|
reason:
|
|
570
|
-
`${counts.unresolved} unresolved bookkeeping rows from this session (${counts.auto} auto, ${counts.note} note, ${counts.needsSession} needs-session) in docs/operations/bookkeeping.md. ` +
|
|
435
|
+
`${counts.unresolved} unresolved bookkeeping rows from this session (${counts.auto} auto, ${counts.note} note, ${counts.needsSession} needs-session, ${counts.expiredWaived} expired-waived) in docs/operations/bookkeeping.md. ` +
|
|
571
436
|
"Dispatch the `bookkeeper` agent to drain them, resolve them now, or convert to `waived:` rows with reason + expiry. " +
|
|
437
|
+
(counts.expiredWaived > 0
|
|
438
|
+
? "An expired waiver is a deferral that ran out — re-judge whether its reason still holds rather than re-waiving by default. "
|
|
439
|
+
: "") +
|
|
572
440
|
"If implementation of the open plan is complete, also report it and offer the verification gate — never run it unbidden.",
|
|
573
441
|
};
|
|
574
442
|
}
|
|
@@ -648,16 +516,11 @@ function main() {
|
|
|
648
516
|
if (payload.tool_name === "Bash") {
|
|
649
517
|
out = bashGate(commandRoot(projectDir, payload, input.command), input.command, payload.session_id);
|
|
650
518
|
} else {
|
|
651
|
-
const rel = relIfInside(projectDir, input.file_path
|
|
652
|
-
if (rel) out = fileGate(projectDir, rel, payload.
|
|
519
|
+
const rel = relIfInside(projectDir, input.file_path);
|
|
520
|
+
if (rel) out = fileGate(projectDir, rel, payload.session_id);
|
|
653
521
|
}
|
|
654
522
|
} else if (event === "Stop") {
|
|
655
523
|
out = stopGate(projectDir, payload);
|
|
656
|
-
// Advisory rides alongside a block rather than replacing it — both are true at once.
|
|
657
|
-
if (!payload.stop_hook_active) {
|
|
658
|
-
const note = sessionSizeNote(payload);
|
|
659
|
-
if (note) out = { ...(out || {}), systemMessage: note };
|
|
660
|
-
}
|
|
661
524
|
}
|
|
662
525
|
|
|
663
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 (
|
|
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
|
-
|
|
60
|
-
|
|
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
|
|
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
|
|
281
|
-
owners.push({ doc: entry.doc, glob,
|
|
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
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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
|
+
}
|