ruvnet-brain 4.0.7 → 4.0.12

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 (39) hide show
  1. package/README.md +1 -1
  2. package/bin/install.mjs +115 -44
  3. package/console/app.js +32 -11
  4. package/console/style.css +5 -0
  5. package/package.json +1 -1
  6. package/plugin/.claude-plugin/plugin.json +1 -1
  7. package/plugin/.codex-plugin/plugin.json +1 -1
  8. package/plugin/hooks/codex-hooks.json +17 -17
  9. package/plugin/scripts/codex-hook-wrapper.mjs +72 -1
  10. package/plugin/scripts/continuation-gate.mjs +41 -1
  11. package/plugin/scripts/learn-flush.mjs +37 -2
  12. package/plugin/scripts/project-identity.mjs +89 -0
  13. package/plugin/scripts/ruflo-bin.mjs +81 -0
  14. package/plugin/scripts/session-snapshot-hook.mjs +4 -1
  15. package/plugin/scripts/session-start-core.mjs +19 -2
  16. package/plugin/skills/release-proof/scripts/release-proof.mjs +12 -5
  17. package/scripts/candidate-host-evidence.mjs +49 -0
  18. package/scripts/ci/learning-replay-codex-adapter.mjs +13 -1
  19. package/scripts/console-runtime-identity.mjs +74 -0
  20. package/scripts/distill-project.mjs +9 -1
  21. package/scripts/doc-currency.mjs +15 -2
  22. package/scripts/health-repair.mjs +88 -11
  23. package/scripts/host-install-matrix.mjs +155 -0
  24. package/scripts/learning-replay-contract.mjs +14 -4
  25. package/scripts/learning-replay-execution.mjs +4 -3
  26. package/scripts/learning-replay-fixture.mjs +23 -2
  27. package/scripts/memory-doctor.mjs +26 -23
  28. package/scripts/model-router-catalog.mjs +34 -0
  29. package/scripts/onboarding-console.mjs +34 -12
  30. package/scripts/publication-receipt.mjs +135 -34
  31. package/scripts/release-evidence-aggregate.mjs +55 -0
  32. package/scripts/release-payload.mjs +100 -0
  33. package/scripts/release-transaction-provider.mjs +260 -55
  34. package/scripts/release-transaction.mjs +295 -105
  35. package/scripts/release.mjs +41 -12
  36. package/scripts/rvf-generation.mjs +17 -0
  37. package/scripts/selfcheck.mjs +20 -5
  38. package/scripts/staged-host-verifier.mjs +27 -57
  39. package/scripts/sync-version.mjs +10 -10
@@ -160,7 +160,47 @@ const nowMs = Date.now();
160
160
  // force. This closes the empty-stdin hole that LOOP-SAFETY 1's `__source` check does not cover.
161
161
  if (!hookInput.session_id) process.exit(EXIT_ALLOW);
162
162
 
163
- const open = led.items.filter((i) => !i.done);
163
+ /**
164
+ * ARTIFACT-DERIVED OPEN WORK — the half that cannot be forgotten.
165
+ *
166
+ * WHY THIS EXISTS, measured rather than supposed. On 2026-08-04 the owner asked why the model had
167
+ * gone back to stopping early, and the ledger answered: 25 items, ZERO open, last written 2026-07-25.
168
+ * The gate had been structurally silent for ten days. Not broken — starved.
169
+ *
170
+ * The cause is the design, not the drift. Until now the ONLY source of "is work outstanding" was
171
+ * `--commit-to`, i.e. the model noticing its own commitment and recording it. So the guard against
172
+ * the model stopping early depended on the model remembering to arm it, and the failure mode is
173
+ * silent in exactly the sessions where it matters most. That is this project's oldest rule broken
174
+ * inside the mechanism meant to enforce it: status must be DERIVED FROM A VERIFIABLE ARTIFACT,
175
+ * never asserted.
176
+ *
177
+ * So the gate now also reads work that exists whether or not anyone remembered to write it down.
178
+ * `open-issues.json` is produced by the issue-watch pipeline against the real repo; an issue past
179
+ * its response SLA is outstanding work by definition, and no amount of forgetting can erase it.
180
+ *
181
+ * Deliberately narrow: ONLY SLA breaches, never the full backlog — a permanently non-empty backlog
182
+ * would make this fire forever, which is nagging, not enforcement. And only a FRESH observation
183
+ * (<6h, the same window session-start-core uses), because a stale file is not evidence of anything.
184
+ */
185
+ function artifactOpenWork() {
186
+ try {
187
+ const file = process.env.RUVNET_OPEN_ISSUES_FILE
188
+ || path.join(HOME, '.cache', 'ruvnet-brain', 'open-issues.json');
189
+ const status = JSON.parse(fs.readFileSync(file, 'utf8'));
190
+ const observedAt = Date.parse(status?.at || '');
191
+ if (!Number.isFinite(observedAt) || nowMs - observedAt > 6 * 3600_000) return [];
192
+ return (Array.isArray(status.issues) ? status.issues : [])
193
+ .filter((issue) => issue?.breach)
194
+ .map((issue) => ({
195
+ text: `issue #${issue.number} on ${status.repo} is ${issue.ageHours}h past its response SLA — ${String(issue.title || '').slice(0, 80)}`,
196
+ done: false,
197
+ at: new Date(observedAt).toISOString(),
198
+ derived: true,
199
+ }));
200
+ } catch { return []; }
201
+ }
202
+
203
+ const open = [...led.items.filter((i) => !i.done), ...artifactOpenWork()];
164
204
  if (!open.length) process.exit(EXIT_ALLOW); // nothing outstanding: silence is correct
