@nurix/etna 0.19.90 → 0.19.92
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/agents/bookkeeper.md +1 -1
- package/data/harnesses/code/harness.json +2 -2
- package/data/harnesses/code/hooks/awareness.mjs +97 -130
- package/data/harnesses/code/hooks/check-bookkeeping-clean.mjs +4 -0
- 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 +64 -188
- 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 +97 -5
- package/data/harnesses/code/skills/documentation/templates/bookkeeping.md +5 -3
- package/data/harnesses/delivery/harness.json +2 -2
- package/data/harnesses/delivery/skills/documentation/templates/bookkeeping.md +5 -3
- package/data/harnesses/etna/harness.json +2 -2
- package/data/harnesses/etna/skills/documentation/templates/bookkeeping.md +5 -3
- package/package.json +2 -2
|
@@ -7,35 +7,17 @@
|
|
|
7
7
|
|
|
8
8
|
import fs from "node:fs";
|
|
9
9
|
import path from "node:path";
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
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";
|
|
14
|
+
import { readQueue, countRows, rowsBlockingCommit, askedThisSession, recordAsked } from "./lib/state.mjs";
|
|
13
15
|
import { frozenStatus } from "./lib/frozen.mjs";
|
|
14
16
|
import { undischargedRows } from "./lib/ledger.mjs";
|
|
15
17
|
import { appendEvent, unresolvedDenials, sessionLogPath, malformedEventLines } from "./lib/session-log.mjs";
|
|
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")
|
|
@@ -234,14 +161,29 @@ function commitGate(projectDir, sessionId) {
|
|
|
234
161
|
);
|
|
235
162
|
}
|
|
236
163
|
}
|
|
237
|
-
|
|
238
|
-
|
|
164
|
+
// The bookkeeping wall is scoped to the staged set — the grammar and its fail-closed cases are
|
|
165
|
+
// `lib/state.mjs` § What a row implicates. Rows about files this commit does not touch are
|
|
166
|
+
// somebody else's, and holding a commit behind rows its session may not drain is a wait with no
|
|
167
|
+
// action that clears it.
|
|
168
|
+
const { blocking, unrelated } = rowsBlockingCommit(readQueue(projectDir), staged);
|
|
169
|
+
if (blocking.length > 0) {
|
|
170
|
+
const shown = blocking.slice(0, 8);
|
|
171
|
+
const lines = shown.map(({ row, hits }) =>
|
|
172
|
+
hits.length > 0
|
|
173
|
+
? ` - ${row.kind}: ${row.body}\n ↳ implicates staged ${hits.join(", ")}`
|
|
174
|
+
: ` - ${row.kind}: ${row.body}\n ↳ names no staged path — a row like this blocks every commit`
|
|
175
|
+
);
|
|
176
|
+
if (blocking.length > shown.length) lines.push(` - …and ${blocking.length - shown.length} more`);
|
|
239
177
|
return deny(
|
|
240
178
|
projectDir,
|
|
241
179
|
sessionId,
|
|
242
180
|
"bookkeeping",
|
|
243
181
|
"git commit",
|
|
244
|
-
`BLOCKED — bookkeeping. ${
|
|
182
|
+
`BLOCKED — bookkeeping. ${blocking.length} unresolved row(s) in docs/operations/bookkeeping.md implicate this commit:\n${lines.join("\n")}\n` +
|
|
183
|
+
"Dispatch the bookkeeper, resolve them, or convert to `waived:` with a reason and expiry." +
|
|
184
|
+
(unrelated > 0
|
|
185
|
+
? ` (${unrelated} further unresolved row(s) implicate nothing staged here — another session's work, and not yours to drain.)`
|
|
186
|
+
: "")
|
|
245
187
|
);
|
|
246
188
|
}
|
|
247
189
|
|
|
@@ -271,7 +213,7 @@ const PKG_SPEC = /^(@[a-z0-9~-][\w.-]*\/)?[a-z0-9~-][\w.-]*(@[\w.^~*<>=|x-]+)?$/
|
|
|
271
213
|
|
|
272
214
|
function packageGate(projectDir, command, sessionId) {
|
|
273
215
|
const m = new RegExp(
|
|
274
|
-
String.raw`(
|
|
216
|
+
String.raw`(?:^|\n|&&|;|\|)\s*(?:sudo\s+)?(?:pnpm|npm|yarn|bun)\s+(?:add|install|i)(?=\s|$)([^&;|\n]*)`
|
|
275
217
|
).exec(command);
|
|
276
218
|
if (!m) return null;
|
|
277
219
|
const approved = approvedPackages(projectDir);
|
|
@@ -288,9 +230,11 @@ function packageGate(projectDir, command, sessionId) {
|
|
|
288
230
|
[...approved].filter((n) => n.startsWith("@")).map((n) => n.split("/")[0])
|
|
289
231
|
);
|
|
290
232
|
approvedScopes.add("@types");
|
|
291
|
-
const
|
|
292
|
-
|
|
293
|
-
|
|
233
|
+
const pkgs = raw
|
|
234
|
+
.split(/\s+/)
|
|
235
|
+
.filter((a) => a && !a.startsWith("-") && PKG_SPEC.test(a))
|
|
236
|
+
.map((arg) => (arg.startsWith("@") ? arg.split("@").slice(0, 2).join("@") : arg.split("@")[0]));
|
|
237
|
+
for (const pkg of pkgs) {
|
|
294
238
|
if (approved.has(pkg)) continue;
|
|
295
239
|
if (isDev && pkg.startsWith("@") && approvedScopes.has(pkg.split("/")[0])) continue;
|
|
296
240
|
// Approved wins over rejected: `jest` is banned for web and Express and prescribed for Expo,
|
|
@@ -311,33 +255,11 @@ function packageGate(projectDir, command, sessionId) {
|
|
|
311
255
|
// No decision exists for this package — not approved, not rejected. Allowed, and harvested
|
|
312
256
|
// centrally by the fleet package census rather than blocked here.
|
|
313
257
|
}
|
|
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
|
-
}
|
|
258
|
+
// Every named package passed — a package-table denial for one of them is a successful retry.
|
|
259
|
+
for (const pkg of pkgs) autoResolve(projectDir, sessionId, "package-table", pkg);
|
|
319
260
|
return null;
|
|
320
261
|
}
|
|
321
262
|
|
|
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
263
|
// An ask class fires once per session: the first occurrence asks, later ones pass silently —
|
|
342
264
|
// the user has already exercised judgment on this class this session.
|
|
343
265
|
function askOnce(projectDir, sessionId, key, message) {
|
|
@@ -346,9 +268,11 @@ function askOnce(projectDir, sessionId, key, message) {
|
|
|
346
268
|
return decision("ask", message);
|
|
347
269
|
}
|
|
348
270
|
|
|
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
|
-
|
|
271
|
+
// Command matchers anchor at statement boundaries (start, newline, &&, ;, |) so quoted text — an
|
|
272
|
+
// echoed payload, a commit message naming a command — can never trigger a gate, while a command
|
|
273
|
+
// on its own line cannot slip past one. (Heredoc bodies still look like statements; that false
|
|
274
|
+
// positive is accepted over the false negative.)
|
|
275
|
+
const AT_STATEMENT = String.raw`(?:^|\n|&&|;|\|)\s*(?:sudo\s+)?(?:npx\s+(?:--yes\s+)?)?`;
|
|
352
276
|
const statement = (body) => new RegExp(AT_STATEMENT + body);
|
|
353
277
|
|
|
354
278
|
function bashGate(projectDir, command, sessionId) {
|
|
@@ -460,13 +384,13 @@ function bashWriteScreen(projectDir, command, sessionId) {
|
|
|
460
384
|
// Stop
|
|
461
385
|
// ---------------------------------------------------------------------------
|
|
462
386
|
|
|
463
|
-
// True when the transcript's recent tail shows a bookkeeper dispatch
|
|
464
|
-
//
|
|
387
|
+
// True when the transcript's recent tail shows a REAL bookkeeper dispatch — a tool record naming
|
|
388
|
+
// the agent type, not the word appearing in prose (policy text quoting "bookkeeper" rides in most
|
|
389
|
+
// transcripts, and a prose match silently suppressed this wall). Best-effort: absent or
|
|
390
|
+
// unreadable transcript reads as "not dispatched".
|
|
465
391
|
function bookkeeperDispatched(transcriptPath) {
|
|
466
|
-
const
|
|
467
|
-
|
|
468
|
-
const tail = text.slice(-200000);
|
|
469
|
-
return /bookkeeper/i.test(tail) && /"(Task|Agent)"/.test(tail);
|
|
392
|
+
const tail = readSafe(transcriptPath || "").slice(-200000);
|
|
393
|
+
return /"subagent_type"\s*:\s*"bookkeeper"/.test(tail);
|
|
470
394
|
}
|
|
471
395
|
|
|
472
396
|
// Plans this session flipped to Complete: Status is Complete AND the file appears in this
|
|
@@ -493,19 +417,12 @@ function plansCompletedThisSession(projectDir, sessionId) {
|
|
|
493
417
|
return out;
|
|
494
418
|
}
|
|
495
419
|
|
|
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.
|
|
420
|
+
// Whether some changelog entry already accounts for this plan. An entry names its plan by slug
|
|
421
|
+
// (the template links `../plans/<slug>.md`), so the slug is what is searched for — over every
|
|
422
|
+
// entry on disk, committed or draft. A dirty-but-unrelated file in the changelog directory does
|
|
423
|
+
// NOT count: that shortcut made the wall satisfiable by `touch docs/history/changelog/x.md`,
|
|
424
|
+
// for every plan at once.
|
|
506
425
|
function planHasChangelogEntry(projectDir, planRel) {
|
|
507
|
-
if ((git(projectDir, ["status", "--porcelain", "--", "docs/history/changelog/"]) || "").trim()) return true;
|
|
508
|
-
|
|
509
426
|
const slug = path.basename(planRel, ".md");
|
|
510
427
|
const dir = path.join(projectDir, "docs", "history", "changelog");
|
|
511
428
|
let names = [];
|
|
@@ -517,48 +434,12 @@ function planHasChangelogEntry(projectDir, planRel) {
|
|
|
517
434
|
return names.some((name) => name.endsWith(".md") && readSafe(path.join(dir, name)).includes(slug));
|
|
518
435
|
}
|
|
519
436
|
|
|
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
437
|
function stopGate(projectDir, payload) {
|
|
558
438
|
if (payload.stop_hook_active) return null; // the runtime's infinite-loop guard — never re-block
|
|
559
439
|
|
|
560
440
|
// The Stop wall is scoped to this session's own rows (plus sid-less ones) — a concurrent
|
|
561
|
-
// session's open work must not block this one's stop.
|
|
441
|
+
// session's open work must not block this one's stop. End-of-session accountability is per
|
|
442
|
+
// session and stays that way; the commit wall scopes by staged path instead, and CI by nothing.
|
|
562
443
|
const mine = readQueue(projectDir).filter(
|
|
563
444
|
(row) => !/\bsid=/.test(row.body) || row.body.includes(`sid=${payload.session_id}`)
|
|
564
445
|
);
|
|
@@ -651,16 +532,11 @@ function main() {
|
|
|
651
532
|
if (payload.tool_name === "Bash") {
|
|
652
533
|
out = bashGate(commandRoot(projectDir, payload, input.command), input.command, payload.session_id);
|
|
653
534
|
} else {
|
|
654
|
-
const rel = relIfInside(projectDir, input.file_path
|
|
655
|
-
if (rel) out = fileGate(projectDir, rel, payload.
|
|
535
|
+
const rel = relIfInside(projectDir, input.file_path);
|
|
536
|
+
if (rel) out = fileGate(projectDir, rel, payload.session_id);
|
|
656
537
|
}
|
|
657
538
|
} else if (event === "Stop") {
|
|
658
539
|
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
540
|
}
|
|
665
541
|
|
|
666
542
|
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
|
+
}
|