ruvnet-brain 4.3.28 → 4.3.30

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 (73) hide show
  1. package/README.md +1 -1
  2. package/bin/install.mjs +90 -8
  3. package/console/app.js +7 -2
  4. package/kb/corpus-release-identity.mjs +73 -0
  5. package/package.json +4 -3
  6. package/plugin/.claude-plugin/plugin.json +1 -1
  7. package/plugin/.codex-plugin/plugin.json +1 -1
  8. package/plugin/mcp/managed-cli-interface.mjs +83 -9
  9. package/plugin/scripts/advocacy-route.mjs +4 -1
  10. package/plugin/scripts/capacity-aware-parallel-work.mjs +4 -0
  11. package/plugin/scripts/continuation-gate.mjs +71 -0
  12. package/plugin/scripts/decision-gate.mjs +29 -57
  13. package/plugin/scripts/ground-ruvnet.sh +77 -4
  14. package/plugin/scripts/grounding-stamp.sh +34 -5
  15. package/plugin/scripts/grounding-turn-gate.mjs +8 -2
  16. package/plugin/scripts/grounding-turn-mark.mjs +6 -1
  17. package/plugin/scripts/hook-input.mjs +54 -0
  18. package/plugin/scripts/hook-shim.mjs +10 -0
  19. package/plugin/scripts/memory-doctor.mjs +43 -0
  20. package/plugin/scripts/nightly-controller.mjs +6 -1
  21. package/plugin/scripts/project-progression-contract.mjs +17 -0
  22. package/plugin/scripts/project-progression-hook.mjs +18 -5
  23. package/plugin/scripts/project-progression-producer.mjs +16 -12
  24. package/plugin/scripts/ruvnet-gate1-pattern.mjs +17 -0
  25. package/plugin/scripts/session-start-core.mjs +11 -5
  26. package/plugin/scripts/session-start-update-plane.mjs +5 -1
  27. package/plugin/scripts/unprompted-runtime.mjs +29 -8
  28. package/plugin/skills/ruvnet-brain/PLAYBOOK.md +1 -1
  29. package/scripts/adr-072-completion.mjs +2 -1
  30. package/scripts/calibrate-router.mjs +6 -6
  31. package/scripts/corpus-reconcile.mjs +32 -3
  32. package/scripts/correction-detect.mjs +10 -11
  33. package/scripts/dispatch-receipt.mjs +2 -2
  34. package/scripts/dual-host-deliberation.mjs +3 -2
  35. package/scripts/execution-policy.mjs +10 -2
  36. package/scripts/gen-console-images.mjs +0 -1
  37. package/scripts/gen-images.mjs +0 -4
  38. package/scripts/host-install-matrix.mjs +7 -4
  39. package/scripts/independent-review-receipt.mjs +7 -8
  40. package/scripts/ingest-repo.mjs +45 -1
  41. package/scripts/learning-replay-cli.mjs +2 -1
  42. package/scripts/learning-replay-fixture.mjs +2 -1
  43. package/scripts/learnings.mjs +19 -5
  44. package/scripts/lesson-migrate-agentdb.mjs +635 -0
  45. package/scripts/loop-checkpoint.mjs +37 -1
  46. package/scripts/metaharness-receipts.mjs +3 -2
  47. package/scripts/nightly-two-run-proof.mjs +6 -6
  48. package/scripts/nightly-watchdog.mjs +10 -2
  49. package/scripts/onboarding-console.mjs +98 -49
  50. package/scripts/oracle/producer-hosts.mjs +2 -1
  51. package/scripts/private-overlay.mjs +31 -9
  52. package/scripts/public-verification-aggregate.mjs +4 -3
  53. package/scripts/publication-receipt.mjs +9 -0
  54. package/scripts/qe/agentic-qe-4.3.mjs +0 -1
  55. package/scripts/rebuild-gists-from-receipts.mjs +1 -1
  56. package/scripts/reconcile-project.mjs +0 -0
  57. package/scripts/release.mjs +34 -82
  58. package/scripts/retrieval-canary.mjs +3 -2
  59. package/scripts/review-model-defaults.mjs +26 -0
  60. package/scripts/route-cheap.mjs +20 -7
  61. package/scripts/router-utilization.mjs +5 -5
  62. package/scripts/rvf-generation.mjs +68 -2
  63. package/scripts/single-source-check.mjs +270 -0
  64. package/scripts/subscription-hosts.mjs +4 -0
  65. package/scripts/sync-version.mjs +22 -0
  66. package/scripts/trismart.mjs +3 -3
  67. package/scripts/wired-check.mjs +40 -4
  68. package/console/assets/memory.webp +0 -0
  69. package/plugin/scripts/version-bump-gate.sh +0 -124
  70. package/scripts/qe/aggregate-4.3.mjs +0 -42
  71. package/scripts/release-convergence-watchdog.mjs +0 -112
  72. package/scripts/stamp-existing-rvf-generations.mjs +0 -53
  73. package/scripts/verify-channels.mjs +0 -196
package/README.md CHANGED
@@ -7,7 +7,7 @@ Created: 2026-06-29 22:36:38 EDT
7
7
 
8
8
  # 🧠 RuvNet Brain
9
9
 
