ruvnet-brain 4.0.1 → 4.0.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.
Files changed (195) hide show
  1. package/.claude-plugin/marketplace.json +1 -0
  2. package/README.md +4 -4
  3. package/bin/install.mjs +303 -24
  4. package/console/CONTRACT.md +172 -0
  5. package/console/activity.js +753 -0
  6. package/console/app.js +4189 -0
  7. package/console/architecture.html +1221 -0
  8. package/console/assets/depth-1.webp +0 -0
  9. package/console/assets/depth-2.webp +0 -0
  10. package/console/assets/depth-3.webp +0 -0
  11. package/console/assets/harness-vs-plain.svg +259 -0
  12. package/console/assets/hero.webp +0 -0
  13. package/console/assets/memory.webp +0 -0
  14. package/console/assets/metaharness.svg +247 -0
  15. package/console/index.html +777 -0
  16. package/console/install-architecture.html +162 -0
  17. package/console/install-mockup.html +543 -0
  18. package/console/style.css +2144 -0
  19. package/console/tips.css +926 -0
  20. package/console/tips.html +858 -0
  21. package/console/tips.js +128 -0
  22. package/docs/RELEASE-NOTES-4.0.md +88 -0
  23. package/kb/model-requirements.mjs +37 -6
  24. package/keys/ruvnet-brain-signing.pub.pem +3 -0
  25. package/package.json +8 -22
  26. package/plugin/.claude-plugin/marketplace.json +1 -0
  27. package/plugin/.claude-plugin/plugin.json +2 -3
  28. package/plugin/.codex-plugin/plugin.json +1 -1
  29. package/plugin/commands/brain-console.md +2 -2
  30. package/plugin/commands/configure.md +3 -2
  31. package/plugin/commands/rvbc.md +4 -3
  32. package/plugin/commands/rvcb.md +2 -2
  33. package/plugin/commands/whats-new.md +6 -6
  34. package/plugin/docs/RELEASE-NOTES-4.0.md +88 -0
  35. package/plugin/hooks/hooks.json +1 -2
  36. package/plugin/mcp/managed-cli-interface.mjs +47 -4
  37. package/plugin/mcp/server.mjs +90 -32
  38. package/plugin/scripts/detach.mjs +14 -0
  39. package/plugin/scripts/first-session-worker.mjs +38 -0
  40. package/plugin/scripts/ground-ruvnet.sh +16 -6
  41. package/plugin/scripts/hook-shim.mjs +34 -29
  42. package/plugin/scripts/learn-capture.sh +22 -3
  43. package/plugin/scripts/learn-flush.mjs +21 -4
  44. package/plugin/scripts/runtime-preferences.mjs +269 -0
  45. package/plugin/scripts/session-start-core.mjs +503 -0
  46. package/plugin/scripts/session-start.sh +3 -858
  47. package/plugin/scripts/whats-new.mjs +42 -0
  48. package/plugin/skills/brain-console/SKILL.md +4 -2
  49. package/plugin/skills/release-proof/SKILL.md +98 -0
  50. package/plugin/skills/release-proof/agents/openai.yaml +4 -0
  51. package/plugin/skills/release-proof/references/receipt-contract.md +44 -0
  52. package/plugin/skills/release-proof/scripts/release-proof.mjs +286 -0
  53. package/plugin/skills/ruvnet-brain/PLAYBOOK.md +5 -1
  54. package/plugin/skills/ruvnet-brain/SKILL.md +22 -7
  55. package/plugin/skills/rvbc/SKILL.md +9 -6
  56. package/plugin/skills/whats-new/SKILL.md +4 -4
  57. package/scripts/adr-backfill.mjs +107 -0
  58. package/scripts/advocacy-outcomes.mjs +808 -0
  59. package/scripts/agentdb-context.mjs +216 -0
  60. package/scripts/agentdb-fleet-doctor.mjs +101 -0
  61. package/scripts/ascii-drift.mjs +236 -0
  62. package/scripts/behavioral-l1-l4.mjs +210 -0
  63. package/scripts/brain-capability-check.mjs +72 -0
  64. package/scripts/brain-grade-groundtruth.mjs +100 -0
  65. package/scripts/brain-latency-50.mjs +227 -0
  66. package/scripts/brain-novice-50.mjs +189 -0
  67. package/scripts/brain-stamp.mjs +94 -0
  68. package/scripts/brain-state.mjs +212 -0
  69. package/scripts/build-bundle.mjs +531 -0
  70. package/scripts/build-concepts.mjs +132 -0
  71. package/scripts/build-l2.mjs +71 -0
  72. package/scripts/build-primer.mjs +73 -0
  73. package/scripts/build-symbols.mjs +68 -0
  74. package/scripts/calibrate-router.mjs +97 -0
  75. package/scripts/capability-audit.mjs +321 -0
  76. package/scripts/capability-registry.mjs +876 -0
  77. package/scripts/check-indexation.mjs +108 -0
  78. package/scripts/check-legibility.mjs +189 -0
  79. package/scripts/ci/build-fixture-kb.mjs +67 -0
  80. package/scripts/ci/learning-replay-codex-adapter.mjs +62 -0
  81. package/scripts/ci/learning-replay-recorder.mjs +59 -0
  82. package/scripts/ci/mutate-hook-timeout.mjs +70 -0
  83. package/scripts/ci/stranger-fixture-stage.mjs +17 -0
  84. package/scripts/ci/stranger-scenario.mjs +228 -0
  85. package/scripts/ci/stranger-timeout.mjs +25 -0
  86. package/scripts/ci-verdict.mjs +29 -0
  87. package/scripts/claims-verify.mjs +710 -0
  88. package/scripts/clear-claude-tmp.sh +31 -0
  89. package/scripts/console-engine.mjs +434 -0
  90. package/scripts/console-engine.test.mjs +125 -0
  91. package/scripts/corpus-qa.mjs +250 -0
  92. package/scripts/correction-detect-embed.mjs +346 -0
  93. package/scripts/correction-detect-measure.mjs +270 -0
  94. package/scripts/correction-detect.mjs +686 -0
  95. package/scripts/count-chunks.mjs +54 -0
  96. package/scripts/described-questions.json +30 -0
  97. package/scripts/design-grade.mjs +58 -0
  98. package/scripts/dev-plugin-link.sh +105 -0
  99. package/scripts/distill-project.mjs +200 -0
  100. package/scripts/doc-currency.mjs +801 -0
  101. package/scripts/eval-brain.mjs +244 -0
  102. package/scripts/fix-metaharness-memretrieve.mjs +121 -0
  103. package/scripts/fix-workstream.mjs +291 -0
  104. package/scripts/full-hints.mjs +87 -0
  105. package/scripts/gate.sh +39 -0
  106. package/scripts/gates.mjs +146 -0
  107. package/scripts/gen-console-images.mjs +54 -0
  108. package/scripts/gen-images.mjs +47 -0
  109. package/scripts/git-clone-refresh.mjs +52 -0
  110. package/scripts/git-hooks/pre-push +126 -0
  111. package/scripts/goal-match.mjs +398 -0
  112. package/scripts/goldie-research.mjs +223 -0
  113. package/scripts/goldie-weekly.sh +67 -0
  114. package/scripts/health-repair.mjs +237 -0
  115. package/scripts/helix-scenario-questions.json +10 -0
  116. package/scripts/ingest-gists.mjs +230 -0
  117. package/scripts/ingest-meeting.mjs +115 -0
  118. package/scripts/ingest-repo.mjs +79 -0
  119. package/scripts/install-npx-witness.sh +49 -0
  120. package/scripts/issue-fix.mjs +558 -0
  121. package/scripts/issue-watch.mjs +276 -0
  122. package/scripts/issue4-close-note.md +31 -0
  123. package/scripts/key-canary.mjs +91 -0
  124. package/scripts/latency-to-surface.mjs +233 -0
  125. package/scripts/learning-enable.mjs +380 -0
  126. package/scripts/learning-replay.mjs +1570 -0
  127. package/scripts/learnings.mjs +62 -0
  128. package/scripts/lesson-gate.mjs +680 -0
  129. package/scripts/lesson-lifecycle.mjs +449 -0
  130. package/scripts/lesson-promote.mjs +262 -0
  131. package/scripts/lesson-ratify.mjs +98 -0
  132. package/scripts/lesson-seed.mjs +252 -0
  133. package/scripts/lesson-store.mjs +447 -0
  134. package/scripts/loop-checkpoint.mjs +86 -0
  135. package/scripts/memdb-health.sh +14 -0
  136. package/scripts/memory-doctor.mjs +326 -0
  137. package/scripts/model-catalog.mjs +79 -0
  138. package/scripts/nightly-controller.mjs +66 -0
  139. package/scripts/nightly-gists.sh +72 -0
  140. package/scripts/nightly-wrapper.sh +172 -0
  141. package/scripts/notify.sh +12 -0
  142. package/scripts/npx-witness.sh +56 -0
  143. package/scripts/onboarding-console.mjs +2922 -0
  144. package/scripts/private-fence.mjs +69 -0
  145. package/scripts/proactivity-metrics.mjs +118 -0
  146. package/scripts/proof-questions.json +56 -0
  147. package/scripts/protected-release-invocation.mjs +76 -0
  148. package/scripts/prove.mjs +95 -0
  149. package/scripts/proxy/claude-proxied.sh +57 -0
  150. package/scripts/proxy/proxy-revert.sh +59 -0
  151. package/scripts/proxy/proxy-up.sh +60 -0
  152. package/scripts/proxy/proxy-verify.mjs +142 -0
  153. package/scripts/publication-receipt.mjs +307 -0
  154. package/scripts/published-surface-probe.mjs +241 -0
  155. package/scripts/qe/card-lane-gate.mjs +162 -0
  156. package/scripts/qe/session-start-gate.mjs +229 -0
  157. package/scripts/qe/ux-suite.mjs +323 -0
  158. package/scripts/reconcile-project.mjs +0 -0
  159. package/scripts/record-lesson.mjs +113 -0
  160. package/scripts/refresh-model-catalog.mjs +99 -0
  161. package/scripts/release-authority.mjs +93 -0
  162. package/scripts/release-proof.mjs +9 -0
  163. package/scripts/release-vector.mjs +281 -0
  164. package/scripts/release.mjs +439 -0
  165. package/scripts/remedy-registry.mjs +247 -0
  166. package/scripts/rerank-cap-eval.mjs +265 -0
  167. package/scripts/rerank-cap-warm-ab.mjs +129 -0
  168. package/scripts/route-cheap.mjs +20 -15
  169. package/scripts/router-utilization.mjs +182 -0
  170. package/scripts/routing-flywheel.mjs +596 -0
  171. package/scripts/rvf-generation.mjs +104 -0
  172. package/scripts/rvf-index-audit.mjs +138 -0
  173. package/scripts/self-update.mjs +296 -0
  174. package/scripts/selfcheck.mjs +7 -1
  175. package/scripts/sign-bundle.mjs +69 -0
  176. package/scripts/signal-watch.mjs +171 -0
  177. package/scripts/stabilization-receipt.mjs +108 -0
  178. package/scripts/stack-sync.mjs +469 -0
  179. package/scripts/stamp-existing-rvf-generations.mjs +53 -0
  180. package/scripts/stamp-sweep.mjs +144 -0
  181. package/scripts/status-honesty.mjs +102 -0
  182. package/scripts/sync-version.mjs +217 -0
  183. package/scripts/token-report.mjs +102 -0
  184. package/scripts/top100-benchmark.mjs +479 -0
  185. package/scripts/top100-corpus.mjs +112 -0
  186. package/scripts/top100-semantic-assertions.mjs +449 -0
  187. package/scripts/update-apply.mjs +9 -0
  188. package/scripts/upgrade-notice.mjs +14 -0
  189. package/scripts/verify-bundle.mjs +51 -0
  190. package/scripts/verify-channels.mjs +184 -0
  191. package/scripts/verify-model-catalog.mjs +104 -0
  192. package/scripts/verify-nightly-close-issue4.sh +31 -0
  193. package/scripts/version.mjs +40 -0
  194. package/scripts/wired-check.mjs +867 -0
  195. package/plugin/scripts/finalize-token-meter.mjs +0 -25
