@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
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 rows remain — dispatch the `bookkeeper` agent to drain the queue. The queue is a queue, never an archive: a drained row is **deleted**, so a fully drained file holds no rows at all.
|
|
142
|
+
- ✅ **Bookkeeping** — hooks silently append doc-debt rows to `docs/operations/bookkeeping.md`; the commit hook blocks while unresolved rows remain — dispatch the `bookkeeper` agent to drain the queue. 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.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"harness": "code",
|
|
3
3
|
"name": "code",
|
|
4
|
-
"version": "3.10.
|
|
4
|
+
"version": "3.10.14",
|
|
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": "98eb07166e9f"
|
|
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,65 +93,178 @@ function memoryLine(projectDir) {
|
|
|
104
93
|
}
|
|
105
94
|
|
|
106
95
|
function openPlanLines(projectDir) {
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// --- the re-verification sweep ---------------------------------------------------------------
|
|
107
|
+
//
|
|
108
|
+
// THE QUEUE IS DERIVED, NOT ACCUMULATED. Every finding here is recomputable from the repo, so a row
|
|
109
|
+
// is a cache of a condition rather than a record of an event — and a cache nothing invalidates is
|
|
110
|
+
// an archive. Before any wall reads the queue, each row is re-tested against the repo as it is NOW,
|
|
111
|
+
// and a row whose condition has died is deleted.
|
|
112
|
+
//
|
|
113
|
+
// THIS RUNS OVER `waived:` ROWS TOO, WHICH IS THE POINT. A waiver defers a finding; it does not
|
|
114
|
+
// make the finding immortal. Without this, waiving was strictly WORSE than leaving a row alone —
|
|
115
|
+
// the one existing resolver filtered `kind === "auto"`, so waiving a self-clearing row opted it out
|
|
116
|
+
// of its own return path forever. The condition, not the row's kind, decides.
|
|
117
|
+
//
|
|
118
|
+
// A kind with no verifier here is not swept — it ages out through `expires=` instead. Add a
|
|
119
|
+
// verifier whenever a finding becomes mechanically checkable; silence is the only failure mode
|
|
120
|
+
// this file has, so a verifier that is unsure must return false and leave the row standing.
|
|
121
|
+
const ROW_VERIFIERS = {
|
|
122
|
+
// "<rel> lacks its file-path header comment" — dead once the header is there (or the file is gone).
|
|
123
|
+
"header-missing": (projectDir, body) => {
|
|
124
|
+
const m = /·\s+header-missing\s+·\s+(\S+)\s+lacks/.exec(body);
|
|
125
|
+
if (!m) return false;
|
|
126
|
+
const rel = m[1];
|
|
127
|
+
if (!fs.existsSync(path.join(projectDir, rel))) return true;
|
|
128
|
+
return hasHeaderComment(projectDir, rel);
|
|
129
|
+
},
|
|
130
|
+
|
|
131
|
+
// "owns: glob `<glob>` in <doc> matches no files" — dead once the glob matches something again.
|
|
132
|
+
"dead-glob": (projectDir, body, ctx) => {
|
|
133
|
+
const m = /`([^`]+)`/.exec(body);
|
|
134
|
+
if (!m) return false;
|
|
135
|
+
const re = globToRegExp(m[1]);
|
|
136
|
+
return ctx.repoFiles.some((f) => re.test(f));
|
|
137
|
+
},
|
|
138
|
+
|
|
139
|
+
// "<rel> is a frozen record and carries uncommitted modifications" — dead once it is committed.
|
|
140
|
+
// Immutability begins at commit, so the finding exists only while the edit is still in flight.
|
|
141
|
+
"frozen-record-edited": (projectDir, body) => {
|
|
142
|
+
const m = /·\s+frozen-record-edited\s+·\s+(\S+)\s+is a frozen record/.exec(body);
|
|
143
|
+
if (!m) return false;
|
|
144
|
+
const rel = m[1];
|
|
145
|
+
if (!fs.existsSync(path.join(projectDir, rel))) return true;
|
|
146
|
+
const res = spawnSync("git", ["diff", "--quiet", "HEAD", "--", rel], { cwd: projectDir, timeout: 5000 });
|
|
147
|
+
// status 0 = no uncommitted modifications. A git failure leaves the row standing.
|
|
148
|
+
return res.status === 0;
|
|
149
|
+
},
|
|
150
|
+
|
|
151
|
+
// "<src> changed; owning doc <doc> may need an update" — dead once the doc is newer than the
|
|
152
|
+
// source that implicated it, which is the same freshness test the drift report already applies.
|
|
153
|
+
"doc-implicated": (projectDir, body) => {
|
|
154
|
+
const m = /·\s+doc-implicated\s+·\s+(\S+)\s+changed;\s+owning doc\s+(\S+?)\s+may need/.exec(body);
|
|
155
|
+
if (!m) return false;
|
|
156
|
+
const [, src, doc] = m;
|
|
157
|
+
const mtime = (rel) => {
|
|
158
|
+
try {
|
|
159
|
+
return fs.statSync(path.join(projectDir, rel)).mtimeMs;
|
|
160
|
+
} catch (e) {
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
const docTime = mtime(doc);
|
|
165
|
+
const srcTime = mtime(src);
|
|
166
|
+
if (docTime === null) return false; // the doc is gone — a real finding, not a resolved one
|
|
167
|
+
if (srcTime === null) return true; // the source is gone; nothing implicates the doc any more
|
|
168
|
+
return docTime >= srcTime;
|
|
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",
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
// Delete every row — `auto:` or `waived:` — whose condition no longer holds. Returns the count.
|
|
212
|
+
export function sweepResolvedRows(projectDir, repoFiles) {
|
|
213
|
+
const ctx = { repoFiles: repoFiles || listRepoFiles(projectDir) };
|
|
214
|
+
return resolveRows(projectDir, (row) => {
|
|
215
|
+
if (row.kind === "needs-session") return false; // a human judgment, never swept mechanically
|
|
216
|
+
for (const [kind, verify] of Object.entries(ROW_VERIFIERS)) {
|
|
217
|
+
if (!row.body.includes(kind)) continue;
|
|
218
|
+
try {
|
|
219
|
+
return verify(projectDir, row.body, ctx, row);
|
|
220
|
+
} catch (e) {
|
|
221
|
+
return false; // never delete a row because a verifier threw
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return false;
|
|
225
|
+
});
|
|
127
226
|
}
|
|
128
227
|
|
|
129
228
|
// Compute drift facts once; write the full report, return the one-line banner.
|
|
130
229
|
function driftBanner(projectDir) {
|
|
131
230
|
const repoFiles = listRepoFiles(projectDir);
|
|
132
|
-
|
|
231
|
+
// Re-verify before counting: the walls must act on conditions that are live NOW, not on rows
|
|
232
|
+
// that happen to still be written down.
|
|
233
|
+
const swept = sweepResolvedRows(projectDir, repoFiles);
|
|
234
|
+
const docs = scanFeatureDocs(projectDir);
|
|
235
|
+
const dead = deadGlobs(projectDir, repoFiles, docs);
|
|
133
236
|
const queueRows = readQueue(projectDir);
|
|
134
237
|
const counts = countRows(queueRows);
|
|
135
|
-
const
|
|
136
|
-
const unwaivedDead = dead.filter((d) => !waivers.some((w) => w.body.includes(d.glob)));
|
|
238
|
+
const unwaivedDead = dead.filter((d) => !waiverCovers(queueRows, "dead-glob", d.glob, today()));
|
|
137
239
|
|
|
138
240
|
// A queued dead-glob row covered by an unexpired waiver is resolved history — drop it,
|
|
139
241
|
// so waiving a finding class actually empties the rows it supersedes.
|
|
140
242
|
resolveRows(projectDir, (row) => {
|
|
141
243
|
if (row.kind !== "auto" || !row.body.includes("dead-glob")) return false;
|
|
142
244
|
const m = /`([^`]+)`/.exec(row.body);
|
|
143
|
-
return !!m &&
|
|
245
|
+
return !!m && waiverCovers(queueRows, "dead-glob", m[1], today());
|
|
144
246
|
});
|
|
145
247
|
|
|
146
|
-
// Docs older than the code they own (doc mtime < newest owned-file mtime)
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
docTime = fs.statSync(path.join(projectDir, entry.doc)).mtimeMs;
|
|
152
|
-
} catch (e) {
|
|
153
|
-
continue;
|
|
154
|
-
}
|
|
155
|
-
const res = entry.owns.map((g) => globToRegExp(g));
|
|
156
|
-
let newest = 0;
|
|
157
|
-
for (const file of repoFiles) {
|
|
158
|
-
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)) {
|
|
159
253
|
try {
|
|
160
|
-
|
|
254
|
+
mtimes.set(rel, fs.statSync(path.join(projectDir, rel)).mtimeMs);
|
|
161
255
|
} catch (e) {
|
|
162
|
-
|
|
256
|
+
mtimes.set(rel, null);
|
|
163
257
|
}
|
|
164
258
|
}
|
|
165
|
-
|
|
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);
|
|
166
268
|
}
|
|
167
269
|
|
|
168
270
|
const report = [
|
|
@@ -174,7 +276,7 @@ function driftBanner(projectDir) {
|
|
|
174
276
|
`## Docs older than owned code (${staleDocs.length})`,
|
|
175
277
|
...staleDocs.map((d) => `- ${d}`),
|
|
176
278
|
"",
|
|
177
|
-
`## Bookkeeping queue (${counts.unresolved} unresolved: ${counts.auto} auto · ${counts.note} note · ${counts.needsSession} needs-session · ${counts.waived} waived)`,
|
|
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)`,
|
|
178
280
|
].join("\n");
|
|
179
281
|
try {
|
|
180
282
|
fs.writeFileSync(path.join(stateDir(projectDir), "drift-report.md"), report + "\n");
|
|
@@ -196,16 +298,10 @@ function driftBanner(projectDir) {
|
|
|
196
298
|
|
|
197
299
|
function sessionStart(projectDir, payload = {}) {
|
|
198
300
|
// Stamp the session's start so gitless repos still have a "modified since" horizon for the
|
|
199
|
-
// outcome detector's fallback scan (dirtyPaths).
|
|
200
|
-
// the per-sid twin exists because two concurrent sessions clobber the shared one. Best-effort.
|
|
301
|
+
// outcome detector's fallback scan (dirtyPaths). Best-effort.
|
|
201
302
|
const sid = payload.session_id;
|
|
202
|
-
const stamp = new Date().toISOString() + "\n";
|
|
203
303
|
try {
|
|
204
|
-
fs.writeFileSync(path.join(stateDir(projectDir), "session-started"),
|
|
205
|
-
if (sid) {
|
|
206
|
-
const short = String(sid).replace(/[^a-zA-Z0-9-]/g, "").slice(0, 8);
|
|
207
|
-
fs.writeFileSync(path.join(stateDir(projectDir), `session-started-${short}`), stamp);
|
|
208
|
-
}
|
|
304
|
+
fs.writeFileSync(path.join(stateDir(projectDir), "session-started"), new Date().toISOString() + "\n");
|
|
209
305
|
} catch (e) {
|
|
210
306
|
/* the stamp is a convenience for the fallback; git repos never need it */
|
|
211
307
|
}
|
|
@@ -245,6 +341,14 @@ function sessionStart(projectDir, payload = {}) {
|
|
|
245
341
|
const memory = memoryLine(projectDir);
|
|
246
342
|
if (memory) lines.push(memory);
|
|
247
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
|
+
}
|
|
248
352
|
try {
|
|
249
353
|
const drift = driftBanner(projectDir);
|
|
250
354
|
if (drift) lines.push(drift);
|
|
@@ -268,15 +372,6 @@ function sessionStart(projectDir, payload = {}) {
|
|
|
268
372
|
// PostToolUse — the silent detector
|
|
269
373
|
// ---------------------------------------------------------------------------
|
|
270
374
|
|
|
271
|
-
const SOURCE_EXT = /\.(m?[jt]sx?|py|rs|go|rb|java|kt|swift|c|h|cpp|hpp|cs|sh|sql|css|scss|vue|svelte)$/;
|
|
272
|
-
|
|
273
|
-
function relIfInside(projectDir, filePath) {
|
|
274
|
-
if (!filePath) return null;
|
|
275
|
-
const rel = path.relative(projectDir, path.resolve(projectDir, filePath));
|
|
276
|
-
if (rel.startsWith("..") || path.isAbsolute(rel)) return null;
|
|
277
|
-
return rel.split(path.sep).join("/");
|
|
278
|
-
}
|
|
279
|
-
|
|
280
375
|
// Spawn the installed doc-lint over one file; returns finding lines (empty when absent/clean).
|
|
281
376
|
function docLint(projectDir, rel) {
|
|
282
377
|
const script = path.join(projectDir, ".claude", "skills", "documentation", "scripts", "doc-lint.mjs");
|
|
@@ -336,20 +431,6 @@ function detectorForFile(projectDir, rel, meta, rows) {
|
|
|
336
431
|
);
|
|
337
432
|
}
|
|
338
433
|
}
|
|
339
|
-
const segment = rel.split("/")[1];
|
|
340
|
-
if (rel.startsWith("docs/") && segment && !segment.endsWith(".md")) {
|
|
341
|
-
const structure = readSafe(path.join(projectDir, "docs", "structure.md"));
|
|
342
|
-
if (structure && !structure.includes(`${segment}/`) && !structure.includes(`docs/${segment}`)) {
|
|
343
|
-
rows.push(
|
|
344
|
-
formatAutoRow({
|
|
345
|
-
...meta,
|
|
346
|
-
kind: "doc-placement",
|
|
347
|
-
description: `${rel} sits in a folder absent from docs/structure.md's authority map`,
|
|
348
|
-
action: "move the doc per the add-a-doc table, or record the new folder in structure.md",
|
|
349
|
-
})
|
|
350
|
-
);
|
|
351
|
-
}
|
|
352
|
-
}
|
|
353
434
|
if (rel.startsWith("docs/features/") && !readSafe(path.join(projectDir, "docs", "README.md")).includes(path.basename(rel))) {
|
|
354
435
|
rows.push(
|
|
355
436
|
formatAutoRow({
|
|
@@ -384,24 +465,14 @@ function detectorForFile(projectDir, rel, meta, rows) {
|
|
|
384
465
|
})
|
|
385
466
|
);
|
|
386
467
|
}
|
|
387
|
-
if (/(schema|init\.sql|migration|auth|webhook|\benv\b|wrangler)/i.test(rel)) {
|
|
388
|
-
rows.push(
|
|
389
|
-
formatAutoRow({
|
|
390
|
-
...meta,
|
|
391
|
-
kind: "boundary-scan-suggested",
|
|
392
|
-
description: `${rel} is contract-heavy`,
|
|
393
|
-
action: "run the boundary skill scoped to the diff",
|
|
394
|
-
})
|
|
395
|
-
);
|
|
396
|
-
}
|
|
397
468
|
}
|
|
398
469
|
}
|
|
399
470
|
|
|
400
471
|
function detectRenames(projectDir, command, meta, rows) {
|
|
401
472
|
if (!/\bgit mv\b|\bmv\b/.test(command || "")) return;
|
|
402
|
-
const
|
|
473
|
+
const queueRows = readQueue(projectDir);
|
|
403
474
|
for (const { glob, doc } of deadGlobs(projectDir)) {
|
|
404
|
-
if (
|
|
475
|
+
if (waiverCovers(queueRows, "dead-glob", glob, today())) continue;
|
|
405
476
|
rows.push(
|
|
406
477
|
formatAutoRow({
|
|
407
478
|
...meta,
|
|
@@ -507,7 +578,9 @@ function detectOutcomes(projectDir, meta, rows) {
|
|
|
507
578
|
}
|
|
508
579
|
}
|
|
509
580
|
if (rel.startsWith("docs/history/")) {
|
|
510
|
-
|
|
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);
|
|
511
584
|
if (frozen.frozen && !raised(`${rel} is a frozen record`)) {
|
|
512
585
|
rows.push(
|
|
513
586
|
formatAutoRow({
|
|
@@ -533,11 +606,10 @@ function postToolUse(projectDir, payload) {
|
|
|
533
606
|
const rows = [];
|
|
534
607
|
if (payload.tool_name === "Bash") {
|
|
535
608
|
// A completed `git commit` releases the claim compliance took at PreToolUse — the `end` line
|
|
536
|
-
// is what lets a waiting session verify the actor finished. Root resolution
|
|
537
|
-
//
|
|
538
|
-
if (/(
|
|
539
|
-
const
|
|
540
|
-
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);
|
|
541
613
|
try {
|
|
542
614
|
releaseActivity(root, "commit", payload.session_id);
|
|
543
615
|
appendEvent(root, payload.session_id, "coord", "commit-claim", "released");
|
|
@@ -13,7 +13,12 @@ if (counts.unresolved === 0) {
|
|
|
13
13
|
}
|
|
14
14
|
console.error(
|
|
15
15
|
`check-bookkeeping-clean: ${counts.unresolved} unresolved row(s) in docs/operations/bookkeeping.md ` +
|
|
16
|
-
`(${counts.auto} auto, ${counts.note} note, ${counts.needsSession} needs-session
|
|
17
|
-
|
|
16
|
+
`(${counts.auto} auto, ${counts.note} note, ${counts.needsSession} needs-session, ` +
|
|
17
|
+
`${counts.expiredWaived} expired-waived). ` +
|
|
18
|
+
"Drain them (bookkeeper agent), resolve them, or convert to waived: with reason + expires=. " +
|
|
19
|
+
(counts.expiredWaived > 0
|
|
20
|
+
? "An expired waiver is a deferral that ran out: re-judge whether its reason still holds — " +
|
|
21
|
+
"re-waive with a fresh date only if it does, and never by default."
|
|
22
|
+
: "")
|
|
18
23
|
);
|
|
19
24
|
process.exit(1);
|
|
@@ -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
|
|