10
- ### 🧠 RuvNet Brain — [![RuvNet Brain version 4.3.28 — updated 2026-07-30 03:24 EDT](https://img.shields.io/badge/version_4.3.28-updated_2026--07--30_03:24_EDT-1E90FF?style=for-the-badge&labelColor=0757BA)](https://github.com/stuinfla/ruvnet-brain/blob/main/plugin/.claude-plugin/plugin.json)
10
+ ### 🧠 RuvNet Brain — [![RuvNet Brain version 4.3.30 — updated 2026-07-30 03:24 EDT](https://img.shields.io/badge/version_4.3.30-updated_2026--07--30_03:24_EDT-1E90FF?style=for-the-badge&labelColor=0757BA)](https://github.com/stuinfla/ruvnet-brain/blob/main/plugin/.claude-plugin/plugin.json)
11
11
 
12
12
  **A portable, source-grounded brain over Reuven Cohen's (rUv's) RuvNet stack — delivered as a Claude Code plugin that makes Claude _use_ the stack instead of fighting it.**
13
13
 
package/bin/install.mjs CHANGED
@@ -561,7 +561,7 @@ export function ensureUpdaterPrerequisites(kbDir) {
561
561
  return { updater: true, validator: placeTrustedCoverageValidator(kbDir) };
562
562
  }
563
563
 
