@nurix/etna 0.20.2 → 0.20.4
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/catalog/rules/testing.md +20 -14
- package/data/harness-index.json +1 -1
- package/data/harnesses/code/CLAUDE.md +15 -8
- package/data/harnesses/code/agents/bookkeeper.md +1 -1
- package/data/harnesses/code/agents/issue-bridge.md +16 -0
- package/data/harnesses/code/agents/maintenance.md +36 -3
- package/data/harnesses/code/docs/README.md +1 -1
- package/data/harnesses/code/docs/history/backlog.md +1 -1
- package/data/harnesses/code/docs/structure.md +2 -2
- package/data/harnesses/code/harness.json +12 -2
- package/data/harnesses/code/hooks/awareness.mjs +75 -14
- package/data/harnesses/code/hooks/compliance.mjs +26 -18
- package/data/harnesses/code/hooks/lib/exempt.mjs +7 -5
- package/data/harnesses/code/hooks/lib/frozen.mjs +9 -6
- package/data/harnesses/code/hooks/lib/ledger.mjs +26 -4
- package/data/harnesses/code/hooks/lib/ownership.mjs +73 -2
- package/data/harnesses/code/hooks/lib/plans.mjs +2 -2
- package/data/harnesses/code/hooks/lib/repo.mjs +22 -0
- package/data/harnesses/code/hooks/lib/state.mjs +38 -2
- package/data/harnesses/code/hooks/lib/sweep.mjs +37 -15
- package/data/harnesses/code/rules/testing.md +20 -14
- package/data/harnesses/code/skills/boundary/SKILL.md +2 -2
- package/data/harnesses/code/skills/codegraph/SKILL.md +57 -0
- package/data/harnesses/code/skills/documentation/SKILL.md +12 -5
- package/data/harnesses/code/skills/documentation/references/frontmatter.md +29 -4
- package/data/harnesses/code/skills/documentation/templates/behavioral-checklist.md +18 -1
- package/data/harnesses/code/skills/documentation/templates/feature.md +1 -0
- package/data/harnesses/code/skills/documentation/templates/plan.md +3 -1
- package/data/harnesses/code/skills/documentation/templates/ux-behavior.md +1 -1
- package/data/harnesses/code/skills/issue-bridge/SKILL.md +55 -0
- package/data/harnesses/code/skills/issue-bridge/references/trackers/github.md +37 -0
- package/data/harnesses/code/skills/issue-bridge/references/trackers/jira.md +40 -0
- package/data/harnesses/code/skills/issue-bridge/references/trackers/linear.md +37 -0
- package/data/harnesses/code/skills/testing/SKILL.md +24 -8
- package/data/harnesses/delivery/agents/maintenance.md +36 -3
- package/data/harnesses/delivery/harness.json +4 -2
- package/data/harnesses/delivery/skills/documentation/SKILL.md +12 -5
- package/data/harnesses/delivery/skills/documentation/references/frontmatter.md +29 -4
- package/data/harnesses/delivery/skills/documentation/templates/behavioral-checklist.md +18 -1
- package/data/harnesses/delivery/skills/documentation/templates/feature.md +1 -0
- package/data/harnesses/delivery/skills/documentation/templates/plan.md +3 -1
- package/data/harnesses/delivery/skills/documentation/templates/ux-behavior.md +1 -1
- package/data/harnesses/discover/harness.json +2 -0
- package/data/harnesses/discussion/harness.json +2 -0
- package/data/harnesses/etna/agents/maintenance.md +36 -3
- package/data/harnesses/etna/harness.json +4 -2
- package/data/harnesses/etna/rules/testing.md +20 -14
- package/data/harnesses/etna/skills/boundary/SKILL.md +2 -2
- package/data/harnesses/etna/skills/documentation/SKILL.md +12 -5
- package/data/harnesses/etna/skills/documentation/references/frontmatter.md +29 -4
- package/data/harnesses/etna/skills/documentation/templates/behavioral-checklist.md +18 -1
- package/data/harnesses/etna/skills/documentation/templates/feature.md +1 -0
- package/data/harnesses/etna/skills/documentation/templates/plan.md +3 -1
- package/data/harnesses/etna/skills/documentation/templates/ux-behavior.md +1 -1
- package/data/harnesses/hr/harness.json +2 -0
- package/data/harnesses/kb-agi/harness.json +2 -0
- package/lib/install/plan.mjs +22 -1
- package/package.json +2 -2
|
@@ -8,12 +8,18 @@
|
|
|
8
8
|
import fs from "node:fs";
|
|
9
9
|
import path from "node:path";
|
|
10
10
|
import { readStdin, resolveProjectDir } from "./lib/ownership.mjs";
|
|
11
|
-
import { readSafe, relIfInside, git, commandRoot, backtickTokens, SOURCE_EXT } from "./lib/repo.mjs";
|
|
11
|
+
import { readSafe, relIfInside, git, commandRoot, backtickTokens, SOURCE_EXT, docsRoot } from "./lib/repo.mjs";
|
|
12
12
|
import { openGatedPlans, inPlanScope } from "./lib/plans.mjs";
|
|
13
13
|
import { stagedExempt } from "./lib/exempt.mjs";
|
|
14
14
|
import { readQueue, countRows, rowsBlockingCommit, askedThisSession, recordAsked } from "./lib/state.mjs";
|
|
15
15
|
import { liveRows } from "./lib/sweep.mjs";
|
|
16
|
-
|
|
16
|
+
// frozenStatusAtHead, not frozenStatus: a record frozen only in the WORKTREE is a freeze born in the
|
|
17
|
+
// current edit — a plan legitimately flipping to Complete — and denying that walls the completion
|
|
18
|
+
// ceremony itself, stranding the `Completed:` field the very same edit was meant to fill. Only an edit
|
|
19
|
+
// to something HEAD already froze is an evasion. frozen.mjs says exactly this above frozenStatusAtHead;
|
|
20
|
+
// this call site disagreed with it until 2026-08-20, when a verification gate flipped Status, was frozen
|
|
21
|
+
// mid-ceremony, and could not write its own completion date.
|
|
22
|
+
import { frozenStatusAtHead } from "./lib/frozen.mjs";
|
|
17
23
|
import { undischargedRows } from "./lib/ledger.mjs";
|
|
18
24
|
import { appendEvent, unresolvedDenials, sessionLogPath, malformedEventLines } from "./lib/session-log.mjs";
|
|
19
25
|
import { approvedPackages, rejectedPackages } from "./lib/approved.mjs";
|
|
@@ -50,13 +56,14 @@ function autoResolve(projectDir, sessionId, gate, target) {
|
|
|
50
56
|
let protectedCache = null;
|
|
51
57
|
function protectedPaths(projectDir) {
|
|
52
58
|
if (protectedCache) return protectedCache;
|
|
53
|
-
const
|
|
54
|
-
|
|
59
|
+
const root = docsRoot(projectDir);
|
|
60
|
+
const text = readSafe(path.join(projectDir, root, "operations", "protected-files.md"));
|
|
61
|
+
protectedCache = ["AGENTS.md", `${root}/operations/protected-files.md`, ...backtickTokens(text)];
|
|
55
62
|
return protectedCache;
|
|
56
63
|
}
|
|
57
64
|
|
|
58
65
|
function fileGate(projectDir, rel, sessionId) {
|
|
59
|
-
const frozen =
|
|
66
|
+
const frozen = frozenStatusAtHead(projectDir, rel);
|
|
60
67
|
if (frozen.frozen) {
|
|
61
68
|
return deny(
|
|
62
69
|
projectDir,
|
|
@@ -91,7 +98,7 @@ function fileGate(projectDir, rel, sessionId) {
|
|
|
91
98
|
/* presence is a convenience — never let it block an edit on failure */
|
|
92
99
|
}
|
|
93
100
|
|
|
94
|
-
const inDocs = rel.startsWith(
|
|
101
|
+
const inDocs = rel.startsWith(`${docsRoot(projectDir)}/`) || rel.startsWith(".claude/") || rel.endsWith(".md");
|
|
95
102
|
if (!inDocs && SOURCE_EXT.test(rel)) {
|
|
96
103
|
for (const plan of openGatedPlans(projectDir)) {
|
|
97
104
|
// The gate binds the plan's own paths, so disjoint plans run in parallel.
|
|
@@ -159,14 +166,15 @@ function commitGate(projectDir, sessionId) {
|
|
|
159
166
|
.map((s) => s.trim())
|
|
160
167
|
.filter(Boolean);
|
|
161
168
|
const allRenames = nameStatus.length > 0 && nameStatus.every((line) => line.startsWith("R"));
|
|
162
|
-
|
|
163
|
-
|
|
169
|
+
const root = docsRoot(projectDir);
|
|
170
|
+
if (staged.length > 0 && !stagedExempt(projectDir, staged) && !allRenames) {
|
|
171
|
+
if (!staged.some((f) => f.startsWith(`${root}/history/changelog/`))) {
|
|
164
172
|
return deny(
|
|
165
173
|
projectDir,
|
|
166
174
|
sessionId,
|
|
167
175
|
"changelog",
|
|
168
176
|
"git commit",
|
|
169
|
-
|
|
177
|
+
`BLOCKED — changelog. This commit stages no \`${root}/history/changelog/\` entry. Every non-exempt commit carries its entry inside it (exempt: release/bot, harness installs and bumps, lockfile-only, pure renames, changelog housekeeping — state which if claiming one, then stage accordingly or re-run).`
|
|
170
178
|
);
|
|
171
179
|
}
|
|
172
180
|
}
|
|
@@ -190,8 +198,8 @@ function commitGate(projectDir, sessionId) {
|
|
|
190
198
|
sessionId,
|
|
191
199
|
"bookkeeping",
|
|
192
200
|
"git commit",
|
|
193
|
-
`BLOCKED — bookkeeping. ${blocking.length} unresolved row(s) in
|
|
194
|
-
"Dispatch the bookkeeper
|
|
201
|
+
`BLOCKED — bookkeeping. ${blocking.length} unresolved row(s) in ${root}/operations/bookkeeping.md implicate this commit (each re-verified against the repo just now — these are live):\n${lines.join("\n")}\n` +
|
|
202
|
+
"Dispatch the bookkeeper to drain them (never drain auto:/note: rows in your own loop; needs-session: rows are yours), or convert to `waived:` with a reason and expiry; a successful retry re-tests everything again." +
|
|
195
203
|
(unrelated > 0
|
|
196
204
|
? ` (${unrelated} further unresolved row(s) implicate nothing staged here — another session's work, and not yours to drain.)`
|
|
197
205
|
: "")
|
|
@@ -322,7 +330,7 @@ function bashGate(projectDir, command, sessionId) {
|
|
|
322
330
|
projectDir,
|
|
323
331
|
sessionId,
|
|
324
332
|
"live-command",
|
|
325
|
-
|
|
333
|
+
`Run this live-system command (deploy / remote DB / secrets)? If it's a one-shot migration, its runbook should exist in ${docsRoot(projectDir)}/history/migrations/ first. Asked once per session.`
|
|
326
334
|
);
|
|
327
335
|
}
|
|
328
336
|
|
|
@@ -358,7 +366,7 @@ function bashWriteScreen(projectDir, command, sessionId) {
|
|
|
358
366
|
for (const target of bashWriteTargets(command)) {
|
|
359
367
|
const rel = relIfInside(projectDir, target);
|
|
360
368
|
if (!rel) continue;
|
|
361
|
-
const frozen =
|
|
369
|
+
const frozen = frozenStatusAtHead(projectDir, rel);
|
|
362
370
|
if (frozen.frozen) {
|
|
363
371
|
return deny(
|
|
364
372
|
projectDir,
|
|
@@ -408,7 +416,7 @@ function plansCompletedThisSession(projectDir, sessionId) {
|
|
|
408
416
|
// existed carry no session and belong to none, so they are skipped rather than assumed.
|
|
409
417
|
.filter((cols) => sessionId && cols[3] === sessionId)
|
|
410
418
|
.map((cols) => cols[1])
|
|
411
|
-
.filter((p) => p && p.startsWith(
|
|
419
|
+
.filter((p) => p && p.startsWith(`${docsRoot(projectDir)}/history/plans/`))
|
|
412
420
|
);
|
|
413
421
|
const out = [];
|
|
414
422
|
for (const rel of touchedPlans) {
|
|
@@ -425,7 +433,7 @@ function plansCompletedThisSession(projectDir, sessionId) {
|
|
|
425
433
|
// for every plan at once.
|
|
426
434
|
function planHasChangelogEntry(projectDir, planRel) {
|
|
427
435
|
const slug = path.basename(planRel, ".md");
|
|
428
|
-
const dir = path.join(projectDir,
|
|
436
|
+
const dir = path.join(projectDir, docsRoot(projectDir), "history", "changelog");
|
|
429
437
|
let names = [];
|
|
430
438
|
try {
|
|
431
439
|
names = fs.readdirSync(dir);
|
|
@@ -452,12 +460,12 @@ function stopGate(projectDir, payload) {
|
|
|
452
460
|
return {
|
|
453
461
|
decision: "block",
|
|
454
462
|
reason:
|
|
455
|
-
`${counts.unresolved} unresolved bookkeeping rows from this session (${counts.auto} auto, ${counts.note} note, ${counts.needsSession} needs-session, ${counts.expiredWaived} expired-waived) in
|
|
456
|
-
"Dispatch the `bookkeeper` agent to drain them
|
|
463
|
+
`${counts.unresolved} unresolved bookkeeping rows from this session (${counts.auto} auto, ${counts.note} note, ${counts.needsSession} needs-session, ${counts.expiredWaived} expired-waived) in ${docsRoot(projectDir)}/operations/bookkeeping.md. ` +
|
|
464
|
+
"Dispatch the `bookkeeper` agent to drain them — inline resolution is for needs-session: rows only — or convert to `waived:` rows with reason + expiry. " +
|
|
457
465
|
(counts.expiredWaived > 0
|
|
458
466
|
? "An expired waiver is a deferral that ran out — re-judge whether its reason still holds rather than re-waiving by default. "
|
|
459
467
|
: "") +
|
|
460
|
-
"If
|
|
468
|
+
"If the open plan's code is written, its gate is the build's tail — drive, fix, pin, discharge before stopping; the freeze and the commit are the user's.",
|
|
461
469
|
};
|
|
462
470
|
}
|
|
463
471
|
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// whether the same commit owed an entry.
|
|
5
5
|
|
|
6
6
|
import path from "node:path";
|
|
7
|
+
import { docsRoot } from "./repo.mjs";
|
|
7
8
|
|
|
8
9
|
const LOCKFILES = /^(pnpm-lock\.yaml|package-lock\.json|yarn\.lock|bun\.lockb?|Cargo\.lock)$/;
|
|
9
10
|
const HARNESS_PREFIXES = [".claude/", ".agents/", ".codex/"];
|
|
@@ -16,17 +17,18 @@ const HARNESS_FILES = [
|
|
|
16
17
|
".prettierrc.json",
|
|
17
18
|
];
|
|
18
19
|
|
|
19
|
-
export function changelogExemptFile(f) {
|
|
20
|
+
export function changelogExemptFile(projectDir, f) {
|
|
21
|
+
const root = docsRoot(projectDir);
|
|
20
22
|
return (
|
|
21
23
|
LOCKFILES.test(path.basename(f)) ||
|
|
22
24
|
HARNESS_PREFIXES.some((p) => f.startsWith(p)) ||
|
|
23
25
|
HARNESS_FILES.includes(f) ||
|
|
24
|
-
f.startsWith(
|
|
26
|
+
f.startsWith(`${root}/history/changelog/`) ||
|
|
25
27
|
f.includes(".bkp.") ||
|
|
26
|
-
f ===
|
|
28
|
+
f === `${root}/history/CHANGELOG.md`
|
|
27
29
|
);
|
|
28
30
|
}
|
|
29
31
|
|
|
30
|
-
export function stagedExempt(files) {
|
|
31
|
-
return files.every(changelogExemptFile);
|
|
32
|
+
export function stagedExempt(projectDir, files) {
|
|
33
|
+
return files.every((f) => changelogExemptFile(projectDir, f));
|
|
32
34
|
}
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import fs from "node:fs";
|
|
8
8
|
import path from "node:path";
|
|
9
9
|
import { spawnSync } from "node:child_process";
|
|
10
|
+
import { docsRoot } from "./repo.mjs";
|
|
10
11
|
|
|
11
12
|
function relPosix(projectDir, filePath) {
|
|
12
13
|
return path.relative(projectDir, path.resolve(projectDir, filePath)).split(path.sep).join("/");
|
|
@@ -30,23 +31,24 @@ function readSafe(projectDir, filePath) {
|
|
|
30
31
|
// The predicate. Returns { frozen: boolean, reason?: string }.
|
|
31
32
|
export function frozenStatus(projectDir, filePath) {
|
|
32
33
|
const rel = relPosix(projectDir, filePath);
|
|
34
|
+
const root = docsRoot(projectDir);
|
|
33
35
|
// *.bkp.* siblings are install-time backups, not records — the maintenance fold edits and
|
|
34
36
|
// deletes them, which a frozen verdict would forbid.
|
|
35
37
|
if (rel.includes(".bkp.")) return { frozen: false };
|
|
36
|
-
if (rel.startsWith(
|
|
38
|
+
if (rel.startsWith(`${root}/history/changelog/`) && rel.endsWith(".md")) {
|
|
37
39
|
if (isCommitted(projectDir, rel)) {
|
|
38
40
|
return { frozen: true, reason: "committed changelog entry — immutability begins at commit" };
|
|
39
41
|
}
|
|
40
42
|
return { frozen: false };
|
|
41
43
|
}
|
|
42
|
-
if (rel.startsWith(
|
|
44
|
+
if (rel.startsWith(`${root}/history/plans/`) && rel.endsWith(".md")) {
|
|
43
45
|
const text = readSafe(projectDir, rel);
|
|
44
46
|
if (/^\*\*Status:\*\*\s*(Complete|Abandoned)\b/m.test(text)) {
|
|
45
47
|
return { frozen: true, reason: "plan with Status: Complete or Abandoned" };
|
|
46
48
|
}
|
|
47
49
|
return { frozen: false };
|
|
48
50
|
}
|
|
49
|
-
if (rel.startsWith(
|
|
51
|
+
if (rel.startsWith(`${root}/history/migrations/`) && rel.endsWith(".md")) {
|
|
50
52
|
const text = readSafe(projectDir, rel);
|
|
51
53
|
if (/^\*\*Executed:\*\*\s*\d{4}-\d{2}-\d{2}/m.test(text)) {
|
|
52
54
|
return { frozen: true, reason: "outcome-stamped migration runbook" };
|
|
@@ -62,8 +64,9 @@ export function frozenStatus(projectDir, filePath) {
|
|
|
62
64
|
// Only an edit to something HEAD already froze is an evasion.
|
|
63
65
|
export function frozenStatusAtHead(projectDir, filePath) {
|
|
64
66
|
const rel = relPosix(projectDir, filePath);
|
|
67
|
+
const root = docsRoot(projectDir);
|
|
65
68
|
if (rel.includes(".bkp.")) return { frozen: false };
|
|
66
|
-
if (rel.startsWith(
|
|
69
|
+
if (rel.startsWith(`${root}/history/changelog/`) && rel.endsWith(".md")) {
|
|
67
70
|
return frozenStatus(projectDir, filePath); // already keyed on isCommitted
|
|
68
71
|
}
|
|
69
72
|
const inRepo = spawnSync("git", ["rev-parse", "--git-dir"], { cwd: projectDir, stdio: "ignore", timeout: 5000 });
|
|
@@ -71,10 +74,10 @@ export function frozenStatusAtHead(projectDir, filePath) {
|
|
|
71
74
|
const res = spawnSync("git", ["show", `HEAD:${rel}`], { cwd: projectDir, encoding: "utf8", timeout: 5000 });
|
|
72
75
|
const headText = res.status === 0 ? res.stdout || "" : "";
|
|
73
76
|
if (!headText) return { frozen: false }; // never committed — a freeze born in this worktree is a ceremony, not an evasion
|
|
74
|
-
if (rel.startsWith(
|
|
77
|
+
if (rel.startsWith(`${root}/history/plans/`) && /^\*\*Status:\*\*\s*(Complete|Abandoned)\b/m.test(headText)) {
|
|
75
78
|
return { frozen: true, reason: "plan committed with Status: Complete or Abandoned" };
|
|
76
79
|
}
|
|
77
|
-
if (rel.startsWith(
|
|
80
|
+
if (rel.startsWith(`${root}/history/migrations/`) && /^\*\*Executed:\*\*\s*\d{4}-\d{2}-\d{2}/m.test(headText)) {
|
|
78
81
|
return { frozen: true, reason: "outcome-stamped migration runbook" };
|
|
79
82
|
}
|
|
80
83
|
return { frozen: false };
|
|
@@ -8,6 +8,20 @@
|
|
|
8
8
|
const OUTCOME = /^(satisfied|owes nothing|blocked)\b/i;
|
|
9
9
|
const DOC_OUTCOME = /^(updated|owes nothing|blocked)\b/i;
|
|
10
10
|
|
|
11
|
+
// Split one table row into trimmed cells, honouring GFM's escaped pipe.
|
|
12
|
+
//
|
|
13
|
+
// A THIRD TRAP OF THE SAME FAMILY, closed 2026-08-21. `\\|` is the only way to write a literal pipe
|
|
14
|
+
// inside a table cell, and splitting on every `|` invents a column — every cell after it shifts
|
|
15
|
+
// left, so the FIXED Outcome index lands on a neighbour and a row plainly reading `satisfied` is
|
|
16
|
+
// reported undischarged. Unlike the two traps below it fails in the FALSE-POSITIVE direction: it
|
|
17
|
+
// blocks a gate that is already satisfied, and the row renders and reads correctly to a human, so
|
|
18
|
+
// the file looks right while the wall insists it is wrong. Split on unescaped pipes, then unescape.
|
|
19
|
+
function splitRow(line) {
|
|
20
|
+
return line
|
|
21
|
+
.split(/(?<!\\)\|/)
|
|
22
|
+
.map((cell) => cell.replace(/\\\|/g, "|").trim());
|
|
23
|
+
}
|
|
24
|
+
|
|
11
25
|
// The table rows of one `## <heading>` section, split into trimmed cells, plus the Outcome column
|
|
12
26
|
// index (null when the table predates the column — legacy, out of contract).
|
|
13
27
|
function sectionTable(planText, heading) {
|
|
@@ -20,19 +34,27 @@ function sectionTable(planText, heading) {
|
|
|
20
34
|
}
|
|
21
35
|
const rows = body.filter((line) => /^\s*\|/.test(line));
|
|
22
36
|
if (rows.length < 3) return null; // header + divider + at least one data row
|
|
23
|
-
const header = rows[0]
|
|
37
|
+
const header = splitRow(rows[0]).map((cell) => cell.toLowerCase());
|
|
24
38
|
const col = header.indexOf("outcome");
|
|
25
|
-
return { rows: rows.slice(2).map(
|
|
39
|
+
return { rows: rows.slice(2).map(splitRow), col: col === -1 ? null : col };
|
|
26
40
|
}
|
|
27
41
|
|
|
28
42
|
// Row numbers (the `#` column) of every ledger row whose Outcome cell is empty or off-vocabulary.
|
|
43
|
+
// Two traps this closed on 2026-08-20, both found by a real gate:
|
|
44
|
+
// - A row id may carry a suffix. Ledgers legitimately number an inserted row `12b`, and `^\d+$`
|
|
45
|
+
// skipped it — silently, and in the FALSE-NEGATIVE direction: an undischarged `12b` passed the
|
|
46
|
+
// wall unseen. A suffixed id is still a row.
|
|
47
|
+
// - Outcome cells arrive bolded (`**satisfied**`). undischargedDocRows already strips `*` and says
|
|
48
|
+
// so; this one did not, so the two ledgers in the same file disagreed about markdown. Nothing
|
|
49
|
+
// documented the asymmetry, and the vocabulary is about the WORD, not its emphasis.
|
|
29
50
|
export function undischargedRows(planText) {
|
|
30
51
|
const table = sectionTable(planText, "Test Obligations");
|
|
31
52
|
if (!table || table.col === null) return [];
|
|
32
53
|
const out = [];
|
|
33
54
|
for (const cells of table.rows) {
|
|
34
|
-
if (!/^\d
|
|
35
|
-
|
|
55
|
+
if (!/^\d+[a-z]?$/i.test(cells[1] || "")) continue;
|
|
56
|
+
const outcome = (cells[table.col] || "").replace(/\*/g, "").trim();
|
|
57
|
+
if (!OUTCOME.test(outcome)) out.push(cells[1]);
|
|
36
58
|
}
|
|
37
59
|
return out;
|
|
38
60
|
}
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import fs from "node:fs";
|
|
6
6
|
import path from "node:path";
|
|
7
|
+
import { docsRoot } from "./repo.mjs";
|
|
7
8
|
|
|
8
9
|
// Read all of stdin synchronously; returns "" if nothing is piped.
|
|
9
10
|
export function readStdin() {
|
|
@@ -116,7 +117,7 @@ export function asList(v) {
|
|
|
116
117
|
// Scan docs/features/**/*.md and return one entry per feature doc that declares owns:.
|
|
117
118
|
// Shape: { doc (repo-relative, /-separated), owns[], module, keywords[], status }.
|
|
118
119
|
export function scanFeatureDocs(projectDir) {
|
|
119
|
-
const featuresDir = path.join(projectDir,
|
|
120
|
+
const featuresDir = path.join(projectDir, docsRoot(projectDir), "features");
|
|
120
121
|
// *.bkp.* files are install-time backups awaiting the maintenance fold — a backed-up feature
|
|
121
122
|
// doc still carries owns: frontmatter, and scanning it would double-own every path it names.
|
|
122
123
|
const files = listMarkdown(featuresDir).filter((f) => !f.includes(".bkp."));
|
|
@@ -147,7 +148,7 @@ export function scanFeatureDocs(projectDir) {
|
|
|
147
148
|
export function buildIndex(projectDir) {
|
|
148
149
|
// Total markdown under docs/features/, counted independently of scanFeatureDocs — which
|
|
149
150
|
// drops a doc carrying no owns:, making a parse failure and a doc that owns nothing look alike.
|
|
150
|
-
const total = listMarkdown(path.join(projectDir,
|
|
151
|
+
const total = listMarkdown(path.join(projectDir, docsRoot(projectDir), "features")).length;
|
|
151
152
|
const docs = scanFeatureDocs(projectDir);
|
|
152
153
|
const lines = [];
|
|
153
154
|
let deprecated = 0;
|
|
@@ -177,6 +178,76 @@ export function buildIndex(projectDir) {
|
|
|
177
178
|
return header + lines.join("\n") + footer;
|
|
178
179
|
}
|
|
179
180
|
|
|
181
|
+
// Scan every doc that declares a doc→doc edge and return the edges, resolved.
|
|
182
|
+
// Shape: { from, kind ("verifies"|"applies"), to, missing (the target file is absent) }.
|
|
183
|
+
//
|
|
184
|
+
// EVERY EDGE IS DECLARED AT THE CONSUMING END — a checklist names what it verifies, a feature names
|
|
185
|
+
// the doctrine it obeys — so this walks the consumers and never looks for a hand-authored inverse.
|
|
186
|
+
// A doc listing its own dependents is a defect the graph is built to make unnecessary.
|
|
187
|
+
export function scanDocEdges(projectDir) {
|
|
188
|
+
const docsDir = path.join(projectDir, docsRoot(projectDir));
|
|
189
|
+
const files = listMarkdown(docsDir).filter((f) => !f.includes(".bkp."));
|
|
190
|
+
const edges = [];
|
|
191
|
+
for (const file of files) {
|
|
192
|
+
let text;
|
|
193
|
+
try {
|
|
194
|
+
text = fs.readFileSync(file, "utf8");
|
|
195
|
+
} catch (e) {
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
const block = frontmatter(text);
|
|
199
|
+
if (!block) continue;
|
|
200
|
+
const from = path.relative(projectDir, file).split(path.sep).join("/");
|
|
201
|
+
for (const kind of ["verifies", "applies"]) {
|
|
202
|
+
for (const to of asList(field(block, kind))) {
|
|
203
|
+
const target = to.toString().trim();
|
|
204
|
+
if (!target) continue;
|
|
205
|
+
edges.push({
|
|
206
|
+
from,
|
|
207
|
+
kind,
|
|
208
|
+
to: target,
|
|
209
|
+
missing: !fs.existsSync(path.join(projectDir, target)),
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return edges;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Build the inverse-edge index: what each doc is verified by, and what obeys it.
|
|
218
|
+
//
|
|
219
|
+
// THE INVERSE IS GENERATED, NEVER AUTHORED. Read this to answer "if I change this doctrine, what
|
|
220
|
+
// is bound by it?" and "if I change this surface, which checklist proves it?" — the two questions
|
|
221
|
+
// a prose link cannot answer, and the reason the edges are frontmatter rather than sentences.
|
|
222
|
+
// A doc with no inbound edge is reachable by nothing: no change implicates it, so it rots without
|
|
223
|
+
// the rot being detectable. The footer counts those, because that count IS the health metric.
|
|
224
|
+
export function buildEdgeIndex(projectDir) {
|
|
225
|
+
const edges = scanDocEdges(projectDir);
|
|
226
|
+
if (edges.length === 0) return "";
|
|
227
|
+
const inverse = new Map();
|
|
228
|
+
for (const e of edges) {
|
|
229
|
+
if (!inverse.has(e.to)) inverse.set(e.to, []);
|
|
230
|
+
inverse.get(e.to).push(e);
|
|
231
|
+
}
|
|
232
|
+
const lines = [];
|
|
233
|
+
for (const [to, list] of [...inverse].sort((a, b) => a[0].localeCompare(b[0]))) {
|
|
234
|
+
const verifiedBy = list.filter((e) => e.kind === "verifies").map((e) => e.from);
|
|
235
|
+
const appliedBy = list.filter((e) => e.kind === "applies").map((e) => e.from);
|
|
236
|
+
const parts = [];
|
|
237
|
+
if (verifiedBy.length) parts.push(`verified by ${verifiedBy.sort().join(", ")}`);
|
|
238
|
+
if (appliedBy.length) parts.push(`applied by ${appliedBy.sort().join(", ")}`);
|
|
239
|
+
const absent = list.some((e) => e.missing) ? " ← TARGET MISSING" : "";
|
|
240
|
+
lines.push(`${to} ← ${parts.join(" · ")}${absent}`);
|
|
241
|
+
}
|
|
242
|
+
const dangling = edges.filter((e) => e.missing).length;
|
|
243
|
+
const header =
|
|
244
|
+
"# Doc edge index (generated) — doc ← who verifies it · who is bound by it\n" +
|
|
245
|
+
"# Declared once at the consuming end (verifies:/applies: frontmatter); this inverse is derived.\n" +
|
|
246
|
+
"# Before changing a doc, read what points AT it — those are the docs the change implicates.\n";
|
|
247
|
+
const footer = dangling > 0 ? `\n# (${dangling} edge(s) name a doc that does not exist — fix the frontmatter)` : "";
|
|
248
|
+
return header + lines.join("\n") + footer;
|
|
249
|
+
}
|
|
250
|
+
|
|
180
251
|
// Compile one owns:-style glob (`**` any depth, `*` within a segment) to a RegExp
|
|
181
252
|
// over /-separated repo-relative paths. `a/**` also matches `a` itself.
|
|
182
253
|
export function globToRegExp(glob) {
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import fs from "node:fs";
|
|
6
6
|
import path from "node:path";
|
|
7
|
-
import { readSafe, backtickTokens } from "./repo.mjs";
|
|
7
|
+
import { readSafe, backtickTokens, docsRoot } from "./repo.mjs";
|
|
8
8
|
import { globToRegExp } from "./ownership.mjs";
|
|
9
9
|
|
|
10
10
|
// A plan's scope: path-shaped backticked tokens in its `## Proposed Changes` section.
|
|
@@ -31,7 +31,7 @@ export function inPlanScope(rel, scope) {
|
|
|
31
31
|
// Every plan in the folder, parsed once: { file, status, gates, scope, hasLedger,
|
|
32
32
|
// hasDocLedger, ledgerRows }. `gates` is null on legacy plans (pre-Gates format).
|
|
33
33
|
export function listPlans(projectDir) {
|
|
34
|
-
const dir = path.join(projectDir,
|
|
34
|
+
const dir = path.join(projectDir, docsRoot(projectDir), "history", "plans");
|
|
35
35
|
let entries;
|
|
36
36
|
try {
|
|
37
37
|
entries = fs.readdirSync(dir).filter((f) => f.endsWith(".md"));
|
|
@@ -14,6 +14,28 @@ export function readSafe(file) {
|
|
|
14
14
|
}
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
// The docs tree root for a project: <projectDir>/.claude/harness.json's `docsRoot` key when
|
|
18
|
+
// present and non-empty, else "docs". A repo that already owns "docs/" upstream (the Superset
|
|
19
|
+
// field trial) renames the harness's tree to "docs-nurix/" and declares it there — every hook
|
|
20
|
+
// and CI check reads the root through here rather than hardcoding the segment. Tolerant: a
|
|
21
|
+
// missing file, an unreadable one, or malformed JSON all fall through to the default.
|
|
22
|
+
// Memoized per projectDir — hook processes are short-lived, so this only avoids re-reading the
|
|
23
|
+
// marker file more than once within a single invocation, never caches across runs.
|
|
24
|
+
const docsRootCache = new Map();
|
|
25
|
+
export function docsRoot(projectDir) {
|
|
26
|
+
if (docsRootCache.has(projectDir)) return docsRootCache.get(projectDir);
|
|
27
|
+
let root = "docs";
|
|
28
|
+
try {
|
|
29
|
+
const marker = JSON.parse(readSafe(path.join(projectDir, ".claude", "harness.json")));
|
|
30
|
+
const declared = marker && typeof marker.docsRoot === "string" ? marker.docsRoot.trim().replace(/\/+$/, "") : "";
|
|
31
|
+
if (declared) root = declared;
|
|
32
|
+
} catch (e) {
|
|
33
|
+
/* missing, unreadable, or malformed marker — the default stands */
|
|
34
|
+
}
|
|
35
|
+
docsRootCache.set(projectDir, root);
|
|
36
|
+
return root;
|
|
37
|
+
}
|
|
38
|
+
|
|
17
39
|
// Repo-relative /-separated path, or null when the target lies outside the project.
|
|
18
40
|
export function relIfInside(projectDir, filePath) {
|
|
19
41
|
if (!filePath) return null;
|
|
@@ -17,8 +17,10 @@
|
|
|
17
17
|
import fs from "node:fs";
|
|
18
18
|
import path from "node:path";
|
|
19
19
|
import { globToRegExp } from "./ownership.mjs";
|
|
20
|
+
import { docsRoot } from "./repo.mjs";
|
|
20
21
|
|
|
21
|
-
|
|
22
|
+
// Relative to the docs root (lib/repo.mjs#docsRoot) — never a bare "docs/..." literal.
|
|
23
|
+
export const QUEUE_PATH = ["operations", "bookkeeping.md"];
|
|
22
24
|
|
|
23
25
|
export const QUEUE_HEADER = `# Bookkeeping
|
|
24
26
|
|
|
@@ -26,7 +28,7 @@ The shared work queue keeping documentation and code in sync — a queue, never
|
|
|
26
28
|
`;
|
|
27
29
|
|
|
28
30
|
export function queueFile(projectDir) {
|
|
29
|
-
return path.join(projectDir, ...QUEUE_PATH);
|
|
31
|
+
return path.join(projectDir, docsRoot(projectDir), ...QUEUE_PATH);
|
|
30
32
|
}
|
|
31
33
|
|
|
32
34
|
// --- The write cell -------------------------------------------------------
|
|
@@ -351,6 +353,40 @@ export function recordAsked(projectDir, sessionId, key) {
|
|
|
351
353
|
fs.appendFileSync(path.join(stateDir(projectDir), "asked.tsv"), `${sessionId}\t${key}\n`);
|
|
352
354
|
}
|
|
353
355
|
|
|
356
|
+
// Repo-scoped decision memory — the sibling of askedThisSession, and deliberately a different
|
|
357
|
+
// LIFETIME. `askedThisSession` forgets at the session boundary, which is right for a gate that may
|
|
358
|
+
// legitimately re-fire tomorrow; a decision like "do not install this tool here" must outlive the
|
|
359
|
+
// session or the user is asked forever, which is the whole complaint the opt-in flow answers.
|
|
360
|
+
// Records live in .claude/state/decisions.tsv (machine-local, self-ignored by stateDir) — whether a
|
|
361
|
+
// binary is installed is a property of the MACHINE, not of the repo, so one developer's answer must
|
|
362
|
+
// not bind a teammate on another box.
|
|
363
|
+
//
|
|
364
|
+
// FAIL OPEN TOWARD ASKING. Every unreadable path — no file, no key, malformed line, unrecognized
|
|
365
|
+
// value — returns undefined, never "declined". The asymmetry is the point: a wrong "undecided"
|
|
366
|
+
// costs one prompt, while a wrong "declined" silently disables a capability forever with no error
|
|
367
|
+
// to observe and no way for the user to tell it apart from the tool simply never helping.
|
|
368
|
+
const DECISIONS = ["accepted", "declined"];
|
|
369
|
+
|
|
370
|
+
export function readDecision(projectDir, key) {
|
|
371
|
+
try {
|
|
372
|
+
const text = fs.readFileSync(path.join(projectDir, ".claude", "state", "decisions.tsv"), "utf8");
|
|
373
|
+
// Last write wins: the file is append-only, so a later answer supersedes an earlier one.
|
|
374
|
+
let found;
|
|
375
|
+
for (const line of text.split("\n")) {
|
|
376
|
+
const [k, v] = line.split("\t");
|
|
377
|
+
if (k === key && DECISIONS.includes(v)) found = v;
|
|
378
|
+
}
|
|
379
|
+
return found;
|
|
380
|
+
} catch (e) {
|
|
381
|
+
return undefined;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
export function recordDecision(projectDir, key, value) {
|
|
386
|
+
if (!key || !DECISIONS.includes(value)) return;
|
|
387
|
+
fs.appendFileSync(path.join(stateDir(projectDir), "decisions.tsv"), `${key}\t${value}\n`);
|
|
388
|
+
}
|
|
389
|
+
|
|
354
390
|
// Append one tab-separated line to .claude/state/touched.tsv (file → owning doc map for the session).
|
|
355
391
|
//
|
|
356
392
|
// THE SESSION ID IS THE FOURTH COLUMN, and it is what lets a reader tell one session's edits from
|
|
@@ -20,7 +20,7 @@ import fs from "node:fs";
|
|
|
20
20
|
import path from "node:path";
|
|
21
21
|
import { spawnSync } from "node:child_process";
|
|
22
22
|
import { globToRegExp, listRepoFiles } from "./ownership.mjs";
|
|
23
|
-
import { readSafe } from "./repo.mjs";
|
|
23
|
+
import { readSafe, docsRoot } from "./repo.mjs";
|
|
24
24
|
import { resolveRows } from "./state.mjs";
|
|
25
25
|
import { rejectedPackages } from "./approved.mjs";
|
|
26
26
|
|
|
@@ -72,24 +72,46 @@ export const ROW_VERIFIERS = {
|
|
|
72
72
|
return res.status === 0;
|
|
73
73
|
},
|
|
74
74
|
|
|
75
|
-
// "<src> changed; owning doc <doc> may need an update" — dead once the doc
|
|
76
|
-
//
|
|
75
|
+
// "<src> changed; owning doc <doc> may need an update" — dead once the doc's PROSE has actually
|
|
76
|
+
// changed in a commit since the source's last one.
|
|
77
|
+
//
|
|
78
|
+
// NOT MTIME, WHICH IS NOT EVIDENCE OF ANYTHING. This compared `statSync().mtimeMs` and resolved
|
|
79
|
+
// the row whenever the doc's file was newer — so a formatting pass, a `git checkout`, a rebase, a
|
|
80
|
+
// stash pop, or a clone (which stamps every file at once, making the order arbitrary) all read as
|
|
81
|
+
// "the doc was updated". It fired for real: a one-line frontmatter addition across 33 owned docs
|
|
82
|
+
// silently swept a waived row whose plan was still open.
|
|
83
|
+
//
|
|
84
|
+
// NOT FRONTMATTER EITHER, and that follows from the harness's own doctrine rather than taste:
|
|
85
|
+
// `type:`/`owns:`/`applies:` and their siblings are tooling metadata, "preserved on edits, never
|
|
86
|
+
// narrated" — a key that by definition states nothing about behavior cannot answer a claim that
|
|
87
|
+
// documented behavior may have moved. So the bodies are compared with the frontmatter stripped.
|
|
88
|
+
//
|
|
89
|
+
// Unproven ⇒ the row stands. Every git failure returns false, per this file's rule that silence
|
|
90
|
+
// is its only acceptable failure mode.
|
|
77
91
|
"doc-implicated": (projectDir, body) => {
|
|
78
92
|
const m = /·\s+doc-implicated\s+·\s+(\S+)\s+changed;\s+owning doc\s+(\S+?)\s+may need/.exec(body);
|
|
79
93
|
if (!m) return false;
|
|
80
94
|
const [, src, doc] = m;
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
95
|
+
if (!fs.existsSync(path.join(projectDir, doc))) return false; // the doc is gone — a real finding
|
|
96
|
+
if (!fs.existsSync(path.join(projectDir, src))) return true; // the source is gone; nothing implicates it
|
|
97
|
+
|
|
98
|
+
const git = (...args) => spawnSync("git", args, { cwd: projectDir, timeout: 5000, encoding: "utf8" });
|
|
99
|
+
const lastCommit = git("log", "-1", "--format=%H", "--", src);
|
|
100
|
+
if (lastCommit.status !== 0) return false;
|
|
101
|
+
const since = lastCommit.stdout.trim();
|
|
102
|
+
if (!since) return false; // the source has never been committed — nothing to measure against
|
|
103
|
+
|
|
104
|
+
// Strip the leading `---` block so a metadata-only edit reads as no change at all.
|
|
105
|
+
const prose = (text) => (text.startsWith("---") ? text.slice(text.indexOf("\n---", 3) + 4) : text);
|
|
106
|
+
const at = (rev) => {
|
|
107
|
+
const res = git("show", `${rev}:${doc}`);
|
|
108
|
+
return res.status === 0 ? prose(res.stdout) : null;
|
|
87
109
|
};
|
|
88
|
-
const
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
if (
|
|
92
|
-
return
|
|
110
|
+
const then = at(since);
|
|
111
|
+
if (then === null) return true; // the doc did not exist at that commit — creating it IS the update
|
|
112
|
+
const now = at("HEAD");
|
|
113
|
+
if (now === null) return false;
|
|
114
|
+
return now !== then;
|
|
93
115
|
},
|
|
94
116
|
|
|
95
117
|
// "<rel> has N doc-lint finding(s)" — dead once a re-run comes back clean (or the doc is gone).
|
|
@@ -105,7 +127,7 @@ export const ROW_VERIFIERS = {
|
|
|
105
127
|
const m = /·\s+index-row-missing\s+·\s+(\S+)\s+is not linked/.exec(body);
|
|
106
128
|
if (!m) return false;
|
|
107
129
|
if (!fs.existsSync(path.join(projectDir, m[1]))) return true;
|
|
108
|
-
return readSafe(path.join(projectDir,
|
|
130
|
+
return readSafe(path.join(projectDir, docsRoot(projectDir), "README.md")).includes(path.basename(m[1]));
|
|
109
131
|
},
|
|
110
132
|
|
|
111
133
|
// "<rel> declares `<name>` (package-rejected)" — dead once the manifest drops the package
|