165
205
 
166
206
  /**
@@ -14,6 +14,13 @@ import path from 'node:path';
14
14
  import { execFileSync } from 'node:child_process';
15
15
  import { readStdinBounded } from './hook-input.mjs';
16
16
  import { loadRuntimePreferences } from './runtime-preferences.mjs';
17
+ import { resolveRuflo, RUFLO_MISSING } from './ruflo-bin.mjs';
18
+
19
+ // ONE BOUNDED LINE ON STDERR. stderr because a SessionEnd hook's stdout is not surfaced, and bounded
20
+ // because a hook that prints a stack trace on every `/clear` gets muted — and a muted diagnostic is
21
+ // no diagnostic at all (the same lesson as the session-start line that reported its own defect every
22
+ // session for eight days and went unread).
23
+ const warn = (msg) => { try { process.stderr.write(`learn-flush: ${msg}\n`); } catch { /* stderr gone */ } };
17
24
 
18
25
  const HOME = os.homedir();
19
26
  const PROJECT = process.env.RUVNET_BRAIN_PROJECT_DIR || process.cwd();
@@ -48,7 +55,11 @@ const QUEUE_ROOT = LEARNING_SCOPE === 'user'
48
55
  ? path.join(HOME, '.cache', 'ruvnet-brain', 'learn')
49
56
  : path.join(PROJECT, '.swarm', 'ruvnet-brain-learn');
50
57
  const QUEUE = process.env.LEARN_QUEUE || path.join(QUEUE_ROOT, `session-${SID}.jsonl`);
51
- const RUFLO = path.join(HOME, '.npm-global/bin/ruflo');
58
+ // Issue #105: this was a hardcoded `path.join(HOME, '.npm-global/bin/ruflo')` — the owner's npm
59
+ // prefix. On any other prefix (Homebrew, nvm, Volta, plain `npm -g`) the path simply did not exist,
60
+ // every feed below threw ENOENT, and every throw landed in a `catch {}` that said nothing. One
61
+ // resolver, shared with distill-project.mjs and health-repair.mjs's original — see ruflo-bin.mjs.
62
+ const RUFLO = resolveRuflo();
52
63
  const RUFLO_ENV = { ...process.env, RUFLO_DAEMON_AUTOSTART: '0' };
53
64
  const MAX_ACTIONS = 8; // bound the work so SessionEnd stays fast
54
65
 
@@ -98,7 +109,16 @@ for (const line of lines) {
98
109
  const actions = allDistinct.slice(0, MAX_ACTIONS);
99
110
  const deferred = allDistinct.slice(MAX_ACTIONS);
100
111
 
112
+ // ruflo is genuinely not on this machine. SAY SO — once — and keep the queue. Exiting 0 keeps the
113
+ // best-effort contract (an absent optional learner must never break SessionEnd); saying nothing at
114
+ // all is what turned #105 into eight invisible ENOENTs and a queue that never drained.
115
+ if (!RUFLO && actions.length) {
116
+ warn(`0/${actions.length} fed — ${RUFLO_MISSING}. The queue is KEPT for retry.`);
117
+ process.exit(0);
118
+ }
119
+
101
120
  let fed = 0;
121
+ const failures = []; // WHY each feed failed — the thing `catch {}` used to destroy
102
122
  let stoppedAt = actions.length; // how far the feed actually got before the deadline
103
123
  for (let i = 0; i < actions.length; i++) {
104
124
  const remaining = DEADLINE - Date.now();
@@ -120,7 +140,22 @@ for (let i = 0; i < actions.length; i++) {
120
140
  timeout: Math.min(6000, remaining),
121
141
  });
122
142
  fed++;
123
- } catch { /* best-effort — one slow/failed record must not stall session end */ }
143
+ } catch (e) {
144
+ // BEST-EFFORT, NOT SILENT. The old `catch { /* best-effort */ }` swallowed the reason, so a
145
+ // machine where every call failed looked exactly like one where every call worked: exit 0,
146
+ // no output, and the only trace a queue that never shrank. "Reports success while doing
147
+ // nothing" is the defect class this project treats as the worst thing it can ship. Keep going
148
+ // (one bad record must not stall session end), but keep the reason.
149
+ failures.push(String(e?.message || e).split('\n')[0].slice(0, 120));
150
+ }
151
+ }
152
+ // SURFACE IT. Distinct reasons only, at most two: eight copies of the same ENOENT is noise, and the
153
+ // second distinct reason is usually where the real information is.
154
+ if (failures.length) {
155
+ const distinct = [...new Set(failures)];
156
+ warn(`${failures.length}/${actions.length} feed call(s) FAILED via ${RUFLO}`
157
+ + ` — ${distinct.slice(0, 2).join(' | ')}${distinct.length > 2 ? ` (+${distinct.length - 2} more kind(s))` : ''}`
158
+ + (fed === 0 ? '. Nothing was learned; the queue is KEPT for retry.' : `. ${fed} succeeded.`));
124
159
  }
125
160
  // Whatever the deadline cut off is WORK, not waste: it goes back on the front of the queue so the
126
161
  // next flush continues from there. Dropping it would turn a time limit into the same silent data
