@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
package/data/harness-index.json
CHANGED
|
@@ -139,7 +139,7 @@ Defaults — ❌ prohibitions hold unless the user explicitly asks otherwise;
|
|
|
139
139
|
- ❌ **Branches & Worktrees [override]** – Never self-create git branches or worktrees; commit straight to the checked-out branch. This replaces any built-in "branch first on the default branch" default. Exception: when the user asks for a pull request, create the branch the PR requires before committing its changes.
|
|
140
140
|
- ❌ **Protected Files** – Once per session, if `docs/operations/protected-files.md` exists, read it and never modify any path it lists; never modify `AGENTS.md` (harness-regenerated) or `protected-files.md` itself.
|
|
141
141
|
- ✅ **Browser Automation** – If the runtime provides browser automation, use it freely; no need to ask first. If it doesn't — or the executing agent can't hold the browser connection (see Runtime Notes) — return a browser-verification checklist (URLs, actions, expected outcomes) for the orchestrating session or the user to run.
|
|
142
|
-
- ✅ **Bookkeeping** — hooks silently append doc-debt rows to `docs/operations/bookkeeping.md`; the commit hook blocks while unresolved
|
|
142
|
+
- ✅ **Bookkeeping** — hooks silently append doc-debt rows to `docs/operations/bookkeeping.md`; the commit hook blocks while any unresolved row **implicates a file the commit stages** — dispatch the `bookkeeper` agent to drain those rows. Rows about files the commit does not touch are whoever raised them's, and are not yours to drain; a row naming no path, a `needs-session:` row, an expired waiver, and a commit staging nothing block regardless. The queue is a queue, never an archive: a drained row is **deleted**, so a fully drained file holds no rows at all. **Every row is a cache of a condition, not a record of an event** — the session-start sweep re-tests each row against the repo and deletes the ones whose condition has died, `waived:` rows included, so the file empties itself as the work lands. What the sweep cannot re-test ages out instead: **a `waived:` row is unresolved once `expires=` passes**, which returns the wall and re-summons the bookkeeper to re-judge the reason. A waiver defers a finding; it never makes one immortal.
|
|
143
143
|
- ✅ **Cleanup removes the cause, not the record of it.** When the user asks for something to be deleted or cleaned up, the ask is for the underlying condition to be gone. Deleting the record while the condition survives is worse than doing nothing — it destroys the only evidence the debt exists. The order is fixed: **read what the record is masking** (a `waived:` row, an `UNBUILT:` marker, a `status:` flag, a lint suppression — each asserts something is fine; check whether that is still true), **fix the condition**, **then delete the record**. Reversing it backfires concretely: a deleted waiver's finding is re-appended by the detector as an `auto:` row, which *fails* the commit wall the `waived:` row passed.
|
|
144
144
|
- ✅ **Delegation** — when to fan out, tiers, and trust are § Delegation.
|
|
145
145
|
- ⚠️ **Plans** – Plans are created directly in `docs/history/plans/` (see Workflows). Once `Status: Complete` or `Status: Abandoned`, the file is frozen — never edited. Abandoning a plan whose code was committed first converts its undischarged Test Obligations into backlog items (the `testing` rule). If scope changes after completion, write a new dated plan that links back to the old one.
|
|
@@ -7,7 +7,7 @@ model: sonnet
|
|
|
7
7
|
|
|
8
8
|
# Bookkeeper Agent
|
|
9
9
|
|
|
10
|
-
You drain the shared work queue at `docs/operations/bookkeeping.md`. Each row is one finding; your job is to resolve it — by doing the work it names — and delete the row. You are the asynchronous half of the harness's enforcement loop: the detector appends rows silently, you drain them, and the
|
|
10
|
+
You drain the shared work queue at `docs/operations/bookkeeping.md`. Each row is one finding; your job is to resolve it — by doing the work it names — and delete the row. You are the asynchronous half of the harness's enforcement loop: the detector appends rows silently, you drain them, and the walls hold until the rows each one reads are clean or waived — Stop reads the ending session's rows, the commit gate reads the rows implicating that commit's staged files, CI reads them all.
|
|
11
11
|
|
|
12
12
|
## Source-of-truth order — the repo first, the transcript last
|
|
13
13
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"harness": "code",
|
|
3
3
|
"name": "code",
|
|
4
|
-
"version": "3.10.
|
|
4
|
+
"version": "3.10.15",
|
|
5
5
|
"files": [
|
|
6
6
|
".prettierignore",
|
|
7
7
|
".prettierrc.json",
|
|
@@ -15,5 +15,5 @@
|
|
|
15
15
|
"docs/memory/main.md",
|
|
16
16
|
".mcp.json"
|
|
17
17
|
],
|
|
18
|
-
"digest": "
|
|
18
|
+
"digest": "f07032c97db1"
|
|
19
19
|
}
|
|
@@ -26,11 +26,13 @@ import {
|
|
|
26
26
|
formatNeedsSessionRow,
|
|
27
27
|
appendRows,
|
|
28
28
|
resolveRows,
|
|
29
|
-
|
|
29
|
+
waiverCovers,
|
|
30
30
|
stateDir,
|
|
31
31
|
recordTouched,
|
|
32
32
|
} from "./lib/state.mjs";
|
|
33
|
-
import {
|
|
33
|
+
import { readSafe, relIfInside, today, SOURCE_EXT, commandRoot } from "./lib/repo.mjs";
|
|
34
|
+
import { listPlans } from "./lib/plans.mjs";
|
|
35
|
+
import { frozenStatusAtHead } from "./lib/frozen.mjs";
|
|
34
36
|
import { approvedPackages, rejectedPackages } from "./lib/approved.mjs";
|
|
35
37
|
import {
|
|
36
38
|
recordSession,
|
|
@@ -42,24 +44,6 @@ import {
|
|
|
42
44
|
} from "./lib/claims.mjs";
|
|
43
45
|
import { appendEvent } from "./lib/session-log.mjs";
|
|
44
46
|
|
|
45
|
-
const today = () => new Date().toISOString().slice(0, 10);
|
|
46
|
-
|
|
47
|
-
function readSafe(file) {
|
|
48
|
-
try {
|
|
49
|
-
return fs.readFileSync(file, "utf8");
|
|
50
|
-
} catch (e) {
|
|
51
|
-
return "";
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
function readJsonSafe(file) {
|
|
56
|
-
try {
|
|
57
|
-
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
58
|
-
} catch (e) {
|
|
59
|
-
return null;
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
|
|
63
47
|
// ---------------------------------------------------------------------------
|
|
64
48
|
// SessionStart
|
|
65
49
|
// ---------------------------------------------------------------------------
|
|
@@ -79,7 +63,12 @@ function detectPackageManager(projectDir) {
|
|
|
79
63
|
}
|
|
80
64
|
|
|
81
65
|
function harnessLine(projectDir) {
|
|
82
|
-
|
|
66
|
+
let marker = null;
|
|
67
|
+
try {
|
|
68
|
+
marker = JSON.parse(readSafe(path.join(projectDir, ".claude", "harness.json")));
|
|
69
|
+
} catch (e) {
|
|
70
|
+
/* absent or unparseable — no line */
|
|
71
|
+
}
|
|
83
72
|
if (!marker || !marker.version) return null;
|
|
84
73
|
const structure = readSafe(path.join(projectDir, "docs", "structure.md"));
|
|
85
74
|
const stamped = (/harness-version:\s*([\w.-]+)/.exec(frontmatter(structure) || structure) || [])[1];
|
|
@@ -104,26 +93,14 @@ function memoryLine(projectDir) {
|
|
|
104
93
|
}
|
|
105
94
|
|
|
106
95
|
function openPlanLines(projectDir) {
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
for (const file of entries) {
|
|
116
|
-
const text = readSafe(path.join(dir, file));
|
|
117
|
-
const status = (/^\*\*Status:\*\*\s*(.+)$/m.exec(text) || [])[1]?.trim();
|
|
118
|
-
if (!status || /Complete|Abandoned/.test(status)) continue;
|
|
119
|
-
const gates = (/^\*\*Gates:\*\*\s*(.+)$/m.exec(text) || [])[1]?.trim();
|
|
120
|
-
const ledgerRows = (text.match(/^\|\s*\d+\s*\|/gm) || []).length;
|
|
121
|
-
const parts = [`Open plan: ${file} (${status}`];
|
|
122
|
-
if (gates) parts.push(` · Gates: ${gates}`);
|
|
123
|
-
if (ledgerRows) parts.push(` · ledger: ${ledgerRows} rows`);
|
|
124
|
-
lines.push(parts.join("") + ")");
|
|
125
|
-
}
|
|
126
|
-
return lines;
|
|
96
|
+
return listPlans(projectDir)
|
|
97
|
+
.filter((p) => !/Complete|Abandoned/.test(p.status))
|
|
98
|
+
.map((p) => {
|
|
99
|
+
const parts = [`Open plan: ${p.file} (${p.status}`];
|
|
100
|
+
if (p.gates) parts.push(` · Gates: ${p.gates}`);
|
|
101
|
+
if (p.ledgerRows) parts.push(` · ledger: ${p.ledgerRows} rows`);
|
|
102
|
+
return parts.join("") + ")";
|
|
103
|
+
});
|
|
127
104
|
}
|
|
128
105
|
|
|
129
106
|
// --- the re-verification sweep ---------------------------------------------------------------
|
|
@@ -190,6 +167,45 @@ const ROW_VERIFIERS = {
|
|
|
190
167
|
if (srcTime === null) return true; // the source is gone; nothing implicates the doc any more
|
|
191
168
|
return docTime >= srcTime;
|
|
192
169
|
},
|
|
170
|
+
|
|
171
|
+
// "<rel> has N doc-lint finding(s)" — dead once a re-run comes back clean (or the doc is gone).
|
|
172
|
+
"doc-lint": (projectDir, body) => {
|
|
173
|
+
const m = /·\s+doc-lint\s+·\s+(\S+)\s+has/.exec(body);
|
|
174
|
+
if (!m) return false;
|
|
175
|
+
if (!fs.existsSync(path.join(projectDir, m[1]))) return true;
|
|
176
|
+
return docLint(projectDir, m[1]).length === 0;
|
|
177
|
+
},
|
|
178
|
+
|
|
179
|
+
// "<rel> is not linked from docs/README.md" — dead once the index names it (or it is gone).
|
|
180
|
+
"index-row-missing": (projectDir, body) => {
|
|
181
|
+
const m = /·\s+index-row-missing\s+·\s+(\S+)\s+is not linked/.exec(body);
|
|
182
|
+
if (!m) return false;
|
|
183
|
+
if (!fs.existsSync(path.join(projectDir, m[1]))) return true;
|
|
184
|
+
return readSafe(path.join(projectDir, "docs", "README.md")).includes(path.basename(m[1]));
|
|
185
|
+
},
|
|
186
|
+
|
|
187
|
+
// "<rel> declares `<name>` (package-rejected)" — dead once the manifest drops the package
|
|
188
|
+
// or the table stops rejecting it.
|
|
189
|
+
"package-rejected": (projectDir, body) => {
|
|
190
|
+
const m = /·\s+package-rejected\s+·\s+(\S+)\s+declares\s+`([^`]+)`/.exec(body);
|
|
191
|
+
if (!m) return false;
|
|
192
|
+
const [, rel, name] = m;
|
|
193
|
+
let manifest;
|
|
194
|
+
try {
|
|
195
|
+
manifest = JSON.parse(readSafe(path.join(projectDir, rel)));
|
|
196
|
+
} catch (e) {
|
|
197
|
+
return !fs.existsSync(path.join(projectDir, rel)); // gone ⇒ resolved; unparseable ⇒ standing
|
|
198
|
+
}
|
|
199
|
+
const declared = { ...manifest.dependencies, ...manifest.devDependencies };
|
|
200
|
+
if (!(name in declared)) return true;
|
|
201
|
+
return !rejectedPackages(projectDir).has(name);
|
|
202
|
+
},
|
|
203
|
+
|
|
204
|
+
// Retired detector kinds — the harness no longer raises these, so a standing row caches a
|
|
205
|
+
// condition nothing can re-derive. Swept unconditionally — but only machine-raised rows: a
|
|
206
|
+
// note: or waived: row whose free text merely mentions the phrase is a human's, not ours.
|
|
207
|
+
"doc-placement": (projectDir, body, ctx, row) => row.kind === "auto",
|
|
208
|
+
"boundary-scan-suggested": (projectDir, body, ctx, row) => row.kind === "auto",
|
|
193
209
|
};
|
|
194
210
|
|
|
195
211
|
// Delete every row — `auto:` or `waived:` — whose condition no longer holds. Returns the count.
|
|
@@ -200,7 +216,7 @@ export function sweepResolvedRows(projectDir, repoFiles) {
|
|
|
200
216
|
for (const [kind, verify] of Object.entries(ROW_VERIFIERS)) {
|
|
201
217
|
if (!row.body.includes(kind)) continue;
|
|
202
218
|
try {
|
|
203
|
-
return verify(projectDir, row.body, ctx);
|
|
219
|
+
return verify(projectDir, row.body, ctx, row);
|
|
204
220
|
} catch (e) {
|
|
205
221
|
return false; // never delete a row because a verifier threw
|
|
206
222
|
}
|
|
@@ -215,56 +231,40 @@ function driftBanner(projectDir) {
|
|
|
215
231
|
// Re-verify before counting: the walls must act on conditions that are live NOW, not on rows
|
|
216
232
|
// that happen to still be written down.
|
|
217
233
|
const swept = sweepResolvedRows(projectDir, repoFiles);
|
|
218
|
-
const
|
|
234
|
+
const docs = scanFeatureDocs(projectDir);
|
|
235
|
+
const dead = deadGlobs(projectDir, repoFiles, docs);
|
|
219
236
|
const queueRows = readQueue(projectDir);
|
|
220
237
|
const counts = countRows(queueRows);
|
|
221
|
-
const
|
|
222
|
-
const unwaivedDead = dead.filter((d) => !waivers.some((w) => w.body.includes(d.glob)));
|
|
223
|
-
|
|
224
|
-
// A waiver becomes a wall the day it expires, so the queue says so a week ahead — the deferral
|
|
225
|
-
// ending should be a date somebody saw coming, not a commit that suddenly blocks.
|
|
226
|
-
const soon = new Date(Date.now() + 7 * 864e5).toISOString().slice(0, 10);
|
|
227
|
-
const expiringSoon = queueRows
|
|
228
|
-
.filter((r) => r.kind === "waived")
|
|
229
|
-
.map((r) => {
|
|
230
|
-
const d = (/expires=(\d{4}-\d{2}-\d{2})/.exec(r.body) || [])[1];
|
|
231
|
-
if (!d || d < today() || d > soon) return null;
|
|
232
|
-
// Drop the row's own raised-date prefix and its reason tail — the expiry and the finding are
|
|
233
|
-
// what a reader is scanning for here.
|
|
234
|
-
const finding = r.body.replace(/^\d{4}-\d{2}-\d{2}\s+·\s+/, "").split(" · reason:")[0];
|
|
235
|
-
return `expires ${d} · ${finding.slice(0, 120)}`;
|
|
236
|
-
})
|
|
237
|
-
.filter(Boolean)
|
|
238
|
-
.sort();
|
|
238
|
+
const unwaivedDead = dead.filter((d) => !waiverCovers(queueRows, "dead-glob", d.glob, today()));
|
|
239
239
|
|
|
240
240
|
// A queued dead-glob row covered by an unexpired waiver is resolved history — drop it,
|
|
241
241
|
// so waiving a finding class actually empties the rows it supersedes.
|
|
242
242
|
resolveRows(projectDir, (row) => {
|
|
243
243
|
if (row.kind !== "auto" || !row.body.includes("dead-glob")) return false;
|
|
244
244
|
const m = /`([^`]+)`/.exec(row.body);
|
|
245
|
-
return !!m &&
|
|
245
|
+
return !!m && waiverCovers(queueRows, "dead-glob", m[1], today());
|
|
246
246
|
});
|
|
247
247
|
|
|
248
|
-
// Docs older than the code they own (doc mtime < newest owned-file mtime)
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
docTime = fs.statSync(path.join(projectDir, entry.doc)).mtimeMs;
|
|
254
|
-
} catch (e) {
|
|
255
|
-
continue;
|
|
256
|
-
}
|
|
257
|
-
const res = entry.owns.map((g) => globToRegExp(g));
|
|
258
|
-
let newest = 0;
|
|
259
|
-
for (const file of repoFiles) {
|
|
260
|
-
if (!res.some((re) => re.test(file))) continue;
|
|
248
|
+
// Docs older than the code they own (doc mtime < newest owned-file mtime) — one stat per
|
|
249
|
+
// repo file, cached across docs, instead of docs × files stats.
|
|
250
|
+
const mtimes = new Map();
|
|
251
|
+
const mtimeOf = (rel) => {
|
|
252
|
+
if (!mtimes.has(rel)) {
|
|
261
253
|
try {
|
|
262
|
-
|
|
254
|
+
mtimes.set(rel, fs.statSync(path.join(projectDir, rel)).mtimeMs);
|
|
263
255
|
} catch (e) {
|
|
264
|
-
|
|
256
|
+
mtimes.set(rel, null);
|
|
265
257
|
}
|
|
266
258
|
}
|
|
267
|
-
|
|
259
|
+
return mtimes.get(rel);
|
|
260
|
+
};
|
|
261
|
+
const staleDocs = [];
|
|
262
|
+
for (const entry of docs) {
|
|
263
|
+
const docTime = mtimeOf(entry.doc);
|
|
264
|
+
if (docTime === null) continue;
|
|
265
|
+
const res = entry.owns.map((g) => globToRegExp(g));
|
|
266
|
+
const stale = repoFiles.some((file) => res.some((re) => re.test(file)) && (mtimeOf(file) ?? 0) > docTime);
|
|
267
|
+
if (stale) staleDocs.push(entry.doc);
|
|
268
268
|
}
|
|
269
269
|
|
|
270
270
|
const report = [
|
|
@@ -277,9 +277,6 @@ function driftBanner(projectDir) {
|
|
|
277
277
|
...staleDocs.map((d) => `- ${d}`),
|
|
278
278
|
"",
|
|
279
279
|
`## Bookkeeping queue (${counts.unresolved} unresolved: ${counts.auto} auto · ${counts.note} note · ${counts.needsSession} needs-session · ${counts.expiredWaived} expired-waived; ${counts.waived} waived total, ${swept} swept as resolved this session)`,
|
|
280
|
-
...(expiringSoon.length
|
|
281
|
-
? ["", `## Waivers expiring within 7 days (${expiringSoon.length})`, ...expiringSoon.map((w) => `- ${w}`)]
|
|
282
|
-
: []),
|
|
283
280
|
].join("\n");
|
|
284
281
|
try {
|
|
285
282
|
fs.writeFileSync(path.join(stateDir(projectDir), "drift-report.md"), report + "\n");
|
|
@@ -301,16 +298,10 @@ function driftBanner(projectDir) {
|
|
|
301
298
|
|
|
302
299
|
function sessionStart(projectDir, payload = {}) {
|
|
303
300
|
// Stamp the session's start so gitless repos still have a "modified since" horizon for the
|
|
304
|
-
// outcome detector's fallback scan (dirtyPaths).
|
|
305
|
-
// the per-sid twin exists because two concurrent sessions clobber the shared one. Best-effort.
|
|
301
|
+
// outcome detector's fallback scan (dirtyPaths). Best-effort.
|
|
306
302
|
const sid = payload.session_id;
|
|
307
|
-
const stamp = new Date().toISOString() + "\n";
|
|
308
303
|
try {
|
|
309
|
-
fs.writeFileSync(path.join(stateDir(projectDir), "session-started"),
|
|
310
|
-
if (sid) {
|
|
311
|
-
const short = String(sid).replace(/[^a-zA-Z0-9-]/g, "").slice(0, 8);
|
|
312
|
-
fs.writeFileSync(path.join(stateDir(projectDir), `session-started-${short}`), stamp);
|
|
313
|
-
}
|
|
304
|
+
fs.writeFileSync(path.join(stateDir(projectDir), "session-started"), new Date().toISOString() + "\n");
|
|
314
305
|
} catch (e) {
|
|
315
306
|
/* the stamp is a convenience for the fallback; git repos never need it */
|
|
316
307
|
}
|
|
@@ -350,6 +341,14 @@ function sessionStart(projectDir, payload = {}) {
|
|
|
350
341
|
const memory = memoryLine(projectDir);
|
|
351
342
|
if (memory) lines.push(memory);
|
|
352
343
|
lines.push(...openPlanLines(projectDir).slice(0, 4));
|
|
344
|
+
// A gate that crashed or skipped a write left a trace here — surface the count so the
|
|
345
|
+
// failure log finally has its reader.
|
|
346
|
+
try {
|
|
347
|
+
const failures = readSafe(path.join(stateDir(projectDir), "hook-failures.log")).split("\n").filter(Boolean);
|
|
348
|
+
if (failures.length) lines.push(`Hook failures logged: ${failures.length} (.claude/state/hook-failures.log) — enforcement may have gaps.`);
|
|
349
|
+
} catch (e) {
|
|
350
|
+
/* the count is a convenience */
|
|
351
|
+
}
|
|
353
352
|
try {
|
|
354
353
|
const drift = driftBanner(projectDir);
|
|
355
354
|
if (drift) lines.push(drift);
|
|
@@ -373,15 +372,6 @@ function sessionStart(projectDir, payload = {}) {
|
|
|
373
372
|
// PostToolUse — the silent detector
|
|
374
373
|
// ---------------------------------------------------------------------------
|
|
375
374
|
|
|
376
|
-
const SOURCE_EXT = /\.(m?[jt]sx?|py|rs|go|rb|java|kt|swift|c|h|cpp|hpp|cs|sh|sql|css|scss|vue|svelte)$/;
|
|
377
|
-
|
|
378
|
-
function relIfInside(projectDir, filePath) {
|
|
379
|
-
if (!filePath) return null;
|
|
380
|
-
const rel = path.relative(projectDir, path.resolve(projectDir, filePath));
|
|
381
|
-
if (rel.startsWith("..") || path.isAbsolute(rel)) return null;
|
|
382
|
-
return rel.split(path.sep).join("/");
|
|
383
|
-
}
|
|
384
|
-
|
|
385
375
|
// Spawn the installed doc-lint over one file; returns finding lines (empty when absent/clean).
|
|
386
376
|
function docLint(projectDir, rel) {
|
|
387
377
|
const script = path.join(projectDir, ".claude", "skills", "documentation", "scripts", "doc-lint.mjs");
|
|
@@ -441,20 +431,6 @@ function detectorForFile(projectDir, rel, meta, rows) {
|
|
|
441
431
|
);
|
|
442
432
|
}
|
|
443
433
|
}
|
|
444
|
-
const segment = rel.split("/")[1];
|
|
445
|
-
if (rel.startsWith("docs/") && segment && !segment.endsWith(".md")) {
|
|
446
|
-
const structure = readSafe(path.join(projectDir, "docs", "structure.md"));
|
|
447
|
-
if (structure && !structure.includes(`${segment}/`) && !structure.includes(`docs/${segment}`)) {
|
|
448
|
-
rows.push(
|
|
449
|
-
formatAutoRow({
|
|
450
|
-
...meta,
|
|
451
|
-
kind: "doc-placement",
|
|
452
|
-
description: `${rel} sits in a folder absent from docs/structure.md's authority map`,
|
|
453
|
-
action: "move the doc per the add-a-doc table, or record the new folder in structure.md",
|
|
454
|
-
})
|
|
455
|
-
);
|
|
456
|
-
}
|
|
457
|
-
}
|
|
458
434
|
if (rel.startsWith("docs/features/") && !readSafe(path.join(projectDir, "docs", "README.md")).includes(path.basename(rel))) {
|
|
459
435
|
rows.push(
|
|
460
436
|
formatAutoRow({
|
|
@@ -489,24 +465,14 @@ function detectorForFile(projectDir, rel, meta, rows) {
|
|
|
489
465
|
})
|
|
490
466
|
);
|
|
491
467
|
}
|
|
492
|
-
if (/(schema|init\.sql|migration|auth|webhook|\benv\b|wrangler)/i.test(rel)) {
|
|
493
|
-
rows.push(
|
|
494
|
-
formatAutoRow({
|
|
495
|
-
...meta,
|
|
496
|
-
kind: "boundary-scan-suggested",
|
|
497
|
-
description: `${rel} is contract-heavy`,
|
|
498
|
-
action: "run the boundary skill scoped to the diff",
|
|
499
|
-
})
|
|
500
|
-
);
|
|
501
|
-
}
|
|
502
468
|
}
|
|
503
469
|
}
|
|
504
470
|
|
|
505
471
|
function detectRenames(projectDir, command, meta, rows) {
|
|
506
472
|
if (!/\bgit mv\b|\bmv\b/.test(command || "")) return;
|
|
507
|
-
const
|
|
473
|
+
const queueRows = readQueue(projectDir);
|
|
508
474
|
for (const { glob, doc } of deadGlobs(projectDir)) {
|
|
509
|
-
if (
|
|
475
|
+
if (waiverCovers(queueRows, "dead-glob", glob, today())) continue;
|
|
510
476
|
rows.push(
|
|
511
477
|
formatAutoRow({
|
|
512
478
|
...meta,
|
|
@@ -612,7 +578,9 @@ function detectOutcomes(projectDir, meta, rows) {
|
|
|
612
578
|
}
|
|
613
579
|
}
|
|
614
580
|
if (rel.startsWith("docs/history/")) {
|
|
615
|
-
|
|
581
|
+
// Frozen AT HEAD, not in the worktree: the completion flip makes a plan dirty+Complete in
|
|
582
|
+
// one edit, and the worktree predicate walled the very commit that ceremony requires.
|
|
583
|
+
const frozen = frozenStatusAtHead(projectDir, rel);
|
|
616
584
|
if (frozen.frozen && !raised(`${rel} is a frozen record`)) {
|
|
617
585
|
rows.push(
|
|
618
586
|
formatAutoRow({
|
|
@@ -638,11 +606,10 @@ function postToolUse(projectDir, payload) {
|
|
|
638
606
|
const rows = [];
|
|
639
607
|
if (payload.tool_name === "Bash") {
|
|
640
608
|
// A completed `git commit` releases the claim compliance took at PreToolUse — the `end` line
|
|
641
|
-
// is what lets a waiting session verify the actor finished. Root resolution
|
|
642
|
-
//
|
|
643
|
-
if (/(
|
|
644
|
-
const
|
|
645
|
-
const root = cdMatch && path.isAbsolute(cdMatch[1]) && fs.existsSync(cdMatch[1]) ? cdMatch[1] : projectDir;
|
|
609
|
+
// is what lets a waiting session verify the actor finished. Root resolution is the gate's own
|
|
610
|
+
// `commandRoot`, shared, so a claim and its release can never resolve to different repos.
|
|
611
|
+
if (/(?:^|\n|&&|;|\|)\s*(?:sudo\s+)?git\s+commit\b/.test(input.command || "")) {
|
|
612
|
+
const root = commandRoot(projectDir, payload, input.command);
|
|
646
613
|
try {
|
|
647
614
|
releaseActivity(root, "commit", payload.session_id);
|
|
648
615
|
appendEvent(root, payload.session_id, "coord", "commit-claim", "released");
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
// harness/code/hooks/check-bookkeeping-clean.mjs — CI backstop: no unresolved bookkeeping rows
|
|
2
2
|
// (auto:/note:/needs-session:) may reach the remote. waived: rows pass — they are the recorded,
|
|
3
3
|
// PR-reviewable exceptions. Exit 1 when unresolved rows exist.
|
|
4
|
+
//
|
|
5
|
+
// THIS CHECK IS GLOBAL AND STAYS GLOBAL. The commit gate scopes to one commit's staged set so
|
|
6
|
+
// concurrent agents in one checkout do not wall each other; CI judges the branch as a whole, where
|
|
7
|
+
// there is no staged set to scope by and every row has had every commit to be drained in.
|
|
4
8
|
|
|
5
9
|
import { readQueue, countRows } from "./lib/state.mjs";
|
|
6
10
|
|
|
@@ -3,15 +3,11 @@
|
|
|
3
3
|
// judged mechanically: lockfile-only, harness-file-only, pure renames, changelog housekeeping,
|
|
4
4
|
// or a bot author. Exit 1 when a non-exempt HEAD stages no entry.
|
|
5
5
|
|
|
6
|
-
import {
|
|
7
|
-
import
|
|
6
|
+
import { changelogExemptFile } from "./lib/exempt.mjs";
|
|
7
|
+
import { git as gitAt } from "./lib/repo.mjs";
|
|
8
8
|
|
|
9
9
|
const projectDir = process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
10
|
-
|
|
11
|
-
function git(args) {
|
|
12
|
-
const res = spawnSync("git", args, { cwd: projectDir, encoding: "utf8" });
|
|
13
|
-
return res.status === 0 ? (res.stdout || "").trim() : null;
|
|
14
|
-
}
|
|
10
|
+
const git = (args) => gitAt(projectDir, args);
|
|
15
11
|
|
|
16
12
|
const nameStatus = git(["diff-tree", "--root", "--no-commit-id", "--name-status", "-r", "HEAD"]) || "";
|
|
17
13
|
const entries = nameStatus
|
|
@@ -22,28 +18,11 @@ const entries = nameStatus
|
|
|
22
18
|
return { status, file: files[files.length - 1] };
|
|
23
19
|
});
|
|
24
20
|
|
|
25
|
-
const LOCKFILES = /^(pnpm-lock\.yaml|package-lock\.json|yarn\.lock|bun\.lockb?|Cargo\.lock)$/;
|
|
26
|
-
const HARNESS_PREFIXES = [".claude/", ".agents/", ".codex/"];
|
|
27
|
-
const HARNESS_FILES = [
|
|
28
|
-
"AGENTS.md",
|
|
29
|
-
"CLAUDE.md",
|
|
30
|
-
".mcp.json",
|
|
31
|
-
".vscode/settings.json",
|
|
32
|
-
".prettierignore",
|
|
33
|
-
".prettierrc.json",
|
|
34
|
-
];
|
|
35
|
-
|
|
36
|
-
const exemptFile = (f) =>
|
|
37
|
-
LOCKFILES.test(path.basename(f)) ||
|
|
38
|
-
HARNESS_PREFIXES.some((p) => f.startsWith(p)) ||
|
|
39
|
-
HARNESS_FILES.includes(f) ||
|
|
40
|
-
f.startsWith("docs/history/changelog/") ||
|
|
41
|
-
f === "docs/history/CHANGELOG.md";
|
|
42
21
|
|
|
43
22
|
const author = git(["log", "-1", "--format=%an <%ae>"]) || "";
|
|
44
23
|
const isBot = /\[bot\]|actions@github\.com/i.test(author);
|
|
45
24
|
const allRenames = entries.length > 0 && entries.every((e) => e.status.startsWith("R"));
|
|
46
|
-
const allExempt = entries.length === 0 || entries.every((e) =>
|
|
25
|
+
const allExempt = entries.length === 0 || entries.every((e) => changelogExemptFile(e.file));
|
|
47
26
|
const hasEntry = entries.some((e) => e.file.startsWith("docs/history/changelog/") && e.file.endsWith(".md"));
|
|
48
27
|
|
|
49
28
|
if (isBot || allRenames || allExempt || hasEntry) {
|
|
@@ -8,18 +8,11 @@ import fs from "node:fs";
|
|
|
8
8
|
import path from "node:path";
|
|
9
9
|
import { frontmatter, field } from "./lib/ownership.mjs";
|
|
10
10
|
import { undischargedRows } from "./lib/ledger.mjs";
|
|
11
|
+
import { readSafe } from "./lib/repo.mjs";
|
|
11
12
|
|
|
12
13
|
const projectDir = process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
13
14
|
const plansDir = path.join(projectDir, "docs", "history", "plans");
|
|
14
15
|
|
|
15
|
-
function readSafe(file) {
|
|
16
|
-
try {
|
|
17
|
-
return fs.readFileSync(file, "utf8");
|
|
18
|
-
} catch (e) {
|
|
19
|
-
return "";
|
|
20
|
-
}
|
|
21
|
-
}
|
|
22
|
-
|
|
23
16
|
let failures = 0;
|
|
24
17
|
const fail = (msg) => {
|
|
25
18
|
failures += 1;
|
|
@@ -6,14 +6,14 @@
|
|
|
6
6
|
// leaves "most specific wins" unable to pick an owner
|
|
7
7
|
//
|
|
8
8
|
// Either is silenced by an unexpired `waived:` row in docs/operations/bookkeeping.md naming its
|
|
9
|
-
// kind plus the offending glob (dead-glob) or the shared path (ownership-overlap)
|
|
9
|
+
// kind plus the offending glob (dead-glob) or the shared path (ownership-overlap) in backticks.
|
|
10
10
|
// Exit 1 on anything unwaived.
|
|
11
11
|
//
|
|
12
12
|
// A glob at strictly HIGHER specificity carving a sub-path out of a broader `**` is the designed
|
|
13
13
|
// layering, not a conflict — only an exact tie is reported.
|
|
14
14
|
|
|
15
15
|
import { deadGlobs, listRepoFiles, overlaps } from "./lib/ownership.mjs";
|
|
16
|
-
import { readQueue,
|
|
16
|
+
import { readQueue, waiverCovers } from "./lib/state.mjs";
|
|
17
17
|
|
|
18
18
|
const projectDir = process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
19
19
|
const today = new Date().toISOString().slice(0, 10);
|
|
@@ -22,12 +22,10 @@ const files = listRepoFiles(projectDir);
|
|
|
22
22
|
const queue = readQueue(projectDir);
|
|
23
23
|
|
|
24
24
|
const dead = deadGlobs(projectDir, files);
|
|
25
|
-
const
|
|
26
|
-
const unwaivedDead = dead.filter((d) => !deadWaivers.some((w) => w.body.includes(d.glob)));
|
|
25
|
+
const unwaivedDead = dead.filter((d) => !waiverCovers(queue, "dead-glob", d.glob, today));
|
|
27
26
|
|
|
28
27
|
const conflicts = overlaps(projectDir, files);
|
|
29
|
-
const
|
|
30
|
-
const unwaivedOverlaps = conflicts.filter((c) => !overlapWaivers.some((w) => w.body.includes(c.example)));
|
|
28
|
+
const unwaivedOverlaps = conflicts.filter((c) => !waiverCovers(queue, "ownership-overlap", c.example, today));
|
|
31
29
|
|
|
32
30
|
if (unwaivedDead.length === 0 && unwaivedOverlaps.length === 0) {
|
|
33
31
|
console.log(
|
|
@@ -39,7 +37,7 @@ if (unwaivedDead.length === 0 && unwaivedOverlaps.length === 0) {
|
|
|
39
37
|
if (unwaivedDead.length > 0) {
|
|
40
38
|
console.error(`check-ownership: ${unwaivedDead.length} unwaived dead owns: glob(s):`);
|
|
41
39
|
for (const d of unwaivedDead) console.error(` ${d.glob} → ${d.doc}`);
|
|
42
|
-
console.error("Fix the owning doc's owns: frontmatter, or add a `waived:` dead-glob row with reason + expires=.");
|
|
40
|
+
console.error("Fix the owning doc's owns: frontmatter, or add a `waived:` dead-glob row naming the glob in backticks, with reason + expires=.");
|
|
43
41
|
}
|
|
44
42
|
|
|
45
43
|
if (unwaivedOverlaps.length > 0) {
|
|
@@ -50,7 +48,7 @@ if (unwaivedOverlaps.length > 0) {
|
|
|
50
48
|
console.error(` ${c.b.glob} → ${c.b.doc}`);
|
|
51
49
|
}
|
|
52
50
|
console.error(
|
|
53
|
-
"Narrow one doc's glob so a single owner wins, or add a `waived:` ownership-overlap row naming the shared path with reason + expires=.",
|
|
51
|
+
"Narrow one doc's glob so a single owner wins, or add a `waived:` ownership-overlap row naming the shared path in backticks with reason + expires=.",
|
|
54
52
|
);
|
|
55
53
|
}
|
|
56
54
|
|