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,469 @@
1
+ #!/usr/bin/env node
2
+ // stack-sync.mjs — THE one rule for the RuvNet stack. One global copy. Correct. Provable.
3
+ //
4
+ // WHY THIS EXISTS (found live 2026-07-14, because Stuart noticed an update nag pointing BACKWARDS):
5
+ //
6
+ // TWO installers were fighting on this machine:
7
+ // com.stuartkerr.ruflo-autoupdate nightly 03:30 npm install -g <38 pkgs>, core on @alpha
8
+ // io.ruv.auto-subscribe HOURLY npm install -g <4 pkgs> on @latest
9
+ // The moment rUv publishes an alpha ahead of latest — the entire point of an alpha track — the
10
+ // nightly puts you on alpha and the hourly drags you back down. Every hour. Forever.
11
+ //
12
+ // Meanwhile ~190 hook invocations across 16 projects call `npx <pkg>@latest` on every Edit, Bash
13
+ // and prompt. npx runs its OWN private copy from ~/.npm/_npx, which can be badly stale (rvf sat
14
+ // at 0.1.9 in that cache while the global was 0.2.3). npx does not merely fail to catch drift —
15
+ // it MANUFACTURES THE ILLUSION OF CURRENCY: every command works, every --version prints the new
16
+ // number, and the binary the MCP server actually executes quietly rots.
17
+ //
18
+ // THE ROOT CAUSE, stated once: currency is an ORDERING question, not an EQUALITY one.
19
+ // Three separate files compared installed vs latest with `!=`, which fires in EITHER direction:
20
+ // plugin/scripts/ground-ruvnet.sh -> advised a DOWNGRADE every prompt (fixed 2.7.2)
21
+ // ~/.claude/hooks/ruflo-upgrade-awareness.sh -> same bug, same bad advice
22
+ // ~/.claude/scripts/ruvnet-auto-subscribe.sh -> same bug, but it EXECUTES npm install -g
23
+ // `!=` only ever LOOKS right because installed is normally <= latest. Nothing asserted direction.
24
+ //
25
+ // So this file has exactly ONE comparator, and it is the only place ordering is decided.
26
+ // A downgrade is not "policy-forbidden" here — it is STRUCTURALLY UNREACHABLE: the only install
27
+ // gate is isBehind(), and AHEAD is an explicitly allowed, untouched state.
28
+ //
29
+ // GROUNDING (search_ruvnet across 37 repos): rUv ships no machine-wide package updater. His docs
30
+ // present `npm install -g` and `npx` as equally valid with no ruling on which wins — which is
31
+ // exactly where the drift lives. Closest precedent: ruflo/plugins/ruflo-ruvector ADR-0001
32
+ // (ACCEPTED) — pin, verify against a smoke test, bump deliberately. This is OUR operator script
33
+ // honoring that discipline. It is not an rUv product and does not wear rUv's name.
34
+
35
+ import { spawnSync } from 'node:child_process';
36
+ import fs from 'node:fs';
37
+ import os from 'node:os';
38
+ import path from 'node:path';
39
+
40
+ const HOME = os.homedir();
41
+
42
+ // ISSUE #18 (Henrik Pettersen): PREFIX used to be hardcoded to ~/.npm-global. That path is Stuart's
43
+ // own convention, not an npm default — on any machine managing Node via mise/nvm/volta/fnm, npm's
44
+ // real global prefix lives somewhere else entirely (e.g. ~/.local/share/mise/installs/node/24.14.1),
45
+ // so the hardcoded path never existed there and this tool reported "0 packages on your global
46
+ // stack" with the full stack actually installed under the real prefix — reporting health while
47
+ // blind, the exact failure ADR-0013 (Onboarding Console) exists to kill.
48
+ // Fix: ask npm itself where its prefix is, at runtime, and resolve it ONCE at module load (not per
49
+ // call — `npm config get prefix` is a subprocess spawn, not free). Fall back to ~/.npm-global only
50
+ // if npm can't tell us anything usable.
51
+ function resolveNpmPrefix() {
52
+ const r = spawnSync('npm', ['config', 'get', 'prefix'], { encoding: 'utf8', timeout: 10000 });
53
+ const out = r.status === 0 && r.stdout ? r.stdout.trim() : '';
54
+ return out || path.join(HOME, '.npm-global');
55
+ }
56
+
57
+ // npm's on-disk layout for global packages differs by platform: macOS/Linux nest an extra `lib/`
58
+ // segment (<prefix>/lib/node_modules), Windows does not (<prefix>/node_modules). Rather than assume
59
+ // either, prefer whichever actually exists on disk so one code path works on both.
60
+ function resolveGlobalLib(prefix) {
61
+ const withLib = path.join(prefix, 'lib', 'node_modules');
62
+ const withoutLib = path.join(prefix, 'node_modules');
63
+ if (fs.existsSync(withLib)) return withLib;
64
+ if (fs.existsSync(withoutLib)) return withoutLib;
65
+ return withLib; // neither exists yet (e.g. a fresh/empty prefix) — keep the more common shape
66
+ }
67
+
68
+ const PREFIX = resolveNpmPrefix();
69
+ const GLOBAL_LIB = resolveGlobalLib(PREFIX);
70
+ const NPX_CACHE = path.join(HOME, '.npm/_npx');
71
+ const RECEIPT = path.join(HOME, '.cache/ruvnet-brain/stack-sync-receipt.json');
72
+ // ISSUE #22: rUv tools installed through the Claude Code plugin MARKETPLACE (not `npm install -g`)
73
+ // live here, and were invisible to this auditor — so plugin-only users saw a permanently undercounted
74
+ // stack, and any plugin they DID have could never be reported "installed". Claude Code writes the
75
+ // authoritative install list (which plugin, which marketplace, which cached version dir is active) to
76
+ // installed_plugins.json under this dir; we read THAT rather than guessing among the dozens of stale
77
+ // version folders the cache keeps around.
78
+ const PLUGINS_DIR = path.join(HOME, '.claude', 'plugins');
79
+
80
+ // The tag policy. ONE table — the single source of truth for "what SHOULD be installed".
81
+ // The orchestration core tracks alpha (rUv ships fast: 3.26 -> 3.28 inside one 18-hour window).
82
+ // Everything else tracks latest. Unlisted packages get DEFAULT_TAG.
83
+ export const TAG_POLICY = { ruflo: 'alpha', '@claude-flow/cli': 'alpha' };
84
+ export const DEFAULT_TAG = 'latest';
85
+
86
+ // Resolve a package's TARGET version to the NEWEST of its candidate tags — the policy tag AND latest —
87
+ // not blindly the policy tag. TAG_POLICY names the tag the orchestration core USUALLY leads on (@alpha,
88
+ // because rUv ships fast). But the reverse happens: on 2026-07-18 the core's @latest was 3.32.7 while
89
+ // its @alpha sat at 3.32.0, so "track @alpha" alone pinned the install 7 releases behind the newest
90
+ // published build — and the nightly reported SUCCESS doing it (installed 3.32.2 read as AHEAD of the
91
+ // 3.32.0 @alpha target, so it correctly refused to downgrade and never chased 3.32.7). Considering both
92
+ // tags and taking the higher version fixes that in EITHER direction: alpha-leads → track alpha;
93
+ // latest-leads → track latest. You are never left behind the newest thing rUv actually published.
94
+ export function pickTargetTag(tags, want, defaultTag = DEFAULT_TAG) {
95
+ if (!tags) return { tag: null, target: null };
96
+ const candidates = [...new Set([want, defaultTag])].filter((t) => tags[t]);
97
+ let tag = null, target = null;
98
+ for (const t of candidates) {
99
+ if (target === null || cmpVersion(tags[t], target) > 0) { tag = t; target = tags[t]; }
100
+ }
101
+ return { tag, target };
102
+ }
103
+
104
+ // What counts as "the stack": an explicit allow-list pattern, not a loose scope match, so a stray
105
+ // package can never be swept into a global install by accident.
106
+ export const FAMILY = /^(ruflo|ruvector|ruvector-extensions|ruvi|ruvbot|qudag|flow-nexus|agent-browser|agent-browser-mcp|agentic-flow|agentic-qe|agentic-robotics|agentic-payments|ruv-swarm|@ruvector\/|@claude-flow\/|@metaharness\/|@agentic-robotics\/)/;
107
+
108
+ // The plugin side of the same allow-list intent (ISSUE #22). Plugin identifiers are marketplace
109
+ // names (`ruflo-core`, `ruvnet-brain`, `cog-beehive-monitor`), NOT npm package names, so FAMILY
110
+ // alone cannot classify them — `ruvnet-brain` and cognitum's plugins never match it. The reliable
111
+ // signal is the MARKETPLACE a plugin came from: these four are rUv/ruvnet marketplaces (verified in
112
+ // ~/.claude/plugins/known_marketplaces.json → ruvnet/ruflo, ruvnet/RuView, stuinfla/ruvnet-brain,
113
+ // cognitum.one). An explicit set, mirroring FAMILY's "allow-list, never a loose scope match" so a
114
+ // stray third-party plugin can never be swept into the RuvNet stack by accident. FAMILY is still
115
+ // applied as a secondary matcher, so a future rUv plugin shipped through a different marketplace is
116
+ // still counted.
117
+ export const PLUGIN_MARKETPLACES = new Set(['ruflo', 'ruview', 'ruvnet-brain', 'cognitum']);
118
+
119
+ const log = (m) => console.log(m);
120
+ const die = (m) => { console.error(`\n FAILED: ${m}`); process.exit(1); };
121
+
122
+ // THE COMPARATOR. The only place ordering is decided anywhere in this system.
123
+ // Prerelease-aware per semver: a prerelease sorts BEFORE its release (3.28.0-alpha.1 < 3.28.0),
124
+ // and numeric identifiers compare numerically — alpha.9 < alpha.10, which a string compare gets
125
+ // backwards, and a string compare is precisely the bug this whole file exists to kill.
126
+ export function cmpVersion(a, b) {
127
+ const split = (v) => {
128
+ const [core, pre] = String(v).split('-');
129
+ return [core.split('.').map((n) => parseInt(n, 10) || 0), pre ? pre.split('.') : null];
130
+ };
131
+ const [ac, ap] = split(a);
132
+ const [bc, bp] = split(b);
133
+ for (let i = 0; i < 3; i++) {
134
+ const d = (ac[i] || 0) - (bc[i] || 0);
135
+ if (d !== 0) return d < 0 ? -1 : 1;
136
+ }
137
+ if (!ap && !bp) return 0;
138
+ if (ap && !bp) return -1;
139
+ if (!ap && bp) return 1;
140
+ for (let i = 0; i < Math.max(ap.length, bp.length); i++) {
141
+ const x = ap[i], y = bp[i];
142
+ if (x === undefined) return -1;
143
+ if (y === undefined) return 1;
144
+ const nx = /^\d+$/.test(x), ny = /^\d+$/.test(y);
145
+ if (nx && ny) { const d = parseInt(x, 10) - parseInt(y, 10); if (d) return d < 0 ? -1 : 1; }
146
+ else if (x !== y) return x < y ? -1 : 1;
147
+ }
148
+ return 0;
149
+ }
150
+ export const isBehind = (installed, target) => cmpVersion(installed, target) < 0;
151
+
152
+ export function installedVersion(pkg, lib = GLOBAL_LIB) {
153
+ const pj = path.join(lib, pkg, 'package.json');
154
+ if (!fs.existsSync(pj)) return null;
155
+ try { return JSON.parse(fs.readFileSync(pj, 'utf8')).version || null; } catch { return null; }
156
+ }
157
+
158
+ // ISSUE #22 — the version of an installed Claude Code plugin is its plugin.json `version`, read from
159
+ // the exact cached version dir Claude Code marked active (installPath). Injectable exactly like
160
+ // installedVersion(pkg, lib) so it is unit-testable against a temp dir.
161
+ export function pluginVersion(installPath) {
162
+ const pj = path.join(installPath, '.claude-plugin', 'plugin.json');
163
+ try { return JSON.parse(fs.readFileSync(pj, 'utf8')).version || null; } catch { return null; }
164
+ }
165
+
166
+ // ISSUE #22 — scan the Claude Code plugin cache for rUv-family plugins. Source of truth is
167
+ // installed_plugins.json (Claude Code's own record of what is installed, its marketplace, and which
168
+ // cached version dir is active) — NOT a raw walk of the cache, which keeps dozens of stale version
169
+ // folders per plugin. `pluginsDir` is injectable, mirroring the lib=GLOBAL_LIB pattern, so tests can
170
+ // point it at a fixture. Returns the same {name, installed} shape as the npm scan, tagged source:'plugin'.
171
+ export function listInstalledPlugins(pluginsDir = PLUGINS_DIR) {
172
+ const manifest = path.join(pluginsDir, 'installed_plugins.json');
173
+ if (!fs.existsSync(manifest)) return [];
174
+ let data;
175
+ try { data = JSON.parse(fs.readFileSync(manifest, 'utf8')); } catch { return []; }
176
+ const plugins = data && typeof data.plugins === 'object' ? data.plugins : {};
177
+ const out = [];
178
+ for (const [key, records] of Object.entries(plugins)) {
179
+ // key = "<plugin>@<marketplace>"; the marketplace is the last @-segment.
180
+ const at = key.lastIndexOf('@');
181
+ const name = at > 0 ? key.slice(0, at) : key;
182
+ const marketplace = at > 0 ? key.slice(at + 1) : '';
183
+ if (!(PLUGIN_MARKETPLACES.has(marketplace) || FAMILY.test(name))) continue;
184
+ // A plugin can have several install records (user + per-project scope). Pick the highest readable
185
+ // version so a stale project-scoped copy can never mask a newer user-scoped one — same "ordering,
186
+ // not equality" discipline as the rest of this file. Fall back to the manifest's own version field
187
+ // if the on-disk plugin.json is unreadable, so a present plugin is NEVER reported "not installed".
188
+ let installed = null;
189
+ for (const rec of Array.isArray(records) ? records : []) {
190
+ const v = (rec && rec.installPath ? pluginVersion(rec.installPath) : null)
191
+ || (rec && rec.version && rec.version !== 'unknown' ? rec.version : null);
192
+ if (!v) continue;
193
+ if (installed === null || cmpVersion(v, installed) > 0) installed = v;
194
+ }
195
+ out.push({ name, installed, source: 'plugin', marketplace });
196
+ }
197
+ return out;
198
+ }
199
+
200
+ function listInstalled({ lib = GLOBAL_LIB, pluginsDir = PLUGINS_DIR } = {}) {
201
+ const out = [];
202
+ const scan = (dir, scope = '') => {
203
+ if (!fs.existsSync(dir)) return;
204
+ for (const e of fs.readdirSync(dir)) {
205
+ if (e.startsWith('.')) continue;
206
+ if (e.startsWith('@') && !scope) { scan(path.join(dir, e), e + '/'); continue; }
207
+ const name = scope + e;
208
+ if (FAMILY.test(name)) out.push({ name, installed: installedVersion(name, lib), source: 'npm-global' });
209
+ }
210
+ };
211
+ scan(lib);
212
+ // Merge in plugin-sourced tools (ISSUE #22). Dedup by name: a tool present BOTH globally and as a
213
+ // plugin appears once, and the global-npm copy wins — it is the one classify() can compare against
214
+ // npm dist-tags. A plugin-only tool is added, so it can never be reported "not installed".
215
+ const seen = new Set(out.map((r) => r.name));
216
+ for (const p of listInstalledPlugins(pluginsDir)) {
217
+ if (seen.has(p.name)) continue;
218
+ seen.add(p.name);
219
+ out.push(p);
220
+ }
221
+ return out.sort((a, b) => a.name.localeCompare(b.name));
222
+ }
223
+
224
+ function registryTags(pkg) {
225
+ const r = spawnSync('npm', ['view', pkg, 'dist-tags', '--json'], { encoding: 'utf8', timeout: 30000 });
226
+ if (r.status !== 0 || !r.stdout) return null;
227
+ try { return JSON.parse(r.stdout); } catch { return null; }
228
+ }
229
+
230
+ // A second copy of a stack package in the npx cache can only ever SHADOW the global one.
231
+ // It is never useful. This is how "two ruflos" happens.
232
+ export function findShadows(npxCache = NPX_CACHE, lib = GLOBAL_LIB) {
233
+ const shadows = [];
234
+ if (!fs.existsSync(npxCache)) return shadows;
235
+ for (const d of fs.readdirSync(npxCache)) {
236
+ const nm = path.join(npxCache, d, 'node_modules');
237
+ if (!fs.existsSync(nm)) continue;
238
+ const scan = (dir, scope = '') => {
239
+ for (const e of fs.readdirSync(dir)) {
240
+ if (e.startsWith('.')) continue;
241
+ if (e.startsWith('@') && !scope) { scan(path.join(dir, e), e + '/'); continue; }
242
+ const name = scope + e;
243
+ if (!FAMILY.test(name)) continue;
244
+ try {
245
+ const v = JSON.parse(fs.readFileSync(path.join(dir, e, 'package.json'), 'utf8')).version;
246
+ shadows.push({ name, version: v, dir: path.join(npxCache, d), global: installedVersion(name, lib) });
247
+ } catch { /* unreadable copy: still a shadow, but we cannot name its version */ }
248
+ }
249
+ };
250
+ try { scan(nm); } catch { /* unreadable cache dir */ }
251
+ }
252
+ return shadows;
253
+ }
254
+
255
+ // Classify installed packages against the registry. Ordering is STILL decided only in cmpVersion/
256
+ // isBehind — this function assigns a state label, it does not compare versions itself except through
257
+ // the single comparator. Extracted so audit() (CLI, may exit) and auditModel() (embedders, never
258
+ // exits) share ONE classification, never two that can drift.
259
+ export function classify(pkgs) {
260
+ return pkgs.map((p) => {
261
+ // ISSUE #22 — a Claude Code plugin tracks ITS MARKETPLACE's update cadence, not npm semver. There
262
+ // is no npm dist-tag oracle for it (querying `npm view <plugin>` would compare against an unrelated
263
+ // package or 404), so we do not manufacture a drift signal. It is present and installed ⇒ CURRENT;
264
+ // a plugin is only BROKEN if we could not read any version at all. Either way it is COUNTED and is
265
+ // never reported "not installed". This also keeps the npm-registry "blind tool" guard scoped to
266
+ // real npm rows (see audit()/auditModel()).
267
+ if (p.source === 'plugin') {
268
+ return { ...p, tag: 'plugin', target: p.installed, state: p.installed ? 'CURRENT' : 'BROKEN' };
269
+ }
270
+ const want = TAG_POLICY[p.name] || DEFAULT_TAG;
271
+ const tags = registryTags(p.name);
272
+ // Newest of {policy tag, latest} — see pickTargetTag: "track @alpha" alone pinned the core behind
273
+ // @latest on 2026-07-18. A package with no alpha tag falls through to latest.
274
+ const { tag, target } = pickTargetTag(tags, want);
275
+ let state;
276
+ if (!p.installed) state = 'BROKEN';
277
+ else if (!target) state = 'UNRESOLVED';
278
+ else if (isBehind(p.installed, target)) state = 'BEHIND';
279
+ else if (cmpVersion(p.installed, target) > 0) state = 'AHEAD';
280
+ else state = 'CURRENT';
281
+ return { ...p, tag, target, state };
282
+ });
283
+ }
284
+
285
+ function audit() {
286
+ const pkgs = listInstalled();
287
+ if (!pkgs.length) die(`no stack packages under ${GLOBAL_LIB} — is the npm prefix right?`);
288
+
289
+ // A BLIND TOOL MUST NOT REPORT HEALTH. (Adversarial review, 2026-07-14 — a real bug in the first
290
+ // version of this file.) If the registry is unreachable, EVERY package resolves UNRESOLVED, the
291
+ // drift count is 0+0+0, and --audit used to print "all current" and exit 0 — a currency claim
292
+ // that was never measured. That is the exact failure this whole file exists to kill, reproduced
293
+ // inside the fix for it. Silence is not health.
294
+ const rows = classify(pkgs);
295
+ const unresolved = rows.filter((r) => r.state === 'UNRESOLVED');
296
+ // Guard scoped to npm rows (ISSUE #22): plugin rows are CURRENT by construction, so counting them
297
+ // here would let a fully-unreachable registry hide behind a couple of installed plugins — the exact
298
+ // "blind tool reports health" bug this guard exists to kill, reintroduced. Denominator = npm rows.
299
+ const npmRows = rows.filter((r) => r.source !== 'plugin');
300
+ if (npmRows.length && unresolved.length === npmRows.length) {
301
+ die(`could not reach the npm registry for ANY of ${npmRows.length} npm packages.\n` +
302
+ ` This tool refuses to report on a stack it could not measure. Check the network and re-run.`);
303
+ }
304
+
305
+ const shadows = findShadows();
306
+ return { rows, unresolved, shadows, stale: shadows.filter((s) => s.global && s.version !== s.global) };
307
+ }
308
+
309
+ // Non-exiting audit for embedders (the Onboarding Console). Same measurement as audit(), but returns
310
+ // a model with an `error` field instead of calling process.exit — a long-lived server must never be
311
+ // killed by a transient registry blip. Honours the SAME "a blind tool must not report health" rule:
312
+ // if the registry was unreachable for EVERY package, that is surfaced as an error, not as "all current".
313
+ export function auditModel() {
314
+ const pkgs = listInstalled();
315
+ if (!pkgs.length) return { error: `no stack packages under ${GLOBAL_LIB}`, rows: [], unresolved: [], shadows: [], stale: [] };
316
+ const rows = classify(pkgs);
317
+ const unresolved = rows.filter((r) => r.state === 'UNRESOLVED');
318
+ const shadows = findShadows();
319
+ const stale = shadows.filter((s) => s.global && s.version !== s.global);
320
+ // Same npm-scoped guard as audit() (ISSUE #22): plugin rows never count toward "registry unreachable".
321
+ const npmRows = rows.filter((r) => r.source !== 'plugin');
322
+ const error = npmRows.length && unresolved.length === npmRows.length
323
+ ? `could not reach the npm registry for any of ${npmRows.length} npm packages` : null;
324
+ return { rows, unresolved, shadows, stale, error };
325
+ }
326
+
327
+ function report({ rows, shadows, stale }) {
328
+ const w = Math.max(...rows.map((r) => r.name.length));
329
+ const nPlugin = rows.filter((r) => r.source === 'plugin').length;
330
+ const nNpm = rows.length - nPlugin;
331
+ log(`\n RuvNet stack — ${rows.length} packages (${nNpm} npm-global, ${nPlugin} Claude Code plugin)\n`);
332
+ for (const r of rows) {
333
+ const mark = { CURRENT: ' ok ', BEHIND: 'BEHIND', AHEAD: ' ahead', BROKEN: 'BROKEN', UNRESOLVED: ' ?? ' }[r.state];
334
+ const detail = r.state === 'BEHIND' ? `${r.installed} -> ${r.target} (@${r.tag})`
335
+ : r.state === 'AHEAD' ? `${r.installed} (ahead of @${r.tag} ${r.target} — alpha track; left alone)`
336
+ : r.state === 'BROKEN' ? `no readable version on disk; registry has ${r.target ?? '?'}`
337
+ : r.source === 'plugin' ? `${r.installed} (plugin · ${r.marketplace} marketplace)`
338
+ : r.installed;
339
+ log(` [${mark}] ${r.name.padEnd(w)} ${detail}`);
340
+ }
341
+ if (shadows.length) {
342
+ log(`\n npx shadow copies:`);
343
+ for (const s of shadows) {
344
+ log(` ${s.name}@${s.version}${s.global && s.version !== s.global ? ` STALE — global is ${s.global}` : ''}`);
345
+ }
346
+ }
347
+ log('');
348
+ return { behind: rows.filter((r) => r.state === 'BEHIND'), broken: rows.filter((r) => r.state === 'BROKEN'), stale };
349
+ }
350
+
351
+ function writeReceipt(a, installed, purged) {
352
+ fs.mkdirSync(path.dirname(RECEIPT), { recursive: true });
353
+ fs.writeFileSync(RECEIPT, JSON.stringify({
354
+ at: new Date().toISOString(), installed, purged,
355
+ packages: a.rows.map((r) => ({ name: r.name, version: r.installed, state: r.state })),
356
+ }, null, 2));
357
+ }
358
+
359
+ // EXCLUSIVE LOCK. (Adversarial review, 2026-07-14.) Nothing stopped the nightly job and a manual
360
+ // run (or two Claude Code windows) from both running `npm install -g` on the same package at the
361
+ // same instant. Two concurrent installs interleaving in one node_modules dir is how you get a
362
+ // half-written package — which is almost certainly how @ruvector/edge-net ended up present-but-
363
+ // versionless with an orphaned `sharp` inside it. O_EXCL is atomic; a stale lock from a crashed
364
+ // run is reclaimed after 20 minutes (longer than the npm timeout).
365
+ const LOCK = path.join(HOME, '.cache/ruvnet-brain/stack-sync.lock');
366
+ function acquireLock() {
367
+ fs.mkdirSync(path.dirname(LOCK), { recursive: true });
368
+ try {
369
+ fs.writeFileSync(LOCK, JSON.stringify({ pid: process.pid, at: Date.now() }), { flag: 'wx' });
370
+ } catch (e) {
371
+ if (e.code !== 'EEXIST') throw e;
372
+ let held = {};
373
+ try { held = JSON.parse(fs.readFileSync(LOCK, 'utf8')); } catch { /* unparseable = stale */ }
374
+ const ageMin = (Date.now() - (held.at ?? 0)) / 60000;
375
+ if (ageMin < 20) {
376
+ die(`another stack-sync is running (pid ${held.pid}, started ${ageMin.toFixed(1)}m ago).\n` +
377
+ ` Refusing to run two installers at once — that is how packages get half-written.`);
378
+ }
379
+ fs.writeFileSync(LOCK, JSON.stringify({ pid: process.pid, at: Date.now() }));
380
+ }
381
+ const release = () => { try { fs.unlinkSync(LOCK); } catch { /* already gone */ } };
382
+ process.on('exit', release);
383
+ process.on('SIGINT', () => { release(); process.exit(130); });
384
+ process.on('SIGTERM', () => { release(); process.exit(143); });
385
+ }
386
+
387
+ function sync({ dryRun = false } = {}) {
388
+ if (!dryRun) acquireLock();
389
+ const a = audit();
390
+ const { behind, broken, stale } = report(a);
391
+ const toInstall = [...behind, ...broken.filter((r) => r.target)].map((r) => `${r.name}@${r.target}`);
392
+
393
+ if (!toInstall.length && !stale.length) {
394
+ log(' one copy of everything, all current, no shadows. Nothing to do.');
395
+ writeReceipt(a, [], []);
396
+ return;
397
+ }
398
+ if (dryRun) {
399
+ if (toInstall.length) log(` would install: ${toInstall.join(' ')}`);
400
+ if (stale.length) log(` would purge ${stale.length} stale npx shadow(s)`);
401
+ return;
402
+ }
403
+
404
+ // INSTALL FIRST, PURGE SECOND. (Adversarial review, 2026-07-14.) The first version purged the
405
+ // shadows before installing — so a failed install left the user with their shadows gone AND the
406
+ // global not yet fixed: strictly WORSE than when they started, with no way back. A repair step
407
+ // that can leave you worse off than not running it is not a repair.
408
+ if (toInstall.length) {
409
+ log(` installing: ${toInstall.join(' ')}`);
410
+ const r = spawnSync('npm', ['install', '-g', '--prefix', PREFIX, ...toInstall],
411
+ { stdio: 'inherit', timeout: 15 * 60 * 1000 });
412
+ if (r.status !== 0) die(`npm install -g exited ${r.status}; the stack is NOT synced. Shadows left untouched.`);
413
+ }
414
+
415
+ const purged = [];
416
+ for (const s of stale) {
417
+ // Purge the whole npx dir: it is a disposable resolution cache (npm re-creates it on demand),
418
+ // and a mixed-version dir is exactly how a stale transitive copy survives a targeted delete.
419
+ try { fs.rmSync(s.dir, { recursive: true, force: true }); purged.push(`${s.name}@${s.version}`); } catch { /* best effort */ }
420
+ }
421
+ if (purged.length) log(` purged ${purged.length} stale shadow(s): ${purged.join(', ')}`);
422
+
423
+ // VERIFY AGAINST THE DISK. An installer that trusts its own exit code is a hope, not a guarantee:
424
+ // the old nightly printed "auto-update finished cleanly" from npm's status alone, which is exactly
425
+ // how a stack rots while every log line insists it is healthy.
426
+ const wrong = [];
427
+ for (const r of [...behind, ...broken]) {
428
+ if (!r.target) continue;
429
+ const now = installedVersion(r.name);
430
+ if (now !== r.target) wrong.push(`${r.name}: expected ${r.target}, disk says ${now ?? 'MISSING'}`);
431
+ }
432
+ if (wrong.length) die(`npm reported success but THE DISK DISAGREES:\n - ${wrong.join('\n - ')}`);
433
+
434
+ writeReceipt(audit(), toInstall, purged);
435
+ log(`\n synced ${toInstall.length} package(s); purged ${purged.length} shadow(s); verified against disk.`);
436
+ }
437
+
438
+ // Importable as a module by the tests; only acts when run as a CLI.
439
+ if (process.argv[1] && path.resolve(process.argv[1]).endsWith('stack-sync.mjs')) {
440
+ const args = process.argv.slice(2);
441
+ if (args.includes('--audit')) {
442
+ const a = audit();
443
+ const { behind, broken, stale } = report(a);
444
+ // UNRESOLVED counts as drift, not as health: a package we could not measure is a package we
445
+ // cannot vouch for. Reporting "current" for something we never checked is the lie this tool exists to stop.
446
+ const unres = a.unresolved.length;
447
+ const bad = behind.length + broken.length + stale.length + unres;
448
+ if (bad) {
449
+ log(` DRIFT: ${behind.length} behind, ${broken.length} broken, ${stale.length} stale shadow(s)` +
450
+ (unres ? `, ${unres} UNMEASURED (registry unreachable)` : '') + '.');
451
+ log(` Fix: node scripts/stack-sync.mjs --sync\n`);
452
+ process.exit(1); // non-zero: a watchdog must never call a drifted stack "green"
453
+ }
454
+ log(' one copy of everything, all current, no shadows.\n');
455
+ } else if (args.includes('--sync')) {
456
+ sync({ dryRun: args.includes('--dry-run') });
457
+ } else {
458
+ log(`
459
+ stack-sync — one global copy of the RuvNet stack. Correct. Provable.
460
+
461
+ --audit report drift; exit 1 if anything is behind, broken, or shadowed
462
+ --sync install what is BEHIND (never downgrades), purge npx shadows, verify against disk
463
+ --dry-run with --sync: say what it would do, change nothing
464
+
465
+ Tag policy: ${JSON.stringify(TAG_POLICY)}; everything else @${DEFAULT_TAG}
466
+ Receipt: ${RECEIPT}
467
+ `);
468
+ }
469
+ }
@@ -0,0 +1,53 @@
1
+ #!/usr/bin/env node
2
+ // Bind every existing canonical RVF to the current Brain release without re-embedding. Optional
3
+ // legacy pruning is fail-closed: the canonical passages/meta and big RVF sidecars must exist first.
4
+
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+ import { writeRvfGeneration } from './rvf-generation.mjs';
9
+
10
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
11
+ const KB = path.join(ROOT, 'kb');
12
+ const PRUNE = process.argv.includes('--prune-legacy');
13
+ const stores = fs.readdirSync(KB)
14
+ .filter((file) => file.endsWith('.big.rvf'))
15
+ .map((file) => file.slice(0, -'.big.rvf'.length))
16
+ .sort();
17
+
18
+ let pruned = 0;
19
+ for (const store of stores) {
20
+ const embedFile = path.join(KB, `${store}.big.rvf.embed.json`);
21
+ const idmapFile = path.join(KB, `${store}.big.rvf.idmap.json`);
22
+ const passagesFile = path.join(KB, `${store}.passages.jsonl`);
23
+ const metaFile = path.join(KB, `${store}.meta.json`);
24
+ for (const required of [embedFile, idmapFile, passagesFile, metaFile]) {
25
+ if (!fs.existsSync(required)) throw new Error(`${store}: missing ${path.basename(required)}; refusing to stamp/prune`);
26
+ }
27
+ const embed = JSON.parse(fs.readFileSync(embedFile, 'utf8'));
28
+ const generation = writeRvfGeneration({
29
+ dir: KB,
30
+ store,
31
+ model: embed.model,
32
+ dimensions: embed.dimensions,
33
+ sourceCommit: null,
34
+ });
35
+ console.log(`[generation] ${store}: ${generation.sha256.slice(0, 16)}… ${generation.bytes} bytes`);
36
+
37
+ if (PRUNE) {
38
+ for (const legacy of [
39
+ `${store}.rvf`,
40
+ `${store}.rvf.idmap.json`,
41
+ `${store}.rvf.embed.json`,
42
+ `${store}.big.passages.jsonl`,
43
+ `${store}.big.meta.json`,
44
+ ]) {
45
+ const file = path.join(KB, legacy);
46
+ if (fs.existsSync(file)) {
47
+ fs.rmSync(file);
48
+ pruned++;
49
+ }
50
+ }
51
+ }
52
+ }
53
+ console.log(`[generation] stamped ${stores.length} canonical RVFs${PRUNE ? `; pruned ${pruned} legacy files` : ''}`);
@@ -0,0 +1,144 @@
1
+ #!/usr/bin/env node
2
+ // scripts/stamp-sweep.mjs — ADR-056 §2. The ONE-TIME half of the owner's first rule.
3
+ //
4
+ // node scripts/stamp-sweep.mjs report: every authored .md, its stamp state, its verdict
5
+ // node scripts/stamp-sweep.mjs --apply write the stamps git can prove
6
+ // node scripts/stamp-sweep.mjs --json machine-readable, same evaluation
7
+ //
8
+ // ─────────────────────────────────────────────────────────────────────────────────────────────────
9
+ // WHY THIS EXISTS SEPARATELY FROM THE HOOK. `plugin/scripts/md-stamp.mjs` maintains stamps when a
10
+ // file is edited. That is necessary and it is not sufficient, and the adversarial duel on ADR-056
11
+ // said why in one sentence:
12
+ //
13
+ // "Insert-on-touch fires only when a file IS edited — so it reaches actively-edited files, the
14
+ // ones least likely to be stale, and never reaches a stale file, BY DEFINITION OF STALE."
15
+ //
16
+ // The owner's rule is about the documents sitting on disk TODAY: "so I know if they're stale or
17
+ // current actually in there so that I can read it, not just you." Measured 2026-07-27: 166 of 239
18
+ // .md files carry no stamp. The hook would have reached approximately none of them. This reaches
19
+ // them once; the hook keeps them true afterwards.
20
+ //
21
+ // ONE IMPLEMENTATION, TWO CALLERS. Every placement decision lives in md-stamp.mjs (`ensureStamp`,
22
+ // `stampInsertionPoint`, `hasStamp`) and is imported here. A second copy of "where does the stamp
23
+ // go" is the exact drift this whole ADR is about.
24
+ //
25
+ // NOTHING IS EVER INVENTED — the load-bearing rule, inherited verbatim from doc-currency.mjs:
26
+ // · no git history -> SKIP and say so. A file git cannot date does not get a date.
27
+ // · dirty working tree -> SKIP. The last commit's date is not the date of the current contents,
28
+ // and stamping it would assert a freshness that is not true.
29
+ // · already stamped -> SKIP, byte-for-byte untouched.
30
+ // · unrecognised prologue -> SKIP. MDX exports, HTML comments, license banners: an insertion that
31
+ // corrupts someone's document is worse than a document with no date.
32
+ //
33
+ // GENERATED MARKDOWN IS NOT AN AUTHORED DOCUMENT. 96 of the 166 unstamped files live under kb/,
34
+ // dist/ and .agentic-qe/logs/. Stamping machine output is noise wearing the costume of signal, and
35
+ // it would bury the ~70 files where the stamp actually carries information.
36
+
37
+ import fs from 'node:fs';
38
+ import path from 'node:path';
39
+ import { spawnSync } from 'node:child_process';
40
+ import { fileURLToPath } from 'node:url';
41
+ import { ensureStamp, hasStamp, stampInsertionPoint } from '../plugin/scripts/md-stamp.mjs';
42
+
43
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
44
+ export const REPO_ROOT = path.resolve(HERE, '..');
45
+
46
+ // Directories whose markdown is GENERATED, vendored, or archival — never authored by a human here.
47
+ export const EXCLUDED = [
48
+ 'node_modules', '.git', 'kb', 'dist', 'clones', 'archive', '.agentic-qe',
49
+ 'coverage', '.next', 'build', 'tmp', '.swarm',
50
+ ];
51
+
52
+ function git(root, args) {
53
+ const r = spawnSync('git', args, { cwd: root, encoding: 'utf8' });
54
+ return { ok: r.status === 0, out: (r.stdout || '').trim() };
55
+ }
56
+
57
+ /** Every authored .md tracked by git. Untracked files are out of scope — git cannot date them. */
58
+ export function authoredDocs(root = REPO_ROOT) {
59
+ const ls = git(root, ['ls-files', '--', '*.md']);
60
+ if (!ls.ok) return [];
61
+ return ls.out.split('\n').filter(Boolean).filter((rel) => {
62
+ const segs = rel.split('/');
63
+ return !segs.some((s) => EXCLUDED.includes(s));
64
+ });
65
+ }
66
+
67
+ /** Dates git can PROVE for this path. Absent is absent; nothing is inferred from a neighbour. */
68
+ export function gitDates(root, rel) {
69
+ const created = git(root, ['log', '--follow', '--diff-filter=A', '--format=%cs', '-1', '--', rel]);
70
+ const updated = git(root, ['log', '-1', '--format=%cs', '--', rel]);
71
+ const dirty = git(root, ['status', '--porcelain', '--', rel]);
72
+ return {
73
+ created: created.ok && /^\d{4}-\d{2}-\d{2}$/.test(created.out) ? created.out : null,
74
+ updated: updated.ok && /^\d{4}-\d{2}-\d{2}$/.test(updated.out) ? updated.out : null,
75
+ dirty: Boolean(dirty.out),
76
+ };
77
+ }
78
+
79
+ export function evaluate(root = REPO_ROOT, docs = authoredDocs(root)) {
80
+ const rows = [];
81
+ for (const rel of docs) {
82
+ const abs = path.join(root, rel);
83
+ let content;
84
+ try { content = fs.readFileSync(abs, 'utf8'); } catch { continue; }
85
+
86
+ if (hasStamp(content)) { rows.push({ rel, verdict: 'already-stamped' }); continue; }
87
+
88
+ const at = stampInsertionPoint(content);
89
+ if (!at) { rows.push({ rel, verdict: 'skip-prologue', why: 'prologue shape not recognised — refusing to insert' }); continue; }
90
+
91
+ const d = gitDates(root, rel);
92
+ if (d.dirty) { rows.push({ rel, verdict: 'skip-dirty', why: 'working tree modified — the last commit date is not the date of these contents' }); continue; }
93
+ if (!d.updated) { rows.push({ rel, verdict: 'skip-no-history', why: 'no commits — no date can be derived, and none will be invented' }); continue; }
94
+
95
+ const next = ensureStamp(content, { updated: d.updated, created: d.created });
96
+ if (next === content) { rows.push({ rel, verdict: 'no-change' }); continue; }
97
+ rows.push({ rel, verdict: 'would-stamp', placement: at.kind, updated: d.updated, created: d.created, next });
98
+ }
99
+ return rows;
100
+ }
101
+
102
+ function main() {
103
+ const argv = process.argv.slice(2);
104
+ const apply = argv.includes('--apply');
105
+ const asJson = argv.includes('--json');
106
+ const rows = evaluate();
107
+
108
+ if (asJson) {
109
+ console.log(JSON.stringify(rows.map(({ next, ...r }) => r), null, 2));
110
+ return 0;
111
+ }
112
+
113
+ const by = (v) => rows.filter((r) => r.verdict === v);
114
+ const todo = by('would-stamp');
115
+
116
+ console.log(`\nstamp sweep — ${rows.length} authored document(s) under ${REPO_ROOT}\n`);
117
+ for (const r of todo) {
118
+ const c = r.created && r.created !== r.updated ? `, created ${r.created}` : '';
119
+ console.log(` ${apply ? '[stamped] ' : '[would stamp]'} ${r.rel} — updated ${r.updated}${c} (derived-from-git, ${r.placement})`);
120
+ if (apply) {
121
+ try { fs.writeFileSync(path.join(REPO_ROOT, r.rel), r.next); }
122
+ catch (e) { console.log(` [failed] ${r.rel}: ${e.message}`); }
123
+ }
124
+ }
125
+
126
+ const skipped = [...by('skip-prologue'), ...by('skip-dirty'), ...by('skip-no-history')];
127
+ if (skipped.length) {
128
+ console.log(`\n ${skipped.length} deliberately NOT stamped — no date will be invented:\n`);
129
+ for (const s of skipped) console.log(` ○ ${s.rel}\n ${s.why}`);
130
+ }
131
+
132
+ console.log(`\nsummary: ${by('already-stamped').length} already stamped · ${todo.length} `
133
+ + `${apply ? 'stamped' : 'stampable'} · ${skipped.length} skipped (honestly)`);
134
+ if (!apply && todo.length) console.log('\ndry run — nothing written. Re-run with --apply to write them.');
135
+ console.log('Dates are only ever copied out of git; nothing is reconstructed.\n');
136
+ return 0;
137
+ }
138
+
139
+ function isMain() {
140
+ try { return process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url); }
141
+ catch { return false; }
142
+ }
143
+
144
+ if (isMain()) process.exit(main());