@@ -0,0 +1,801 @@
1
+ #!/usr/bin/env node
2
+ // scripts/doc-currency.mjs — DOCUMENT CURRENCY. ADR-034 / DDD-0008, decided originally in ADR-0009
3
+ // on 2026-07-06 and left as prose for sixteen days, during which zero of this repo's scripts
4
+ // implemented it. This is the gate half of that decision.
5
+ //
6
+ // node scripts/doc-currency.mjs --report human table: every doc, its stamps, its verdict
7
+ // node scripts/doc-currency.mjs --check exit 1 on a real violation (CI / pre-push)
8
+ // node scripts/doc-currency.mjs --fix backfill ONLY dates git can prove; label them
9
+ // node scripts/doc-currency.mjs --json machine-readable, same evaluation
10
+ //
11
+ // ─────────────────────────────────────────────────────────────────────────────────────────────────
12
+ // THE ONE LAW (ADR-0024, already law here): "a status must be RE-DERIVED from the verifiable
13
+ // artifact, never read from a self-asserted field." Every value this tool prints is derived from
14
+ // git or the filesystem. A frontmatter `impl:` is treated as an untrusted CLAIM to be checked
15
+ // against the derivation, never as an input to it.
16
+ //
17
+ // FIVE FAILURE MODES THIS DESIGN EXISTS TO AVOID — each one found by adversarial review of the
18
+ // naive version, each one reproduced against this repo before being fixed here:
19
+ //
20
+ // 1. A missing or renamed governed path made `git rev-parse HEAD:<p>` print the literal
21
+ // "HEAD:<p>" to stdout and exit 128. Hashing that literal produced a STABLE digest that could
22
+ // never change again — so renaming a governed file (the most common way a doc goes stale)
23
+ // froze its verification green forever. Here: resolution is checked per path via
24
+ // `git cat-file --batch-check`, and ANY unresolvable member makes the digest uncomputable.
25
+ // A digest is never computed over a guess. (see resolveGoverned / computeDigest)
26
+ //
27
+ // 2. A DIRECTORY in `governs:` hashed the whole tree. `HEAD:docs/adr` resolves to a tree object
28
+ // that changes when ANY of the 34 ADRs changes — mass-expiring unrelated verifications on day
29
+ // one. That is the wolf-cry that gets a gate switched off. Here: trees are a hard finding, and
30
+ // the fix (a glob) is printed.
31
+ //
32
+ // 3. The digest covered only the CODE, so editing the DOCUMENT left the stamp green. You could
33
+ // rewrite a verified ADR to describe behaviour the code never had and nothing expired. Here
34
+ // the document's own normative body is an input to the digest, so either side moving expires
35
+ // the verification. The currency log is excluded, because appending the row that RECORDS a
36
+ // verification must not invalidate that same verification.
37
+ //
38
+ // 4. `impl:` used ANY-semantics ("≥1 governed path is wired"), which reported `wired` for a doc
39
+ // governing an unwired file plus one wired helper — i.e. green on exactly the built-but-
40
+ // unwired failure the rung was invented to catch. Here impl is derived PER PATH and the
41
+ // WEAKEST member wins, with the unwired members named.
42
+ //
43
+ // 5. Falling back to `date:` when `updated:` is absent counted a CREATION stamp as a drifted
44
+ // UPDATE stamp. That is how "4 of 20 stamps are wrong" was measured when only 1 committed ADR
45
+ // actually had a drifted `updated:` — three of the four accused carry no `updated:` key at
46
+ // all. Absent is absent here; it is reported as absent and never inferred from a neighbour.
47
+ //
48
+ // FALSE POSITIVES ARE THE DESIGNED FAILURE MODE. A gate that cries wolf gets bypassed and a
49
+ // bypassed gate protects nothing (ADR-0024 said the same about its own scope). So: age is NOWHERE
50
+ // in the drift formula — only movement of the governed set is; a doc with a dirty working tree is
51
+ // exempted from stamp checks rather than accused; a doc with no `governs:` has no drift and cannot
52
+ // be flagged stale; and the twelve legacy ADRs whose frontmatter is a bare `id:` are REPORTED
53
+ // forever and BLOCK never (they predate the convention; blocking twelve pre-existing violations on
54
+ // day one is how a gate dies).
55
+
56
+ import fs from 'node:fs';
57
+ import path from 'node:path';
58
+ import crypto from 'node:crypto';
59
+ import { spawnSync } from 'node:child_process';
60
+ import { fileURLToPath } from 'node:url';
61
+
62
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
63
+ export const REPO_ROOT = path.resolve(HERE, '..');
64
+ export const DEFAULT_DIRS = ['docs/adr', 'docs/ddd'];
65
+
66
+ // Label for the digest recipe. Bumping it deliberately expires every stamp, which is the honest
67
+ // behaviour when the meaning of a stamp changes.
68
+ export const DIGEST_RECIPE = 'sha256-manifest';
69
+
70
+ // Ladder. Lower is weaker. `unknown` sits below everything because "we cannot tell" must never
71
+ // outrank "we checked and it is not built".
72
+ export const IMPL_LADDER = ['unknown', 'unbuilt', 'built', 'wired', 'verified'];
73
+ const rank = (v) => { const i = IMPL_LADDER.indexOf(v); return i < 0 ? 0 : i; };
74
+ export const weakest = (vals) => (vals.length ? vals.reduce((a, b) => (rank(b) < rank(a) ? b : a)) : 'unknown');
75
+
76
+ // rUv's ADR enum, unchanged and un-extended (ruflo-adr REFERENCE.md). `Implemented` is deliberately
77
+ // NOT here: it is a fifth value this repo invented on someone else's key, and it is the lie-shaped
78
+ // one — it says something about code on a key that means something about a decision.
79
+ export const RUV_STATUSES = ['Proposed', 'Accepted', 'Deprecated', 'Superseded'];
80
+
81
+ // ── git ─────────────────────────────────────────────────────────────────────────────────────────
82
+ // Every call is `-C root` so fixtures in tmp dirs are first-class; nothing reads process.cwd().
83
+
84
+ function git(root, args, { input } = {}) {
85
+ const r = spawnSync('git', ['-C', root, ...args], { encoding: 'utf8', input, maxBuffer: 32 * 1024 * 1024 });
86
+ return { ok: r.status === 0, code: r.status, out: (r.stdout || '').trim(), err: (r.stderr || '').trim() };
87
+ }
88
+
89
+ export function isGitRepo(root) {
90
+ return git(root, ['rev-parse', '--git-dir']).ok;
91
+ }
92
+
93
+ export function hasCommits(root) {
94
+ return git(root, ['rev-parse', '--verify', 'HEAD']).ok;
95
+ }
96
+
97
+ // Last commit date (author date, in the committer's own timezone — the date a human would have
98
+ // typed) + sha, for one path. Empty when the path has never been committed.
99
+ export function lastCommit(root, rel) {
100
+ const r = git(root, ['log', '-1', '--format=%ad|%H', '--date=short', '--', rel]);
101
+ if (!r.ok || !r.out) return null;
102
+ const [date, sha] = r.out.split('|');
103
+ return { date, sha };
104
+ }
105
+
106
+ // The commit that ADDED the file, following renames. `--diff-filter=A` can emit several rows across
107
+ // a rename chain; the earliest (last line) is the creation.
108
+ export function firstCommit(root, rel) {
109
+ const r = git(root, ['log', '--follow', '--diff-filter=A', '--format=%ad|%H', '--date=short', '--', rel]);
110
+ if (!r.ok || !r.out) return null;
111
+ const lines = r.out.split('\n').filter(Boolean);
112
+ if (!lines.length) return null;
113
+ const [date, sha] = lines[lines.length - 1].split('|');
114
+ return { date, sha };
115
+ }
116
+
117
+ // Working tree differs from HEAD, or the file is not tracked at all. A dirty doc's "last commit
118
+ // date" is not the date of its current contents, so every stamp comparison is suspended for it.
119
+ export function isDirty(root, rel) {
120
+ const r = git(root, ['status', '--porcelain', '--', rel]);
121
+ return r.ok ? r.out.length > 0 : false;
122
+ }
123
+
124
+ // ── frontmatter ─────────────────────────────────────────────────────────────────────────────────
125
+ // Deliberately a small hand parser rather than a YAML dependency: the shapes are fixed, the repo
126
+ // has no YAML dep, and a parse failure here must degrade to "unreadable", never throw.
127
+
128
+ export function parseFrontmatter(text) {
129
+ const lines = text.split('\n');
130
+ if (lines[0]?.trim() !== '---') return { present: false, keys: {}, endLine: -1, raw: [] };
131
+ let end = -1;
132
+ for (let i = 1; i < lines.length; i++) if (lines[i].trim() === '---') { end = i; break; }
133
+ if (end < 0) return { present: false, keys: {}, endLine: -1, raw: [] };
134
+
135
+ const keys = {};
136
+ const raw = lines.slice(1, end);
137
+ let listKey = null;
138
+ for (const ln of raw) {
139
+ const item = ln.match(/^\s+-\s+(.*)$/);
140
+ if (item && listKey) { keys[listKey].push(item[1].trim().replace(/^["']|["']$/g, '')); continue; }
141
+ const kv = ln.match(/^([A-Za-z_][A-Za-z0-9_-]*)\s*:\s*(.*)$/);
142
+ if (!kv) continue;
143
+ const [, k, vRaw] = kv;
144
+ const v = vRaw.trim();
145
+ if (v === '') { listKey = k; keys[k] = []; continue; }
146
+ listKey = null;
147
+ if (v.startsWith('[') && v.endsWith(']')) {
148
+ keys[k] = v.slice(1, -1).split(',').map((s) => s.trim().replace(/^["']|["']$/g, '')).filter(Boolean);
149
+ } else {
150
+ keys[k] = v.replace(/^["']|["']$/g, '');
151
+ }
152
+ }
153
+ return { present: true, keys, endLine: end, raw };
154
+ }
155
+
156
+ // The part of a document a verification is a reading OF. Frontmatter is excluded (it holds the
157
+ // stamp itself — including it would make every stamp invalidate itself the moment it was written).
158
+ // The currency log is excluded for the same reason one rung up: recording a verification appends a
159
+ // row, and that row must not expire the verification it records.
160
+ export function normativeBody(text) {
161
+ const fm = parseFrontmatter(text);
162
+ const lines = text.split('\n');
163
+ const body = fm.present ? lines.slice(fm.endLine + 1) : lines;
164
+ const out = [];
165
+ let skipping = false;
166
+ for (const ln of body) {
167
+ const h = ln.match(/^#{1,6}\s+(.*)$/);
168
+ if (h) skipping = /^currency log\b/i.test(h[1].trim());
169
+ if (!skipping) out.push(ln.replace(/[ \t]+$/, ''));
170
+ }
171
+ return out.join('\n').replace(/\n{3,}/g, '\n\n').trim();
172
+ }
173
+
174
+ const sha256 = (s) => crypto.createHash('sha256').update(s).digest('hex');
175
+
176
+ // ── the governed set ────────────────────────────────────────────────────────────────────────────
177
+
178
+ // Resolve every declared entry to a concrete blob at HEAD. Globs expand through the index; a
179
+ // literal entry is looked up with `git cat-file --batch-check`, which distinguishes blob / tree /
180
+ // missing in ONE process and — unlike `git rev-parse HEAD:<p>` — never prints the requested path
181
+ // back as if it were an answer.
182
+ export function resolveGoverned(root, entries) {
183
+ const results = [];
184
+ if (!entries.length) return results;
185
+
186
+ const expanded = [];
187
+ for (const e of entries) {
188
+ const clean = String(e).trim();
189
+ if (!clean) continue;
190
+ if (/[*?[\]]/.test(clean)) {
191
+ const ls = git(root, ['ls-files', '--', clean]);
192
+ const hits = ls.ok ? ls.out.split('\n').filter(Boolean) : [];
193
+ if (!hits.length) { expanded.push({ path: clean, from: clean, glob: true, empty: true }); continue; }
194
+ for (const h of hits) expanded.push({ path: h, from: clean, glob: true });
195
+ } else {
196
+ expanded.push({ path: clean.replace(/\/+$/, ''), from: clean, trailingSlash: /\/$/.test(clean) });
197
+ }
198
+ }
199
+
200
+ const lookups = expanded.filter((e) => !e.empty);
201
+ const byPath = new Map();
202
+ if (lookups.length && hasCommits(root)) {
203
+ const input = lookups.map((e) => `HEAD:${e.path}`).join('\n') + '\n';
204
+ const r = git(root, ['cat-file', '--batch-check'], { input });
205
+ const lines = r.out ? r.out.split('\n') : [];
206
+ lines.forEach((ln, i) => {
207
+ const m = ln.match(/^([0-9a-f]{40})\s+(\w+)\s+(\d+)$/);
208
+ const e = lookups[i];
209
+ if (!e) return;
210
+ if (m) byPath.set(e.path, { sha: m[1], type: m[2] });
211
+ else byPath.set(e.path, { sha: null, type: 'missing' });
212
+ });
213
+ }
214
+
215
+ for (const e of expanded) {
216
+ const abs = path.join(root, e.path);
217
+ const onDisk = fs.existsSync(abs);
218
+ if (e.empty) {
219
+ results.push({ ...e, type: 'no-match', sha: null, onDisk: false, resolved: false });
220
+ continue;
221
+ }
222
+ const hit = byPath.get(e.path) || { sha: null, type: onDisk ? 'untracked' : 'missing' };
223
+ const type = hit.type === 'missing' && onDisk ? 'untracked' : hit.type;
224
+ results.push({ ...e, type, sha: hit.sha, onDisk, resolved: type === 'blob' });
225
+ }
226
+ return results;
227
+ }
228
+
229
+ // A digest is a value you cannot type from memory — that is its entire job. It is therefore
230
+ // computed ONLY when every input is a real, resolved blob. If any member is a tree, missing,
231
+ // untracked, or an empty glob, the answer is `null` plus a reason. Never a hash over a placeholder.
232
+ export function computeDigest(root, docRelPath, docText, governed) {
233
+ const unresolved = governed.filter((g) => !g.resolved);
234
+ if (!governed.length) return { digest: null, reason: 'governs: is empty — nothing to verify against' };
235
+ if (unresolved.length) {
236
+ return {
237
+ digest: null,
238
+ reason: `unresolvable governed path(s): ${unresolved.map((g) => `${g.path} (${g.type})`).join(', ')}`,
239
+ unresolved,
240
+ };
241
+ }
242
+ const manifest = [
243
+ ...governed.map((g) => `blob ${g.sha} ${g.path}`).sort(),
244
+ `doc ${sha256(normativeBody(docText))} ${docRelPath}`,
245
+ `recipe ${DIGEST_RECIPE}`,
246
+ ].join('\n');
247
+ return { digest: sha256(manifest).slice(0, 12), manifest };
248
+ }
249
+
250
+ // Is this path referenced from something that is neither a test nor a document? The gap between
251
+ // "exists" and "someone calls it" is this repo's signature failure (a capability registry with zero
252
+ // call sites; lessons mined, weighted and consumed by nobody). It is also the one check with a real
253
+ // false-negative rate — hook-invoked scripts and dynamic dispatch have no greppable caller — so a
254
+ // negative result WARNS and never blocks.
255
+ export function findCallers(root, rel) {
256
+ const base = path.basename(rel);
257
+ const r = git(root, ['grep', '-l', '-I', '--untracked', '-F', '-e', rel, '-e', base]);
258
+ if (!r.ok || !r.out) return [];
259
+ return r.out.split('\n').filter(Boolean).filter((f) => {
260
+ if (f === rel) return false;
261
+ if (/(^|\/)tests?\//.test(f)) return false;
262
+ if (/\.test\.[cm]?[jt]s$/.test(f)) return false;
263
+ if (/\.(md|markdown)$/i.test(f)) return false;
264
+ return true;
265
+ });
266
+ }
267
+
268
+ // ── impl derivation ─────────────────────────────────────────────────────────────────────────────
269
+ // PER PATH, then the WEAKEST wins. Any-semantics ("one member is wired ⇒ wired") reports green on
270
+ // exactly the built-but-unwired case the rung exists to catch.
271
+ export function deriveImpl(root, governed, { checkWiring = true } = {}) {
272
+ if (!governed.length) return { impl: 'unknown', perPath: [], unwired: [], reason: 'no governs: set' };
273
+ const perPath = governed.map((g) => {
274
+ if (!g.onDisk && !g.resolved) return { path: g.path, impl: 'unbuilt', type: g.type };
275
+ const callers = checkWiring ? findCallers(root, g.path) : [];
276
+ return { path: g.path, impl: callers.length ? 'wired' : 'built', callers, type: g.type };
277
+ });
278
+ return {
279
+ impl: weakest(perPath.map((p) => p.impl)),
280
+ perPath,
281
+ unwired: perPath.filter((p) => p.impl === 'built').map((p) => p.path),
282
+ missing: perPath.filter((p) => p.impl === 'unbuilt').map((p) => p.path),
283
+ };
284
+ }
285
+
286
+ // ── drift ───────────────────────────────────────────────────────────────────────────────────────
287
+ // Drift is MOVEMENT, never age. `presumed-stale` says "nobody has checked since the code moved",
288
+ // which is exactly what is true. A doc untouched for months whose governed code has not moved is
289
+ // `current`, silently. That property is what keeps this gate alive.
290
+ export const DRIFT_COMMITS_STALE = 2;
291
+ export const DRIFT_DAYS_STALE = 7;
292
+
293
+ export function deriveDrift(root, docRel, governed) {
294
+ const paths = governed.filter((g) => g.resolved).map((g) => g.path);
295
+ if (!paths.length) return { state: 'not-applicable', commits: 0, days: 0, reason: 'no resolvable governed paths' };
296
+ const doc = lastCommit(root, docRel);
297
+ if (!doc) return { state: 'not-applicable', commits: 0, days: 0, reason: 'document has no git history' };
298
+
299
+ const rl = git(root, ['rev-list', '--count', `${doc.sha}..HEAD`, '--', ...paths]);
300
+ const commits = rl.ok ? parseInt(rl.out || '0', 10) || 0 : 0;
301
+
302
+ const codeR = git(root, ['log', '-1', '--format=%ad', '--date=short', `${doc.sha}..HEAD`, '--', ...paths]);
303
+ const codeDate = codeR.ok && codeR.out ? codeR.out.split('\n')[0] : null;
304
+
305
+ let days = 0;
306
+ if (codeDate) {
307
+ const d0 = Date.parse(`${doc.date}T00:00:00Z`);
308
+ const d1 = Date.parse(`${codeDate}T00:00:00Z`);
309
+ if (Number.isFinite(d0) && Number.isFinite(d1)) days = Math.max(0, Math.round((d1 - d0) / 86400000));
310
+ }
311
+
312
+ let state = 'current';
313
+ if (commits > 0) {
314
+ state = (commits >= DRIFT_COMMITS_STALE || days >= DRIFT_DAYS_STALE) ? 'presumed-stale' : 'lagging';
315
+ }
316
+ return { state, commits, days, docDate: doc.date, docSha: doc.sha, codeDate, paths };
317
+ }
318
+
319
+ // ── currency log ────────────────────────────────────────────────────────────────────────────────
320
+ // A *why* is judged on STRUCTURE, never on meaning — a gate that claimed to judge sincerity would
321
+ // be a fifth lie-shaped status. It must carry at least one RESOLVABLE referent. This raises the
322
+ // cost of filler; it does not detect insincerity and must never claim to. What it reliably kills is
323
+ // the empty why ("updated docs"), which is the failure that actually happens.
324
+ export function countReferents(root, why) {
325
+ const refs = [];
326
+ for (const m of why.matchAll(/(?:^|[\s`'"(])((?:[\w.-]+\/)+[\w.-]+\.[A-Za-z0-9]+)/g)) {
327
+ if (fs.existsSync(path.join(root, m[1]))) refs.push({ kind: 'path', value: m[1] });
328
+ }
329
+ for (const m of why.matchAll(/\b(ADR|DDD|SEC)-0*(\d{1,4})\b/gi)) {
330
+ refs.push({ kind: 'doc-id', value: `${m[1].toUpperCase()}-${m[2]}` });
331
+ }
332
+ for (const m of why.matchAll(/\b([0-9a-f]{7,40})\b/g)) {
333
+ if (hasCommits(root) && git(root, ['cat-file', '-e', `${m[1]}^{commit}`]).ok) refs.push({ kind: 'sha', value: m[1] });
334
+ }
335
+ for (const m of why.matchAll(/(?:^|\s)#(\d{1,6})\b/g)) refs.push({ kind: 'issue', value: `#${m[1]}` });
336
+ for (const m of why.matchAll(/\d{4}-\d{2}-\d{2}/g)) {
337
+ if (/["'“”‘’*]/.test(why)) refs.push({ kind: 'dated-quote', value: m[0] });
338
+ }
339
+ return refs;
340
+ }
341
+
342
+ export function parseCurrencyLog(root, text) {
343
+ const lines = text.split('\n');
344
+ let inSection = false;
345
+ const rows = [];
346
+ for (const ln of lines) {
347
+ const h = ln.match(/^#{1,6}\s+(.*)$/);
348
+ if (h) { inSection = /^currency log\b/i.test(h[1].trim()); continue; }
349
+ if (!inSection) continue;
350
+ if (!ln.trim().startsWith('|')) continue;
351
+ const cells = ln.split('|').slice(1, -1).map((c) => c.trim());
352
+ if (cells.length < 3) continue;
353
+ if (/^-{2,}$/.test(cells[0].replace(/[: ]/g, '-'))) continue;
354
+ if (/^date$/i.test(cells[0])) continue;
355
+ const [date, what, ...rest] = cells;
356
+ const why = rest.join(' | ');
357
+ rows.push({ date, what, why, referents: countReferents(root, why) });
358
+ }
359
+ return { present: rows.length > 0 || /^#{1,6}\s+currency log\b/im.test(text), rows };
360
+ }
361
+
362
+ // ── evaluation ──────────────────────────────────────────────────────────────────────────────────
363
+
364
+ const BLOCK = 'block';
365
+ const WARN = 'warn';
366
+
367
+ export function listDocs(root, dirs = DEFAULT_DIRS) {
368
+ const out = [];
369
+ for (const d of dirs) {
370
+ const abs = path.join(root, d);
371
+ if (!fs.existsSync(abs)) continue;
372
+ for (const name of fs.readdirSync(abs).sort()) {
373
+ if (!/^\d{4}.*\.md$/.test(name)) continue;
374
+ out.push(path.posix.join(d.split(path.sep).join('/'), name));
375
+ }
376
+ }
377
+ return out;
378
+ }
379
+
380
+ export function evaluateDoc(root, rel, opts = {}) {
381
+ const { checkWiring = true } = opts;
382
+ const abs = path.join(root, rel);
383
+ const text = fs.readFileSync(abs, 'utf8');
384
+ const fm = parseFrontmatter(text);
385
+ const k = fm.keys;
386
+
387
+ const CONVENTION_KEYS = ['status', 'date', 'updated', 'impl', 'governs', 'verified', 'verified_digest'];
388
+ const declared = CONVENTION_KEYS.filter((key) => k[key] !== undefined);
389
+ // A document predating the convention carries NONE of its keys. That is a derived property of the
390
+ // file, not a hardcoded list of filenames — a new doc written to the convention can never be
391
+ // mistaken for one, and nobody has to maintain a grandfather roster.
392
+ const legacy = fm.present && declared.length === 0;
393
+
394
+ const findings = [];
395
+ const add = (level, code, message, extra = {}) => findings.push({ level, code, message, ...extra });
396
+
397
+ const tracked = hasCommits(root) && git(root, ['ls-files', '--error-unmatch', '--', rel]).ok;
398
+ const dirty = !tracked || isDirty(root, rel);
399
+ const docCommit = tracked ? lastCommit(root, rel) : null;
400
+ const addedCommit = tracked ? firstCommit(root, rel) : null;
401
+
402
+ const doc = {
403
+ file: rel,
404
+ id: k.id ?? null,
405
+ legacy,
406
+ tracked,
407
+ dirty,
408
+ status: k.status ?? null,
409
+ date: k.date ?? null,
410
+ updated: k.updated ?? null,
411
+ implStored: k.impl ?? null,
412
+ verifiedStamp: k.verified ?? null,
413
+ verifiedDigestStored: k.verified_digest ?? null,
414
+ governsDeclared: Array.isArray(k.governs) ? k.governs : (k.governs ? [String(k.governs)] : []),
415
+ docCommit,
416
+ addedCommit,
417
+ findings,
418
+ };
419
+
420
+ if (!fm.present) {
421
+ add(WARN, 'no-frontmatter', 'no YAML frontmatter — cannot carry stamps; not currency-checkable');
422
+ doc.impl = 'unknown';
423
+ doc.drift = { state: 'not-applicable', commits: 0, days: 0 };
424
+ doc.digest = { computed: null, stored: null, match: null };
425
+ return doc;
426
+ }
427
+
428
+ // ── stamps ────────────────────────────────────────────────────────────────────────────────────
429
+ // Legacy docs are reported and never blocked. Retro-stamping twelve documents is not a push-time
430
+ // job, and a gate whose first act is to block twelve pre-existing violations gets disabled.
431
+ const stampLevel = legacy ? WARN : BLOCK;
432
+ if (legacy) add(WARN, 'legacy-unstamped', `frontmatter carries none of ${CONVENTION_KEYS.join('/')} — predates the convention; reported, never blocked`);
433
+
434
+ // The enum is judged on the LEADING TOKEN. `status: Proposed (awaiting Stuart's approval)` is a
435
+ // Proposed decision with a note attached, and flagging it would be a false positive on a doc doing
436
+ // nothing wrong — the parenthetical is how a human says something the enum has no room for.
437
+ const statusWord = doc.status ? (doc.status.match(/^[A-Za-z]+/)?.[0] ?? doc.status) : null;
438
+ if (!doc.status) add(stampLevel, 'missing-status', 'no `status:` — the decision state is unstated in the machine-readable half');
439
+ else if (!RUV_STATUSES.includes(statusWord)) {
440
+ // `Implemented` is the specific historical case: a fifth value invented on rUv's key, saying
441
+ // something about CODE on a key that means something about a DECISION.
442
+ add(WARN, 'status-not-in-enum', `status: ${doc.status} is not one of ${RUV_STATUSES.join('|')} (rUv's enum)`
443
+ + (/^implemented$/i.test(doc.status) ? ' — this is the code-state axis; it belongs on `impl:`, which is derived' : ''));
444
+ }
445
+ if (!doc.date) add(stampLevel, 'missing-created', 'no `date:` — creation is unstamped');
446
+ if (!doc.updated) add(stampLevel, 'missing-updated', 'no `updated:` — last-change is unstamped (NOT inferred from `date:`)');
447
+
448
+ if (!tracked) add(WARN, 'no-git-history', 'not committed — every git-derived value is unavailable for this document');
449
+
450
+ // Stamp-vs-git. Suspended entirely when the working tree is dirty: the last COMMIT date is not
451
+ // the date of the current CONTENTS, so accusing a doc mid-edit is a guaranteed false positive.
452
+ if (doc.updated && docCommit && !dirty) {
453
+ const d = Date.parse(`${doc.updated}T00:00:00Z`);
454
+ const g = Date.parse(`${docCommit.date}T00:00:00Z`);
455
+ if (Number.isFinite(d) && Number.isFinite(g)) {
456
+ const deltaDays = Math.round((g - d) / 86400000);
457
+ if (deltaDays >= 1) {
458
+ add(BLOCK, 'stamp-lags-doc',
459
+ `updated: ${doc.updated} but the document's own last commit is ${docCommit.date} (${docCommit.sha.slice(0, 8)}) — edited without touching its own stamp`);
460
+ } else if (deltaDays <= -1) {
461
+ // Typed ahead. WARN only: a stamp dated today on a change not yet committed is CORRECT, and
462
+ // blocking it would make the gate fire on the very act of doing the right thing.
463
+ add(WARN, 'stamp-ahead-of-doc', `updated: ${doc.updated} is ahead of the last commit ${docCommit.date} — expected while the change is uncommitted; a violation only if it stays`);
464
+ }
465
+ }
466
+ } else if (doc.updated && dirty) {
467
+ add(WARN, 'stamp-unverifiable-dirty', 'working tree modified — stamp-vs-git comparison suspended rather than guessed');
468
+ }
469
+
470
+ if (doc.date && addedCommit && doc.date !== addedCommit.date) {
471
+ add(WARN, 'created-differs-from-first-commit', `date: ${doc.date} but git says the file was added ${addedCommit.date} — the created stamp is immutable, so this is reported, never auto-corrected`);
472
+ }
473
+
474
+ // ── governed set ──────────────────────────────────────────────────────────────────────────────
475
+ const governed = resolveGoverned(root, doc.governsDeclared);
476
+ doc.governed = governed;
477
+
478
+ for (const g of governed) {
479
+ if (g.type === 'tree') {
480
+ add(BLOCK, 'governs-directory',
481
+ `governs: "${g.from}" is a DIRECTORY — its tree object changes when any file under it changes, mass-expiring unrelated verifications. Use a glob (e.g. "${g.path}/*.md") so the set expands to files.`);
482
+ } else if (g.type === 'missing') {
483
+ // Severity depends on what the document CLAIMS. A Proposed ADR naming the artifact it intends
484
+ // to create is the `unbuilt` state working exactly as designed — blocking it would make this
485
+ // gate fire on the act of writing a design doc, which is the wolf-cry that gets it removed.
486
+ // A document that claims built/wired/verified over a path that is gone is a different thing:
487
+ // the claim is refuted and the digest is uncomputable.
488
+ const claims = doc.verifiedDigestStored || (doc.implStored && rank(doc.implStored) >= rank('built'));
489
+ add(claims ? BLOCK : WARN, 'governs-unresolvable',
490
+ `governs: "${g.from}" does not exist at HEAD and is not on disk`
491
+ + (claims ? ' — but this document claims it is built or verified; the claim is refuted and no digest can be computed' : ' — not yet created, so this document derives `impl: unbuilt`'));
492
+ } else if (g.type === 'untracked') {
493
+ add(WARN, 'governs-untracked', `governs: "${g.path}" exists on disk but is not tracked by git — it can never contribute to a digest, so this document can never reach verified while it is listed`);
494
+ } else if (g.type === 'no-match') {
495
+ add(WARN, 'governs-glob-empty', `governs: "${g.from}" matched no tracked file`);
496
+ }
497
+ }
498
+ if (!doc.legacy && doc.governsDeclared.length === 0) {
499
+ add(WARN, 'no-governs', 'no `governs:` set — this document makes no machine-checkable claim about code, so it can never be verified and can never be found stale');
500
+ }
501
+
502
+ // ── impl (derived; the stored value is a claim to be checked, never an input) ──────────────────
503
+ const derived = deriveImpl(root, governed, { checkWiring });
504
+ const digest = computeDigest(root, rel, text, governed);
505
+ const storedDigest = doc.verifiedDigestStored;
506
+ const digestMatch = digest.digest && storedDigest ? digest.digest === storedDigest : null;
507
+
508
+ let impl = derived.impl;
509
+ if (impl === 'wired' && storedDigest) impl = digestMatch ? 'verified' : 'verification-expired';
510
+ doc.impl = impl;
511
+ doc.implPerPath = derived.perPath;
512
+ doc.digest = { computed: digest.digest, stored: storedDigest ?? null, match: digestMatch, reason: digest.reason ?? null };
513
+
514
+ if (derived.unwired?.length) {
515
+ add(WARN, 'built-not-wired',
516
+ `no non-test, non-doc caller found for: ${derived.unwired.join(', ')} — built but apparently unreachable. WARNS only: hook-invoked and dynamically dispatched code is wired without a greppable caller.`);
517
+ }
518
+ if (storedDigest && digestMatch === false) {
519
+ add(WARN, 'verification-expired',
520
+ `verified_digest: ${storedDigest} no longer recomputes (now ${digest.digest ?? 'uncomputable'}) — the governed code or the document's own normative body has moved since anyone read them together`);
521
+ }
522
+ if (storedDigest && !digest.digest) {
523
+ add(WARN, 'verification-uncomputable', `a verified_digest is stamped but the digest cannot be recomputed: ${digest.reason}`);
524
+ }
525
+ if (doc.verifiedStamp && !storedDigest) {
526
+ add(BLOCK, 'verified-without-digest', 'a `verified:` date with no `verified_digest:` is a typeable stamp — exactly the class of claim this convention exists to replace');
527
+ }
528
+ if (doc.verifiedStamp && governed.length === 0) {
529
+ add(BLOCK, 'verified-without-governs', 'verified against an empty governed set — verifies nothing and shows green');
530
+ }
531
+
532
+ // ADR-0024 applied to this file's own frontmatter: a stored status must never outrank what the
533
+ // artifact supports. Overclaiming BLOCKS; understating WARNS (it is stale, not a lie).
534
+ if (doc.implStored) {
535
+ const storedRank = rank(doc.implStored);
536
+ const derivedRank = rank(impl === 'verification-expired' ? 'built' : impl);
537
+ if (!IMPL_LADDER.includes(doc.implStored)) {
538
+ add(WARN, 'impl-unknown-value', `impl: ${doc.implStored} is not one of ${IMPL_LADDER.join('|')}`);
539
+ } else if (storedRank > derivedRank) {
540
+ add(BLOCK, 'impl-overclaimed',
541
+ `impl: ${doc.implStored} but the artifact only supports "${impl}"${derived.missing?.length ? ` (missing: ${derived.missing.join(', ')})` : ''}${derived.unwired?.length ? ` (unwired: ${derived.unwired.join(', ')})` : ''} — a status the artifact refutes is void (ADR-0024)`);
542
+ } else if (storedRank < derivedRank) {
543
+ add(WARN, 'impl-understated', `impl: ${doc.implStored} but the artifact now supports "${impl}" — the stored value has fallen behind the code`);
544
+ }
545
+ }
546
+
547
+ // ── drift ─────────────────────────────────────────────────────────────────────────────────────
548
+ const drift = deriveDrift(root, rel, governed);
549
+ doc.drift = drift;
550
+ // A DECISION THAT IS NOT IN FORCE CANNOT DRIFT (ADR-056, 2026-07-27). Rejected / Superseded /
551
+ // Deprecated documents describe a path NOT taken, or one another document has since taken over.
552
+ // Their `governs:` set names code that was never meant to implement them, so "the governed code
553
+ // moved and nobody re-checked" is not a defect — there is nothing to re-check against. Blocking a
554
+ // push over it is a textbook false positive, and this file's own header names false positives as
555
+ // the DESIGNED failure mode: "a gate that cries wolf gets bypassed and a bypassed gate protects
556
+ // nothing." Found by ADR-046 and ADR-047 — both Rejected — sitting in the blocking set with
557
+ // nothing an author could ever do to clear them short of deleting the record of a rejected idea.
558
+ // Still REPORTED, never silent: a superseded document whose code moves is worth seeing, and the
559
+ // day someone un-rejects it the finding returns to BLOCK on its own.
560
+ const notInForce = /^(rejected|superseded|deprecated)$/i.test(statusWord || '');
561
+ if (drift.state === 'presumed-stale') {
562
+ add(notInForce ? WARN : BLOCK, 'presumed-stale',
563
+ `governed code moved ${drift.commits} commit(s) (${drift.days}d) after the document's own last commit (${drift.docDate}) — nobody has checked since it moved. Paths: ${drift.paths.join(', ')}`
564
+ + (notInForce ? ` — reported, NOT blocked: status "${statusWord}" means this decision is not in force, so it cannot drift from code it never governed` : ''));
565
+ } else if (drift.state === 'lagging') {
566
+ add(WARN, 'lagging', `governed code moved ${drift.commits} commit(s) after the document — normal within a session; reported, not blocked`);
567
+ }
568
+
569
+ // ── currency log ──────────────────────────────────────────────────────────────────────────────
570
+ const log = parseCurrencyLog(root, text);
571
+ doc.currencyLog = log;
572
+ if (!doc.legacy && !log.present) {
573
+ add(WARN, 'no-currency-log', 'no `## Currency log` section — the *why* of each change is unrecorded');
574
+ }
575
+ for (const row of log.rows) {
576
+ if (!row.referents.length) {
577
+ add(WARN, 'why-without-referent',
578
+ `currency-log row ${row.date}: the *why* carries no resolvable referent (path / ADR id / commit sha / issue / dated quote) — "${row.why.slice(0, 60)}"`);
579
+ }
580
+ }
581
+ if (doc.updated && log.rows.length && log.rows[0].date && log.rows[0].date !== doc.updated) {
582
+ add(WARN, 'log-head-differs-from-updated', `updated: ${doc.updated} but the newest currency-log row is dated ${log.rows[0].date}`);
583
+ }
584
+
585
+ return doc;
586
+ }
587
+
588
+ export function evaluate(root = REPO_ROOT, opts = {}) {
589
+ const dirs = opts.dirs ?? DEFAULT_DIRS;
590
+ const docs = (opts.files ?? listDocs(root, dirs)).map((rel) => evaluateDoc(root, rel, opts));
591
+ return { root, docs };
592
+ }
593
+
594
+ // ── --fix : backfill ONLY what git can prove ────────────────────────────────────────────────────
595
+ // The anti-goal is explicit and absolute: a reconstructed stamp is a fabricated number on a
596
+ // user-facing surface. Every value written here comes back out of `git log`, is labelled
597
+ // `*_source: derived-from-git`, and when git cannot answer, NOTHING is written and the gap is
598
+ // reported. `status:` is never written — it is a social fact no script may set. `impl:` is never
599
+ // written — it is derived on every read, and a stored copy is the lie this tool exists to catch.
600
+ export function planFix(root, doc) {
601
+ const changes = [];
602
+ const blocked = [];
603
+ if (!doc.tracked) { blocked.push({ code: 'no-git-history', why: 'file has no commits — no date can be derived, and none will be invented' }); return { changes, blocked }; }
604
+
605
+ if (!doc.date) {
606
+ if (doc.addedCommit) changes.push({ key: 'date', value: doc.addedCommit.date, source: 'derived-from-git', evidence: `git log --follow --diff-filter=A ${doc.file} → ${doc.addedCommit.sha.slice(0, 8)}` });
607
+ else blocked.push({ code: 'missing-created', why: 'git reports no adding commit for this path' });
608
+ }
609
+ if (!doc.updated) {
610
+ if (doc.dirty) blocked.push({ code: 'missing-updated', why: 'working tree is modified — the last commit date is not the date of the current contents' });
611
+ else if (doc.docCommit) changes.push({ key: 'updated', value: doc.docCommit.date, source: 'derived-from-git', evidence: `git log -1 ${doc.file} → ${doc.docCommit.sha.slice(0, 8)}` });
612
+ else blocked.push({ code: 'missing-updated', why: 'git reports no commit touching this path' });
613
+ } else if (doc.findings.some((f) => f.code === 'stamp-lags-doc') && doc.docCommit && !doc.dirty) {
614
+ changes.push({ key: 'updated', value: doc.docCommit.date, source: 'derived-from-git', replaces: doc.updated, evidence: `git log -1 ${doc.file} → ${doc.docCommit.sha.slice(0, 8)}` });
615
+ }
616
+ return { changes, blocked };
617
+ }
618
+
619
+ export function applyFix(root, doc, plan) {
620
+ if (!plan.changes.length) return { written: false, changes: [] };
621
+ const abs = path.join(root, doc.file);
622
+ const text = fs.readFileSync(abs, 'utf8');
623
+ const fm = parseFrontmatter(text);
624
+ if (!fm.present) return { written: false, changes: [], skipped: 'no frontmatter to write into' };
625
+
626
+ const lines = text.split('\n');
627
+ const head = lines.slice(0, fm.endLine); // '---' + keys
628
+ const tail = lines.slice(fm.endLine); // closing '---' onward
629
+
630
+ for (const c of plan.changes) {
631
+ const keyRe = new RegExp(`^${c.key}\\s*:`);
632
+ const srcRe = new RegExp(`^${c.key}_source\\s*:`);
633
+ const idx = head.findIndex((l) => keyRe.test(l));
634
+ const line = `${c.key}: ${c.value}`;
635
+ const srcLine = `${c.key}_source: ${c.source}`;
636
+ if (idx >= 0) head[idx] = line; else head.push(line);
637
+ const sIdx = head.findIndex((l) => srcRe.test(l));
638
+ if (sIdx >= 0) head[sIdx] = srcLine; else head.splice((idx >= 0 ? idx : head.length - 1) + 1, 0, srcLine);
639
+ }
640
+ fs.writeFileSync(abs, [...head, ...tail].join('\n'));
641
+ return { written: true, changes: plan.changes };
642
+ }
643
+
644
+ // ── reporting ───────────────────────────────────────────────────────────────────────────────────
645
+
646
+ const pad = (s, n) => String(s ?? '').padEnd(n).slice(0, n);
647
+
648
+ export function renderReport({ root, docs }) {
649
+ const out = [];
650
+ out.push(`document currency — ${docs.length} document(s) under ${root}`);
651
+ out.push('');
652
+ out.push(`${pad('document', 46)} ${pad('status', 12)} ${pad('created', 11)} ${pad('updated', 11)} ${pad('impl', 21)} ${pad('drift', 15)} findings`);
653
+ out.push('─'.repeat(140));
654
+ for (const d of docs) {
655
+ const b = d.findings.filter((f) => f.level === BLOCK).length;
656
+ const w = d.findings.filter((f) => f.level === WARN).length;
657
+ out.push([
658
+ pad(d.file.replace(/^docs\//, ''), 46),
659
+ pad(d.status ?? '—', 12),
660
+ pad(d.date ?? '—', 11),
661
+ pad(d.updated ?? '—', 11),
662
+ pad(d.impl ?? 'unknown', 21),
663
+ pad(d.drift?.state ?? '—', 15),
664
+ `${b ? `${b} BLOCK` : ''}${b && w ? ' · ' : ''}${w ? `${w} warn` : ''}${!b && !w ? 'clean' : ''}`,
665
+ ].join(' '));
666
+ }
667
+ out.push('');
668
+
669
+ const withFindings = docs.filter((d) => d.findings.length);
670
+ if (withFindings.length) {
671
+ out.push('detail');
672
+ out.push('─'.repeat(140));
673
+ for (const d of withFindings) {
674
+ out.push(` ${d.file}`);
675
+ for (const f of d.findings) out.push(` [${f.level === BLOCK ? 'BLOCK' : ' warn'}] ${f.code}: ${f.message}`);
676
+ out.push('');
677
+ }
678
+ }
679
+
680
+ const blocks = docs.reduce((n, d) => n + d.findings.filter((f) => f.level === BLOCK).length, 0);
681
+ const warns = docs.reduce((n, d) => n + d.findings.filter((f) => f.level === WARN).length, 0);
682
+ const stamped = docs.filter((d) => d.updated).length;
683
+ out.push(`summary: ${docs.length} documents · ${stamped} carry an \`updated:\` stamp · ${docs.length - stamped} do not (absent, NOT inferred from \`date:\`)`);
684
+ out.push(` ${blocks} blocking finding(s) · ${warns} warning(s)`);
685
+ return out.join('\n');
686
+ }
687
+
688
+ // ── CLI ─────────────────────────────────────────────────────────────────────────────────────────
689
+
690
+ function parseArgs(argv) {
691
+ const a = { mode: null, root: REPO_ROOT, dirs: null, strict: false, warnDrift: false, dryRun: false, json: false, changed: null, noWiring: false };
692
+ for (let i = 0; i < argv.length; i++) {
693
+ const v = argv[i];
694
+ if (v === '--report') a.mode = 'report';
695
+ else if (v === '--check') a.mode = 'check';
696
+ else if (v === '--fix') a.mode = 'fix';
697
+ else if (v === '--json') { a.json = true; if (!a.mode) a.mode = 'report'; }
698
+ else if (v === '--strict') a.strict = true;
699
+ else if (v === '--warn-drift') a.warnDrift = true;
700
+ else if (v === '--dry-run') a.dryRun = true;
701
+ else if (v === '--no-wiring') a.noWiring = true;
702
+ else if (v === '--root') a.root = path.resolve(argv[++i]);
703
+ else if (v === '--changed') a.changed = argv[++i];
704
+ else if (v === '--dir') a.dirs = argv[++i].split(',').map((s) => s.trim()).filter(Boolean);
705
+ }
706
+ if (!a.mode) a.mode = 'report';
707
+ return a;
708
+ }
709
+
710
+ // Which findings actually stop a push. `--strict` promotes the judgement-shaped warnings; nothing
711
+ // promotes `built-not-wired`, whose false-negative rate is real and known.
712
+ export function blockingFindings(docs, { strict = false, warnDrift = false, scope = null } = {}) {
713
+ const out = [];
714
+ for (const d of docs) {
715
+ if (scope && !scope.has(d.file)) continue;
716
+ for (const f of d.findings) {
717
+ let level = f.level;
718
+ if (warnDrift && f.code === 'presumed-stale') level = WARN;
719
+ if (strict && (f.code === 'why-without-referent' || f.code === 'legacy-unstamped' || f.code === 'no-governs')) level = BLOCK;
720
+ if (strict && d.legacy && (f.code === 'missing-status' || f.code === 'missing-created' || f.code === 'missing-updated')) level = BLOCK;
721
+ if (level === BLOCK) out.push({ file: d.file, ...f });
722
+ }
723
+ }
724
+ return out;
725
+ }
726
+
727
+ // A changed-scope gate must follow the Document -> Governed set relationship in both directions.
728
+ // Looking only for directly touched ADR filenames lets code invalidate an ADR without evaluating it.
729
+ export function changedDocumentScope(docs, touched) {
730
+ return new Set(docs
731
+ .filter((d) => touched.has(d.file)
732
+ || (d.governed ?? []).some((g) => g.resolved && touched.has(g.path)))
733
+ .map((d) => d.file));
734
+ }
735
+
736
+ export function main(argv = process.argv.slice(2)) {
737
+ const a = parseArgs(argv);
738
+ if (!isGitRepo(a.root)) {
739
+ // FAIL OPEN, loudly. A gate that stops work because it could not read git is a gate people
740
+ // switch off — and every value here is git-derived, so without git there is nothing to say.
741
+ process.stderr.write(`[doc-currency] not a git repository: ${a.root} — nothing is derivable; passing.\n`);
742
+ return 0;
743
+ }
744
+
745
+ const dirs = a.dirs ?? DEFAULT_DIRS;
746
+ const result = evaluate(a.root, { dirs, checkWiring: !a.noWiring });
747
+
748
+ let scope = null;
749
+ if (a.changed) {
750
+ const r = git(a.root, ['diff', '--name-only', `${a.changed}...HEAD`]);
751
+ const touched = new Set(r.ok ? r.out.split('\n').filter(Boolean) : []);
752
+ scope = changedDocumentScope(result.docs, touched);
753
+ }
754
+
755
+ if (a.mode === 'fix') {
756
+ const applied = [];
757
+ for (const d of result.docs) {
758
+ const plan = planFix(a.root, d);
759
+ if (!plan.changes.length && !plan.blocked.length) continue;
760
+ for (const c of plan.changes) {
761
+ process.stdout.write(`${a.dryRun ? '[would fix]' : '[fixed] '} ${d.file}: ${c.key} = ${c.value} (derived-from-git; ${c.evidence})\n`);
762
+ }
763
+ for (const b of plan.blocked) {
764
+ process.stdout.write(`[cannot] ${d.file}: ${b.code} — ${b.why}. NOT invented.\n`);
765
+ }
766
+ if (!a.dryRun) applied.push(applyFix(a.root, d, plan));
767
+ }
768
+ process.stdout.write(`\n${a.dryRun ? 'dry run — nothing written' : `${applied.filter((x) => x.written).length} document(s) updated`}. Dates are only ever copied out of git; nothing is reconstructed.\n`);
769
+ return 0;
770
+ }
771
+
772
+ if (a.json) {
773
+ process.stdout.write(JSON.stringify({
774
+ root: result.root,
775
+ recipe: DIGEST_RECIPE,
776
+ docs: result.docs.map((d) => ({
777
+ file: d.file, id: d.id, legacy: d.legacy, status: d.status, date: d.date, updated: d.updated,
778
+ implStored: d.implStored, impl: d.impl, digest: d.digest, drift: d.drift,
779
+ governs: d.governsDeclared, findings: d.findings,
780
+ })),
781
+ }, null, 2) + '\n');
782
+ } else {
783
+ process.stdout.write(renderReport(result) + '\n');
784
+ }
785
+
786
+ if (a.mode !== 'check') return 0;
787
+
788
+ const blocking = blockingFindings(result.docs, { strict: a.strict, warnDrift: a.warnDrift, scope });
789
+ if (!blocking.length) {
790
+ process.stderr.write('[doc-currency] no blocking currency violations.\n');
791
+ return 0;
792
+ }
793
+ process.stderr.write(`\n[doc-currency] ${blocking.length} BLOCKING violation(s):\n`);
794
+ for (const f of blocking) process.stderr.write(` ${f.file}: ${f.code} — ${f.message}\n`);
795
+ process.stderr.write('\nFix: `node scripts/doc-currency.mjs --fix` backfills the dates git can prove.\n'
796
+ + 'Everything else is a claim only a human can make — including `status:`, which no script may set.\n');
797
+ return 1;
798
+ }
799
+
800
+ const invokedDirectly = process.argv[1] && path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url));
801
+ if (invokedDirectly) process.exit(main());