ruvnet-brain 4.0.35 → 4.0.90-dev

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 (61) hide show
  1. package/README.md +4 -4
  2. package/bin/install.mjs +283 -23
  3. package/data/model-catalog.json +104 -15
  4. package/package.json +2 -1
  5. package/plugin/.claude-plugin/plugin.json +2 -2
  6. package/plugin/.codex-plugin/plugin.json +1 -1
  7. package/plugin/commands/brain-console.md +72 -9
  8. package/plugin/commands/configure.md +67 -21
  9. package/plugin/commands/rvcb.md +72 -9
  10. package/plugin/hooks/codex-hooks.json +40 -33
  11. package/plugin/hooks/hook-contracts.json +14 -24
  12. package/plugin/hooks/hooks.json +7 -42
  13. package/plugin/mcp/server.mjs +23 -6
  14. package/plugin/scripts/adr-currency-gate.mjs +150 -0
  15. package/plugin/scripts/capability-registry.mjs +10 -1
  16. package/plugin/scripts/codex-hook-adapter.mjs +121 -19
  17. package/plugin/scripts/codex-hook-wrapper.mjs +61 -4
  18. package/plugin/scripts/continuation-gate.mjs +148 -8
  19. package/plugin/scripts/decision-gate.mjs +428 -0
  20. package/plugin/scripts/decision-outcomes.mjs +0 -0
  21. package/plugin/scripts/degradation-watch.mjs +271 -0
  22. package/plugin/scripts/ground-ruvnet.sh +51 -11
  23. package/plugin/scripts/hijack-ruvnet.sh +11 -3
  24. package/plugin/scripts/hook-registry.mjs +48 -3
  25. package/plugin/scripts/hook-shim.mjs +58 -3
  26. package/plugin/scripts/identifier-preflight.mjs +134 -0
  27. package/plugin/scripts/learn-capture.sh +50 -1
  28. package/plugin/scripts/learn-flush.mjs +5 -5
  29. package/plugin/scripts/lesson-bridge.mjs +343 -0
  30. package/plugin/scripts/lesson-hooks.sh +26 -0
  31. package/plugin/scripts/lesson-promote.mjs +50 -0
  32. package/plugin/scripts/lesson-store.mjs +6 -1
  33. package/plugin/scripts/mcp-readiness.mjs +107 -0
  34. package/plugin/scripts/protect-brain-state.sh +9 -0
  35. package/plugin/scripts/runtime-preferences.mjs +40 -0
  36. package/plugin/scripts/session-snapshot-hook.mjs +15 -6
  37. package/plugin/scripts/spend-guard.mjs +125 -0
  38. package/plugin/scripts/unprompted-runtime.mjs +12 -2
  39. package/plugin/scripts/update-apply.mjs +7 -2
  40. package/plugin/skills/ruvnet-brain/PLAYBOOK.md +20 -5
  41. package/plugin/skills/ruvnet-brain/SKILL.md +3 -3
  42. package/scripts/brain-score.mjs +252 -0
  43. package/scripts/brain-stamp.mjs +5 -1
  44. package/scripts/build-bundle.mjs +25 -1
  45. package/scripts/console-engine.mjs +1 -1
  46. package/scripts/health-repair.mjs +11 -2
  47. package/scripts/host-install-matrix.mjs +21 -0
  48. package/scripts/ingest-repo.mjs +66 -6
  49. package/scripts/learning-replay-cli.mjs +8 -3
  50. package/scripts/learning-replay-fixture.mjs +25 -6
  51. package/scripts/learning-replay-proof.mjs +38 -0
  52. package/scripts/nightly-wrapper.sh +13 -0
  53. package/scripts/onboarding-console.mjs +21 -1
  54. package/scripts/org-repo-count.mjs +119 -0
  55. package/scripts/publication-receipt.mjs +7 -3
  56. package/scripts/repo-count-detector.mjs +62 -0
  57. package/scripts/restore-local-ingests.mjs +116 -0
  58. package/scripts/selfcheck.mjs +9 -1
  59. package/scripts/stabilization-receipt.mjs +11 -1
  60. package/scripts/sync-census.mjs +0 -0
  61. package/scripts/sync-commands.mjs +117 -0