564
- export async function unzipInto(zipPath, cacheDir, sourceDir = null, { releaseTag = null } = {}) {
564
+ export async function unzipInto(zipPath, cacheDir, sourceDir = null, { releaseTag = null, activate = true } = {}) {
565
565
  step(
566
566
  'Unpacking the brain into place',
567
567
  'so the plugin finds forge-mcp-all.mjs and the vector stores right where it looks',
@@ -668,6 +668,13 @@ export async function unzipInto(zipPath, cacheDir, sourceDir = null, { releaseTa
668
668
  'The live brain was not touched. Fetch a complete current release and retry.');
669
669
  }
670
670
 
671
+ // Recovery (stageBundleForRecovery / kb/forge-update.mjs's applyVerifiedStagedRelease) consumes
672
+ // this authenticated, validated stage through runStorageTransaction instead — the SAME apply
673
+ // primitive main()'s normal --apply uses, which preserves a private overlay properly. Keep fresh
674
+ // activation below as the only owner of installer replacement and the private-overlay refusal:
675
+ // a caller that wants staged-only output must never fall through to it.
676
+ if (!activate) return { status: 'STAGED', stageDir, extractedBy, coverage: stagedCoverage };
677
+
671
678
  // Activation is an exact directory generation swap. A malformed candidate never reaches this
672
679
  // point, retired public files cannot survive as overlay debris, and a failed rename restores the
673
680
  // prior generation. Existing private overlays are update-owned and must never be stripped by the
@@ -750,6 +757,20 @@ export async function unzipInto(zipPath, cacheDir, sourceDir = null, { releaseTa
750
757
  : null };
751
758
  }
752
759
 
760
+ /** Stage and validate a bundle for forge-update's private-overlay recovery rail
761
+ * (kb/forge-update.mjs's applyVerifiedStagedRelease) without touching the live generation. */
762
+ export async function stageBundleForRecovery(zipPath, cacheDir, sourceDir = null, { releaseTag = null, signaturePath = `${zipPath}.sig` } = {}) {
763
+ const verified = verifyBundle(zipPath, signaturePath);
764
+ if (!verified.ok) throw new Error(`recovery bundle signature verification failed: ${verified.reason}`);
765
+ const bundleSha256 = crypto.createHash('sha256').update(fs.readFileSync(zipPath)).digest('hex');
766
+ const result = await unzipInto(zipPath, cacheDir, sourceDir, { releaseTag, activate: false });
767
+ fs.writeFileSync(`${result.stageDir}.staged-release.json`, JSON.stringify({
768
+ schemaVersion: 1, kind: 'ruvnet-brain-authenticated-stage', bundleSha256,
769
+ releaseTag, stagedAt: new Date().toISOString(),
770
+ }) + '\n', { mode: 0o600 });
771
+ return { ...result, bundleSha256 };
772
+ }
773
+
753
774
  /**
754
775
  * ISSUE #128 / #153 — STALE PLUGIN GENERATIONS MAY STILL BACK LIVE SESSIONS.
755
776
  *
@@ -986,7 +1007,12 @@ export function serverDependencies(source, seen = new Set()) {
986
1007
  seen.add(file);
987
1008
  let src = '';
988
1009
  try { src = fs.readFileSync(file, 'utf8'); } catch { return; }
989
- for (const m of src.matchAll(/^\s*(?:import|export)[^'"\n]*from\s*['"](\.[^'"]+)['"]/gm)) {
1010
+ // Lazy [^'"]* (no \n exclusion) so a multi-line named import — `import {\n a,\n} from './x.mjs';`
1011
+ // — is still seen: the exclusion used to stop at the first newline, so this walker silently
1012
+ // missed any dependency imported that way. Caught live 2026-09-27: session-snapshot-hook.mjs's
1013
+ // multi-line import of project-progression-hook.mjs never reached the Codex MCP server package,
1014
+ // reproducing the exact packaging-boundary failure this function's own header warns about.
1015
+ for (const m of src.matchAll(/^\s*(?:import|export)[^'"]*?from\s*['"](\.[^'"]+)['"]/gm)) {
990
1016
  const spec = specPrefix ? path.join(specPrefix, m[1]) : m[1];
991
1017
  const from = path.resolve(path.dirname(file), m[1]);
992
1018
  if (out.some((d) => d.from === from)) continue;
@@ -2423,7 +2449,22 @@ async function smokeQuery(cacheDir) {
2423
2449
  // The stable MCP shell sets this exact default before spawning its worker. Passing the same
2424
2450
  // path here makes the install smoke warm the model cache the product will actually reopen,
2425
2451
  // instead of a second kb-local cache that can go green while the real door stays cold.
2426
- env: { ...process.env, KB_MODEL_CACHE: resolveRuntimeModelCache() },
2452
+ //
2453
+ // RUVNET_BRAIN_QUERY_DEADLINE_MS: this ONE probe is the very first query ever run against a
2454
+ // freshly-installed cache — the model is cold and the cross-encoder "rerank" phase has to pay
2455
+ // load cost that every later, warm query never pays again. Measured on macOS GitHub Actions
2456
+ // runners 2026-09-27 (public-verification runs 36324328134 job 108636740357, 28.5-28.7s; and
2457
+ // 36325803503 job 108638381147, 27.1-27.2s): this exact probe consistently needs ~27-29s on
2458
+ // that platform, against the general 20s deadline (kb/query-deadline.mjs
2459
+ // DEFAULT_QUERY_DEADLINE_MS) that is correct for every normal, warm query. 45s keeps this
2460
+ // bounded (never unbounded — the module's core guarantee) while giving this one cold-start
2461
+ // probe real margin, without touching the default that protects normal queries everywhere
2462
+ // else. Only applied if the caller hasn't already set an explicit override.
2463
+ env: {
2464
+ ...process.env,
2465
+ KB_MODEL_CACHE: resolveRuntimeModelCache(),
2466
+ RUVNET_BRAIN_QUERY_DEADLINE_MS: process.env.RUVNET_BRAIN_QUERY_DEADLINE_MS ?? '45000',
2467
+ },
2427
2468
  });
2428
2469
  } catch {
2429
2470
  warn("skipped the live test (couldn't launch the reader) — it'll warm on your first real question");
@@ -3180,6 +3221,11 @@ export function classifyUpdaterExit(status, { fallbackAllowed = true, result = n
3180
3221
  if (status === 0) {
3181
3222
  if (result?.terminalVerdict === 'applied') return { verdict: 'applied', fallback: false, exitCode: 0 };
3182
3223
  if (result?.terminalVerdict === 'noop') return { verdict: 'noop', fallback: false, exitCode: 0 };
3224
+ // S2 (ONE CURRENCY VERDICT): rollback protection — the offered candidate's corpus generation
3225
+ // predates the installed one. A clean, intentional no-op, not a failure: the live tree is exactly
3226
+ // as it was, on purpose, and reporting it as 'invalid-result'/failed would turn the updater's own
3227
+ // correct refusal into a reported --update failure (the exact issue #106 shape, one gate later).
3228
+ if (result?.terminalVerdict === 'refused') return { verdict: 'refused', fallback: false, exitCode: 0 };
3183
3229
  if (!requireResult) return { verdict: 'legacy-success', fallback: false, exitCode: 0 };
3184
3230
  return { verdict: 'invalid-result', fallback: false, exitCode: 1 };
3185
3231
  }
@@ -3422,7 +3468,7 @@ export function classifyHostConvergence(receipt, expectedVersion = PACKAGE_VERSI
3422
3468
  return { healthy: true, state: 'channels-converged' };
3423
3469
  }
3424
3470
 
3425
- function runUpdate() {
3471
+ async function runUpdate() {
3426
3472
  printBanner('update');
3427
3473
  const kbDir = resolvedKbDir();
3428
3474
  const brainHome = process.env.RUVNET_BRAIN_HOME || path.dirname(kbDir);
@@ -3564,10 +3610,46 @@ function runUpdate() {
3564
3610
  warn("the knowledge bundle could not refresh; continuing with executable host synchronization only");
3565
3611
  updateStatus = 0;
3566
3612
  } else if (outcome.fallback) {
3567
- warn("\nthe bundle's own updater couldn't complete — falling back to a fresh install of the latest Release (this always works)…\n");
3568
- const self = fileURLToPath(import.meta.url);
3569
- const fr = spawnSync(process.execPath, [self, '--force'], { stdio: 'inherit',
3570
- env: { ...refreshEnv, RUVNET_BRAIN_NO_UPDATE_FALLBACK: '1' } });
3613
+ // A blind fresh --force reinstall REFUSES to activate on top of a private overlay (unzipInto's
3614
+ // own "fresh-install activation refused" guard, above) — so for a private-overlay install this
3615
+ // branch used to die() in a loop every time the bundle's own updater failed: same refusal, same
3616
+ // non-zero exit, forever, with no path that ever actually recovers the KB. The authenticated
3617
+ // staged recovery rail (kb/forge-update.mjs's applyVerifiedStagedRelease) exists for exactly
3618
+ // this case: it stages and independently re-verifies the signed bundle, then activates through
3619
+ // runStorageTransaction — the SAME apply primitive main()'s normal --apply uses — which DOES
3620
+ // preserve the private overlay, instead of a second, less careful mechanism that cannot.
3621
+ let hasPrivateOverlay = false;
3622
+ try {
3623
+ const installedSource = JSON.parse(fs.readFileSync(path.join(kbDir, 'SOURCE.json'), 'utf8'));
3624
+ const installedStores = Array.isArray(installedSource.stores) ? installedSource.stores : Object.values(installedSource.stores || {});
3625
+ hasPrivateOverlay = installedStores.some((store) => store?.updateManaged === false);
3626
+ } catch { /* updater already supplied the authoritative failure */ }
3627
+ let fr;
3628
+ if (hasPrivateOverlay) {
3629
+ warn("\nthe installed updater failed; using authenticated staged recovery to preserve private stores…\n");
3630
+ const release = await resolveRelease();
3631
+ const bundle = await obtainBundle(release);
3632
+ if (!bundle.zipPath) throw new Error('private-overlay recovery requires a downloadable signed bundle');
3633
+ const sigPath = `${bundle.zipPath}.sig`;
3634
+ const stagedRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ruvnet-recovery-stage-'));
3635
+ const staged = await stageBundleForRecovery(bundle.zipPath, stagedRoot, null, { signaturePath: sigPath,
3636
+ releaseTag: release?.source === 'latest' ? (release.tag || null) : null });
3637
+ const descriptor = path.join(stagedRoot, 'recovery-input.json');
3638
+ let expectedRuntimeVersion = PACKAGE_VERSION;
3639
+ try { expectedRuntimeVersion = JSON.parse(fs.readFileSync(path.join(kbDir, 'RUNTIME-IDENTITY.json'), 'utf8')).brainVersion || expectedRuntimeVersion; } catch { /* validator reports missing identity */ }
3640
+ fs.writeFileSync(descriptor, JSON.stringify({ stagedDir: staged.stageDir, liveDir: kbDir,
3641
+ bundlePath: bundle.zipPath, signaturePath: sigPath, bundleSha256: staged.bundleSha256,
3642
+ expectedRuntimeVersion, releaseTag: release?.source === 'latest' ? (release.tag || null) : null,
3643
+ }) + '\n', { mode: 0o600 });
3644
+ fr = spawnSync(process.execPath, [path.join(REPO_ROOT, 'kb', 'forge-update.mjs'), '--staged-release', descriptor], {
3645
+ stdio: 'inherit', cwd: path.dirname(kbDir), env: { ...refreshEnv, RUVNET_BRAIN_NO_UPDATE_FALLBACK: '1' },
3646
+ });
3647
+ } else {
3648
+ warn("\nthe bundle's own updater couldn't complete — falling back to a fresh install of the latest Release (this always works)…\n");
3649
+ const self = fileURLToPath(import.meta.url);
3650
+ fr = spawnSync(process.execPath, [self, '--force'], { stdio: 'inherit',
3651
+ env: { ...refreshEnv, RUVNET_BRAIN_NO_UPDATE_FALLBACK: '1' } });
3652
+ }
3571
3653
  updateStatus = fr.error ? 1 : (fr.status === null ? 1 : fr.status);
3572
3654
  } else {
3573
3655
  updateStatus = outcome.exitCode;
package/console/app.js CHANGED
@@ -3672,7 +3672,12 @@ async function doManualRefresh() {
3672
3672
  announce('The refresh could not be started.');
3673
3673
  return;
3674
3674
  }
3675
- if (body.started === false && pill) {
3675
+ if (body.refresh?.status === 'failed') {
3676
+ if (pill) { pill.className = 'chip tone-warn'; pill.textContent = 'refresh failed — click to try again'; }
3677
+ announce(`The refresh failed: ${body.refresh.error || 'the measurement child exited'}.`);
3678
+ return;
3679
+ }
3680
+ if (body.started === false && body.refresh?.status === 'already-running' && pill) {
3676
3681
  // Honest, and not an error: a measurement was already running, so this click joined it instead
3677
3682
  // of starting a second full scan of the same machine.
3678
3683
  pill.className = 'chip tone-cyan';
@@ -4099,7 +4104,7 @@ async function mockGet(url) {
4099
4104
 
4100
4105
  async function mockPost(url, body) {
4101
4106
  await sleep(850);
4102
- if (url === '/api/refresh') return { status: 200, ok: true, data: { ok: true, refreshing: true, started: true } };
4107
+ if (url === '/api/refresh') return { status: 200, ok: true, data: { ok: true, refreshing: true, started: true, refresh: { status: 'started', runId: 'mock-refresh' } } };
4103
4108
  if (url === '/api/save-brain-power') {
4104
4109
  return { status: 200, ok: true, data: { ok: true, off: !!(body.values || {}).off, log: 'mock: the switch moved and was read back from disk' } };
4105
4110
  }
@@ -50,6 +50,42 @@ import { createHash } from 'node:crypto';
50
50
  /** The corpus transport tag shape published by scripts/corpus-seed-publish.mjs's corpusSeedTag(). */
51
51
  export const CORPUS_TAG_PATTERN = /^corpus-sha256-[0-9a-f]{64}$/;
52
52
 
53
+ // CODE_TAG_PATTERN / releaseKind / CORPUS_GENERATION_FIELD / parseCorpusGeneration below are literal
54
+ // copies of scripts/release-channel-kind.mjs and scripts/corpus-promotion.mjs. This module SHIPS
55
+ // standalone inside the distributed KB bundle (kb/forge-update.mjs's only imports are siblings in
56
+ // this same directory — see the fixtures in tests/unit/*-apply-rollback.test.mjs, which copy exactly
57
+ // the kb/*.mjs files a real bundle carries); `scripts/` is build/release tooling that never ships.
58
+ // CORPUS_TAG_PATTERN above is already duplicated this same way for the same reason. Keep both copies
59
+ // byte-identical in behavior; tests/unit/corpus-release-identity-channel-parity.test.mjs pins that.
60
+
61
+ /** Owner-approved product release: a plain semver tag. */
62
+ export const CODE_TAG_PATTERN = /^v\d+\.\d+\.\d+$/;
63
+
64
+ export function releaseKind(tag) {
65
+ const value = String(tag ?? '');
66
+ if (CORPUS_TAG_PATTERN.test(value)) return 'corpus';
67
+ if (CODE_TAG_PATTERN.test(value)) return 'code';
68
+ return 'other';
69
+ }
70
+
71
+ /** The author-side ordering key field name for a corpus release's `releases/latest` promotion
72
+ * (scripts/corpus-promotion.mjs's CORPUS_GENERATION_FIELD) — S2 reuses it as the client-side
73
+ * ordering key so promotion and currency comparisons can never disagree about what "generation"
74
+ * means. */
75
+ export const CORPUS_GENERATION_FIELD = 'Corpus generation:';
76
+
77
+ /** Parse the `Corpus generation:` line out of a release's body/notes text, exactly as
78
+ * scripts/corpus-promotion.mjs's evaluateCorpusPromotion does for publish-time ordering. Returns
79
+ * `{ value, epoch }` (the raw string and its parsed epoch) or null when absent/unparseable. */
80
+ export function parseCorpusGeneration(body) {
81
+ const line = String(body || '').split('\n').map((row) => row.trim())
82
+ .find((row) => row.startsWith(CORPUS_GENERATION_FIELD));
83
+ if (!line) return null;
84
+ const value = line.slice(CORPUS_GENERATION_FIELD.length).trim();
85
+ const parsed = Date.parse(value);
86
+ return Number.isFinite(parsed) ? { value, epoch: parsed } : null;
87
+ }
88
+
53
89
  /** The installer-written record of which approved runtime this KB belongs to. */
54
90
  export const INSTALLED_RUNTIME_FILE = 'RUNTIME-IDENTITY.json';
55
91
 
@@ -210,6 +246,43 @@ export function recordCorpusTransportIdentity(treeDir, { releaseTag }) {
210
246
  return source;
211
247
  }
212
248
 
249
+ /**
250
+ * Record the ordering key a corpus release's generation is compared by (S2, corpus-currency gate).
251
+ * Written IN the tree being installed, next to `recordCorpusTransportIdentity` (same caller, same
252
+ * candidate directory, same atomic rename into place) so a crash between the two can never leave a
253
+ * tree whose transport tag and generation ordering key disagree.
254
+ *
255
+ * `generation` is read from the CANDIDATE release's own published record — parseCorpusGeneration
256
+ * against the release body's `Corpus generation:` line (scripts/corpus-promotion.mjs), the same
257
+ * field that is already the sole author-side ordering key for `releases/latest` promotion. It is
258
+ * never invented locally and never derived from a local clock: kb/forge-update.mjs's isBehind()
259
+ * history (this file's header) is the standing lesson in why a locally-observed timestamp can never
260
+ * safely stand in for an authenticated ordering key.
261
+ *
262
+ * IDEMPOTENT for the same reason recordCorpusTransportIdentity is (see its comment): touch the file
263
+ * only when the field's value actually changes, or a byte-identical re-apply reads as `applied`
264
+ * instead of `noop` and strands a rollback copy.
265
+ */
266
+ export function recordCorpusGenerationIdentity(treeDir, { corpusReleaseTag, generation }) {
267
+ const file = path.join(treeDir, 'SOURCE.json');
268
+ const source = JSON.parse(fs.readFileSync(file, 'utf8'));
269
+ if (isCorpusReleaseTag(corpusReleaseTag) && typeof generation === 'string' && generation) {
270
+ if (source.corpusGeneration === generation) return source;
271
+ source.corpusGeneration = generation;
272
+ } else {
273
+ // No generation identity to record for this candidate — an ordinary code release, or a corpus
274
+ // release whose published record carries no readable ordering key. Never leave a PRIOR
275
+ // generation stamped: a code release supersedes it outright (its bundle IS the corpus), and a
276
+ // corpus candidate that declares no generation of its own must not leave behind a number that no
277
+ // longer describes what is actually on disk — the next comparison must read UNKNOWN, not compare
278
+ // against a generation this tree no longer holds.
279
+ if (!Object.hasOwn(source, 'corpusGeneration')) return source;
280
+ delete source.corpusGeneration;
281
+ }
282
+ atomicJson(file, source);
283
+ return source;
284
+ }
285
+
213
286
  // ── the anti-loop ledger ────────────────────────────────────────────────────────────────────────
214
287
  // A refusal that is rediscovered every night is a download loop with extra steps. The refused tag
215
288
  // is remembered next to the KB (never inside it, where an update would replace it), so the very
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ruvnet-brain",
3
- "version": "4.3.28",
4
- "description": "One-command installer for RuvNet Brain — a portable, source-grounded brain over rUv's RuvNet building blocks, delivered as a Claude Code plugin so Claude uses the stack instead of fighting it.",
3
+ "version": "4.3.30",
4
+ "description": "One-command installer for RuvNet Brain \u2014 a portable, source-grounded brain over rUv's RuvNet building blocks, delivered as a Claude Code plugin so Claude uses the stack instead of fighting it.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "ruvnet-brain": "bin/install.mjs"
@@ -70,7 +70,8 @@
70
70
  "cap:ab": "node scripts/rerank-cap-warm-ab.mjs",
71
71
  "hooks:check": "node scripts/hook-retirement-check.mjs",
72
72
  "release:qualify": "node scripts/release-qualification.mjs",
73
- "rehearse:corpus": "node scripts/rehearse-corpus-pipeline.mjs"
73
+ "rehearse:corpus": "node scripts/rehearse-corpus-pipeline.mjs",
74
+ "single-source:check": "node scripts/single-source-check.mjs"
74
75
  },
75
76
  "files": [
76
77
  ".claude-plugin/marketplace.json",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ruvnet-brain",
3
3
  "description": "RuvNet brain transplant for Claude Code — grounds every RuvNet decision in real source across 77 rUv repositories, prefers Ruflo / RuVector-RVF / AgentDB over training-prior defaults (pgvector, Pinecone, hand-rolled cosine), and can pull in any RuvNet repo on demand. Ships a UserPromptSubmit retrieve-and-inject grounding hook and a PreToolUse write gate that refuses ungrounded rUv-product code until search_ruvnet has been consulted (ADR-0012 / ADR-067).",
4
- "version": "4.3.28",
4
+ "version": "4.3.30",
5
5
  "author": {
6
6
  "name": "Stuart Kerr"
7
7
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ruvnet-brain",
3
- "version": "4.3.28",
3
+ "version": "4.3.30",
4
4
  "description": "Source-grounded RuvNet knowledge, lifecycle enforcement, and learning for Codex.",
5
5
  "author": {
6
6
  "name": "Stuart Kerr"
@@ -5,6 +5,8 @@ import path from 'node:path';
5
5
  import { loadRuntimePreferences, runtimeChildEnv } from '../scripts/runtime-preferences.mjs';
6
6
  import { projectDirectory } from '../scripts/project-identity.mjs';
7
7
  import { recordManagedCliObservation, recordRegistryLatestObservation } from '../scripts/capability-claim-evidence.mjs';
8
+ import { runSessionSnapshotHook } from '../scripts/session-snapshot-hook.mjs';
9
+ import { resolveProjectStore } from '../scripts/project-store-resolver.mjs';
8
10
 
9
11
  export const MANAGED_EXECUTABLES = Object.freeze([
10
12
  'ruflo',
@@ -233,20 +235,69 @@ function execute(executable, argv, env) {
233
235
  clearTimeout(timer);
234
236
  resolve({ code: null, stdout: '', stderr: '', error: error.message });
235
237
  });
236
- child.once('close', (code) => {
237
- if (settled) return;
238
- settled = true;
239
- clearTimeout(timer);
240
- resolve({ code, stdout: Buffer.concat(stdout).toString(), stderr: Buffer.concat(stderr).toString(), error: null });
238
+ child.once('close', (code, signal) => {
239
+ if (settled) return;
240
+ settled = true;
241
+ clearTimeout(timer);
242
+ resolve({ code, signal, stdout: Buffer.concat(stdout).toString(), stderr: Buffer.concat(stderr).toString(), error: null });
241
243
  });
242
244
  });
243
245
  }
244
246
 
247
+ /**
248
+ * Convert the child-process result into the managed boundary's terminal vocabulary. A zero exit
249
+ * status is necessary but not sufficient: a tool that emits a fatal marker or cannot provide a
250
+ * terminal status must remain failure/unknown evidence for progression consumers.
251
+ */
252
+ export function normalizeManagedExecution(execution) {
253
+ const output = [execution?.stdout, execution?.stderr].filter(Boolean)
254
+ .join(execution?.stdout && execution?.stderr ? '\n' : '');
255
+ const contradictoryFailure = /(?:^|\n)\s*(?:❌|\[ERROR\])|invalid pragma command|key not found/i.test(output);
256
+ if (execution?.signal) return { outcome: 'interrupted', output, signal: execution.signal, contradictoryFailure };
257
+ if (execution?.error || contradictoryFailure || execution?.code === null || execution?.code === undefined) {
258
+ return { outcome: execution?.error || contradictoryFailure ? 'failure' : 'unknown', output, contradictoryFailure };
259
+ }
260
+ return { outcome: execution.code === 0 ? 'success' : 'failure', output, contradictoryFailure };
261
+ }
262
+
263
+ function managedSessionId(env) {
264
+ const supplied = env.RUVNET_BRAIN_SESSION_ID || env.RUVNET_HOOK_SESSION_ID;
265
+ return typeof supplied === 'string' && supplied.trim() ? supplied.trim() : `managed-cli-${process.pid}`;
266
+ }
267
+
268
+ /** Bind managed CLI execution to the existing progression writer when this project has adopted it. */
269
+ function managedProgressionCapture({ executable, argv, projectRoot, env, event, execution, normalized }) {
270
+ const adopted = fs.existsSync(path.join(projectRoot, '.swarm'));
271
+ if (!adopted) return { adopted: false, skipped: 'project has not adopted the canonical store' };
272
+ const host = String(env.RUVNET_HOOK_HOST || '').toLowerCase();
273
+ if (!['claude', 'codex'].includes(host)) return { adopted: true, error: 'managed host identity unavailable' };
274
+ let resolution;
275
+ try { resolution = resolveProjectStore({ projectDir: projectRoot }); }
276
+ catch (error) { return { adopted: true, error: `project store could not be resolved: ${error?.message || error}` }; }
277
+ const payload = {
278
+ session_id: managedSessionId(env),
279
+ hook_event_name: event,
280
+ tool_name: executable,
281
+ tool_input: { command: [executable, ...argv].join(' ') },
282
+ ...(execution ? {
283
+ tool_response: {
284
+ stdout: execution.stdout,
285
+ stderr: execution.stderr,
286
+ exit_code: execution.code,
287
+ ...(execution.signal ? { signal: execution.signal, interrupted: true } : {}),
288
+ outcome: normalized?.outcome,
289
+ ...(execution.error ? { error: execution.error } : {}),
290
+ },
291
+ } : {}),
292
+ };
293
+ return { adopted: true, ...runSessionSnapshotHook(projectRoot, event, { rawInput: JSON.stringify(payload), host }) };
294
+ }
295
+
245
296
  function resultOf(executable, argv, result) {
246
297
  const output = [result.stdout, result.stderr].filter(Boolean).join(result.stdout && result.stderr ? '\n' : '');
247
- const contradictoryFailure = /(?:^|\n)\s*(?:❌|\[ERROR\])|invalid pragma command|key not found/i.test(output);
248
- if (result.error || result.code !== 0 || contradictoryFailure) {
249
- const reason = result.error || (contradictoryFailure ? 'fatal output despite exit 0' : `exit ${result.code}`);
298
+ const normalized = normalizeManagedExecution(result);
299
+ if (normalized.outcome !== 'success') {
300
+ const reason = result.error || (normalized.contradictoryFailure ? 'fatal output despite exit 0' : result.code == null ? 'no terminal exit status' : `exit ${result.code}`);
250
301
  return {
251
302
  content: [{ type: 'text', text: output || `${executable} ${argv.join(' ')} failed: ${reason}` }],
252
303
  isError: true,
@@ -258,7 +309,7 @@ function resultOf(executable, argv, result) {
258
309
  };
259
310
  }
260
311
 
261
- export async function callManagedCli(toolName, args, env = process.env, fetchImpl = globalThis.fetch) {
312
+ export async function callManagedCli(toolName, args, env = process.env, fetchImpl = globalThis.fetch, lifecycle = {}) {
262
313
  try {
263
314
  const executable = assertExecutable(args?.executable);
264
315
  const argv = literalArgv(args?.argv ?? []);
@@ -327,11 +378,34 @@ export async function callManagedCli(toolName, args, env = process.env, fetchImp
327
378
  isError: true,
328
379
  };
329
380
  }
381
+ // Host identity is process context established by the native adapter, never a tool argument.
382
+ // A caller can request a CLI command, but must not relabel its continuity record as another
383
+ // host by supplying an arbitrary `host` property.
330
384
  const childEnv = (executable === 'agentic-flow' || executable === 'agentic-qe')
331
385
  ? runtimeChildEnv({ env, cwd: projectRoot })
332
386
  : env;
387
+ const capture = typeof lifecycle.capture === 'function' ? lifecycle.capture : managedProgressionCapture;
388
+ const beforeCapture = await capture({ executable, argv, projectRoot, env: childEnv, event: 'PreToolUse' });
389
+ if (beforeCapture?.error || (beforeCapture?.adopted && beforeCapture.progressionCaptured !== true)) {
390
+ const reason = beforeCapture.error || beforeCapture.skipped || 'pre-execution progression was not durably captured';
391
+ return { content: [{ type: 'text', text: `managed execution refused: ${reason}` }], isError: true };
392
+ }
393
+ if (lifecycle && typeof lifecycle.beforeExecute === 'function') {
394
+ await lifecycle.beforeExecute({ executable, argv, projectRoot, env: childEnv });
395
+ }
333
396
  const execution = await execute(executable, argv, childEnv);
397
+ const normalized = normalizeManagedExecution(execution);
398
+ const afterCapture = await capture({ executable, argv, projectRoot, env: childEnv, event: 'PostToolUse', execution, normalized });
399
+ if (lifecycle && typeof lifecycle.afterExecute === 'function') {
400
+ await lifecycle.afterExecute({ executable, argv, projectRoot, env: childEnv, execution, normalized });
401
+ }
334
402
  recordManagedCliObservation({ toolName, executable, argv, execution, env });
403
+ if (afterCapture?.adopted && afterCapture.progressionCaptured !== true) {
404
+ return {
405
+ content: [{ type: 'text', text: `managed command ${normalized.outcome}; continuity result was not durably captured: ${afterCapture.skipped || 'unknown reason'}` }],
406
+ isError: true,
407
+ };
408
+ }
335
409
  return resultOf(executable, argv, execution);
336
410
  }
337
411
 
@@ -450,7 +450,10 @@ async function main() {
450
450
  }
451
451
  // DIRECT MODE (a human running this file, or a host without the runtime). Here this process IS the
452
452
  // writer, so it owns the denominator too.
453
- try { record({ id: candidate.findingId, action: ACTIONS.OFFERED, severity: 'normal', stateHash: candidate.observationHash }); } catch { /* never break the surface we measure */ }
453
+ let receipt;
454
+ try { receipt = record({ id: candidate.findingId, action: ACTIONS.OFFERED, severity: 'normal', stateHash: candidate.observationHash }); }
455
+ catch { receipt = null; }
456
+ if (!receipt?.ok) return 0; // never emit an untracked offer that cannot be resolved later
454
457
  process.stdout.write(`${candidate.copy}\n`);
455
458
  return 0;
456
459
  }
@@ -12,6 +12,7 @@ import os from 'node:os';
12
12
  import { spawnSync } from 'node:child_process';
13
13
  import path from 'node:path';
14
14
  import { fileURLToPath } from 'node:url';
15
+ import { isHarnessGenerated } from './hook-input.mjs';
15
16
 
16
17
  export const UNKNOWN_RUNTIME_TOTAL_AGENT_CEILING = 4;
17
18
  const INPUT_LIMIT = 32 * 1024;
@@ -180,6 +181,9 @@ export function runCapacityHook(rawInput, sample) {
180
181
  let input;
181
182
  try { input = JSON.parse(String(rawInput || '')); } catch { return ''; }
182
183
  const prompt = input?.prompt ?? input?.user_prompt ?? input?.input;
184
+ // H2: a background task notification or other harness-authored message reads as substantial
185
+ // "independent work" prose but nobody wrote it — never advise a parallel-work fan-out off of one.
186
+ if (isHarnessGenerated(prompt)) return '';
183
187
  if (!isSubstantialParallelWork(prompt)) return '';
184
188
  return formatAdvisory(effectiveAgentRecommendation(sample === undefined ? collectMacPressure() : sample, {
185
189
  configuredMaxChildren: input?.configured_max_children,
@@ -226,6 +226,77 @@ if (has('--done')) {
226
226
  process.exit(0);
227
227
  }
228
228
 
229
+ /**
230
+ * H4: the continuation objective could never be closed. `--commit-to` writes `led.objective` with
231
+ * `state: 'active'` (above), and the Stop-side allow-list at ~275 already checks for
232
+ * `['cancelled', 'completed', 'blocked'].includes(led.objective?.state)` — but until now NOTHING
233
+ * ever wrote one of those three states, so an objective, once opened, forced every Stop for the rest
234
+ * of the project's life (short of `--clear`, which also wipes every unrelated ledger ITEM — a
235
+ * blunt instrument for closing one objective). These two verbs are the missing other half of
236
+ * `--commit-to`: they touch ONLY `led.objective`, leaving `led.items` untouched, and each REQUIRES
237
+ * its evidence/reason text — an empty completion would be exactly the "marked done without doing
238
+ * it" fabrication this whole gate exists to refuse (see the "THE HONEST EXIT" note below on why a
239
+ * genuinely dead item must be closed with a recorded reason, never silently). Nothing here runs
240
+ * automatically; both are explicit, human/model-invoked CLI verbs only.
241
+ */
242
+ if (has('--complete-objective')) {
243
+ const evidence = arg('--complete-objective');
244
+ if (!evidence || !evidence.trim()) {
245
+ console.error('--complete-objective requires evidence: --complete-objective "<what proves it is done>"');
246
+ process.exit(2);
247
+ }
248
+ const led = load();
249
+ if (led.objective) {
250
+ // DERIVED from the required argument checked above, not asserted (ADR-0024 / status-honesty.mjs):
251
+ // this line is unreachable with blank/missing evidence — the guard exits 2 first. That proves an
252
+ // explicit, human/model-invoked CLI action supplied named evidence; it does not verify the
253
+ // evidence is TRUE, which is exactly why continuation-objective.mjs's own header already
254
+ // documents this ledger as non-authoritative ("neither prove user provenance nor establish task
255
+ // completion") — a nudge-suppression preference with the reason attached for audit, not a
256
+ // verified fact standing in for a real completion check.
257
+ led.objective.state = evidence.trim() ? 'completed' : led.objective.state;
258
+ led.objective.completedAt = new Date().toISOString();
259
+ led.objective.completionEvidence = evidence;
260
+ }
261
+ save(led);
262
+ console.log(led.objective ? `objective completed: ${evidence}` : 'no objective to complete');
263
+ process.exit(0);
264
+ }
265
+
266
+ if (has('--cancel-objective')) {
267
+ const reason = arg('--cancel-objective');
268
+ if (!reason || !reason.trim()) {
269
+ console.error('--cancel-objective requires a reason: --cancel-objective "<why this objective no longer applies>"');
270
+ process.exit(2);
271
+ }
272
+ const led = load();
273
+ if (led.objective) {
274
+ led.objective.state = 'cancelled';
275
+ led.objective.cancelledAt = new Date().toISOString();
276
+ led.objective.cancellationReason = reason;
277
+ }
278
+ save(led);
279
+ console.log(led.objective ? `objective cancelled: ${reason}` : 'no objective to cancel');
280
+ process.exit(0);
281
+ }
282
+
283
+ if (has('--help') || has('-h')) {
284
+ console.log([
285
+ 'usage: continuation-gate.mjs <verb> [args] (no verb + JSON on stdin = the Stop hook itself)',
286
+ '',
287
+ ' --commit-to "<text>" record work the model agreed to do; opens/reopens the',
288
+ ' objective as state: active',
289
+ ' --done "<exact item text>" mark a plain ledger item done (exact text match only)',
290
+ ' --complete-objective "<evidence>" close the current objective as state: completed, with the',
291
+ ' completion evidence recorded — leaves other ledger items',
292
+ ' untouched',
293
+ ' --cancel-objective "<reason>" close the current objective as state: cancelled, with the',
294
+ ' reason recorded — leaves other ledger items untouched',
295
+ ' --clear wipe the ENTIRE ledger: every item AND the objective',
296
+ ].join('\n'));
297
+ process.exit(0);
298
+ }
299
+
229
300
  if (has('--clear')) { save({ items: [] }); console.log('ledger cleared'); process.exit(0); }
230
301
 
231
302
  // ── the Stop hook itself (default action) ────────────────────────────────────────────────────────