@nurix/etna 0.19.92 → 0.20.1

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.
Files changed (60) hide show
  1. package/bin/etna.js +0 -11
  2. package/data/catalog/rules/frontend.md +1 -1
  3. package/data/catalog/rules/general.md +3 -3
  4. package/data/catalog/rules/nomenclature.md +0 -4
  5. package/data/catalog/rules/pnpm.md +1 -1
  6. package/data/catalog/rules/testing.md +1 -0
  7. package/data/harness-index.json +1 -1
  8. package/data/harnesses/code/CLAUDE.md +14 -14
  9. package/data/harnesses/code/agents/bookkeeper.md +3 -0
  10. package/data/harnesses/code/agents/maintenance.md +3 -14
  11. package/data/harnesses/code/agents/performance-review.md +1 -1
  12. package/data/harnesses/code/codex/agents/performance-review.toml +1 -1
  13. package/data/harnesses/code/harness.json +2 -2
  14. package/data/harnesses/code/hooks/awareness.mjs +5 -169
  15. package/data/harnesses/code/hooks/compliance.mjs +21 -17
  16. package/data/harnesses/code/hooks/lib/ledger.mjs +36 -13
  17. package/data/harnesses/code/hooks/lib/sweep.mjs +172 -0
  18. package/data/harnesses/code/rules/frontend.md +1 -1
  19. package/data/harnesses/code/rules/general.md +3 -3
  20. package/data/harnesses/code/rules/nomenclature.md +0 -4
  21. package/data/harnesses/code/rules/pnpm.md +1 -1
  22. package/data/harnesses/code/rules/testing.md +1 -0
  23. package/data/harnesses/code/skills/boundary/SKILL.md +1 -1
  24. package/data/harnesses/code/skills/documentation/SKILL.md +1 -1
  25. package/data/harnesses/code/skills/documentation/templates/behavioral-checklist.md +1 -1
  26. package/data/harnesses/code/skills/documentation/templates/bookkeeping.md +12 -8
  27. package/data/harnesses/code/skills/documentation/templates/plan.md +9 -16
  28. package/data/harnesses/code/skills/testing/SKILL.md +1 -2
  29. package/data/harnesses/delivery/agents/maintenance.md +3 -14
  30. package/data/harnesses/delivery/harness.json +2 -2
  31. package/data/harnesses/delivery/hooks/generate-ownership-index.mjs +3 -1
  32. package/data/harnesses/delivery/rules/nomenclature.md +0 -4
  33. package/data/harnesses/delivery/skills/documentation/SKILL.md +1 -1
  34. package/data/harnesses/delivery/skills/documentation/templates/behavioral-checklist.md +1 -1
  35. package/data/harnesses/delivery/skills/documentation/templates/bookkeeping.md +12 -8
  36. package/data/harnesses/delivery/skills/documentation/templates/plan.md +9 -16
  37. package/data/harnesses/etna/agents/maintenance.md +3 -14
  38. package/data/harnesses/etna/agents/performance-review.md +1 -1
  39. package/data/harnesses/etna/codex/agents/performance-review.toml +1 -1
  40. package/data/harnesses/etna/harness.json +2 -2
  41. package/data/harnesses/etna/hooks/generate-ownership-index.mjs +3 -1
  42. package/data/harnesses/etna/rules/general.md +3 -3
  43. package/data/harnesses/etna/rules/nomenclature.md +0 -4
  44. package/data/harnesses/etna/rules/testing.md +1 -0
  45. package/data/harnesses/etna/skills/boundary/SKILL.md +1 -1
  46. package/data/harnesses/etna/skills/documentation/SKILL.md +1 -1
  47. package/data/harnesses/etna/skills/documentation/templates/behavioral-checklist.md +1 -1
  48. package/data/harnesses/etna/skills/documentation/templates/bookkeeping.md +12 -8
  49. package/data/harnesses/etna/skills/documentation/templates/plan.md +9 -16
  50. package/lib/codex/agents-md.mjs +1 -35
  51. package/lib/codex/hooks.mjs +0 -29
  52. package/lib/install/materialize.mjs +12 -34
  53. package/lib/install/plan.mjs +54 -10
  54. package/package.json +3 -3
  55. package/data/harnesses/code/hooks/check-bookkeeping-clean.mjs +0 -28
  56. package/data/harnesses/code/hooks/check-changelog-presence.mjs +0 -36
  57. package/data/harnesses/code/hooks/check-completion-integrity.mjs +0 -72
  58. package/data/harnesses/code/hooks/check-ownership.mjs +0 -55
  59. package/lib/codex/index.mjs +0 -63
  60. package/lib/codex/subagents.mjs +0 -26
@@ -12,6 +12,7 @@ import { readSafe, relIfInside, git, commandRoot, backtickTokens, SOURCE_EXT } f
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
+ import { liveRows } from "./lib/sweep.mjs";
15
16
  import { frozenStatus } from "./lib/frozen.mjs";
16
17
  import { undischargedRows } from "./lib/ledger.mjs";
17
18
  import { appendEvent, unresolvedDenials, sessionLogPath, malformedEventLines } from "./lib/session-log.mjs";