@@ -66,6 +66,7 @@ import { loadLessons, updateLessons, ratify, demote, restore, pending, weightOf,
66
66
  import {
67
67
  openRouterCredentialStatus,
68
68
  saveOpenRouterCredential,
69
+ learnerCwd,
69
70
  } from '../plugin/scripts/runtime-preferences.mjs';
70
71
  import { applyNightlyChoice, nightlyStatus } from './nightly-controller.mjs';
71
72
  // One canonical answer to "which directory is this, and have I counted it already?" — shared with
@@ -2213,10 +2214,29 @@ function observeLearning() {
2213
2214
 
2214
2215
  let lastTrainSeconds = null; let trajectories = 0;
2215
2216
  try {
2217
+ // ISSUE #136 — THE LEARNER IS PROJECT-SCOPED, so this must ask about the SERVED project.
2218
+ //
2219
+ // `ruflo hooks intelligence --status` reports `Data Dir: <cwd>/.claude-flow/neural`. With
2220
+ // `cwd: SYSTEM_HOME` this measured `~/.claude-flow/neural` — a store nothing writes to on a
2221
+ // machine whose work happens inside project directories. Measured on one machine, one minute:
2222
+ // the home store held 1,216 trajectories last trained 6.9 DAYS ago while the served project held
2223
+ // 9,940 last trained 22 SECONDS ago. The card said "Your learner has gone quiet" about a learner
2224
+ // training every few seconds.
2225
+ //
2226
+ // This file already carries the verdict on this exact mistake at the refresh-child spawn: "cwd =
2227
+ // the SERVED project, NOT REPO … it was a real console-honesty bug". Same rule, same file,
2228
+ // different call site — #104's and #134's residual arriving a third time.
2229
+ // ISSUE #139 (@ObiWanKenobi) — `process.cwd()` was a HARDCODE in the opposite direction from
2230
+ // #136's `SYSTEM_HOME`. Neither asked which scope was in effect; the first was wrong by default
2231
+ // and the second is right only BECAUSE `project` is the default. Under
2232
+ // `RUVNET_LEARNING_SCOPE=user` the flush feeds ~/.claude-flow/neural while this read
2233
+ // <project>/.claude-flow/neural — the same false-positive card, inverted. The writer and this
2234
+ // reader now call ONE resolver (runtime-preferences.learnerCwd), so they agree by construction
2235
+ // instead of by coincidence.
2216
2236
  const r = spawnSync(path.join(SYSTEM_HOME, '.npm-global/bin/ruflo'),
2217
2237
  ['hooks', 'intelligence', '--status'],
2218
2238
  {
2219
- cwd: SYSTEM_HOME,
2239
+ cwd: learnerCwd(),
2220
2240
  env: { ...process.env, RUFLO_DAEMON_AUTOSTART: '0' },
2221
2241
  encoding: 'utf8',
2222
2242
  timeout: 20_000,
@@ -0,0 +1,119 @@
1
+ /**
2
+ * org-repo-count.mjs — how many repos rUv actually has, DERIVED, with the source recorded.
3
+ *
4
+ * THE DEFECT THIS REPLACES. `orgTotalApprox: 248` was a literal, written twice:
5
+ *
6
+ * scripts/build-bundle.mjs:402 coverage: { …, orgTotalApprox: 248, … }
7
+ * scripts/brain-stamp.mjs:77 coverage: { …, orgTotalApprox: 248, … }
8
+ *
9
+ * Measured 2026-08-12, two independent ways: `users/ruvnet.public_repos` = 200, and a paginated
10
+ * `users/ruvnet/repos` fetch = 200 distinct names. So the denominator every coverage percentage in
11
+ * this product is computed against was wrong by 48 — and wrong in BOTH copies, which is what a
12
+ * restated fact always does.
13
+ *
14
+ * Replacing 248 with 200 would repeat the mistake with a fresher number. The count changes whenever
15
+ * rUv pushes a new repo, so it is not a constant and must not be stored as one.
16
+ *
17
+ * HONESTY OVER AVAILABILITY. A build with no network must not silently invent a total. `source` is
18
+ * carried alongside the number so every consumer can see whether it is live:
19
+ *
20
+ * { count: 200, source: 'live', at: '2026-08-12T…' } ← queried just now
21
+ * { count: 200, source: 'recorded', at: '2026-08-12T…' } ← last live reading, reused offline
22
+ * { count: null, source: 'unknown', at: null } ← never say a number we cannot source
23
+ *
24
+ * A consumer that receives `unknown` must omit the claim rather than print a guess — the same rule
25
+ * sync-census follows when it refuses to write a non-positive census.
26
+ */
27
+ import fs from 'node:fs';
28
+ import path from 'node:path';
29
+ import { spawnSync } from 'node:child_process';
30
+ import { fileURLToPath } from 'node:url';
31
+
32
+ const ROOT = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
33
+ /** The last LIVE reading, so an offline build reuses a real measurement instead of a literal. */
34
+ export const RECORD_PATH = process.env.RUVNET_ORG_COUNT_RECORD
35
+ || path.join(ROOT, 'data', 'org-repo-count.json');
36
+
37
+ export const OWNER = process.env.RUVNET_ORG_OWNER || 'ruvnet';
38
+
39
+ /** Ask GitHub. Returns a positive integer or null — never a guess, never a partial page. */
40
+ export function fetchLiveCount(owner = OWNER, { run = spawnSync } = {}) {
41
+ // `public_repos` is the account's own count: one request, no pagination to get wrong.
42
+ const r = run('gh', ['api', `users/${owner}`, '--jq', '.public_repos'], { encoding: 'utf8', timeout: 30_000 });
43
+ if (r.error || r.status !== 0) return null;
44
+ const n = Number(String(r.stdout || '').trim());
45
+ return Number.isInteger(n) && n > 0 ? n : null;
46
+ }
47
+
48
+ const readRecord = (file) => {
49
+ try {
50
+ const j = JSON.parse(fs.readFileSync(file, 'utf8'));
51
+ return Number.isInteger(j?.count) && j.count > 0 ? j : null;
52
+ } catch { return null; }
53
+ };
54
+
55
+ /**
56
+ * The count, its provenance, and when it was taken.
57
+ * A successful live read updates the record, so the offline fallback is always a real past reading.
58
+ */
59
+ /**
60
+ * READING A NUMBER MUST NOT MUTATE A TRACKED FILE.
61
+ *
62
+ * `persist` defaults to FALSE, and that default is the fix for a release that could not ship.
63
+ * Every call used to write `data/org-repo-count.json` — a TRACKED file — with a fresh `at`
64
+ * timestamp. Both production callers (`brain-stamp.mjs`, `build-bundle.mjs`) are BUILD scripts, so
65
+ * building the release candidate dirtied the working tree, and `release-qe`'s stabilization seal
66
+ * requires `dirty === false`. Measured 2026-08-19, once the seal was made to name its cause:
67
+ *
68
+ * stabilization seal failed: INVALID_LINEAGE
69
+ * working tree is dirty (1 path(s)):
70
+ * M data/org-repo-count.json
71
+ *
72
+ * That single line had blocked EVERY release since 2026-08-08 (issue #141: nothing published in
73
+ * eleven days while main advanced 46 commits), because the seal previously reported only the code
74
+ * `INVALID_LINEAGE` and named nothing.
75
+ *
76
+ * The committed record still matters — it is the honest offline fallback, so an air-gapped or
77
+ * rate-limited build reuses the last REAL reading instead of inventing one. It just may not be
78
+ * written as a side effect of being read. Refreshing it is now a deliberate act (`--record`), the
79
+ * same separation `refresh-model-catalog.mjs` already uses for the model snapshot.
80
+ */
81
+ export function orgRepoCount({ owner = OWNER, file = RECORD_PATH, now = new Date(), fetch = fetchLiveCount, persist = false } = {}) {
82
+ const live = fetch(owner);
83
+ if (live) {
84
+ const at = now.toISOString();
85
+ try {
86
+ fs.mkdirSync(path.dirname(file), { recursive: true });
87
+ if (persist) fs.writeFileSync(file, `${JSON.stringify({ owner, count: live, at }, null, 2)}\n`);
88
+ } catch { /* recording is best-effort; the live number still stands */ }
89
+ return { count: live, source: 'live', at };
90
+ }
91
+ const rec = readRecord(file);
92
+ if (rec) return { count: rec.count, source: 'recorded', at: rec.at || null };
93
+ return { count: null, source: 'unknown', at: null };
94
+ }
95
+
96
+ /**
97
+ * Refreshing the committed record is a DELIBERATE ACT, never a side effect of a build.
98
+ *
99
+ * node scripts/org-repo-count.mjs --record
100
+ *
101
+ * Same separation `refresh-model-catalog.mjs` uses for the model snapshot: a build READS, a human
102
+ * (or a scheduled refresh that commits its own result) WRITES. That boundary is what keeps the
103
+ * working tree clean for `release-qe`'s stabilization seal.
104
+ */
105
+ const isMain = (() => {
106
+ try { return process.argv[1] && fs.realpathSync(process.argv[1]) === fileURLToPath(import.meta.url); }
107
+ catch { return false; }
108
+ })();
109
+
110
+ if (isMain) {
111
+ const record = process.argv.includes('--record');
112
+ const r = orgRepoCount({ persist: record });
113
+ process.stdout.write(`${JSON.stringify(r, null, 2)}\n`);
114
+ if (record && r.source !== 'live') {
115
+ process.stderr.write('NOT recorded: the live read failed, and a remembered number may not be '
116
+ + 'rewritten as if it were fresh.\n');
117
+ process.exit(1);
118
+ }
119
+ }
@@ -14,7 +14,7 @@ import { spawn, spawnSync } from 'node:child_process';
14
14
  // `fixtures.claude` described the same fixture and nothing could tell. The richer
15
15
  // post-publication proofs below (payload assertions, MCP wiring, SOURCE.json, rpcSearch)
16
16
  // stay here — they are this side's job, not duplication.
17
- import { HOST_MODES, RECEIPT_MODE_NAMES, MODE_FROM_RECEIPT_NAME, classifyDoctor } from './host-install-matrix.mjs';
17
+ import { HOST_MODES, RECEIPT_MODE_NAMES, MODE_FROM_RECEIPT_NAME, classifyDoctor, VARIANTS } from './host-install-matrix.mjs';
18
18
  import { pathToFileURL } from 'node:url';
19
19
  import { evaluateCandidateReceipt, evaluatePublicationReceipt } from './release-proof.mjs';
20
20
  import { verifyPayload } from './release-payload.mjs';
@@ -232,8 +232,12 @@ export function livePublicationAdapter({ root = process.cwd() } = {}) {
232
232
  CODEX_HOME: codexHome,
233
233
  RUVNET_BRAIN_HOME: brainHome,
234
234
  RUVNET_BRAIN_KB: kb,
235
- RUVNET_STRICT_INSTALL: '1',
236
- RUVNET_BRAIN_PROFILE: 'complete',
235
+ // Env comes from the ONE variant table (host-install-matrix VARIANTS.published), not a
236
+ // second hand-written copy — this file's own header promises "ONE doctor rule and ONE mode
237
+ // vocabulary, shared with the staged-side check", and the copy had already drifted: it
238
+ // omitted the Codex hook-trust bypass the staged side carries, which failed the seal on
239
+ // every release.
240
+ ...VARIANTS.published.env({ packageRoot }),
237
241
  CI: 'true',
238
242
  PATH: isolatedPath(mode, temp),
239
243
  };
@@ -0,0 +1,62 @@
1
+ // repo-count-detector.mjs — THE ONE detector for corpus-size claims in public surfaces.
2
+ //
3
+ // Extracted from tests/unit/repo-count.test.mjs on 2026-08-10 so the WRITER (sync-census.mjs)
4
+ // and the GATE (repo-count.test.mjs) consult the same code. They had drifted by construction:
5
+ // the gate found every phrasing, while the writer enumerated a handful by hand and missed
6
+ // "71 of rUv's repos", "71 RuvNet building-block repos", "71 built today", and the
7
+ // HTML-entity variant. A checker and a fixer that disagree guarantee recurring red builds.
8
+
9
+ export const MIN_CORPUS_MAGNITUDE = 20;
10
+
11
+ // Deliberate, documented exceptions: the literal is correct AS WRITTEN because it describes a
12
+ // PAST state (a "before" baseline in a receipts table), not a current claim — not stale data.
13
+ // Anything else that disagrees with ALLOWED fails. Keyed `${file}::${matched text}` so a typo'd
14
+ // exemption can't silently swallow an unrelated real regression.
15
+ export const EXEMPT = new Set([
16
+ 'README.md::24 repos', // v1 (pre-2.0) baseline column in the "what 2.0 proved" before/after table
17
+ 'README.md::24→69 verified repos', // same v1-baseline row, "24→<current>" delta phrasing
18
+ // 2026-08-06: the corpus grew 69 → 71 (tonight's rebuild). The CURRENT claims were updated to 71
19
+ // in README.md and SKILL.md, but these two are HISTORICAL — they describe what release 2.0
20
+ // proved, inside a collapsed "Earlier — what 2.0 proved" section and its before/after table.
21
+ // Rewriting them to 71 would be the easy way to make this gate green and would falsify the record:
22
+ // 2.0 shipped with 69, not 71. A gate that pressures you into editing history is worse than the
23
+ // stale number it caught, so historical mentions are exempted by name and current ones are not.
24
+ 'README.md::69 verified repos', // "what 2.0 proved" summary line — a claim about 2.0, not today
25
+ 'README.md::69 repos', // the 2.0 column of the same v1→v2.0 before/after table
26
+ ]);
27
+
28
+ /** @param {string} src @returns {{text: string, n: number}[]} */
29
+ export function findRepoCountLiterals(src) {
30
+ const found = [];
31
+ // Covers "repo(s)" and the spelled-out "repositor(y|ies)" (SKILL.md line 8's own phrasing:
32
+ // "32 RuvNet (rUv / Reuven Cohen) repositories" — real prose puts several filler words/
33
+ // parentheticals between the number and the noun, so this can't be a fixed word count).
34
+ const REPO_WORD = '(?:repos?|repositor(?:y|ies))';
35
+ // Digit NOT immediately followed by "+": an open lower-bound like "20+ repos" is a true,
36
+ // deliberately-vague qualifier, not a precise count claim — exempt from matching at all rather
37
+ // than needing a per-mention exemption. Filler between the digit and the repo-word is capped at
38
+ // 50 chars and may not cross a sentence end / table-cell boundary (".", "|", newline), so this
39
+ // can't accidentally bridge two unrelated numbers separated by prose.
40
+ // `(?<!\d\.)` — never treat a SEMVER COMPONENT as a repo count. Real false positive: the primer's
41
+ // header line "…v3.4.21-dev · Built: 2026-07-20 · Covers: 69/192 repos" made the detector read the
42
+ // patch number `21` (the `-` after it is a word boundary) and then walk 40-odd filler chars to
43
+ // "repos", reporting a stale count on a line whose actual claim — 69/192 — was correct. The
44
+ // published number was right and the gate cried wolf, which is how a gate loses its authority.
45
+ // Two guards, both added after REAL false positives on the primer's own header line
46
+ // "…<version> · Built: <date> · Covers: 69/192 repos", whose actual claim (69/192) is correct:
47
+ // (?<!\d\.) — a SEMVER COMPONENT is never a repo count. The patch number was being read as
48
+ // one, because the `-` before a prerelease suffix is a word boundary.
49
+ // `·` in the filler exclusion — a middle dot separates INDEPENDENT facts. Without it the walk
50
+ // crossed from the build DATE into the count and reported `2026` as a repo count
51
+ // (found by this file's own detector tests, after the semver fix unmasked it).
52
+ const reSpaced = new RegExp(`(?<!\\d\\.)\\b(\\d{1,4})(?!\\+)\\b(?:(?![.|·\\n])[\\s\\S]){0,50}?\\b${REPO_WORD}\\b`, 'gi');
53
+ const reHyphen = new RegExp(`(?<!\\d\\.)\\b(\\d{1,4})(?!\\+)-repos?\\b`, 'gi');
54
+ for (const re of [reSpaced, reHyphen]) {
55
+ for (const m of src.matchAll(re)) {
56
+ const n = Number(m[1]);
57
+ if (n >= MIN_CORPUS_MAGNITUDE) found.push({ text: m[0], n });
58
+ }
59
+ }
60
+ return found;
61
+ }
62
+
@@ -0,0 +1,116 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * restore-local-ingests.mjs — replay every ingest a bundle apply silently removed.
4
+ *
5
+ * WHAT HAPPENED, overnight 2026-08-13 → 08-14. A scheduled bundle apply extracted an Aug-8 bundle
6
+ * over the live brain cache. The outcome for each piece of the previous evening's work was decided
7
+ * entirely by whether it had been committed:
8
+ *
9
+ * SURVIVED capability-cards.md — 5 hand-written cards, committed and pushed that night. The live
10
+ * brain was restored from the repo copy in one command.
11
+ * DIED helix, rvQR, RuCelium, wifi-veil — ingested and VERIFIED ROUTING hours earlier, gone
12
+ * without a trace. Their cards became orphans pointing at stores that no longer existed,
13
+ * which is worse than absence: the router offers an answer whose source is missing.
14
+ *
15
+ * The owner, correctly and for the second time: "you can't ever let work go overnight — you must
16
+ * always push latest changes or things get overwritten."
17
+ *
18
+ * A `.rvf` is a 32MB gitignored build artifact and CANNOT be committed. So the rule's second half
19
+ * applies: when the artifact cannot be pushed, commit the RECIPE. `ingest-repo.mjs` now records each
20
+ * ingest into `kb/local-ingests.json`, and this replays anything the cache is missing.
21
+ *
22
+ * The wipe was not on either adversarial audit's list, because both read code and this only shows
23
+ * up if you watch the machine overnight. A defect that needs a night to appear needs a ledger to be
24
+ * seen at all.
25
+ *
26
+ * MEASURED 2026-08-19, Dream Cycle memory-durability night: this script cannot tell "the store root
27
+ * was wiped" from "the store root never existed on this host" — a fresh checkout, a CI runner, or
28
+ * this very nightly agent's own ephemeral container all read exactly one recorded ingest away from
29
+ * every other, i.e. IDENTICALLY to a real overnight wipe, because `missingIngests()` only diffs the
30
+ * ledger against `storesAt(root)`, and `storesAt()` silently returns `[]` on ENOENT (kb/store-root.mjs).
31
+ * `restore-local-ingests.mjs` had zero test coverage before tonight, and the repo's own dream-cycle
32
+ * operating notes told this very automation to read ANY non-zero exit as "the nightly bundle wiped
33
+ * local work" — so a routine fresh-checkout run would have logged a false wipe every single night.
34
+ *
35
+ * The fix follows the pattern this repo already uses correctly in `nightly-watchdog.mjs`
36
+ * (MISSING / NEVER-RAN / STALE, three states never collapsed into one alarm) and the general
37
+ * pattern for reconciling a durable recipe against a rebuildable cache (npm's `.package-lock.json`
38
+ * witness co-located with `node_modules`, Dynamo-style tombstones): check whether the root was ever
39
+ * initialized BEFORE treating its contents as evidence of loss.
40
+ */
41
+ import fs from 'node:fs';
42
+ import path from 'node:path';
43
+ import { execFileSync } from 'node:child_process';
44
+ import { fileURLToPath } from 'node:url';
45
+ import { storeRoot, storesAt } from '../kb/store-root.mjs';
46
+
47
+ const ROOT = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
48
+ const LEDGER = path.join(ROOT, 'kb', 'local-ingests.json');
49
+
50
+ export const OK = 'OK';
51
+ export const NEVER_MATERIALIZED = 'NEVER-MATERIALIZED';
52
+ export const WIPED = 'WIPED';
53
+
54
+ /** Which recorded ingests are absent from the store root retrieval actually reads. */
55
+ export function missingIngests({ ledgerFile = LEDGER, root = storeRoot() } = {}) {
56
+ let recorded = [];
57
+ try { recorded = JSON.parse(fs.readFileSync(ledgerFile, 'utf8')).ingests ?? []; } catch { return []; }
58
+ const present = new Set(storesAt(root));
59
+ return recorded.filter((e) => !present.has(e.store ?? e.name.toLowerCase()));
60
+ }
61
+
62
+ /**
63
+ * Classify a diff between the recipe ledger and the live store root into three states, never one:
64
+ * OK every recorded ingest is present.
65
+ * NEVER-MATERIALIZED root does not exist on this host at all — nothing was ever ingested here.
66
+ * Not evidence of loss: a fresh checkout, CI runner, or this agent's own
67
+ * ephemeral container all look like this on a routine first run.
68
+ * WIPED root exists (so this host DID ingest before) but recorded entries are gone —
69
+ * the actual signal `restore-local-ingests.mjs` exists to catch.
70
+ */
71
+ export function classify({ ledgerFile = LEDGER, root = storeRoot() } = {}) {
72
+ const missing = missingIngests({ ledgerFile, root });
73
+ if (!missing.length) return { state: OK, missing };
74
+ if (!fs.existsSync(root)) return { state: NEVER_MATERIALIZED, missing };
75
+ return { state: WIPED, missing };
76
+ }
77
+
78
+ const isMain = (() => {
79
+ try { return process.argv[1] && fs.realpathSync(process.argv[1]) === fileURLToPath(import.meta.url); }
80
+ catch { return false; }
81
+ })();
82
+
83
+ if (isMain) {
84
+ const apply = process.argv.includes('--apply');
85
+ const { state, missing: gone } = classify();
86
+ if (state === OK) {
87
+ console.log(`[restore] every recorded local ingest is present in ${storeRoot()}`);
88
+ process.exit(0);
89
+ }
90
+ // NAMED, never counted. "4 stores missing" is the truncation that reads as completeness.
91
+ if (state === NEVER_MATERIALIZED) {
92
+ console.log(`\n[restore] ${storeRoot()} does not exist on this host — ${gone.length} recorded `
93
+ + `ingest(s) were never materialized here. This is NOT evidence of a wipe (fresh checkout, CI `
94
+ + `runner, or an ephemeral agent container all look like this):`);
95
+ } else {
96
+ console.log(`\n[restore] ${gone.length} recorded ingest(s) are NOT in ${storeRoot()}:`);
97
+ }
98
+ for (const e of gone) console.log(` ${e.name.padEnd(16)} ingested ${String(e.at).slice(0, 10)}`);
99
+ if (!apply) {
100
+ console.log('\n read-only. Re-run with --apply to re-ingest them.\n');
101
+ // NEVER-MATERIALIZED exits 2 (informational: nothing to restore FROM, just re-ingest) so a
102
+ // caller checking the exit code cannot mistake "never synced here" for "the nightly bundle
103
+ // wiped local work" — WIPED keeps exit 1, the real alarm.
104
+ process.exit(state === NEVER_MATERIALIZED ? 2 : 1);
105
+ }
106
+ let failed = 0;
107
+ for (const e of gone) {
108
+ console.log(`\n[restore] re-ingesting ${e.name}`);
109
+ try {
110
+ execFileSync(process.execPath, [path.join(ROOT, 'scripts', 'ingest-repo.mjs'), '--name', e.name],
111
+ { cwd: ROOT, stdio: 'inherit', env: process.env });
112
+ } catch { failed += 1; console.log(`[restore] ${e.name} FAILED — left recorded so the next run retries it`); }
113
+ }
114
+ console.log(`\n[restore] ${gone.length - failed}/${gone.length} restored.\n`);
115
+ process.exit(failed ? 1 : 0);
116
+ }
@@ -532,7 +532,15 @@ export async function runBattery({ home = os.homedir(), repo = null, cwd = os.tm
532
532
  export async function checkCoexistence({ home = os.homedir(), repo = null } = {}) {
533
533
  const reg = await loadRegistry();
534
534
  const registry = reg.buildRegistry({ repo: repo ?? reg.REPO, home, includeMachine: true });
535
- const ours = registry.records.filter((r) => r.layer === 'plugin' || r.layer === 'plugin-installed' || r.layer === 'marketplace-clone');
535
+ // 'codex' joined hook-registry.mjs's mesh 2026-08-20 (Dream Cycle cross-host-conformance) — it is
536
+ // OURS (plugin/hooks/codex-hooks.json, shipped in package.json's "files" tree same as plugin/),
537
+ // never a foreign registration. Omitting it here silently undercounted `ourCount` and dropped all
538
+ // 16 Codex registrations from both buckets — caught by an independent critique before this shipped.
539
+ // 'codex' joined hook-registry.mjs's mesh 2026-08-20 (Dream Cycle cross-host-conformance) — it is
540
+ // OURS (plugin/hooks/codex-hooks.json, shipped in package.json's "files" tree same as plugin/),
541
+ // never a foreign registration. Omitting it here silently undercounted `ourCount` and dropped all
542
+ // 16 Codex registrations from both buckets — caught by an independent critique before this shipped.
543
+ const ours = registry.records.filter((r) => r.layer === 'plugin' || r.layer === 'codex' || r.layer === 'plugin-installed' || r.layer === 'marketplace-clone');
536
544
  const foreign = registry.records.filter((r) => r.layer === 'user' || r.layer.startsWith('third-party:') || r.layer === 'project');
537
545
 
538
546
  // DOUBLE REGISTRATION — reused wholesale from hook-registry (ADR-055 M1): one handler, an
@@ -52,7 +52,12 @@ export function createStabilizationReceipt({ root, artifactPath, qe, audit }) {
52
52
  scoreClaimed: false,
53
53
  sha,
54
54
  tree: git('rev-parse', 'HEAD^{tree}'),
55
+ // NAME WHAT IS DIRTY, or the failure is unactionable. `release-qe` has failed on EVERY main run
56
+ // with a bare `INVALID_LINEAGE`, which says a clean tree is required and not one word about what
57
+ // made it unclean — so the release chain has been blocked with no way to see why from the log.
58
+ // The receipt now carries the offending paths; the seal is unchanged, only its evidence is.
55
59
  dirty: Boolean(git('status', '--porcelain')),
60
+ dirtyPaths: String(git('status', '--porcelain') || '').split('\n').filter(Boolean).slice(0, 40),
56
61
  version: manifest.version,
57
62
  tag: `v${manifest.version}`,
58
63
  sourceVersions: { package: manifest.version, claudePlugin: claude.version, codexPlugin: codex.version },
@@ -75,7 +80,12 @@ export function createStabilizationReceipt({ root, artifactPath, qe, audit }) {
75
80
  ],
76
81
  };
77
82
  const result = evaluateStabilizationCandidateReceipt(receipt);
78
- if (result.verdict !== 'PASS') throw new Error(`stabilization seal failed: ${result.failures.map(({ code }) => code).join(',')}`);
83
+ if (result.verdict !== 'PASS') {
84
+ const detail = receipt.dirty && receipt.dirtyPaths?.length
85
+ ? `\n working tree is dirty (${receipt.dirtyPaths.length} path(s)):\n ${receipt.dirtyPaths.join('\n ')}`
86
+ : '';
87
+ throw new Error(`stabilization seal failed: ${result.failures.map(({ code }) => code).join(',')}${detail}`);
88
+ }
79
89
  return receipt;
80
90
  }
81
91
 
Binary file
@@ -0,0 +1,117 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * sync-commands.mjs — four spellings of one command share ONE body, generated, never hand-copied.
4
+ *
5
+ * ISSUE #135. `/rvbc`, `/rvcb`, `/brain-console` and `/ruvnet-brain:configure` each declare, in their
6
+ * own words, that every spelling is equally valid and the user must never be corrected. Their BODIES
7
+ * had drifted into four independent hand-written specs, measured on 4.0.36:
8
+ *
9
+ * rvbc.md 4116 B rvcb.md 976 B brain-console.md 985 B configure.md 1680 B
10
+ *
11
+ * Four rules lived only in `rvbc.md`: speak first before any tool call; the "Things NOT to do before
12
+ * the page is open" list; the emphatic `run_in_background: true, ALWAYS` *with its reason*; and
13
+ * "already running is success, not an error." So which spelling a user happened to type changed how
14
+ * the assistant behaved — on the one command whose entire promise is that spelling does not matter.
15
+ *
16
+ * Two of those cost real silence. Without the "no knowledge search first" prohibition, grounding
17
+ * before opening the page is a legal move and a cold embedding-model load is allowed up to 180
18
+ * seconds. And `rvcb.md` kept the `run_in_background` RULE but dropped the REASON — that a foreground
19
+ * run hangs the call until the 120-second timeout — and a rule whose reason is missing is a rule that
20
+ * gets traded away.
21
+ *
22
+ * THE FIX IS NOT "COPY THE FOUR RULES ACROSS." That is what produced this: four copies of one fact,
23
+ * drifting the moment anyone edits one. It is ADR-065's rule applied to prompts instead of values —
24
+ * a fact that appears in more than one place gets exactly ONE producer, and every other occurrence is
25
+ * written from it. `rvbc.md` is the producer; the other three are generated.
26
+ *
27
+ * WHAT EACH ALIAS KEEPS: its own `description:`. That is not duplicated knowledge — it is what the
28
+ * user reads in the command picker, and each spelling legitimately introduces itself differently.
29
+ * Everything below the frontmatter is byte-identical by construction.
30
+ *
31
+ * node scripts/sync-commands.mjs # rewrite the aliases from the canonical body
32
+ * node scripts/sync-commands.mjs --check # fail if any alias has drifted (CI / pre-push)
33
+ */
34
+ import fs from 'node:fs';
35
+ import path from 'node:path';
36
+ import { fileURLToPath } from 'node:url';
37
+
38
+ const ROOT = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
39
+ const DIR = path.join(ROOT, 'plugin', 'commands');
40
+ const CANONICAL = 'rvbc.md';
41
+ const ALIASES = ['rvcb.md', 'brain-console.md', 'configure.md'];
42
+ const CHECK = process.argv.includes('--check');
43
+
44
+ /** Split `---\n…\n---\n` frontmatter from the body. Both are returned verbatim. */
45
+ export function splitFrontmatter(text) {
46
+ const m = /^---\n([\s\S]*?)\n---\n/.exec(text);
47
+ if (!m) return { frontmatter: null, body: text };
48
+ return { frontmatter: m[1], body: text.slice(m[0].length) };
49
+ }
50
+
51
+ /** The alias's own description line, kept; everything else in the frontmatter comes from canonical. */
52
+ function rebuild(aliasText, canonicalFrontmatter, canonicalBody) {
53
+ const { frontmatter } = splitFrontmatter(aliasText);
54
+ const desc = /^description:.*(?:\n[ \t]+.*)*$/m.exec(frontmatter || '');
55
+ const rest = canonicalFrontmatter
56
+ .split('\n')
57
+ .filter((l) => !/^description:/.test(l))
58
+ .join('\n');
59
+ const head = [desc ? desc[0] : null, rest].filter(Boolean).join('\n');
60
+ return `---\n${head}\n---\n${canonicalBody}`;
61
+ }
62
+
63
+ // RUN THE CLI ONLY WHEN INVOKED AS ONE. Without this guard, `import { splitFrontmatter }` from a test
64
+ // executed the whole generator — measured: importing this file printed "[commands] synced 0 alias(es)"
65
+ // inside the test runner, and had vitest's argv contained `--check` it would have called process.exit
66
+ // and taken the runner down with it. The identical defect was fixed in tests/unit/nightly-convergence
67
+ // one commit earlier (importing bin/install.mjs ran the installer main); it recurred here because that
68
+ // fix was applied to the CALLER instead of to the module. Fixing it at the module is what makes it
69
+ // stop recurring — same reasoning as ADR-066: one producer, not a rule every caller must remember.
70
+ // realpathSync THROWS on a path that does not resolve, and under vitest on Windows `process.argv[1]`
71
+ // is not a real file — measured in CI as `ENOENT: lstat 'D:\D:'`, which failed the whole SUITE at
72
+ // import time rather than any assertion. A guard that decides "am I the entrypoint" must never be
73
+ // able to crash the caller that merely imported this module: not-resolvable means not-main.
74
+ function resolvedIsMain() {
75
+ try {
76
+ return Boolean(process.argv[1])
77
+ && fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url));
78
+ } catch { return false; }
79
+ }
80
+ if (!resolvedIsMain()) { /* imported for its exports — do nothing else */ } else {
81
+
82
+ const canonicalText = fs.readFileSync(path.join(DIR, CANONICAL), 'utf8');
83
+ const { frontmatter: canonicalFrontmatter, body: canonicalBody } = splitFrontmatter(canonicalText);
84
+ if (!canonicalFrontmatter) {
85
+ process.stderr.write(`[commands] ${CANONICAL} has no frontmatter — refusing to generate from it\n`);
86
+ process.exit(2);
87
+ }
88
+
89
+ const drift = [];
90
+ let wrote = 0;
91
+ for (const alias of ALIASES) {
92
+ const file = path.join(DIR, alias);
93
+ if (!fs.existsSync(file)) { drift.push(`${alias} (missing)`); continue; }
94
+ const before = fs.readFileSync(file, 'utf8');
95
+ const after = rebuild(before, canonicalFrontmatter, canonicalBody);
96
+ if (after === before) continue;
97
+ if (CHECK) { drift.push(alias); continue; }
98
+ fs.writeFileSync(file, after);
99
+ process.stdout.write(`[commands] ${alias}: body regenerated from ${CANONICAL}\n`);
100
+ wrote += 1;
101
+ }
102
+
103
+ if (CHECK) {
104
+ if (drift.length) {
105
+ process.stderr.write(
106
+ `[commands] DRIFT: ${drift.length} alias(es) disagree with ${CANONICAL}. A user who types one\n`
107
+ + ' spelling would get different behaviour from one who types another, on the command\n'
108
+ + ' that promises spelling does not matter. Run: node scripts/sync-commands.mjs\n',
109
+ );
110
+ for (const d of drift) process.stderr.write(` ${d}\n`);
111
+ process.exit(1);
112
+ }
113
+ process.stdout.write(`[commands] all ${ALIASES.length} alias(es) share ${CANONICAL}'s body\n`);
114
+ process.exit(0);
115
+ }
116
+ process.stdout.write(`[commands] synced ${wrote} alias(es) to ${CANONICAL}\n`);
117
+ }