@@ -0,0 +1,89 @@
1
+ // project-identity.mjs — ONE answer to "which directory is this, and have I seen it already?"
2
+ //
3
+ // WHY THIS EXISTS. Two user-filed bugs, #85 and #107, are the same defect: the product derives a
4
+ // project's location independently at each site and the sites then disagree.
5
+ //
6
+ // #85 The PreCompact producer wrote its receipt under `CLAUDE_PROJECT_DIR`; the Console probe
7
+ // that reports whether a receipt exists looked under `process.cwd()`. Launch the Console
8
+ // from a subdirectory of the project and it warns "no supported PreCompact snapshot found"
9
+ // about a snapshot it had itself just written. Nothing was broken except the agreement.
10
+ //
11
+ // #107 `candidateRoots()` returned BOTH `~/Code` and `~/code`, which on APFS are one directory
12
+ // with one inode. Every project under them was scanned, counted and summed twice, so the
13
+ // machine-wide memory total read exactly 2x — silently, confidently, in the one figure the
14
+ // Console exists to make trustworthy. `path.resolve()` was the guard, and it normalises
15
+ // `.`/`..` only: it case-folds nothing and resolves no symlinks, which the guard's own
16
+ // comment ("e.g. a symlink") shows it was believed to do.
17
+ //
18
+ // WHY DEVICE+INODE RATHER THAN LOWER-CASING. Lower-casing is a guess about the filesystem, and it
19
+ // is wrong on the machines that would suffer most: Linux, and case-sensitive APFS volumes, where
20
+ // `Code` and `code` really are two projects and folding them would DELETE one from the report.
21
+ // `st_dev` + `st_ino` is not a guess — it is the filesystem's own answer, correct in both
22
+ // directions on every volume, and it settles symlinks and bind mounts in the same stroke. The
23
+ // case-insensitivity probe the reporter suggested (write a temp file, stat the other spelling)
24
+ // would also work, but it writes to the user's disk to learn something `stat` already knows.
25
+ //
26
+ // `fs.realpathSync.native` is the OS canonicaliser: it resolves symlinks AND returns the case as
27
+ // stored on disk. Plain `fs.realpathSync` is a JavaScript reimplementation that does symlinks only
28
+ // — measured on this repo's own machine, `realpathSync('~/code')` stays `~/code` while
29
+ // `realpathSync.native('~/code')` returns `~/Code`. That one missing word is the whole of #107.
30
+ import fs from 'node:fs';
31
+ import path from 'node:path';
32
+
33
+ const defaultRealpath = (value) => fs.realpathSync.native(value);
34
+ const defaultStat = (value) => fs.statSync(value, { bigint: true });
35
+
36
+ /** The operating system's own spelling of an existing path, or null when it cannot be resolved. */
37
+ export function canonicalPath(value, { realpath = defaultRealpath } = {}) {
38
+ if (typeof value !== 'string' || !value) return null;
39
+ try { return realpath(value); } catch { return null; }
40
+ }
41
+
42
+ /**
43
+ * A key that is equal for two names of one directory and different for two directories.
44
+ * Falls back to the canonical path when the volume reports no usable inode (some Windows and
45
+ * network mounts report 0) — never worse than the raw string compare it replaces.
46
+ */
47
+ export function pathIdentity(value, { realpath = defaultRealpath, stat = defaultStat } = {}) {
48
+ const canonical = canonicalPath(value, { realpath });
49
+ if (!canonical) return null;
50
+ try {
51
+ const info = stat(canonical);
52
+ if (info?.ino) return `${info.dev}:${info.ino}`;
53
+ } catch { /* unreadable — the canonical spelling is still a better key than the raw one */ }
54
+ return canonical;
55
+ }
56
+
57
+ /** True when both names denote the same existing directory or file. */
58
+ export function sameLocation(a, b, options = {}) {
59
+ const left = pathIdentity(a, options);
60
+ return left !== null && left === pathIdentity(b, options);
61
+ }
62
+
63
+ /** True when `child` is `root` or lies beneath it, compared canonically rather than by raw string. */
64
+ export function contains(root, child, options = {}) {
65
+ const canonicalRoot = canonicalPath(root, options);
66
+ const canonicalChild = canonicalPath(child, options);
67
+ if (!canonicalRoot || !canonicalChild) return false;
68
+ if (sameLocation(canonicalRoot, canonicalChild, options)) return true;
69
+ const relative = path.relative(canonicalRoot, canonicalChild);
70
+ return Boolean(relative) && !relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative);
71
+ }
72
+
73
+ /**
74
+ * THE project directory. The PreCompact snapshot producer and the Console probe that detects the
75
+ * snapshot both call this, so they cannot disagree about where the project is (#85).
76
+ *
77
+ * `CLAUDE_PROJECT_DIR` wins only when the current directory actually lies inside it. Containment,
78
+ * not mere presence, is what makes the two sides agree by construction: a hook and a Console
79
+ * launched anywhere within one project resolve to that project's root, while a caller that hands
80
+ * over an unrelated directory (a test fixture, an explicit `--project`) is never overruled by an
81
+ * environment variable it knows nothing about.
82
+ */
83
+ export function projectDirectory({ env = process.env, cwd = process.cwd(), ...options } = {}) {
84
+ const here = canonicalPath(cwd, options) || path.resolve(cwd);
85
+ const declared = typeof env.CLAUDE_PROJECT_DIR === 'string' && env.CLAUDE_PROJECT_DIR.trim()
86
+ ? canonicalPath(env.CLAUDE_PROJECT_DIR, options)
87
+ : null;
88
+ return declared && contains(declared, here, options) ? declared : here;
89
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * ruflo-bin.mjs — the ONE place this repo decides WHERE the global `ruflo` binary is.
3
+ *
4
+ * WHY THIS FILE EXISTS. Issues #99 and #105 are one defect filed twice: `scripts/distill-project.mjs`
5
+ * and `plugin/scripts/learn-flush.mjs` each hardcoded `~/.npm-global/bin/ruflo` — the owner's npm
6
+ * prefix, nobody else's. On a Homebrew, nvm, Volta, or plain `npm -g` install that path does not
7
+ * exist, so #99 died with "ruflo is not at ~/.npm-global/bin/ruflo" and #105 failed every feed
8
+ * silently — both on machines where ruflo was installed and sitting on PATH the whole time.
9
+ *
10
+ * `health-repair.mjs` had already fixed exactly this, and its header says why: "Telling someone their
11
+ * tool is missing when it is on their PATH is the product lying, and it is unfalsifiable from their
12
+ * side: they cannot see why we looked in one place." That fix lived as a private function inside one
13
+ * file, so its two siblings kept the bug — the identical shape ADR-021 was written about ("One class,
14
+ * fixed in one file, regenerated in the sibling"). So the resolver moves here instead of being pasted
15
+ * a third time.
16
+ *
17
+ * MECHANISM — the hardened form, not the first draft. Two resolvers already existed in this repo:
18
+ * • health-repair.mjs (2026-07-21) preferred path, then `sh -lc 'command -v ruflo'`
19
+ * • capability-registry.mjs (2026-07-22) preferred path, then a plain PATH walk, NO shell
20
+ * The second IS the first one, hardened, and it says so in place: `-l` sources the user's entire
21
+ * profile — every export, shim, and one-off line anyone has ever pasted into .profile — as the price
22
+ * of answering "where is ruflo?". Resolving a name against directories is all `command -v` was ever
23
+ * wanted for here, and it needs no shell at all. This module takes that version. Adopting the older
24
+ * mechanism would mean re-introducing, in a shared module, a bug the repo had already fixed one file
25
+ * over.
26
+ *
27
+ * ORDER, and each step earns its place:
28
+ * 1. RUFLO_BIN, if set, is AUTHORITATIVE — returned as given, whether or not it exists. An explicit
29
+ * override that quietly falls back to some other ruflo is not an override, and the caller's
30
+ * "not at <path>" message has to name the path the user actually asked for.
31
+ * 2. ~/.npm-global/bin/ruflo — Rule 21's ONE global binary, checked first so a machine that has it
32
+ * never depends on PATH ordering.
33
+ * 3. A PATH walk — the entire point of #99/#105: an installed ruflo living anywhere else.
34
+ * 4. null — "I could not find it", said plainly. Never guess a path and then blame the user for it.
35
+ *
36
+ * Rule 21 is untouched by this: still ONE ruflo, still the global one, never `npx ruflo@latest`. This
37
+ * resolves WHERE that one global binary is rather than assuming everyone's prefix matches the owner's.
38
+ */
39
+ import fs from 'node:fs';
40
+ import os from 'node:os';
41
+ import path from 'node:path';
42
+
43
+ /**
44
+ * Locate the global ruflo. LOCATES, NEVER EXECUTES — no shell, no daemon, no side effects.
45
+ *
46
+ * @param {{ env?: Record<string, string|undefined>, home?: string }} [opts]
47
+ * @returns {string|null} path to ruflo, or null when it is genuinely not on this machine.
48
+ */
49
+ export function resolveRuflo({ env = process.env, home = os.homedir() } = {}) {
50
+ if (env.RUFLO_BIN) return env.RUFLO_BIN;
51
+
52
+ // On Windows a global npm ruflo is `ruflo.cmd`; the extensionless sibling is a POSIX shell wrapper
53
+ // that Node cannot exec (and refuses to spawn without a shell since CVE-2024-27980).
54
+ const exts = process.platform === 'win32' ? ['.cmd', '.exe', ''] : [''];
55
+
56
+ // The preferred path needs that SAME rule. It first checked a bare `ruflo` only, so on Windows it
57
+ // either missed the real `ruflo.cmd` entirely or returned the POSIX wrapper the comment above
58
+ // says is unrunnable — and the caller then reported "ruflo is not at ~/.npm-global/bin/ruflo" to
59
+ // someone who had ruflo installed. That is the exact complaint #99 and #105 were filed about,
60
+ // reintroduced one platform over.
61
+ const preferredDir = path.join(home, '.npm-global', 'bin');
62
+ for (const ext of exts) {
63
+ const cand = path.join(preferredDir, `ruflo${ext}`);
64
+ try { if (fs.existsSync(cand) && fs.statSync(cand).isFile()) return cand; } catch { /* unreadable */ }
65
+ }
66
+ for (const dir of String(env.PATH || '').split(path.delimiter)) {
67
+ if (!dir) continue;
68
+ for (const ext of exts) {
69
+ const cand = path.join(dir, `ruflo${ext}`);
70
+ try { if (fs.existsSync(cand) && fs.statSync(cand).isFile()) return cand; } catch { /* unreadable PATH entry */ }
71
+ }
72
+ }
73
+ return null;
74
+ }
75
+
76
+ /**
77
+ * What to say when resolveRuflo() returns null. ONE wording, so every caller reports the same thing
78
+ * and names both places it looked — the diagnostic #99 and #105 never gave anyone.
79
+ */
80
+ export const RUFLO_MISSING = 'ruflo was not found in ~/.npm-global/bin or anywhere on your PATH'
81
+ + ' — install it with `npm i -g ruflo@latest`, or set RUFLO_BIN to its full path';
@@ -1,6 +1,7 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { createSessionSnapshot } from './session-snapshot-contract.mjs';
4
+ import { projectDirectory } from './project-identity.mjs';
4
5
 
5
6
  function regularOrAbsent(file) {
6
7
  try {
@@ -30,5 +31,7 @@ export function writeSessionSnapshot(projectDir, event) {
30
31
  }
31
32
 
32
33
  if (process.argv[1] && path.resolve(process.argv[1]).endsWith('session-snapshot-hook.mjs')) {
33
- writeSessionSnapshot(process.env.CLAUDE_PROJECT_DIR || process.cwd(), process.argv[2] || 'SessionEnd');
34
+ // projectDirectory() is the SAME derivation the Console's detector uses. Deriving it here
35
+ // independently is what let this hook write a receipt the Console then reported as missing (#85).
36
+ writeSessionSnapshot(projectDirectory(), process.argv[2] || 'SessionEnd');
34
37
  }
@@ -472,11 +472,28 @@ export async function runSessionStart({
472
472
  emit(`[RuvNet Brain v${bannerVersion} — active this session, RETRIEVAL DOWN]`);
473
473
  emit(`The plugin and its hooks are running, but the brain itself is broken (see the alarm above). Do not claim grounding works. If you mention it at all: "🧠 RuvNet Brain active (v${bannerVersion}) — but its search is down right now."`);
474
474
  } else {
475
- emit(`[RuvNet Brain v${bannerVersion} — active this session${updated ? ` · updated ${updated}` : ''}${kbVersion ? ` · knowledge bundle ${kbVersion}` : ''}]`);
475
+ // ONE VERSION, CUSTOMER-FACING (issue #77, restated from the customer's side).
476
+ //
477
+ // This printed the plugin version and the knowledge bundle's tag side by side —
478
+ // "RuvNet Brain active (v4.0.8, brain v4.0.7)". Those are two internal artefacts of ONE
479
+ // product. Showing both makes every user adjudicate whether their install is out of sync,
480
+ // a question they cannot answer and should never have been asked. Keeping the two in
481
+ // lockstep is this project's job; printing the seam is an admission leaking into the UI.
482
+ //
483
+ // The divergence is NOT hidden — when the tags differ it goes to the maintainer on the
484
+ // same private entitlement that gates open-issue alerts, because a bundle behind its
485
+ // plugin is a RELEASE defect to fix, not a banner to annotate.
486
+ emit(`[RuvNet Brain v${bannerVersion} — active this session${updated ? ` · updated ${updated}` : ''}]`);
487
+ const bundleTag = String(kbVersion).replace(/^v/, '');
488
+ if (bundleTag && bannerVersion !== 'unknown' && bundleTag !== bannerVersion
489
+ && maintainerIssueEntitlement(env, home, 'stuinfla/ruvnet-brain')) {
490
+ emit('[RuvNet Brain — MAINTAINER ONLY: the shipped generation is split. Do NOT surface this to the user.]');
491
+ emit(`Plugin is ${bannerVersion}; the knowledge bundle on this machine is ${kbVersion}. Per issue #77 these ship as ONE generation, so a split means a release published the plugin without its matching bundle asset. The user is correctly shown a single version (${bannerVersion}) — fix the release, never annotate the banner.`);
492
+ }
476
493
  let confidenceInstruction;
477
494
  if (readiness.state === 'ready') {
478
495
  emit('USER-LEVEL: one brain (~/.cache/ruvnet-brain/kb) shared by every project and window here — nothing to reinstall per project. search_ruvnet is ready and live; the grounding hooks are active.');
479
- confidenceInstruction = `Open your FIRST response with ONE short, warm confirmation in your own words (2-3 lines, then move on; never repeat it this session). It must say "🧠 RuvNet Brain active (v${bannerVersion}${kbVersion ? `, brain ${kbVersion}` : ''})" — that version, in parentheses, always — and convey: it grounds rUv's stack (RVF, Ruflo, AgentDB, SPARC, agentic-flow…) in his real source rather than guessing; npx github:stuinfla/ruvnet-brain --doctor checks it; ${consoleInvoke} opens a visual settings page.`;
496
+ confidenceInstruction = `Open your FIRST response with ONE short, warm confirmation in your own words (2-3 lines, then move on; never repeat it this session). It must say "🧠 RuvNet Brain active (v${bannerVersion})" — ONE version, in parentheses, always; never a second number, never a bundle tag beside it — and convey: it grounds rUv's stack (RVF, Ruflo, AgentDB, SPARC, agentic-flow…) in his real source rather than guessing; npx github:stuinfla/ruvnet-brain --doctor checks it; ${consoleInvoke} opens a visual settings page.`;
480
497
  } else if (readiness.state === 'degraded') {
481
498
  const receipt = readiness.receipt || {};
482
499
  emit(`USER-LEVEL: one brain (~/.cache/ruvnet-brain/kb) shared by every project and window here — nothing to reinstall per project. search_ruvnet is registered but degraded (${receipt.phase || 'startup'}: ${receipt.error || 'readiness failed'}); the grounding hooks remain active.`);
@@ -145,8 +145,11 @@ export function evaluatePublicationReceipt(candidate, publication) {
145
145
  const failures = [...candidateResult.failures];
146
146
  const sha = candidateResult.sha;
147
147
  const digest = candidateResult.artifactSha256;
148
- if (publication?.schemaVersion !== 1 || publication?.phase !== 'publication') failures.push(fail('INVALID_PUBLICATION_RECEIPT', 'schemaVersion=1 and phase=publication are required'));
148
+ if (publication?.schemaVersion !== 2 || publication?.phase !== 'publication') failures.push(fail('INVALID_PUBLICATION_RECEIPT', 'schemaVersion=2 and phase=publication are required'));
149
149
  if (publication?.sha !== sha || publication?.artifactSha256 !== digest) failures.push(fail('PUBLICATION_SEAL_MISMATCH', 'publication does not reference candidate seal'));
150
+ if (!cleanHex(publication?.payloadId, 64) || !cleanHex(publication?.bundleArtifactSha256, 64)) {
151
+ failures.push(fail('PUBLIC_PAYLOAD_BINDING_MISSING', 'signed payload and public bundle digests are required'));
152
+ }
150
153
  const version = versionField(candidate?.version);
151
154
  const tag = version ? `v${version}` : null;
152
155
  const identityMismatches = versionIdentityFailures(version, candidate?.tag, [
@@ -155,8 +158,9 @@ export function evaluatePublicationReceipt(candidate, publication) {
155
158
  ['GitHub release tag', publication?.githubRelease?.tag, tag],
156
159
  ['bundle brainVersion', publication?.bundle?.brainVersion],
157
160
  ['bundle releaseTag', publication?.bundle?.releaseTag, tag],
158
- ['installed Claude host', publication?.installed?.claude?.version],
159
- ['installed Codex host', publication?.installed?.codex?.version],
161
+ ['Claude-only host', publication?.installed?.claudeOnly?.version],
162
+ ['Codex-only host', publication?.installed?.codexOnly?.version],
163
+ ['dual host', publication?.installed?.dual?.version],
160
164
  ]);
161
165
  if (identityMismatches.length > 0) {
162
166
  failures.push(fail('PUBLIC_VERSION_IDENTITY_MISMATCH', `public surfaces must identify candidate ${version ?? 'UNKNOWN'}: ${identityMismatches.join(', ')}`));
@@ -165,9 +169,12 @@ export function evaluatePublicationReceipt(candidate, publication) {
165
169
  const item = publication?.[surface];
166
170
  if (item?.sha !== sha || item?.artifactSha256 !== digest) failures.push(fail('PUBLIC_ARTIFACT_MISMATCH', `${surface} differs from candidate seal`));
167
171
  }
168
- for (const hostName of ['claude', 'codex']) {
172
+ for (const hostName of ['claudeOnly', 'codexOnly', 'dual']) {
169
173
  const host = publication?.installed?.[hostName];
170
- if (host?.status !== 'PASS' || host?.artifactSha256 !== digest) failures.push(fail('PUBLIC_HOST_NOT_PASS', `${hostName} is not running sealed public artifact`));
174
+ if (host?.status !== 'PASS' || host?.doctorExit !== 0 || host?.artifactSha256 !== digest
175
+ || host?.functionalSearch !== true || !(host?.searchMs <= Number(publication?.brain?.deadlineMs) * 0.8)) {
176
+ failures.push(fail('PUBLIC_HOST_NOT_PASS', `${hostName} is not running a doctor-clean functional sealed public artifact`));
177
+ }
171
178
  }
172
179
  const brain = publication?.brain || {};
173
180
  if (brain.status !== 'PASS' || brain.selfStore !== true || !(brain.broadMs <= Number(brain.deadlineMs) * 0.8)) failures.push(fail('PUBLIC_BRAIN_NOT_PASS', 'public installed Brain acceptance failed'));
@@ -0,0 +1,49 @@
1
+ #!/usr/bin/env node
2
+ import crypto from 'node:crypto';
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import { stagedHostVerifier } from './staged-host-verifier.mjs';
6
+ import { payloadIdFor } from './release-payload.mjs';
7
+
8
+ const arg = (name) => {
9
+ const index = process.argv.indexOf(name);
10
+ return index >= 0 ? process.argv[index + 1] : null;
11
+ };
12
+ const sha256 = (file) => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
13
+
14
+ const manifestPath = path.resolve(arg('--manifest'));
15
+ const packagePath = path.resolve(arg('--package'));
16
+ const bundlePath = path.resolve(arg('--bundle'));
17
+ const out = path.resolve(arg('--out'));
18
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
19
+ const payloadId = payloadIdFor(manifest);
20
+ const identity = { version: manifest.version, candidateSha: manifest.candidateSha, payloadId };
21
+ const result = await stagedHostVerifier({ assets: { packagePath, bundlePath }, identity })
22
+ .verify({ source: 'candidate', assets: { packagePath, bundlePath } });
23
+ if (result.verdict !== 'PASS') throw new Error(`candidate host matrix failed: ${result.error || 'unknown'}`);
24
+
25
+ const modeNames = { claude: 'claude-only', codex: 'codex-only', dual: 'dual-host' };
26
+ const leaves = Object.entries(modeNames).map(([mode, name]) => {
27
+ const fixture = result.fixtures?.[mode];
28
+ if (fixture?.status !== 'PASS' || fixture?.doctorExit !== 0) throw new Error(`${name} did not produce a clean doctor receipt`);
29
+ return {
30
+ name,
31
+ sha: manifest.candidateSha,
32
+ payloadId,
33
+ status: fixture.status === 'PASS' ? 'completed' : 'failed',
34
+ conclusion: 'success',
35
+ verdict: 'PASS',
36
+ source: 'candidate-host-evidence',
37
+ mode,
38
+ doctorExit: fixture.doctorExit,
39
+ artifactSha256: sha256(packagePath),
40
+ };
41
+ });
42
+ fs.writeFileSync(out, `${JSON.stringify({
43
+ schemaVersion: 1,
44
+ sha: manifest.candidateSha,
45
+ payloadId,
46
+ artifactSha256: sha256(packagePath),
47
+ leaves,
48
+ }, null, 2)}\n`, { flag: 'wx', mode: 0o600 });
49
+ console.log(JSON.stringify({ verdict: 'PASS', payloadId, leaves: leaves.map(({ name }) => name) }));
@@ -23,8 +23,20 @@ const probe = process.env.RUVNET_REPLAY_LESSON_PROBE || '';
23
23
  if (event === 'PreToolUse') {
24
24
  const recorder = process.env.RUVNET_REPLAY_RECORDER || '';
25
25
  if (!recorder) process.exit(2);
26
+ // Codex emits exec_command with tool_input.cmd; the shared Claude/Ruflo boundary consumes
27
+ // Bash with tool_input.command. Normalize the real host envelope before the recorder parses it.
28
+ const recorderInput = Buffer.from(JSON.stringify({
29
+ ...input,
30
+ tool_name: /^(?:functions[._]{1,2})?exec_command$/.test(String(input.tool_name || ''))
31
+ ? 'Bash'
32
+ : input.tool_name,
33
+ tool_input: {
34
+ ...(input.tool_input || {}),
35
+ command: input.tool_input?.command ?? input.tool_input?.cmd ?? input.command ?? '',
36
+ },
37
+ }));
26
38
  const result = spawnSync(process.execPath, [recorder, attemptsFile, sequenceFile], {
27
- input: raw,
39
+ input: recorderInput,
28
40
  encoding: 'utf8',
29
41
  env: process.env,
30
42
  });
@@ -0,0 +1,74 @@
1
+ // ONE definition of "what the persistent Console runtime is" and "which generation is running".
2
+ //
3
+ // #79 reported a Console that survived an update: the browser was served the new frontend from disk
4
+ // while the Node process kept its pre-update router in memory, and every relaunch said "already
5
+ // running". The reuse check compared a `sourceSha256` computed from a SINGLE file —
6
+ // scripts/onboarding-console.mjs — while the runtime the server actually executes is this whole
7
+ // surface: ~230 modules it imports plus the frontend it serves. Two genuinely different candidates
8
+ // whose entrypoint bytes happened to match produced byte-identical identities, so an update that
9
+ // changed console-engine.mjs, capability-registry.mjs or console/app.js was invisible to the
10
+ // launcher. The identity has to derive from something an update cannot leave behind, which is the
11
+ // installed bytes themselves — all of them.
12
+ //
13
+ // #76 reported the same disease at the asset boundary: the runtime copied plugin/scripts without
14
+ // the sibling docs/ and manifest that plugin/scripts/whats-new.mjs reads, so the executable
15
+ // travelled and its assets did not. The copy list and the identity list were two separate
16
+ // enumerations of one payload; they are one list now, so an asset added to the runtime is part of
17
+ // its generation by construction.
18
+ //
19
+ // The installer hashes the staged tree with this function and the running server hashes its own
20
+ // root with this function, over this list. Neither side can define the fact differently.
21
+
22
+ import fs from 'node:fs';
23
+ import path from 'node:path';
24
+ import crypto from 'node:crypto';
25
+
26
+ /**
27
+ * Every path the persistent Console runtime carries, relative to both the candidate source root and
28
+ * the installed runtime root (they are laid out identically). This is simultaneously the installer's
29
+ * copy list and the generation's digest input — one enumeration, so they cannot drift.
30
+ */
31
+ export const CONSOLE_RUNTIME_SURFACE = Object.freeze([
32
+ 'console',
33
+ 'scripts',
34
+ 'plugin/scripts',
35
+ // whats-new.mjs resolves its version manifest and curated notes relative to its own plugin root
36
+ // (#76). Shipping the executable without them is how the installed skill lost its release notes.
37
+ 'plugin/docs',
38
+ 'plugin/.claude-plugin',
39
+ 'data/model-catalog.json',
40
+ 'kb/brain-profile.mjs',
41
+ 'bin/install.mjs',
42
+ 'package.json',
43
+ ]);
44
+
45
+ /** The runtime's own identity file, written by the installer INTO the tree — never part of its own digest. */
46
+ export const CONSOLE_RUNTIME_IDENTITY_FILE = 'runtime-identity.json';
47
+
48
+ /**
49
+ * Deterministic content digest of the runtime surface under `root`.
50
+ *
51
+ * A path that is absent is hashed as absent rather than skipped: a runtime that lost a file is a
52
+ * different generation, not the same one, and a launcher must be able to see that.
53
+ *
54
+ * @param {string} root runtime root (an installed `.console-runtime`, a marketplace clone, or a checkout)
55
+ * @returns {string} hex sha256
56
+ */
57
+ export function consoleRuntimeDigest(root) {
58
+ const hash = crypto.createHash('sha256');
59
+ const visit = (absolute, relative) => {
60
+ let stat;
61
+ try { stat = fs.lstatSync(absolute); } catch { hash.update(`absent\0${relative}\0`); return; }
62
+ if (stat.isDirectory()) {
63
+ hash.update(`d\0${relative}\0`);
64
+ let entries = [];
65
+ try { entries = fs.readdirSync(absolute).sort(); } catch { hash.update(`unreadable\0${relative}\0`); return; }
66
+ for (const entry of entries) visit(path.join(absolute, entry), `${relative}/${entry}`);
67
+ return;
68
+ }
69
+ hash.update(`f\0${relative}\0`);
70
+ try { hash.update(fs.readFileSync(absolute)); } catch { hash.update(`unreadable\0${relative}\0`); }
71
+ };
72
+ for (const relative of CONSOLE_RUNTIME_SURFACE) visit(path.join(root, relative), relative);
73
+ return hash.digest('hex');
74
+ }
@@ -37,9 +37,13 @@ import fs from 'node:fs';
37
37
  import os from 'node:os';
38
38
  import path from 'node:path';
39
39
  import { execFileSync, spawnSync } from 'node:child_process';
40
+ import { resolveRuflo, RUFLO_MISSING } from '../plugin/scripts/ruflo-bin.mjs';
40
41
 
41
42
  const HOME = os.homedir();
42
- const RUFLO = process.env.RUFLO_BIN || path.join(HOME, '.npm-global/bin/ruflo');
43
+ // Issue #99: this was `process.env.RUFLO_BIN || path.join(HOME, '.npm-global/bin/ruflo')`, so every
44
+ // install whose npm prefix is not the owner's (Homebrew, nvm, Volta, plain `npm -g`) was told its
45
+ // tool was missing while ruflo sat on their PATH. One resolver, shared — see ruflo-bin.mjs.
46
+ const RUFLO = resolveRuflo();
43
47
  const RUFLO_ENV = { ...process.env, RUFLO_DAEMON_AUTOSTART: '0' };
44
48
 
45
49
  const argv = process.argv.slice(2);
@@ -126,6 +130,10 @@ if (has('--restore')) {
126
130
  }
127
131
 
128
132
  // ── PRE-FLIGHT ─────────────────────────────────────────────────────────────────────────────────────
133
+ // Two different facts, so two different sentences. `null` means we looked everywhere and found
134
+ // nothing; a path that does not exist means the user pointed RUFLO_BIN somewhere empty, and naming
135
+ // that exact path back to them is the whole value of the message.
136
+ if (!RUFLO) die(RUFLO_MISSING);
129
137
  if (!fs.existsSync(RUFLO)) die(`ruflo is not at ${RUFLO.replace(HOME, '~')} — install it with \`npm i -g ruflo@latest\``);
130
138
  if (!fs.existsSync(DB)) die(`no memory store at ${DB.replace(HOME, '~')} — nothing to distill for this project`);
131
139
 
@@ -408,6 +408,7 @@ export function evaluateDoc(root, rel, opts = {}) {
408
408
  status: k.status ?? null,
409
409
  date: k.date ?? null,
410
410
  updated: k.updated ?? null,
411
+ updatedPinned: k.updated_pinned === true || k.updated_pinned === 'true',
411
412
  implStored: k.impl ?? null,
412
413
  verifiedStamp: k.verified ?? null,
413
414
  verifiedDigestStored: k.verified_digest ?? null,
@@ -455,7 +456,19 @@ export function evaluateDoc(root, rel, opts = {}) {
455
456
  if (Number.isFinite(d) && Number.isFinite(g)) {
456
457
  const deltaDays = Math.round((g - d) / 86400000);
457
458
  if (deltaDays >= 1) {
458
- add(BLOCK, 'stamp-lags-doc',
459
+ // `updated_pinned: true` — the date is a HISTORICAL RECORD, not a currency stamp.
460
+ //
461
+ // Some documents record WHEN SOMETHING HAPPENED, not when the file was last edited.
462
+ // ADR-050's `updated: 2026-08-02` is an incident cutoff and is asserted by
463
+ // tests/unit/fix-workstream-guidance. A blanket "make the stamp match the last commit"
464
+ // rule cannot tell those apart, so it silently rewrote the pinned date — putting two gates
465
+ // into direct contradiction, one demanding the pinned value and one demanding the commit
466
+ // date, with no way to satisfy both. The document itself is the only thing that knows
467
+ // which kind of date it carries, so it now says so, and --fix must never overwrite it.
468
+ if (doc.updatedPinned) {
469
+ add(WARN, 'stamp-pinned',
470
+ `updated: ${doc.updated} is PINNED (updated_pinned: true) and deliberately does not track the last commit ${docCommit.date} — it records an event, not the edit`);
471
+ } else add(BLOCK, 'stamp-lags-doc',
459
472
  `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
473
  } else if (deltaDays <= -1) {
461
474
  // Typed ahead. WARN only: a stamp dated today on a change not yet committed is CORRECT, and
@@ -610,7 +623,7 @@ export function planFix(root, doc) {
610
623
  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
624
  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
625
  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) {
626
+ } else if (!doc.updatedPinned && doc.findings.some((f) => f.code === 'stamp-lags-doc') && doc.docCommit && !doc.dirty) {
614
627
  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
628
  }
616
629
  return { changes, blocked };