@@ -150,7 +151,15 @@ function commitGate(projectDir, sessionId) {
150
151
  .split("\n")
151
152
  .map((s) => s.trim())
152
153
  .filter(Boolean);
153
- if (staged.length > 0 && !stagedExempt(staged)) {
154
+ // A rename-only staged set is on the changelog rule's exemption list; the gate can now honor it
155
+ // the way CI's caller already does. At least one line, and every line a rename — one edit joins
156
+ // the set and the wall returns.
157
+ const nameStatus = (git(projectDir, ["diff", "--cached", "--name-status", "-M"]) || "")
158
+ .split("\n")
159
+ .map((s) => s.trim())
160
+ .filter(Boolean);
161
+ const allRenames = nameStatus.length > 0 && nameStatus.every((line) => line.startsWith("R"));
162
+ if (staged.length > 0 && !stagedExempt(staged) && !allRenames) {
154
163
  if (!staged.some((f) => f.startsWith("docs/history/changelog/"))) {
155
164
  return deny(
156
165
  projectDir,
@@ -164,8 +173,10 @@ function commitGate(projectDir, sessionId) {
164
173
  // The bookkeeping wall is scoped to the staged set — the grammar and its fail-closed cases are
165
174
  // `lib/state.mjs` § What a row implicates. Rows about files this commit does not touch are
166
175
  // somebody else's, and holding a commit behind rows its session may not drain is a wait with no
167
- // action that clears it.
168
- const { blocking, unrelated } = rowsBlockingCommit(readQueue(projectDir), staged);
176
+ // action that clears it. The read goes through `liveRows` first: a gate tests conditions at gate
177
+ // time, never a cached record of them — a row whose condition died since the last sweep must not
178
+ // wall anything. Pure by design; the detector's next sweep deletes what this filtered out.
179
+ const { blocking, unrelated } = rowsBlockingCommit(liveRows(projectDir, readQueue(projectDir)), staged);
169
180
  if (blocking.length > 0) {
170
181
  const shown = blocking.slice(0, 8);
171
182
  const lines = shown.map(({ row, hits }) =>
@@ -179,24 +190,14 @@ function commitGate(projectDir, sessionId) {
179
190
  sessionId,
180
191
  "bookkeeping",
181
192
  "git commit",
182
- `BLOCKED — bookkeeping. ${blocking.length} unresolved row(s) in docs/operations/bookkeeping.md implicate this commit:\n${lines.join("\n")}\n` +
183
- "Dispatch the bookkeeper, resolve them, or convert to `waived:` with a reason and expiry." +
193
+ `BLOCKED — bookkeeping. ${blocking.length} unresolved row(s) in docs/operations/bookkeeping.md implicate this commit (each re-verified against the repo just now — these are live):\n${lines.join("\n")}\n` +
194
+ "Dispatch the bookkeeper, resolve them, or convert to `waived:` with a reason and expiry; a successful retry re-tests everything again." +
184
195
  (unrelated > 0
185
196
  ? ` (${unrelated} further unresolved row(s) implicate nothing staged here — another session's work, and not yours to drain.)`
186
197
  : "")
187
198
  );
188
199
  }
189
200
 
190
- // This session's own open denials wall the commit too — a commit is the arc's end as much as a
191
- // stop is, and an undisclosed failure must not ride out inside one.
192
- const open = unresolvedDenials(projectDir, sessionId).filter((pair) => !pair.startsWith("changelog ·") && !pair.startsWith("bookkeeping ·"));
193
- if (open.length > 0) {
194
- return decision(
195
- "deny",
196
- `BLOCKED — session gate log. ${open.length} denial(s) this session have no resolved line (${open.join("; ")}). Append \`resolved\` to the session log — retried-ok happens automatically on a successful retry; otherwise \`waived → {ref}\` or \`disclosed → {changelog entry}\`. Never delete log lines.`
197
- );
198
- }
199
-
200
201
  // All commit gates pass — a changelog/bookkeeping denial recorded earlier is a successful retry.
201
202
  autoResolve(projectDir, sessionId, "changelog", "git commit");
202
203
  autoResolve(projectDir, sessionId, "bookkeeping", "git commit");
@@ -440,8 +441,11 @@ function stopGate(projectDir, payload) {
440
441
  // The Stop wall is scoped to this session's own rows (plus sid-less ones) — a concurrent
441
442
  // session's open work must not block this one's stop. End-of-session accountability is per
442
443
  // session and stays that way; the commit wall scopes by staged path instead, and CI by nothing.
443
- const mine = readQueue(projectDir).filter(
444
- (row) => !/\bsid=/.test(row.body) || row.body.includes(`sid=${payload.session_id}`)
444
+ // Filtered through `liveRows` (sid first — fewer verifier runs): the wall blocks on conditions
445
+ // that hold NOW, not on rows the last sweep happened to leave written down.
446
+ const mine = liveRows(
447
+ projectDir,
448
+ readQueue(projectDir).filter((row) => !/\bsid=/.test(row.body) || row.body.includes(`sid=${payload.session_id}`))
445
449
  );
446
450
  const counts = countRows(mine);
447
451
  if (counts.unresolved > 0 && !bookkeeperDispatched(payload.transcript_path)) {
@@ -1,31 +1,54 @@
1
- // harness/code/hooks/lib/ledger.mjs — the Test Obligations table, read the way the walls read it.
2
- // A row is discharged when its Outcome cell states one of the gate's three outcomes. The plan file
3
- // is the checked truth — a discharge held only in conversation memory does not exist, which is what
1
+ // harness/code/hooks/lib/ledger.mjs — the plan's two ledgers, read the way the walls read them.
2
+ // A row is discharged when its Outcome cell states one of the closed outcomes. The plan file is
3
+ // the checked truth — a discharge held only in conversation memory does not exist, which is what
4
4
  // forces the gate to re-read the ledger from disk. Tables without an Outcome column predate the
5
5
  // contract and are out of scope: the same legacy tolerance the Gates: line gets, so an upgrade
6
6
  // cannot brick a plan already Complete.
7
7
 
8
8
  const OUTCOME = /^(satisfied|owes nothing|blocked)\b/i;
9
+ const DOC_OUTCOME = /^(updated|owes nothing|blocked)\b/i;
9
10
 
10
- // Row numbers (the `#` column) of every ledger row whose Outcome cell is empty or off-vocabulary.
11
- export function undischargedRows(planText) {
12
- const start = (planText || "").search(/^##\s+Test Obligations\b/m);
13
- if (start === -1) return [];
11
+ // The table rows of one `## <heading>` section, split into trimmed cells, plus the Outcome column
12
+ // index (null when the table predates the column — legacy, out of contract).
13
+ function sectionTable(planText, heading) {
14
+ const start = (planText || "").search(new RegExp(`^##\\s+${heading}\\b`, "m"));
15
+ if (start === -1) return null;
14
16
  const body = [];
15
17
  for (const line of planText.slice(start).split("\n").slice(1)) {
16
- if (/^##\s/.test(line)) break; // next section
18
+ if (/^##\s/.test(line)) break;
17
19
  body.push(line);
18
20
  }
19
21
  const rows = body.filter((line) => /^\s*\|/.test(line));
20
- if (rows.length < 3) return []; // header + divider + at least one data row
22
+ if (rows.length < 3) return null; // header + divider + at least one data row
21
23
  const header = rows[0].split("|").map((cell) => cell.trim().toLowerCase());
22
24
  const col = header.indexOf("outcome");
23
- if (col === -1) return []; // legacy table — predates the Outcome contract
25
+ return { rows: rows.slice(2).map((line) => line.split("|").map((cell) => cell.trim())), col: col === -1 ? null : col };
26
+ }
27
+
28
+ // Row numbers (the `#` column) of every ledger row whose Outcome cell is empty or off-vocabulary.
29
+ export function undischargedRows(planText) {
30
+ const table = sectionTable(planText, "Test Obligations");
31
+ if (!table || table.col === null) return [];
24
32
  const out = [];
25
- for (const line of rows.slice(2)) {
26
- const cells = line.split("|").map((cell) => cell.trim());
33
+ for (const cells of table.rows) {
27
34
  if (!/^\d+$/.test(cells[1] || "")) continue;
28
- if (!OUTCOME.test(cells[col] || "")) out.push(cells[1]);
35
+ if (!OUTCOME.test(cells[table.col] || "")) out.push(cells[1]);
36
+ }
37
+ return out;
38
+ }
39
+
40
+ // Doc names of every Doc Obligations row whose Outcome cell is empty or off-vocabulary. Cells may
41
+ // be bolded (**updated**); a table without the column is legacy and passes — same tolerance as the
42
+ // test ledger, so the column's arrival cannot brick a plan already Complete.
43
+ export function undischargedDocRows(planText) {
44
+ const table = sectionTable(planText, "Doc Obligations");
45
+ if (!table || table.col === null) return [];
46
+ const out = [];
47
+ for (const cells of table.rows) {
48
+ const doc = (cells[1] || "").replace(/`/g, "");
49
+ if (!doc || /^-+$/.test(doc)) continue;
50
+ const outcome = (cells[table.col] || "").replace(/\*/g, "").trim();
51
+ if (!DOC_OUTCOME.test(outcome)) out.push(doc);
29
52
  }
30
53
  return out;
31
54
  }
@@ -0,0 +1,172 @@
1
+ // harness/code/hooks/lib/sweep.mjs — row re-verification, shared by the detector and the walls.
2
+ //
3
+ // THE QUEUE IS DERIVED, NOT ACCUMULATED. Every finding here is recomputable from the repo, so a row
4
+ // is a cache of a condition rather than a record of an event — and a cache nothing invalidates is
5
+ // an archive. Two consumers, two shapes: `sweepResolvedRows` (the detector's writer — deletes dead
6
+ // rows from the queue file) and `liveRows` (the walls' pure read — same verifiers, NO file writes,
7
+ // because a PreToolUse gate or a CI check must never mutate the queue: on `git commit -a` a sweep's
8
+ // deletions would ride into the commit unauthored).
9
+ //
10
+ // THIS RUNS OVER `waived:` ROWS TOO, WHICH IS THE POINT. A waiver defers a finding; it does not
11
+ // make the finding immortal. Without this, waiving was strictly WORSE than leaving a row alone —
12
+ // the one existing resolver filtered `kind === "auto"`, so waiving a self-clearing row opted it out
13
+ // of its own return path forever. The condition, not the row's kind, decides.
14
+ //
15
+ // A kind with no verifier here is not swept — it ages out through `expires=` instead. Add a
16
+ // verifier whenever a finding becomes mechanically checkable; silence is the only failure mode
17
+ // this file has, so a verifier that is unsure must return false and leave the row standing.
18
+
19
+ import fs from "node:fs";
20
+ import path from "node:path";
21
+ import { spawnSync } from "node:child_process";
22
+ import { globToRegExp, listRepoFiles } from "./ownership.mjs";
23
+ import { readSafe } from "./repo.mjs";
24
+ import { resolveRows } from "./state.mjs";
25
+ import { rejectedPackages } from "./approved.mjs";
26
+
27
+ // Spawn the installed doc-lint over one file; returns finding lines (empty when absent/clean).
28
+ export function docLint(projectDir, rel) {
29
+ const script = path.join(projectDir, ".claude", "skills", "documentation", "scripts", "doc-lint.mjs");
30
+ if (!fs.existsSync(script)) return [];
31
+ const res = spawnSync("node", [script, rel], { cwd: projectDir, encoding: "utf8", timeout: 5000 });
32
+ const out = `${res.stdout || ""}`;
33
+ return out.split("\n").filter((line) => /:\d+ \[(block|warn)\]/.test(line));
34
+ }
35
+
36
+ // True when a source file's first lines carry its repo-relative path (the header-comment rule).
37
+ export function hasHeaderComment(projectDir, rel) {
38
+ const head = readSafe(path.join(projectDir, rel)).split("\n").slice(0, 5).join("\n");
39
+ return head.includes(rel) || head.includes(path.basename(rel));
40
+ }
41
+
42
+ // Verifier keys the detector no longer emits — swept when machine-raised, never taught to authors.
43
+ export const RETIRED_KINDS = ["doc-placement", "boundary-scan-suggested"];
44
+
45
+ export const ROW_VERIFIERS = {
46
+ // "<rel> lacks its file-path header comment" — dead once the header is there (or the file is gone).
47
+ "header-missing": (projectDir, body) => {
48
+ const m = /·\s+header-missing\s+·\s+(\S+)\s+lacks/.exec(body);
49
+ if (!m) return false;
50
+ const rel = m[1];
51
+ if (!fs.existsSync(path.join(projectDir, rel))) return true;
52
+ return hasHeaderComment(projectDir, rel);
53
+ },
54
+
55
+ // "owns: glob `<glob>` in <doc> matches no files" — dead once the glob matches something again.
56
+ "dead-glob": (projectDir, body, ctx) => {
57
+ const m = /`([^`]+)`/.exec(body);
58
+ if (!m) return false;
59
+ const re = globToRegExp(m[1]);
60
+ return ctx.repoFiles.some((f) => re.test(f));
61
+ },
62
+
63
+ // "<rel> is a frozen record and carries uncommitted modifications" — dead once it is committed.
64
+ // Immutability begins at commit, so the finding exists only while the edit is still in flight.
65
+ "frozen-record-edited": (projectDir, body) => {
66
+ const m = /·\s+frozen-record-edited\s+·\s+(\S+)\s+is a frozen record/.exec(body);
67
+ if (!m) return false;
68
+ const rel = m[1];
69
+ if (!fs.existsSync(path.join(projectDir, rel))) return true;
70
+ const res = spawnSync("git", ["diff", "--quiet", "HEAD", "--", rel], { cwd: projectDir, timeout: 5000 });
71
+ // status 0 = no uncommitted modifications. A git failure leaves the row standing.
72
+ return res.status === 0;
73
+ },
74
+
75
+ // "<src> changed; owning doc <doc> may need an update" — dead once the doc is newer than the
76
+ // source that implicated it, which is the same freshness test the drift report already applies.
77
+ "doc-implicated": (projectDir, body) => {
78
+ const m = /·\s+doc-implicated\s+·\s+(\S+)\s+changed;\s+owning doc\s+(\S+?)\s+may need/.exec(body);
79
+ if (!m) return false;
80
+ const [, src, doc] = m;
81
+ const mtime = (rel) => {
82
+ try {
83
+ return fs.statSync(path.join(projectDir, rel)).mtimeMs;
84
+ } catch (e) {
85
+ return null;
86
+ }
87
+ };
88
+ const docTime = mtime(doc);
89
+ const srcTime = mtime(src);
90
+ if (docTime === null) return false; // the doc is gone — a real finding, not a resolved one
91
+ if (srcTime === null) return true; // the source is gone; nothing implicates the doc any more
92
+ return docTime >= srcTime;
93
+ },
94
+
95
+ // "<rel> has N doc-lint finding(s)" — dead once a re-run comes back clean (or the doc is gone).
96
+ "doc-lint": (projectDir, body) => {
97
+ const m = /·\s+doc-lint\s+·\s+(\S+)\s+has/.exec(body);
98
+ if (!m) return false;
99
+ if (!fs.existsSync(path.join(projectDir, m[1]))) return true;
100
+ return docLint(projectDir, m[1]).length === 0;
101
+ },
102
+
103
+ // "<rel> is not linked from docs/README.md" — dead once the index names it (or it is gone).
104
+ "index-row-missing": (projectDir, body) => {
105
+ const m = /·\s+index-row-missing\s+·\s+(\S+)\s+is not linked/.exec(body);
106
+ if (!m) return false;
107
+ if (!fs.existsSync(path.join(projectDir, m[1]))) return true;
108
+ return readSafe(path.join(projectDir, "docs", "README.md")).includes(path.basename(m[1]));
109
+ },
110
+
111
+ // "<rel> declares `<name>` (package-rejected)" — dead once the manifest drops the package
112
+ // or the table stops rejecting it.
113
+ "package-rejected": (projectDir, body) => {
114
+ const m = /·\s+package-rejected\s+·\s+(\S+)\s+declares\s+`([^`]+)`/.exec(body);
115
+ if (!m) return false;
116
+ const [, rel, name] = m;
117
+ let manifest;
118
+ try {
119
+ manifest = JSON.parse(readSafe(path.join(projectDir, rel)));
120
+ } catch (e) {
121
+ return !fs.existsSync(path.join(projectDir, rel)); // gone ⇒ resolved; unparseable ⇒ standing
122
+ }
123
+ const declared = { ...manifest.dependencies, ...manifest.devDependencies };
124
+ if (!(name in declared)) return true;
125
+ return !rejectedPackages(projectDir).has(name);
126
+ },
127
+
128
+ // Retired detector kinds — the harness no longer raises these, so a standing row caches a
129
+ // condition nothing can re-derive. Swept unconditionally — but only machine-raised rows: a
130
+ // note: or waived: row whose free text merely mentions the phrase is a human's, not ours.
131
+ "doc-placement": (projectDir, body, ctx, row) => row.kind === "auto",
132
+ "boundary-scan-suggested": (projectDir, body, ctx, row) => row.kind === "auto",
133
+ };
134
+
135
+ // The repo-file list is only needed by the dead-glob verifier — walk the tree lazily, once.
136
+ function verifierContext(projectDir, repoFiles) {
137
+ let cached = repoFiles || null;
138
+ return {
139
+ get repoFiles() {
140
+ if (!cached) cached = listRepoFiles(projectDir);
141
+ return cached;
142
+ },
143
+ };
144
+ }
145
+
146
+ // True when the row's condition no longer holds. Unsure (no verifier, verifier threw) ⇒ false.
147
+ function conditionDead(projectDir, row, ctx) {
148
+ if (row.kind === "needs-session") return false; // a human judgment, never swept mechanically
149
+ for (const [kind, verify] of Object.entries(ROW_VERIFIERS)) {
150
+ if (!row.body.includes(kind)) continue;
151
+ try {
152
+ return verify(projectDir, row.body, ctx, row);
153
+ } catch (e) {
154
+ return false; // never drop a row because a verifier threw
155
+ }
156
+ }
157
+ return false;
158
+ }
159
+
160
+ // The walls' read: rows whose condition still holds, re-tested against the repo as it is NOW.
161
+ // Pure — the queue file is untouched; the detector's next sweep deletes what this filtered out.
162
+ export function liveRows(projectDir, rows, repoFiles) {
163
+ const ctx = verifierContext(projectDir, repoFiles);
164
+ return rows.filter((row) => !conditionDead(projectDir, row, ctx));
165
+ }
166
+
167
+ // The detector's writer: delete every row — `auto:` or `waived:` — whose condition no longer
168
+ // holds. Returns the count removed.
169
+ export function sweepResolvedRows(projectDir, repoFiles) {
170
+ const ctx = verifierContext(projectDir, repoFiles);
171
+ return resolveRows(projectDir, (row) => conditionDead(projectDir, row, ctx));
172
+ }
@@ -29,7 +29,7 @@ Every package compiles under `strict: true` and `noUncheckedIndexedAccess: true`
29
29
 
30
30
  - **Never** `any` to silence the checker. Unknown value → type `unknown`, then narrow (type guard / `zod` parse).
31
31
  - **Never** weaken strictness: no `// @ts-nocheck`; `// @ts-expect-error` only with a same-line reason.
32
- - Infer locals and return types; annotate at boundaries (exports, props, contracts).
32
+ - Annotate at boundaries (exports, props, contracts).
33
33
 
34
34
  ### Generics
35
35
 
@@ -19,11 +19,11 @@ How a package's `src/` is laid out. A single-runtime package drops `server/` and
19
19
 
20
20
  **Never `core/`** — its membership test collapses to "is it important?", making it the queue for files nobody could place; a bare `lib/` holding everything non-visual fails the same way, which is what `shared/` prevents. A route's own nested `lib/` is private and is **never** hoisted, and **dependencies point down** — a shared folder importing one route folder is a defect.
21
21
 
22
- **A folder earns its place at ~4+ files, or by being a boundary somebody could delete whole.** Below that, inline it: a folder around one file is a longer filename, and a folder holding only another folder is cascade with nothing at it. Density — not depth, not root breadth — is what makes a tree read as sprawling. The boundary clause splits the one-file cases: a one-file route folder stays, a one-file component folder goes.
22
+ **A folder earns its place at ~4+ files, or by being a boundary somebody could delete whole.** Below that, inline it. The boundary clause splits the one-file cases: a one-file route folder stays, a one-file component folder goes.
23
23
 
24
- **Never judge dead code by a name grep** — a relative import never contains its own parent's path, so `grep "x/brand"` cannot see `from "./brand/Detail"`, and every folder imported only by its parent reads as dead. Test by transitive reachability from the real entry points, resolving specifiers as the bundler does (`.ts`, `.tsx`, `.css`, `/index.ts`; strip `?raw`/`?url`; map a `.js` specifier onto its `.ts` source), running the test tree as a **second, separate** entry set — reachable from tests alone means a suite pinning code nothing ships, and both go together. Ambient `.d.ts` files enter through tsconfig `include`, so a sweep always reports them dead. A retired mechanism spans more than source — grep the build config for the same concept before calling a removal complete. **Unreachable ≠ unwanted**: a routed surface rendering dummy data is reachable and still not product, and retiring it removes a route, which is a product decision.
24
+ **Never judge dead code by a name grep** — a relative import never contains its own parent's path, so every folder imported only by its parent reads as dead. Test by transitive reachability from the real entry points, resolving specifiers as the bundler does (`.ts`, `.tsx`, `.css`, `/index.ts`; strip `?raw`/`?url`; map a `.js` specifier onto its `.ts` source), with the test tree as a **second, separate** entry set — reachable from tests alone is a suite pinning code nothing ships. Ambient `.d.ts` files enter through tsconfig `include`, so a sweep always reports them dead; grep the build config for a retired mechanism before calling its removal complete. **Unreachable ≠ unwanted**: retiring a routed dummy surface removes a route — a product decision.
25
25
 
26
- **Reshape an existing tree largest-lever-first**, verifying typecheck plus the full suite between steps: purge unreachable files → collapse single-occupancy and pass-through folders → push domain-coupled components down → name the shared layer (the only step that adds a top-level name) → optionally add a path alias, which makes depth invisible at the import site and dissolves most "flatten it" instincts. **Delete and promote before you move** — a folder whose name misdescribes its contents just relocates the lie.
26
+ **Reshape an existing tree largest-lever-first**, verifying typecheck plus the full suite between steps: purge unreachable files → collapse single-occupancy and pass-through folders → push domain-coupled components down → name the shared layer (the only step that adds a top-level name) → optionally add a path alias (depth becomes invisible at the import site). **Delete and promote before you move** — a folder whose name misdescribes its contents just relocates the lie.
27
27
 
28
28
  ## Runtime Versions
29
29
 
@@ -20,11 +20,7 @@ Names state intent in full words — clarity over brevity; no abbreviations unle
20
20
 
21
21
  | Identifier | Shape | Example |
22
22
  | --- | --- | --- |
23
- | Variable | noun phrase | `paymentStatus` |
24
- | Function / method | verb phrase naming the action | `validateInput` |
25
23
  | Boolean | `is` / `has` / `can` / `should` prefix — **never** a bare adjective | `hasPermission` |
26
24
  | Array / collection | plural noun | `errorMessages` |
27
- | Class / component | singular noun phrase | `RequestHandler` |
28
25
  | Event handler | `handle` or `on` prefix | `handleSubmit`, `onPageLoad` |
29
26
  | Interface | `I` prefix acceptable; type aliases stay plain | `IConfigOptions`, `AuthResponse` |
30
- | Async function | verb phrase; `Async` suffix acceptable | `loadConfigAsync` |
@@ -1,6 +1,6 @@
1
1
  # pnpm — `@nurix/*` Workspaces: naming, boundaries, and consuming
2
2
 
3
- How a `@nurix/*` repo is **named**, **structured**, and how its packages are **consumed** — every repo takes the workspace shape, single-package frontends included. Publishing and CLI design live in the `nustack` skill, not here.
3
+ How a `@nurix/*` repo is **named**, **structured**, and how its packages are **consumed** — every repo takes the workspace shape, single-package frontends included. Publishing and CLI design live in the `nustack` skill, not here — no `nustack` skill in the runtime ⇒ ask before crossing the publish boundary.
4
4
 
5
5
  ---
6
6
 
@@ -32,6 +32,7 @@ Rules for the plan regime between the plan gate and the verification gate:
32
32
  - **Precondition: the ledger exists.** A plan with no Test Obligations section is blocked at the gate — run the `testing` skill (plan-gate mode) on it first; an empty ledger passes vacuously and the gap surfaces only after the file is frozen.
33
33
  - **The verdict is never delegated** (the root policy file § Delegation), and where browser work is main-session-only (its § Runtime Notes), the driving is too. Fan-out may prepare fixtures or draft test bodies; the driving and the verdict stay inline.
34
34
  - **The gate needs no stronger tier than the session default** — a standard-tier session suffices whatever tier built the plan; the plan and its ledger carry all the state the gate needs, so gating in a fresh session loses nothing.
35
+ - **Browser driving is the gate's final rung.** Playwright / Chrome CDP drives run once, at the end, after suites and cheap oracles are green — never interleaved mid-loop; they are the most expensive oracle in time and tokens.
35
36
  - **Report what happened, including failures.** A gate that reports green while a suite is red is worse than no gate; state the failing test and its output.
36
37
  - **Abandonment does not erase debt.** Before `Status: Abandoned` freezes a plan whose code was committed, convert its undischarged rows into backlog items; uncommitted code owes nothing.
37
38
 
@@ -65,7 +65,7 @@ The packs are a floor, not a fence: report any contract that passes the ships-gr
65
65
 
66
66
  **BLOCK** = any CRITICAL. HIGH findings yield **PASS WITH AMENDMENTS** — fold the amendments into the plan before coding. Order amendments sequencing-first: many violations dissolve by reordering steps (expand-then-contract, migrate-before-code) rather than changing what ships — prefer that fix, drawn from the entry's safe-change sequence.
67
67
 
68
- 6. **Record the verdict in the plan's `Gates:` line.** Rewrite only your own segment, to exactly `Boundary: passed YYYY-MM-DD` (PASS, or PASS WITH AMENDMENTS once the amendments are folded) or `Boundary: blocked YYYY-MM-DD` (BLOCK). The compliance hook greps these exact forms — implementation edits stay blocked while the segment reads `pending`. A plan with no `Gates:` line predates the format; add the line rather than skipping the write-back.
68
+ 6. **Record the verdict in the plan's `Gates:` line.** Rewrite only your own segment, to exactly `Boundary: passed YYYY-MM-DD` (PASS, or PASS WITH AMENDMENTS once the amendments are folded) or `Boundary: blocked YYYY-MM-DD` (BLOCK). The compliance hook greps exactly this segment — implementation edits inside the plan's scope stay blocked while it reads `Boundary: pending` (the other gates are enforced by section presence, not segment text). A plan with no `Gates:` line predates the format; add the line rather than skipping the write-back.
69
69
 
70
70
  ## Rules
71
71
 
@@ -113,7 +113,7 @@ The two guarantees are complementary and both break on mixing: a reader holding
113
113
  | Plan | Record | `YYYY-MM-DD-kebab-case.md` | Freezes at `Complete` / `Abandoned` |
114
114
  | Changelog entry | Record | `YYYY-MM-DD-kebab-case.md` | Frozen; master index append-only |
115
115
  | Migration runbook | Record | `YYYY-MM-DD-kebab-case.md` | Freezes once executed, outcome stamped |
116
- | Research | Record | `history/research/{topic}/` | Frozen snapshot |
116
+ | Research | Record | in progress: `docs/research/{topic}/` · harvested: `docs/history/brainstorms/{topic}/` | Frozen at harvest |
117
117
  | Memory | Record | `{agent-name}.md` | Append-only dated ledger |
118
118
 
119
119
  ---
@@ -2,7 +2,7 @@
2
2
 
3
3
  Truth document. Use for: **how to verify one feature's promised behavior by driving the real product** — the browser, the CLI binary, HTTP against the running service. One file per feature, named for the feature doc that owns the code (`docs/features/auth.md` → `docs/tests/auth.md`). No frontmatter; a checklist owns no code.
4
4
 
5
- **This is the driving script, and it is the source the suite is drawn from.** At the verification gate it does double duty: rung 1 drives the real surface *against these steps*, and rung 2 pins the durable subset *of these assertions* as suite tests. Writing it is therefore not a report written afterwards — it is authored or updated **before** the driving, so the gate has something to execute and the tests have something to be derived from. Which assertions earn a permanent test is decided by `verification.md` § What earns a durable test, and each section records the answer on its `Pinned:` line.
5
+ **This is the driving script, and it is the source the suite is drawn from.** At the verification gate it does double duty: rung 1 drives the real surface *against these steps*, and rung 2 pins the durable subset *of these assertions* as suite tests. Writing it is therefore not a report written afterwards — it is authored or updated **before** the driving, so the gate has something to execute and the tests have something to be derived from. Which assertions earn a permanent test is decided by the `testing` skill (gate mode) § What earns a suite test, and each section records the answer on its `Pinned:` line.
6
6
 
7
7
  A checklist outlives the tests it seeds. Where a Playwright script breaks on every selector change, a checklist states the behavior and lets the implementation move under it — so it is written in the product's own vocabulary (labels, URLs, rows) and never in the code's (function names, props, internal state).
8
8
 
@@ -2,19 +2,23 @@
2
2
 
3
3
  Truth document with an unusual lifecycle: a **work queue**, not an archive. Rows are appended mechanically by the awareness hook (`auto:`) and deliberately by working sessions (`note:`), drained by the `bookkeeper` agent, and walled at Stop, at commit, and in CI. What survives in git — `waived:` and `needs-session:` rows — is exactly what a PR reviewer should see.
4
4
 
5
- **Each wall reads the queue at its own width.** Stop takes the ending session's own rows, because accountability for an arc is per session. The commit gate takes only the rows **implicating that commit's staged files** — a row names the file its finding is about, and holding a commit behind rows raised by somebody else's edits is a wait nothing the committer may do can clear. CI takes every row on the branch, where there is no staged set to scope by. Fail-closed cases stay repo-wide at the commit gate: a row whose grammar names no path, a `needs-session:` row, an expired waiver, and a commit staging nothing at all.
5
+ **Each wall reads the queue at its own width, and re-verifies conditions at gate time.** Stop takes the ending session's own rows, because accountability for an arc is per session. The commit gate takes only the rows **implicating that commit's staged files** — a row names the file its finding is about, and holding a commit behind rows raised by somebody else's edits is a wait nothing the committer may do can clear. CI takes every row on the branch, where there is no staged set to scope by. All three re-test each row against the repo before blocking, so a row whose condition has died walls nothing. Fail-closed cases stay repo-wide at the commit gate: a row whose grammar names no path, a `needs-session:` row, an expired waiver, and a commit staging nothing at all.
6
6
 
7
- The row grammar is machine-read by the hooks and CI; keep it exact. A `waived:` row's `expires=` date is enforced: CI honors the waiver until the date, then fails. `waived:` rows whose finding kind is `dead-glob` double as the dead-glob allowlist.
7
+ The fenced block's prose is a **verbatim quote of the queue header the hooks self-heal** (`QUEUE_HEADER` in the hooks' `lib/state.mjs`) — code is canonical; a wording change starts there. The row grammar lines beneath it are taught here and parsed by the hooks; keep both exact.
8
8
 
9
9
  ```
10
10
  # Bookkeeping
11
11
 
12
- The shared work queue keeping documentation and code in sync — a queue, never an archive. Rows are appended mechanically (auto:) and by working sessions (note:), drained by the bookkeeper agent, and walled at commit: a commit goes through only when every row **implicating one of its staged files** is either **resolved, which deletes it**, or converted to `waived:`. A row about files the commit does not touch belongs to whoever raised it; a row naming no file, and a commit staging none, block everything. A drained row leaves no trace here; what it closed is recorded in the doc it corrected, and git history holds the row itself. Row grammar and lifecycle: the `documentation` skill's `templates/bookkeeping.md`. A `waived:` row carries `expires=` and is PR-reviewable history.
12
+ The shared work queue keeping documentation and code in sync — a queue, never an archive. Rows are appended mechanically (auto:) and by working sessions (note:), drained by the bookkeeper agent, and walled at commit: a commit goes through only when every row **implicating one of its staged files** is either **resolved, which deletes it**, or converted to `waived:`. A row about files the commit does not touch belongs to whoever raised it; a row naming no file, and a commit staging none, block everything. A drained row leaves no trace here; what it closed is recorded in the doc it corrected, and git history holds the row itself. Row grammar and lifecycle: the `documentation` skill's `templates/bookkeeping.md`.
13
13
 
14
- - auto: YYYY-MM-DD · {finding-kind} · {description} → {action} | sid={session_id} tp={transcript_path} ts={ISO}
15
- - note: YYYY-MM-DD · {area} · {one-line context only the session holds} → lands-in: {owning doc | backlog | memory} | sid=… tp=… ts=…
16
- - needs-session: YYYY-MM-DD · {what the bookkeeper could not ground} → main session resolves | refs={original row ts}
17
- - waived: YYYY-MM-DD · {what is deferred} · reason: {why} → expires=YYYY-MM-DD
14
+ **Every row is a cache of a condition, not a record of an event.** A session-start sweep re-tests each row against the repo and deletes the ones whose condition has died — `waived:` rows included, because a waiver defers a finding and never makes one immortal. What the sweep cannot mechanically re-test ages out instead: a `waived:` row carries `expires=`, is PR-reviewable history until that date, and counts as **unresolved** past it — which returns the wall and re-summons the bookkeeper to re-judge whether the reason still holds.
15
+
16
+ - auto: YYYY-MM-DD · {finding-kind} · {description} → {action} | sid={session_id}
17
+ - note: YYYY-MM-DD · {area} · {one-line context only the session holds} → lands-in: {owning doc | backlog | memory} | sid={session_id}
18
+ - needs-session: YYYY-MM-DD · {what the bookkeeper could not ground} → main session resolves | refs={implicated file}
19
+ - waived: YYYY-MM-DD · {finding-kind} · `{subject}` · reason: {why} → expires=YYYY-MM-DD
18
20
  ```
19
21
 
20
- Finding kinds for `auto:`: `dead-glob` · `doc-implicated` · `doc-lint` · `header-missing` · `index-row-missing` · `package-rejected` · `frozen-record-edited`.
22
+ **A waiver covers a finding only when it names the finding's kind and its subject in backticks** — the matcher (`waiverCovers`) tests the backticked subject exactly, so a waiver written without them silences nothing and the wall stands. A `waived:` row's `expires=` date is enforced: CI honors the waiver until the date, then fails. `dead-glob` waivers double as the dead-glob allowlist; `ownership-overlap` waivers cover the CI ownership check the same way.
23
+
24
+ Finding kinds for `auto:`: `dead-glob` · `doc-implicated` · `doc-lint` · `header-missing` · `index-row-missing` · `package-rejected` · `frozen-record-edited`. The CI ownership check additionally accepts `waived:` rows of kind `ownership-overlap`.
@@ -6,16 +6,16 @@ The `boundary` skill parses the **Proposed Changes** sections to check boundary
6
6
 
7
7
  **Doc Obligations is the one ledger the plan's author writes.** Deriving tests from behavior is judgment, which is why `testing` owns that section and it is marked never-hand-author; resolving Proposed Changes' file paths to the feature docs that own them is a **lookup** against the ownership index already injected at session start. Different work, different rule — fill it yourself, while the touch list is fresh.
8
8
 
9
- The **`Gates:`** line is machine-read: the compliance hook greps exactly the forms below, and implementation edits are blocked while a segment reads `pending`. Each gate skill rewrites only its own segment when it runs — `boundary` to `Boundary: passed YYYY-MM-DD` or `Boundary: blocked YYYY-MM-DD`; `testing` (plan-gate mode) to `Test-plan: written YYYY-MM-DD (N rows)` — the segment label stays `Test-plan`, greppable. The author writes `Docs: mapped YYYY-MM-DD (N docs)` when the Doc Obligations table is filled. Never hand-edit the line to skip a gate.
9
+ The **`Gates:`** line carries one machine-read segment. The compliance hook greps **`Boundary:`** — implementation edits inside the plan's scope are blocked while it reads `Boundary: pending`; the `boundary` skill rewrites it to `Boundary: passed YYYY-MM-DD` or `Boundary: blocked YYYY-MM-DD`. The other two gates are enforced by **section presence**: a plan with no `## Test Obligations` or no `## Doc Obligations` section blocks the same edits — the sections are the artifacts, and the gate derives from them, not from a claim about them. Never hand-edit the line to skip a gate.
10
10
 
11
11
  ```
12
12
  # {Feature Name}
13
13
 
14
14
  **Status:** Draft | In Progress | Complete | Abandoned
15
- **Gates:** Boundary: pending · Test-plan: pending · Docs: pending
15
+ **Gates:** Boundary: pending
16
16
  **Spec:** [docs/features/{feature}.md](../../features/{feature}.md) <!-- one line per feature doc this plan implements against; a plan spanning three features names three -->
17
17
  **Started:** YYYY-MM-DD
18
- **Completed:** YYYY-MM-DD <!-- fill in the SAME edit that flips Status to Complete: the flip freezes the file instantly, so a date left for a second edit is unreachable (the frozen-record gate rightly blocks it) -->
18
+ **Completed:** YYYY-MM-DD <!-- fill in the SAME edit that flips Status to Complete — along with every ledger Outcome cell: the flip freezes the file instantly, so anything left for a second edit is unreachable (the frozen-record gate rightly blocks it) -->
19
19
 
20
20
  ## Background
21
21
 
@@ -52,9 +52,9 @@ Why this work is happening *now*. Link to the spec for *what* we're building; th
52
52
  them against the injected ownership index. Hand-authored: this is a lookup, not research.
53
53
  Amendable while the plan is open; discharged at Completion. -->
54
54
 
55
- | Doc | Claim that changes | `owns:` still accurate? |
56
- | --- | --- | --- |
57
- | `docs/features/{feature}.md` | what it says today that this plan makes false | yes / no — new paths to add |
55
+ | Doc | Claim that changes | `owns:` still accurate? | Outcome |
56
+ | --- | --- | --- | --- |
57
+ | `docs/features/{feature}.md` | what it says today that this plan makes false | yes / no — new paths to add | written empty; discharged at Completion — **updated** / **owes nothing** (with the reason) / **blocked** (→ backlog) |
58
58
 
59
59
  **"None" is valid** — a plan touching only unowned paths owes no doc update, and says so explicitly. It is not a free pass: `boundary` builds its own touch list from the same Proposed Changes and reports docs this table missed.
60
60
 
@@ -67,16 +67,9 @@ Why this work is happening *now*. Link to the spec for *what* we're building; th
67
67
  ## Test Obligations
68
68
 
69
69
  <!-- Written by the `testing` skill (plan-gate mode) at the plan gate (after `boundary`). One row per
70
- promised behavior: layer · target file · oracle · fixtures. Amendable while the
71
- plan is open; discharged at the verification gate. Do not hand-author. -->
72
-
73
- ## Done Criteria
74
-
75
- - [ ] Implementation complete
76
- - [ ] Verification gate passed — every Test Obligations row discharged (satisfied / owes nothing / blocked → backlog), gate run date and results noted here
77
- - [ ] Every Doc Obligations row discharged — **updated** / **owes nothing** (with the reason) / **blocked** (→ backlog item, so the debt outlives the frozen plan)
78
- - [ ] Changelog entry created in `docs/history/changelog/`, carrying the gate's results
79
- - [ ] Plan marked `Status: Complete`, **Completed:** date filled
70
+ promised behavior: layer · target file · oracle · fixtures · an empty Outcome cell the
71
+ verification gate fills (satisfied / owes nothing / blocked → backlog). Amendable while the
72
+ plan is open. Do not hand-author. -->
80
73
 
81
74
  ## Open Questions
82
75
 
@@ -55,7 +55,6 @@ Assign each behavior the **cheapest layer that can prove it**, then name the ora
55
55
  ```
56
56
 
57
57
  A behavior with no obligation is stated too, with the reason — "none — compiler-guarded" reads as *checked*; an empty cell reads as *not analyzed*. A short manifest is valid — do not pad. **`Outcome` is written empty here — it is gate mode's cell**, and the completion walls read it: a plan cannot reach `Status: Complete` while any row's Outcome is empty.
58
- 6. **Record the write in the plan's `Gates:` line**, rewriting only your segment to exactly `Test-plan: written YYYY-MM-DD (N rows)` — the compliance hook greps this exact form. Add the line if the plan predates the format.
59
58
 
60
59
  ---
61
60
 
@@ -153,7 +152,7 @@ Runs only when the user triggers the verification gate (constraints — precondi
153
152
  1. **Write the checklist** — derive or update `docs/tests/{feature}.md` from the ledger and the LLD's promises (the `documentation` skill's `templates/behavioral-checklist.md`; one `**Assert**` per observable). Derived before the driving it is the script; derived after, it is merely a report with nothing to trace tests back to.
154
153
 
155
154
  **The gate owns these files, not just their first draft.** Whoever drives a checklist maintains it in the same pass — nothing else in the repo is watching, because a checklist owns no code and no `owns:` glob implicates it, so a surface can be rewritten under a checklist and every hook stays silent. Before driving, reconcile the file against the surface you are about to drive: **retire** sections whose behavior no longer exists (delete them — a checklist is a truth document, and the history lives in git), **add** sections for behavior shipped since the last run, and **repair every `Pinned:` trace**, because a trace naming a test that no longer exists is worse than no trace — it reports coverage that cannot fail. A section you cannot pin says `none yet` and names the test it owes. **Never soften an assertion to make the run green**: the checklist states what the feature promises, the run records what happened, and the backlog carries the difference.
156
- 2. **Drive the real surface** — browser automation for web UI (the `/test` runner skill where the repo wires it), the actual CLI binary, HTTP against the running worker. **A failing checklist assertion is never softened to make a run green** — record the defect, send it to the backlog. Where the runtime offers no browser automation, emit the browser-verification checklist for the user to run; their report is the rung's verdict and `Status: Complete` waits for it.
155
+ 2. **Drive the real surface — cheapest oracle first, browser last.** Suites, the actual CLI binary, and HTTP against the running worker come first; browser automation (the `/test` runner skill / Chrome CDP / Playwright) is the gate's **final rung**, run once when everything cheaper is already green — browser drives are the most time- and token-expensive oracle, never an iteration loop. **A failing checklist assertion is never softened to make a run green** — record the defect, send it to the backlog. Where the runtime offers no browser automation, emit the browser-verification checklist for the user to run; their report is the rung's verdict and `Status: Complete` waits for it.
157
156
  3. **Pin the durable behaviors** as suite tests per `testing.md`, naming each test's stratum (§ Bridge). **A pinned test must pass from a cold shell** — `pnpm test` with no dev server hand-started, no seeded state assumed beyond what the suite builds for itself. A test whose oracle is "fetch the server I happen to have running" is a driven check wearing a test's name: it goes red the moment the session ends (on Workers, `@cloudflare/vitest-pool-workers` is the self-contained harness; an Express app is driven via `supertest`, never a port). Before discharging, run the suite once from a clean shell — a suite that needs the session's environment discharges nothing. A test is a permanent maintenance cost, bought deliberately:
158
157
 
159
158
  | Earns a suite test | Verified by driving alone |
@@ -28,9 +28,7 @@ Decide the mode once, up front, from the version marker — then run.
28
28
  | behind on minor/patch | **probe first**, below |
29
29
  | equal to installed | **audit only** (the default spine) |
30
30
 
31
- **The probe.** Run the Step B2 detection pass read-only: if any old-shape artifact in the fold table is present, **or** any **scaffolded file** in the target tree is missing (`docs/README.md`, `docs/structure.md`, `docs/history/CHANGELOG.md`, `docs/history/backlog.md`, `docs/memory/main.md`), the shape *did* change ⇒ **baseline mode**. Only when the probe finds nothing ⇒ re-stamp the version (no reshape), then audit.
32
-
33
- > **The version digit is a hint; the fold table is the authority.** A shape change ships in a minor whenever the capability behind it is additive — `docs/history/ledger/` dissolving into `docs/history/changelog/` arrived in a minor, as did `docs/history/migrations/` — so "minor ⇒ nothing to reshape" silently strands dissolved-shape artifacts in the repo forever.
31
+ **The probe.** Run the Step B2 detection pass read-only: if any `*.bkp.*` file sits under `docs/` (a fold awaiting its pass), **or** any **scaffolded file** in the target tree is missing (`docs/README.md`, `docs/structure.md`, `docs/history/CHANGELOG.md`, `docs/history/backlog.md`, `docs/memory/main.md`), the shape *did* change ⇒ **baseline mode**. Only when the probe finds nothing ⇒ re-stamp the version (no reshape), then audit.
34
32
 
35
33
  > **Never probe on a missing folder.** Git does not track empty directories, so an unused folder is absent on every clone — reading that as drift puts the repo in baseline mode *forever*. Only the five scaffolded files above are shape signals; every folder in the target tree is created on first use.
36
34
  3. **Precondition for baseline mode:** a root `CLAUDE.md` must exist. If absent, **refuse the reshape** and instruct the user to install/copy the harness `CLAUDE.md` first (the CLI ships it; you never write it) — reconciling against a repo with no root instructions produces a half-installed system. Detect-and-instruct, not a hard gate on the audit.
@@ -166,21 +164,12 @@ Detecting and folding old-shape artifacts is what makes a baseline more than a f
166
164
 
167
165
  | If you find (old shape) | Fold to (current shape) |
168
166
  | --- | --- |
169
- | a persisted `docs/routing.md` ownership map | migrate each File-Pattern row's ownership into the owning feature doc's `owns:` frontmatter (Pattern → an `owns:` glob; Module column → the doc's `module:` label), then **delete `docs/routing.md`** |
170
- | feature docs carrying `owns`/`touches`/`depends_on` legacy frontmatter | collapse to the single `owns:` key (drop `touches`/`depends_on`; their paths fold into `owns:` if the doc genuinely owns them, else drop) |
171
- | a `docs/modules/{m}/` tree | move each module's `features/` flat into `docs/features/` with the module name as a `module:` tag on each doc; fold module-local `pillars/`/prose into `docs/architecture/`; delete the `docs/modules/` tree |
172
- | repo-wide `docs/pillars/` | cross-module doctrine → `docs/architecture/`; delete the folder |
173
- | `docs/boundaries/` (legacy persisted boundaries) | delete — boundary analysis is a plan-time diagnostic (the `boundary` skill reads contracts fresh from source); fold any human-authored knowledge (external consumers, intent, accepted exceptions) into `docs/architecture/` |
174
- | `docs/OWNERSHIP_INDEX.md` (legacy derived ownership index) | delete — ownership is the union of feature `owns:` frontmatter, surfaced by the generated index |
175
- | `docs/history/ledger/YYYY-MM.md` (the Refinement Ledger, now merged into the changelog) | split each `### ` block into its own `docs/history/changelog/YYYY-MM-DD-{three-words}.md` entry — content preserved **verbatim**, heading promoted to `#`, three words naming the work; append one row per entry to `docs/history/CHANGELOG.md`; delete the `docs/history/ledger/` folder. Records are moved, never rewritten |
176
- | `docs/operations/guardrails.md` | fold durable contract notes into `docs/architecture/` |
177
- | `docs/architecture/overview.md` | split into `domain-model.md` / `glossary.md` / `non-goals.md` / `references.md` / `open-questions.md` |
178
- | `docs/architecture/adr/` (legacy folder name), or `ADR-NNN-*.md` records anywhere under `docs/architecture/` | **rename to the AD genre.** The folder becomes `docs/architecture/decisions/`; each record file `ADR-NNN-{slug}.md` becomes `AD-NNN-{slug}.md`, keeping its number and slug. Inside each record, update the retired vocabulary — "ADR", "Architecture Decision Record", and any supersession framing ("once accepted, an ADR is never edited; a later ADR supersedes it") — to the AD model: one AD per subject, **updated in place**, its journey confined to a `Decision Changelog` tail. A record's **reasoning is never rewritten** by a reconcile; only its genre vocabulary, its own title/id, and paths it cites. The folder's `Readme.md` is an **index, not a record** — reframe its genre description while preserving authored content such as a candidate-decision list verbatim. Repoint live references **inside `docs/`**, including one AD citing another; leave frozen records' links as point-in-time. A live reference **outside** your write scope — most often the root `README.md`, which you never overwrite — cannot be repointed: record it as a backlog item naming the file and line rather than leaving the residue silent |
179
- | a legacy `docs/brainstorm/{topic}/`, a singular `docs/history/brainstorm/`, or a root `brainstorm/`/`brainstorming/` folder | move each **active** record to `docs/research/{topic}-brainstorm/` and each **harvested** one to `docs/history/brainstorms/{topic}/` (dropping the suffix); create/refresh both `Readme.md` indexes; delete the emptied folder |
180
167
  | `*.bkp.*` files under `docs/` (the installer's first-contact backups of the project's pre-existing files) | **fold the content, then delete the backup.** Classify each backup's content by nature and land it in the current shape — a backed-up changelog's rows into `docs/history/CHANGELOG.md` and per-entry files, a backed-up structure/readme's real claims into the scaffolded truth doc, a backed-up feature doc's content merged into its sibling (or adopted whole if the sibling is a pristine seed). Content is folded, never discarded silently; what cannot be classified goes to the backlog with the backup kept. When the fold lands, delete the `.bkp` file |
181
168
  | `*.bkp.*` files at the repo root or in config (`CLAUDE.bkp.md`, `.prettierrc.bkp.json`, …) | **never merge these.** Top-level config is harness-owned by design; list each one to the user as their call, and leave the file in place |
182
169
  | no `harness-version` in `structure.md` | stamp it (Step B4) |
183
170
 
171
+ **The support window: three past minor versions.** The fold table covers shapes at most three minors behind the installed harness — which today means the rows above, because every older dissolved construct (`docs/routing.md`, `docs/modules/`, pillars, the Refinement Ledger, the ADR genre, legacy brainstorm folders, …) predates the window. An older shape is **reported, never improvised**: name what the probe found, point the user at etna's dated migration runbooks (`docs/history/migrations/` in the forge), and recommend a fresh baseline over an archaeological fold — a repo more than three minors behind re-baselines rather than migrates.
172
+
184
173
  ### Step B3 — Present the reconcile plan, then execute
185
174
 
186
175
  Table: `| File | Action | From | To | Reason |` (actions: `Move`, `Merge into {target}`, `Delete`, `Keep`, `Create`, `Fold`). For a fresh baseline this is mostly `Create`; for an upgrade it is mostly `Fold`/`Move`. Ask once: **"Approve this reconcile?"** — you own this interaction (the `documentation` skill is a library and has none).
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "harness": "delivery",
3
3
  "name": "delivery",
4
- "version": "0.2.20",
4
+ "version": "0.2.21",
5
5
  "files": [
6
6
  ".prettierignore",
7
7
  ".prettierrc.json",
8
8
  ".vscode/settings.json"
9
9
  ],
10
10
  "seeds": [],
11
- "digest": "3bb0c6640019"
11
+ "digest": "518aca073125"
12
12
  }