ruvnet-brain 4.3.9 → 4.3.11

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 (87) hide show
  1. package/README.md +26 -9
  2. package/bin/install.mjs +646 -366
  3. package/bin/nightly-refresh.mjs +115 -0
  4. package/kb/brain-profile.mjs +118 -32
  5. package/kb/lifecycle-evidence-retention.mjs +237 -0
  6. package/kb/refresh-run.mjs +367 -0
  7. package/kb/retrieval-result.mjs +39 -0
  8. package/kb/update-storage-transaction.mjs +439 -0
  9. package/package.json +12 -3
  10. package/plugin/.claude-plugin/plugin.json +1 -1
  11. package/plugin/.codex-plugin/plugin.json +1 -1
  12. package/plugin/hooks/codex-hooks.json +2 -165
  13. package/plugin/hooks/hook-contracts.json +4 -65
  14. package/plugin/hooks/hooks.json +2 -208
  15. package/plugin/scripts/adr-currency-gate.mjs +29 -19
  16. package/plugin/scripts/capability-registry.mjs +10 -83
  17. package/plugin/scripts/codex-hook-adapter.mjs +62 -9
  18. package/plugin/scripts/codex-hook-wrapper.mjs +19 -1
  19. package/plugin/scripts/continuation-gate.mjs +30 -28
  20. package/plugin/scripts/continuation-objective.mjs +34 -0
  21. package/plugin/scripts/development-maintenance.mjs +49 -0
  22. package/plugin/scripts/hook-shim.mjs +3 -0
  23. package/plugin/scripts/learn-flush.mjs +6 -1
  24. package/plugin/scripts/lesson-gate.mjs +5 -2
  25. package/plugin/scripts/lesson-presentation.mjs +5 -0
  26. package/plugin/scripts/md-stamp.mjs +109 -6
  27. package/plugin/scripts/memory-doctor.mjs +3 -7
  28. package/plugin/scripts/nightly-controller.mjs +9 -27
  29. package/plugin/scripts/nightly-scheduler.mjs +494 -0
  30. package/plugin/scripts/project-progression-contract.mjs +4 -4
  31. package/plugin/scripts/project-progression-store.mjs +35 -6
  32. package/plugin/scripts/ruflo-bin.mjs +20 -0
  33. package/plugin/scripts/session-snapshot-contract.mjs +6 -1
  34. package/plugin/scripts/version-bump-gate.sh +14 -2
  35. package/plugin/skills/release-proof/SKILL.md +6 -2
  36. package/plugin/skills/release-proof/references/receipt-contract.md +31 -2
  37. package/plugin/skills/release-proof/scripts/release-proof.mjs +11 -2
  38. package/plugin/skills/ruvnet-brain/PLAYBOOK.md +15 -23
  39. package/scripts/build-bundle.mjs +8 -0
  40. package/scripts/build-primer.mjs +2 -3
  41. package/scripts/candidate-host-evidence.mjs +59 -41
  42. package/scripts/ci/build-fixture-kb.mjs +59 -2
  43. package/scripts/ci/mutate-hook-timeout.mjs +24 -47
  44. package/scripts/ci/stranger-scenario.mjs +13 -28
  45. package/scripts/claims-verify.mjs +219 -21
  46. package/scripts/console-engine.mjs +4 -18
  47. package/scripts/console-runtime-identity.mjs +6 -0
  48. package/scripts/development-maintenance.mjs +47 -0
  49. package/scripts/development-push-check.mjs +35 -0
  50. package/scripts/distill-project.mjs +24 -47
  51. package/scripts/doc-currency.mjs +91 -19
  52. package/scripts/git-hooks/pre-push +4 -162
  53. package/scripts/health-repair.mjs +22 -4
  54. package/scripts/hook-retirement-check.mjs +22 -0
  55. package/scripts/host-install-matrix.mjs +125 -61
  56. package/scripts/integration-evidence.mjs +11 -6
  57. package/scripts/learning-replay-fixture.mjs +23 -2
  58. package/scripts/nightly-package-observation.mjs +67 -0
  59. package/scripts/nightly-two-run-proof.mjs +489 -0
  60. package/scripts/nightly-watchdog.mjs +17 -2
  61. package/scripts/npm-invocation.mjs +20 -0
  62. package/scripts/prepublication-evidence.mjs +42 -6
  63. package/scripts/primer-grounding.mjs +20 -0
  64. package/scripts/product-integrity-contract.mjs +22 -2
  65. package/scripts/public-verification-abandon.mjs +101 -0
  66. package/scripts/public-verification-aggregate.mjs +61 -19
  67. package/scripts/public-verification-finalizer.mjs +8 -2
  68. package/scripts/public-verification-lane.mjs +64 -8
  69. package/scripts/publication-receipt.mjs +156 -50
  70. package/scripts/published-surface-probe.mjs +10 -2
  71. package/scripts/qa-contract.mjs +57 -0
  72. package/scripts/qa-lanes.mjs +40 -0
  73. package/scripts/qa-runner.mjs +54 -64
  74. package/scripts/qe/ux-suite.mjs +10 -34
  75. package/scripts/qualified-candidate-check.mjs +124 -0
  76. package/scripts/release-abort-stale.mjs +2 -2
  77. package/scripts/release-projection.mjs +20 -1
  78. package/scripts/release-qualification-contract.mjs +87 -0
  79. package/scripts/release-qualification.mjs +135 -0
  80. package/scripts/release-transaction.mjs +81 -1
  81. package/scripts/remedy-registry.mjs +6 -13
  82. package/scripts/retrieval-canary.mjs +91 -18
  83. package/scripts/selfcheck.mjs +12 -4
  84. package/scripts/snapshot-freshness.mjs +58 -0
  85. package/scripts/stack-sync.mjs +41 -43
  86. package/scripts/staged-host-verifier.mjs +63 -14
  87. package/scripts/wired-check.mjs +31 -2
@@ -18,11 +18,21 @@
18
18
  # is the product. This is that discipline applied at the push boundary.
19
19
  #
20
20
  # CONTRACT: exit 0 = allow · exit 2 + stderr = BLOCK. FAILS OPEN on anything unparseable.
21
- # Opt-in (router profile.json), bash builtins + git only — same hardening as its four siblings.
21
+ # Opt-in (router profile.json); local Git checks and a Node maintenance-state check.
22
22
  # ─────────────────────────────────────────────────────────────────────────────────────────────
23
23
 
24
24
  set -uo pipefail
25
25
 
26
+ # Reversible, repository-scoped development maintenance; no profile, ledger, or Git work first.
27
+ MAINTENANCE_HELPER="${BASH_SOURCE[0]%/*}/development-maintenance.mjs"
28
+ if [ -f "$MAINTENANCE_HELPER" ] && node --input-type=module -e '
29
+ import { pathToFileURL } from "node:url";
30
+ const { developmentHooksSuspended } = await import(pathToFileURL(process.argv[1]));
31
+ process.exit(developmentHooksSuspended() ? 0 : 1);
32
+ ' "$MAINTENANCE_HELPER" >/dev/null 2>&1; then
33
+ exit 0
34
+ fi
35
+
26
36
  INPUT=""
27
37
  # BOUNDED READ (2026-07-27, ADR-055 F20): an unqualified `read` never returns on a stdin that is
28
38
  # opened and never closed — measured across the mesh, 18 of 37 registered commands sat until the
@@ -42,7 +52,9 @@ PROFILE="${MODEL_ROUTER_PROFILE:-$HOME/.claude/model-router/profile.json}"
42
52
  [ -f "$PROFILE" ] || exit 0
43
53
  [ "${RUVNET_SKIP_VERSION_GATE:-0}" = "1" ] && exit 0
44
54
 
45
- field() { local re="\"$1\"[[:space:]]*:[[:space:]]*\"([^\"]*)\""; [[ $INPUT =~ $re ]] && printf '%s' "${BASH_REMATCH[1]}"; }
55
+ # Consume JSON escape pairs so quoted commit messages do not truncate the command before a
56
+ # following git push. This retains the existing bounded Bash parser's matching behavior.
57
+ field() { local re="\"$1\"[[:space:]]*:[[:space:]]*\"([^\"\\\\]*(\\\\.[^\"\\\\]*)*)\""; [[ $INPUT =~ $re ]] && printf '%s' "${BASH_REMATCH[1]}"; }
46
58
  [ "$(field tool_name)" = "Bash" ] || exit 0
47
59
  CMD=$(field command)
48
60
  [[ $CMD == *"git push"* ]] || exit 0
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: release-proof
3
3
  description: Fail-closed exact-artifact release and deployment authority. Use before saying a release is ready, pushing a release commit, publishing npm packages, creating GitHub releases, deploying production, closing release-blocking issues, or claiming all gates are green. Requires clean immutable lineage, zero labeled release blockers, exact-SHA GitHub success, nonzero no-skip QE, packed-artifact host tests, installed Brain/RVF proof, and post-publication byte verification.
4
- updated: 2026-09-04
4
+ updated: 2026-09-07
5
5
  ---
6
6
 
7
7
  # Release Proof
@@ -23,7 +23,11 @@ green.
23
23
  7. Require the source package, Claude manifest, Codex manifest, packed npm version, bundle
24
24
  `brainVersion`/`releaseTag`, and both installed host versions to identify one exact generation.
25
25
  8. Require the active Brain registry to contain the `ruvnet-brain` RVF store and require narrow,
26
- broad, and concurrent cited searches to complete within 80% of their deadline.
26
+ broad, and concurrent cited candidate searches to complete within 80% of their deadline.
27
+ For fresh public stabilization verification, `stabilization-public-deadline-v1` requires the
28
+ first cited search in every host mode and the Brain probe within the enforced 30-second
29
+ deadline. The 24-second headroom target remains a performance follow-up, not promotion authority.
30
+ Receipts without this named policy retain the legacy 80% rule; failed runs are not relabeled.
27
31
  9. Publish only through the protected release workflow. Never run `npm publish` or `gh release
28
32
  create` locally.
29
33
  10. After publication, download npm and GitHub artifacts, compare their bytes with the seal, install
@@ -1,6 +1,6 @@
1
1
  # Receipt contract
2
2
 
3
- Updated: 2026-09-04
3
+ Updated: 2026-09-07
4
4
 
5
5
  The authority accepts only typed receipt schemas. The candidate receipts are emitted by
6
6
  `release-candidate-preflight` and imported by `protected-release`; publication receipts are emitted
@@ -38,9 +38,38 @@ Required bindings:
38
38
  and installed Claude/Codex versions
39
39
  - npm and GitHub release bytes matching the candidate digest
40
40
  - clean installed Claude and Codex results from the public package
41
- - installed Brain self-RVF and broad search within 80 percent of deadline
41
+ - installed Brain self-RVF and measured first cited searches within the declared acceptance policy
42
+ - `acceptancePolicy: stabilization-public-deadline-v1` requires a fixed 30,000ms deadline and
43
+ finite, nonnegative host and Brain timings at or below it; larger budgets are rejected
44
+ - receipts without that policy retain the legacy 80-percent margin; unknown policies fail
42
45
  - successful exact-SHA `published-surface-probe`
43
46
 
47
+ Stuart authorized a materially improved stabilization release on September 7, rather than
48
+ requiring every historical performance goal for promotion. The public runtime already enforces
49
+ a 30-second deadline. The new policy removes only the additional 24-second promotion margin.
50
+ It does not replace first-search timings with warm timings or reduce retrieval, citation,
51
+ artifact-integrity, native-update, or host-installation requirements. Fresh signed evidence is
52
+ required; prior failed workflows remain failed.
53
+
54
+ Native update verification suppresses optional npm command shims only during the initial
55
+ reader installation (`npm_config_bin_links=false`). The scheduler retains npm's default
56
+ command-shim behavior, which `npx` requires. The receipt records this installation configuration.
57
+ The default reader install creates a `semver` symlink that 4.3.10's updater rejects; fixing that
58
+ default-install compatibility remains a follow-up. Symlink rejection is not bypassed.
59
+
60
+ The native scheduler executes its actual production target, `ruvnet-brain@latest`. A fresh isolated
61
+ npm cache is prepared and its package files must match the verified public tarball installation.
62
+ Exactly one cached Brain package is allowed. Full package tree observations before and after each
63
+ of two scheduled runs, plus each run's actual desired version, bind the execution evidence to the
64
+ published package. The consumer validates these observations and their time bounds. A changed
65
+ latest version or changed package bytes fails the proof. Dependencies are outside the package-tree
66
+ comparison. The two runs verify the already-current update path; they do not establish fallback
67
+ reinstallation or new upstream corpus generation. Only empty installer-created preserved
68
+ directories are removed in the disposable fixture; populated or linked entries are retained and
69
+ still fail duplicate-copy acceptance. Storage observations wait for the refresh owner to exit
70
+ and its lock to be removed. The second run may add only measured receipt and bounded log bytes;
71
+ any other managed-storage growth remains a failure.
72
+
44
73
  Before provider mutation, `protected-release` revalidates the imported candidate receipt, package
45
74
  payload, source binding, and digest against current `origin/main`. It consumes the long-lane proof;
46
75
  it does not rerun CI, integration, UX, or stranger qualification.
@@ -169,15 +169,24 @@ export function evaluatePublicationReceipt(candidate, publication) {
169
169
  const item = publication?.[surface];
170
170
  if (item?.sha !== sha || item?.artifactSha256 !== digest) failures.push(fail('PUBLIC_ARTIFACT_MISMATCH', `${surface} differs from candidate seal`));
171
171
  }
172
+ const stabilization = publication?.acceptancePolicy === 'stabilization-public-deadline-v1';
173
+ const deadline = Number(publication?.brain?.deadlineMs);
174
+ if (publication?.acceptancePolicy !== undefined && !stabilization) {
175
+ failures.push(fail('PUBLIC_ACCEPTANCE_POLICY', 'unknown public acceptance policy'));
176
+ }
177
+ const withinDeadline = (value) => typeof value === 'number' && Number.isFinite(value) && value >= 0
178
+ && Number.isFinite(deadline) && deadline > 0
179
+ && (!stabilization || deadline === 30_000)
180
+ && value <= deadline * (stabilization ? 1 : 0.8);
172
181
  for (const hostName of ['claudeOnly', 'codexOnly', 'dual']) {
173
182
  const host = publication?.installed?.[hostName];
174
183
  if (host?.status !== 'PASS' || host?.doctorExit !== 0 || host?.artifactSha256 !== digest
175
- || host?.functionalSearch !== true || !(host?.searchMs <= Number(publication?.brain?.deadlineMs) * 0.8)) {
184
+ || host?.functionalSearch !== true || !withinDeadline(host?.searchMs)) {
176
185
  failures.push(fail('PUBLIC_HOST_NOT_PASS', `${hostName} is not running a doctor-clean functional sealed public artifact`));
177
186
  }
178
187
  }
179
188
  const brain = publication?.brain || {};
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'));
189
+ if (brain.status !== 'PASS' || brain.selfStore !== true || !withinDeadline(brain.broadMs)) failures.push(fail('PUBLIC_BRAIN_NOT_PASS', 'public installed Brain acceptance failed'));
181
190
  const probes = Array.isArray(publication?.postPublicationChecks) ? publication.postPublicationChecks : [];
182
191
  const probe = probes.find((check) => check?.name === 'published-surface-probe' && check?.sha === sha);
183
192
  if (probe?.status !== 'completed' || probe?.conclusion !== 'success') failures.push(fail('POST_PUBLICATION_CHECK_NOT_GREEN', 'published-surface-probe is not green'));
@@ -1,23 +1,19 @@
1
1
  # THE PLAYBOOK — the standing build playbook, in full
2
2
 
3
- Updated: 2026-08-19 | Version 1.1.0
3
+ Updated: 2026-09-07 11:07:28 EDT | Version 1.2.0
4
4
  Created: 2026-07-27
5
5
 
6
- **Read this before your first build response in a session.** `plugin/scripts/session-start.sh`
7
- injects a condensed form of it into every session's context and points here for the full text; the
8
- UserPromptSubmit gate (`plugin/scripts/ground-ruvnet.sh` Gate 3) refers back to it on each build turn
9
- as *THE PLAYBOOK*.
6
+ **Read this when explicitly using the Brain build skill.** Automatic Brain lifecycle hooks are
7
+ retired: no SessionStart or UserPromptSubmit registration injects this playbook. MCP, skills and
8
+ explicit commands remain available; the user or lead agent invokes them when appropriate.
10
9
 
11
- ## Why this file exists rather than nine kilobytes of context
12
-
13
- The full text below used to be printed verbatim by the SessionStart hook, on every session, in every
14
- project on the machine. Measured 2026-07-27: 6,282 bytes of it, inside a 9,127-byte hook output
15
- against `scripts/selfcheck.mjs`'s 4,096-byte cap — a cap that exists because, in that file's own
16
- words, *"it lands in the user's context window."* Static instructional prose does not need to be
17
- re-injected verbatim every session to be obeyed; a directive plus a pointer does the same job for
18
- about a fifth of the bytes, and the full text stays one `Read` away for the turn that actually needs
19
- it. The condensed form the hook injects keeps every operative instruction — what is here and not
20
- there is elaboration, worked examples, and phrasing guidance.
10
+ Release work uses `scripts/release-qualification.mjs` and its reviewed requirement inventory.
11
+ The retained tests were individually reviewed; the historical approximately 4,332 unit cases were
12
+ not all individually audited and remain diagnostics. A diagnostic pass is not production proof.
13
+ Promote only the clean exact candidate SHA and its sealed artifacts after qualification. Public
14
+ acceptance still requires all nine OS/host-mode leaves plus real native installed-update proof on
15
+ each platform, ending at `install-verified`. Imported upstream corpus freshness remains UNKNOWN
16
+ until separately proven. See `docs/QA-RELEASE-PROCESS.md` and the audit records in `docs/reviews/`.
21
17
 
22
18
  ---
23
19
 
@@ -110,14 +106,10 @@ rule-compliance, cite a source the tools didn't return, or claim a check that di
110
106
  A completion moves that task to completed, unblocks its dependents, and the freed slot claims the
111
107
  first unassigned, unblocked pending task immediately. Only allow a slot to idle when no such task
112
108
  exists. Keep dependent integration with the designated integration owner.
113
- - **Claude Code:** its shared task ledger and `TeammateIdle` hook make recycling enforceable: the
114
- shipped recycler refuses idle while a ready unassigned task exists, then Claude's locked
115
- `TaskUpdate` claim performs the transition.
116
- - **Codex:** Codex 0.146.0 exposes no `TeammateIdle` or `TaskCompleted` hook and no equivalent
117
- shared-task hook ledger. Initial fan-out and completion-notification recycling are guidance,
118
- not hook enforcement: the lead must immediately dispatch the next ready ledger item when a
119
- collaboration slot completes. State this degraded boundary if it affects the run; never call it
120
- enforced.
109
+ - **Claude Code and Codex:** the lead explicitly dispatches the next ready task when a slot
110
+ completes. No automatic `TeammateIdle` or `TaskCompleted` Brain handler enforces recycling.
111
+ `plugin/scripts/swarm-slot-recycler.mjs` remains available for explicit invocation with an
112
+ appropriate task ledger; do not describe its mere presence as active host enforcement.
121
113
  If Ruflo / RuVector MCP tools aren't available in this environment, DON'T block or stall — degrade
122
114
  gracefully to the native host's agents and local .rvf, and briefly note the tool that would make
123
115
  it better + how to add it. Never demand a tool the user doesn't have.
@@ -22,6 +22,7 @@ import { getVersion, getVersionTag, stripTag } from './version.mjs';
22
22
  import { auditRvfIndexes } from './rvf-index-audit.mjs';
23
23
  import { readRvfGenerations, validateSelectedRvfGenerations } from './rvf-generation.mjs';
24
24
  import { validatePublicInventory } from './public-inventory.mjs';
25
+ import { bindAssembledReleaseProjection } from './release-projection.mjs';
25
26
  // The org total is DERIVED, never a literal: it was hardcoded 248 in this file and in its
26
27
  // sibling while the account actually had 200 — one stale fact, restated twice (2026-08-12).
27
28
  import { orgRepoCount } from './org-repo-count.mjs';
@@ -590,6 +591,13 @@ if (missing.length) {
590
591
  process.exit(1);
591
592
  }
592
593
 
594
+ if (PROJECTION) {
595
+ const source = spawnSync('git', ['rev-parse', 'HEAD'], { cwd: ROOT, encoding: 'utf8' });
596
+ if (source.status !== 0) throw new Error('cannot identify the projected bundle source');
597
+ bindAssembledReleaseProjection({ assetsDir: OUT, version: stripTag(BRAIN_VERSION),
598
+ sourceSnapshot: source.stdout.trim() });
599
+ }
600
+
593
601
  // ---- exact release artifact -------------------------------------------------------------------
594
602
  // The release signer signs dist/ruvnet-brain.zip, not this assembly directory. Rebuilding only the
595
603
  // directory can therefore sign stale bytes left by an older run—the source tree is green while
@@ -13,6 +13,7 @@ import fs from 'node:fs';
13
13
  import path from 'node:path';
14
14
  import { fileURLToPath } from 'node:url';
15
15
  import { rerankKb } from '../kb/forge-rerank.mjs';
16
+ import { writeGroundedPrimer } from './primer-grounding.mjs';
16
17
 
17
18
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
18
19
  const arg = (f, d) => { const i = process.argv.indexOf(f); return i >= 0 && process.argv[i + 1] ? process.argv[i + 1] : d; };
@@ -57,10 +58,8 @@ for (const a of ARCHES) {
57
58
  }
58
59
 
59
60
  const primer = `# ${NAME} — Primer\n\n<!-- Generated primer · grounded in real source via rerankKb (${VARIANT}) · archetypes: ${ARCHES.map(a => a.key).join(', ')} -->\n\n${sections.join('\n')}`;
60
- const countRefs = (txt) => [...new Set([...allPaths].filter((p) => txt.includes(p) || txt.includes(p.split('/').pop())))];
61
- const refs = countRefs(primer);
62
61
  const outFile = path.join(KB, `${NAME}-primer.md`);
63
- fs.writeFileSync(outFile, primer);
62
+ const refs = writeGroundedPrimer({ primer, sourcePaths: allPaths, output: outFile });
64
63
 
65
64
  // 3-vendor "is this a complete, correct primer?" score (informational; the hard gate is citation count)
66
65
  const jsys = 'Grade 1-100 whether this repo primer is COMPLETE, CORRECT and CONFIDENT for an engineer new to the repo (98=excellent/actionable; vague-or-hedgy=POISON<50). Return ONLY {"score":N,"reason":"<=15 words"}.';
@@ -1,56 +1,74 @@
1
1
  #!/usr/bin/env node
2
- import crypto from 'node:crypto';
3
2
  import fs from 'node:fs';
4
3
  import path from 'node:path';
5
- import { stagedHostVerifier } from './staged-host-verifier.mjs';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { stagedHostVerifier, readCandidateRetrieval, verifyCandidateRetrievalAssets } from './staged-host-verifier.mjs';
6
6
  import { payloadIdFor } from './release-payload.mjs';
7
+ import { validateRetrievalCanaryReceipt } from './retrieval-canary.mjs';
7
8
 
8
9
  const arg = (name) => {
9
10
  const index = process.argv.indexOf(name);
10
11
  return index >= 0 ? process.argv[index + 1] : null;
11
12
  };
12
- const sha256 = (file) => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
13
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
- const grounding = fixture?.grounding;
29
- const grounded = grounding && ['repo', 'path', 'file', 'storedPath']
30
- .every((field) => typeof grounding[field] === 'string' && grounding[field].trim());
31
- if (fixture?.status !== 'PASS' || fixture?.process?.status !== 0 || !grounded) {
32
- throw new Error(`${name} did not produce a clean installed-search grounding receipt`);
14
+ export async function buildCandidateHostEvidence({ manifestFile, packagePath, bundlePath, planFile, coverageFile, failureFile },
15
+ { createVerifier = stagedHostVerifier } = {}) {
16
+ const manifest = JSON.parse(fs.readFileSync(manifestFile, 'utf8'));
17
+ const payloadId = payloadIdFor(manifest);
18
+ const identity = { version: manifest.version, candidateSha: manifest.candidateSha, payloadId };
19
+ const retrieval = readCandidateRetrieval({ manifest, planFile, coverageFile });
20
+ verifyCandidateRetrievalAssets({ retrieval, assets: { packagePath, bundlePath } });
21
+ const result = await createVerifier({ assets: { packagePath, bundlePath }, identity, retrieval })
22
+ .verify({ source: 'candidate', assets: { packagePath, bundlePath } });
23
+ verifyCandidateRetrievalAssets({ retrieval, assets: { packagePath, bundlePath } });
24
+ if (result.verdict !== 'PASS') {
25
+ if (failureFile) fs.writeFileSync(path.resolve(failureFile), JSON.stringify({
26
+ schemaVersion: 1, kind: 'ruvnet-brain-candidate-host-failure', verdict: 'FAIL',
27
+ sha: manifest.candidateSha, payloadId, artifactSha256: retrieval.artifactSha256,
28
+ candidateArchiveSha256: retrieval.candidateArchiveSha256, planSha256: retrieval.plan.planSha256,
29
+ result,
30
+ }, null, 2) + '\n', { flag: 'wx', mode: 0o600 });
31
+ throw new Error(`candidate host matrix failed: ${result.error || 'unknown'}`);
33
32
  }
33
+
34
+ const modeNames = { claude: 'claude-only', codex: 'codex-only', dual: 'dual-host' };
35
+ const leaves = Object.entries(modeNames).map(([mode, name]) => {
36
+ const fixture = result.fixtures?.[mode];
37
+ const grounding = fixture?.grounding;
38
+ const grounded = grounding && ['repo', 'path', 'file', 'storedPath']
39
+ .every((field) => typeof grounding[field] === 'string' && grounding[field].trim());
40
+ if (fixture?.status !== 'PASS' || fixture?.process?.status !== 0 || !grounded) {
41
+ throw new Error(`${name} did not produce a clean installed-search grounding receipt`);
42
+ }
43
+ validateRetrievalCanaryReceipt(fixture.retrieval, { plan: retrieval.plan });
44
+ return {
45
+ name,
46
+ sha: manifest.candidateSha,
47
+ payloadId,
48
+ status: fixture.status === 'PASS' ? 'completed' : 'failed',
49
+ conclusion: 'success',
50
+ verdict: 'PASS',
51
+ source: 'candidate-host-evidence',
52
+ mode,
53
+ functionalSearch: true,
54
+ searchExit: fixture.process.status,
55
+ grounding,
56
+ retrieval: fixture.retrieval,
57
+ artifactSha256: retrieval.artifactSha256,
58
+ };
59
+ });
34
60
  return {
35
- name,
61
+ schemaVersion: 1,
36
62
  sha: manifest.candidateSha,
37
63
  payloadId,
38
- status: fixture.status === 'PASS' ? 'completed' : 'failed',
39
- conclusion: 'success',
40
- verdict: 'PASS',
41
- source: 'candidate-host-evidence',
42
- mode,
43
- functionalSearch: true,
44
- searchExit: fixture.process.status,
45
- grounding,
46
- artifactSha256: sha256(packagePath),
64
+ artifactSha256: retrieval.artifactSha256,
65
+ leaves,
47
66
  };
48
- });
49
- fs.writeFileSync(out, `${JSON.stringify({
50
- schemaVersion: 1,
51
- sha: manifest.candidateSha,
52
- payloadId,
53
- artifactSha256: sha256(packagePath),
54
- leaves,
55
- }, null, 2)}\n`, { flag: 'wx', mode: 0o600 });
56
- console.log(JSON.stringify({ verdict: 'PASS', payloadId, leaves: leaves.map(({ name }) => name) }));
67
+ }
68
+
69
+ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
70
+ const evidence = await buildCandidateHostEvidence({ manifestFile: arg('--manifest'),
71
+ packagePath: arg('--package'), bundlePath: arg('--bundle'), planFile: arg('--plan'), coverageFile: arg('--coverage'), failureFile: arg('--out') ? `${arg('--out')}.failure.json` : null });
72
+ fs.writeFileSync(path.resolve(arg('--out')), `${JSON.stringify(evidence, null, 2)}\n`, { flag: 'wx', mode: 0o600 });
73
+ console.log(JSON.stringify({ verdict: 'PASS', payloadId: evidence.payloadId, leaves: evidence.leaves.map(({ name }) => name) }));
74
+ }
@@ -23,6 +23,9 @@
23
23
  //
24
24
  // node scripts/ci/build-fixture-kb.mjs --out <dir> [--drop-mcp] [--no-rvf]
25
25
  import fs from 'node:fs';
26
+ import crypto from 'node:crypto';
27
+ import { coverageGenerationFor, releaseCoverageGenerationFor, validateCoverageDirectory } from '../../plugin/scripts/coverage-integrity.mjs';
28
+ import { validatePublicInventory } from '../public-inventory.mjs';
26
29
  import path from 'node:path';
27
30
  import { fileURLToPath } from 'node:url';
28
31
 
@@ -60,8 +63,62 @@ fs.writeFileSync(path.join(OUT, 'package.json'), JSON.stringify({
60
63
  },
61
64
  }, null, 2));
62
65
 
63
- if (!NO_RVF) {
64
- fs.writeFileSync(path.join(OUT, 'fixture.rvf'), 'not a real store — presence is what gatherInstallState() counts\n');
66
+ // Synthetic coverage uses the production validators; it is not release evidence.
67
+ {
68
+ const dir = OUT;
69
+ const source = { brainVersion: JSON.parse(fs.readFileSync(path.join(REPO_ROOT, 'package.json'))).version, stores: { fixture: {} } };
70
+ fs.writeFileSync(path.join(dir, 'SOURCE.json'), JSON.stringify(source));
71
+ const storeNames = ['fixture'];
72
+ const sourceSnapshot = 'd'.repeat(40);
73
+ const publicLedger = { schemaVersion: 2, kind: 'ruvnet-brain-public-generation-ledger',
74
+ brainVersion: source.brainVersion, releaseTag: `v${source.brainVersion}`, sourceSnapshot, stores: {} };
75
+ for (const name of storeNames) {
76
+ const bytes = Buffer.alloc(512, 7);
77
+ fs.writeFileSync(path.join(dir, `${name}.big.rvf`), bytes);
78
+ publicLedger.stores[name] = { file: `${name}.big.rvf`, sha256: crypto.createHash('sha256').update(bytes).digest('hex'),
79
+ bytes: bytes.length, sourceCommit: source.stores[name].sourceCommit || null,
80
+ model: 'fixture-model', dimensions: 384, builtUtc: '2026-08-21T12:00:00.000Z' };
81
+ }
82
+ const publicLedgerBytes = Buffer.from(`${JSON.stringify(publicLedger)}\n`);
83
+ fs.writeFileSync(path.join(dir, 'RVF-GENERATIONS.json'), `${JSON.stringify({ ...publicLedger,
84
+ kind: 'ruvnet-brain-runtime-generation-ledger' })}\n`);
85
+ fs.writeFileSync(path.join(dir, 'PUBLIC-RVF-GENERATIONS.json'), publicLedgerBytes);
86
+ fs.writeFileSync(path.join(dir, 'PRIVATE-STORES.json'), JSON.stringify({ privateStores: [] }));
87
+ fs.writeFileSync(path.join(dir, 'public-store-classes.json'), JSON.stringify({ schemaVersion: 1, derived: [] }));
88
+ const rows = storeNames.map((name) => ({ key: `repo:${name}`, kind: 'repository', name,
89
+ url: `https://github.com/ruvnet/${name}`, status: 'CURRENT', disposition: 'eligible', upstream: {},
90
+ artifact: { store: name }, reasons: [] }));
91
+ const enumerationReceipt = { schemaVersion: 1, terminal: true, duplicateKeys: 0,
92
+ repositories: { expected: rows.length, pages: [] }, gists: { expected: 0, pages: [] } };
93
+ const generatorSourceSha = 'a'.repeat(64);
94
+ const snapshotRoot = 'b'.repeat(64);
95
+ const sourceObservationSha256 = 'c'.repeat(64);
96
+ const policy = { policyDispositionDigests: [], exemptionDigests: [] };
97
+ const corpus = { schemaVersion: 1, kind: 'ruvnet-brain-corpus-coverage', generatorSourceSha,
98
+ snapshotRoot, sourceObservationSha256, rows, enumerationReceipt, policy,
99
+ totals: { rows: rows.length, repositories: rows.length, gists: 0, byStatus: { CURRENT: rows.length } } };
100
+ corpus.coverageGeneration = coverageGenerationFor({ generatorSourceSha, snapshotRoot,
101
+ sourceObservationSha256, rows, enumerationReceipt, policyDispositionDigests: [], exemptionDigests: [] });
102
+ const corpusBytes = `${JSON.stringify(corpus, null, 2)}\n`;
103
+ fs.writeFileSync(path.join(dir, 'CORPUS-COVERAGE.json'), corpusBytes);
104
+ const publicInventory = validatePublicInventory({ assetsDir: dir, coverage: corpus, ledger: publicLedger });
105
+ const release = { ...structuredClone(corpus), kind: 'ruvnet-brain-release-coverage',
106
+ releaseIdentity: { version: source.brainVersion, tag: `v${source.brainVersion}`, sourceSnapshot },
107
+ corpusSeed: { tag: `corpus-sha256-${'e'.repeat(64)}`, archiveSha256: 'e'.repeat(64), archiveBytes: 1,
108
+ receiptSha256: 'f'.repeat(64) },
109
+ corpusCoverage: { file: 'CORPUS-COVERAGE.json', sha256: crypto.createHash('sha256').update(corpusBytes).digest('hex'),
110
+ coverageGeneration: corpus.coverageGeneration },
111
+ generationLedger: { file: 'PUBLIC-RVF-GENERATIONS.json', sha256: crypto.createHash('sha256').update(publicLedgerBytes).digest('hex'),
112
+ bytes: publicLedgerBytes.length, storeCount: storeNames.length },
113
+ publicInventoryPartitionSha256: publicInventory.partitionSha256,
114
+ installedProjectionSchema: 2 };
115
+ delete release.coverageGeneration;
116
+ release.releaseCoverageGeneration = releaseCoverageGenerationFor(release);
117
+ fs.writeFileSync(path.join(dir, 'COVERAGE.json'), JSON.stringify(release));
118
+ const validation = validateCoverageDirectory(dir, { expectedVersion: source.brainVersion });
119
+ if (!validation.valid) throw new Error(validation.failures.join('; '));
120
+ // Deliberately corrupt the validated fixture only for the early integrity-failure scenario.
121
+ if (NO_RVF) fs.rmSync(path.join(dir, 'fixture.big.rvf'));
65
122
  }
66
123
 
67
124
  console.log(`[build-fixture-kb] staged ${OUT} (mcp: ${!DROP_MCP}, rvf: ${!NO_RVF})`);
@@ -1,20 +1,10 @@
1
1
  #!/usr/bin/env node
2
- // scripts/ci/mutate-hook-timeout.mjs — M-D8c (ADR-058 §D8): register ONE additional hook in the
3
- // PACKED, INSTALLED hooks.json whose body sleeps past its declared timeout, then run
4
- // `--doctor --hooks` again against the SAME installed surface and assert the battery goes RED.
5
- // Proves the hook-FIRE assertion is real (a genuine watchdog catch), not manifest-present —
6
- // tests/integration/install-smoke.mjs's own `--doctor --hooks goes RED when a registered hook
7
- // sleeps past its declared timeout` test is the fast local rehearsal of this exact mechanism
8
- // against a synthetic surface; this runs it against the REAL packed-and-installed plugin tree a
9
- // stranger's machine actually has, right after the `healthy` scenario's install.
10
- //
11
- // node scripts/ci/mutate-hook-timeout.mjs --installed <dir> --home <dir>
2
+ // Legacy filename: verify forbidden automatic registration is refused without executing its body.
3
+ // Uses the actual installed package manifest and doctor, not a synthetic status callback.
12
4
  import fs from 'node:fs';
13
5
  import path from 'node:path';
14
6
  import { spawnSync } from 'node:child_process';
15
- import { fileURLToPath } from 'node:url';
16
7
 
17
- const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
18
8
  const argv = process.argv.slice(2);
19
9
  const arg = (flag) => { const i = argv.indexOf(flag); return i >= 0 && argv[i + 1] ? argv[i + 1] : null; };
20
10
  const INSTALLED = arg('--installed');
@@ -25,46 +15,33 @@ if (!INSTALLED || !HOME_DIR) {
25
15
  process.exit(2);
26
16
  }
27
17
 
28
- const pluginRoot = path.join(HOME_DIR, '.claude', 'plugins', 'marketplaces', 'ruvnet-brain', 'plugin');
18
+ const pluginRoot = path.join(INSTALLED, 'plugin');
29
19
  const hooksFile = path.join(pluginRoot, 'hooks', 'hooks.json');
30
20
  if (!fs.existsSync(hooksFile)) {
31
21
  console.error(`[mutate-hook-timeout] FAIL: no installed hooks.json at ${hooksFile} — run the healthy scenario first`);
32
22
  process.exit(1);
33
23
  }
34
24
 
35
- // The REAL held-open-stdin hang fixture this repo already uses to prove the watchdog
36
- // (tests/fixtures/selfcheck-hooks/hang.mjs) — a synchronous stdin read freezes the event loop, so
37
- // only an EXTERNAL watchdog (never an in-process timer) can catch it.
38
- fs.copyFileSync(
39
- path.join(REPO_ROOT, 'tests', 'fixtures', 'selfcheck-hooks', 'hang.mjs'),
40
- path.join(pluginRoot, 'scripts', 'ci-hang-fixture.mjs'),
41
- );
42
-
43
- const doc = JSON.parse(fs.readFileSync(hooksFile, 'utf8'));
25
+ // Legacy filename retained. The current invariant rejects registration without running its body.
26
+ const original = fs.readFileSync(hooksFile);
27
+ const marker = path.join(HOME_DIR, 'forbidden-hook-executed');
28
+ const body = path.join(pluginRoot, 'scripts', 'ci-forbidden-hook.mjs');
29
+ fs.writeFileSync(body, `import fs from 'node:fs'; fs.writeFileSync(${JSON.stringify(marker)}, 'executed');`);
30
+ const doc = JSON.parse(original);
44
31
  doc.hooks ??= {};
45
- doc.hooks.UserPromptSubmit ??= [];
46
- doc.hooks.UserPromptSubmit.push({
47
- matcher: '*',
48
- hooks: [{ type: 'command', command: 'node "${CLAUDE_PLUGIN_ROOT}/scripts/ci-hang-fixture.mjs"', timeout: 1 }],
49
- });
50
- fs.writeFileSync(hooksFile, JSON.stringify(doc, null, 2));
51
- console.log(`[mutate-hook-timeout] registered a 1s-timeout sleeping hook in ${hooksFile}`);
52
-
53
- const r = spawnSync(process.execPath, [path.join(INSTALLED, 'bin', 'install.mjs'), '--doctor', '--hooks'], {
54
- env: { ...process.env, HOME: HOME_DIR, USERPROFILE: HOME_DIR, RUVNET_BRAIN_TEST: '1' },
55
- input: '',
56
- encoding: 'utf8',
57
- timeout: 120_000,
58
- });
59
- console.log(r.stdout);
60
- if (r.stderr) console.error(r.stderr);
61
-
62
- if (r.status === 0) {
63
- console.error('[mutate-hook-timeout] FAIL: expected --doctor --hooks to go RED with a sleeping hook registered, got exit 0');
64
- process.exit(1);
65
- }
66
- if (!/\bhang\b/.test(r.stdout || '')) {
67
- console.error('[mutate-hook-timeout] FAIL: exited non-zero, but no "hang" violation was named — some OTHER check may have failed instead');
68
- process.exit(1);
32
+ doc.hooks.UserPromptSubmit = [{ hooks: [{ type: 'command', command: 'node "${CLAUDE_PLUGIN_ROOT}/scripts/ci-forbidden-hook.mjs"', timeout: 1 }] }];
33
+ fs.writeFileSync(hooksFile, JSON.stringify(doc));
34
+ try {
35
+ const r = spawnSync(process.execPath, [path.join(INSTALLED, 'bin', 'install.mjs'), '--doctor', '--hooks'], {
36
+ env: { ...process.env, HOME: HOME_DIR, USERPROFILE: HOME_DIR, RUVNET_BRAIN_TEST: '1' },
37
+ input: '', encoding: 'utf8', timeout: 120000,
38
+ });
39
+ console.log(r.stdout);
40
+ if (r.stderr) console.error(r.stderr);
41
+ if (r.status !== 1 || !/retired registry must not declare event UserPromptSubmit/.test(r.stdout || '')
42
+ || fs.existsSync(marker)) throw new Error('doctor did not reject the forbidden installed registration without executing it');
43
+ console.log('[mutate-hook-timeout] PASS — forbidden registration refused; its body did not execute');
44
+ } finally {
45
+ fs.writeFileSync(hooksFile, original);
46
+ fs.rmSync(body, { force: true });
69
47
  }
70
- console.log(`[mutate-hook-timeout] PASS — the battery went RED (exit ${r.status}) with a real "hang" violation`);
@@ -13,7 +13,7 @@
13
13
  //
14
14
  // SCENARIOS:
15
15
  // healthy — a complete fixture bundle. Asserts exit 0, then runs --doctor --hooks and
16
- // asserts AT LEAST ONE hook fired through the INSTALLED registration, and
16
+ // asserts automatic hooks are absent from the INSTALLED registries, and
17
17
  // that no author-local ~/.claude/settings.json exists in this virgin image.
18
18
  // seeded-broken — forge-mcp-all.mjs deleted from the fixture (M-D8a). Asserts exit NON-ZERO.
19
19
  // strict-ungrounded — a healthy-shaped fixture (already never ships forge-ask-all.mjs — see
@@ -29,7 +29,7 @@ import fs from 'node:fs';
29
29
  import path from 'node:path';
30
30
  import os from 'node:os';
31
31
  import { spawnSync, execFileSync } from 'node:child_process';
32
- import { fileURLToPath } from 'node:url';
32
+ import { fileURLToPath, pathToFileURL } from 'node:url';
33
33
  import { selfCheckOuterTimeoutMs } from './stranger-timeout.mjs';
34
34
  import { stageLocalBundle } from './stranger-fixture-stage.mjs';
35
35
 
@@ -83,7 +83,7 @@ function buildKbFixture({ dropMcp, noRvf }) {
83
83
 
84
84
  /**
85
85
  * Marketplace-clone-shaped plugin surface, the REAL plugin/ tree at the candidate SHA — never a
86
- * synthetic fixture — so "at least one hook fired" is a real, meaningful registration.
86
+ * synthetic fixture — so retirement is checked against the real installed registry.
87
87
  */
88
88
  function seedPluginSurface() {
89
89
  const dest = path.join(HOME_DIR, '.claude', 'plugins', 'marketplaces', 'ruvnet-brain', 'plugin');
@@ -181,33 +181,18 @@ if (SCENARIO === 'healthy') {
181
181
  console.log(doctor.stdout);
182
182
  if (doctor.stderr) console.error(doctor.stderr);
183
183
 
184
- // TWO REQUIREMENTS OF D8 CONTRADICTED EACH OTHER HERE, and the contradiction is worth stating
185
- // rather than papering over.
186
- //
187
- // · The hook battery must pass on a healthy image — that is this cell's whole purpose.
188
- // · `--doctor` DELIBERATELY exits non-zero when grounding is unproven. That is the D8 decision:
189
- // the install-time smoke stays non-fatal (an air-gapped machine is not a broken install), but
190
- // the verdict must stop EVAPORATING — it persists to install-state.json and --doctor gates on
191
- // it until a real cited answer clears it.
192
- //
193
- // Every CI image is offline with no model weights, so grounding is ALWAYS unproven here and
194
- // `--doctor` will ALWAYS exit 1. Asserting exit 0 asserted that D8's own design does not work.
195
- //
196
- // So the assertion binds to the SUBSTANCE: the battery must report zero contract violations, and
197
- // any non-zero exit must be attributable ONLY to the grounding verdict. A doctor that fails for a
198
- // hook violation still fails this cell — which is the property that was actually wanted.
184
+ const installedApi = await import(pathToFileURL(path.join(INSTALLED, 'bin', 'install.mjs')).href);
185
+ const retirement = installedApi.automaticHookRetirementStatus(INSTALLED, { scope: 'installed' });
186
+ const installedClaude = installedApi.claudeInstalledHookRetirementStatus({ home: HOME_DIR,
187
+ plugin: { managed: true, installed: true, installPath: path.join(HOME_DIR, '.claude', 'plugins', 'marketplaces', 'ruvnet-brain', 'plugin') } });
188
+ if (!retirement.ok || !installedClaude.ok) fail(`automatic hook retirement failed: ${JSON.stringify({ retirement, installedClaude })}`);
199
189
  const out = `${doctor.stdout || ''}${doctor.stderr || ''}`;
200
- const batteryClean = /Self-check passed/.test(out);
201
- const onlyGrounding = /Grounding UNPROVEN/.test(out) && !/contract violation/.test(out);
202
- if (doctor.status !== 0 && !(batteryClean && onlyGrounding)) {
203
- fail(`--doctor --hooks ended with ${describeExit(doctor)} for a reason other than unproven grounding on a healthy install`);
190
+ if (!/automatic Brain hook retirement: 0 registration\(s\), 0 manifest error\(s\)/.test(out)) fail('doctor did not verify automatic hook retirement');
191
+ const onlyGrounding = /Grounding UNPROVEN/.test(out) && !/automatic hook retirement failed/i.test(out);
192
+ if (doctor.status !== 0 && !(doctor.status === 1 && onlyGrounding)) {
193
+ fail(`doctor failed beyond unproven grounding: ${describeExit(doctor)}`);
204
194
  }
205
- if (!batteryClean) fail('--doctor --hooks did not report a clean hook battery on a healthy install');
206
- const firingsMatch = /registrations from marketplace-clone,\s*\d+\s*stdin regimes each\s*\((\d+)\s*firings\)/.exec(doctor.stdout || '');
207
- if (!firingsMatch) fail('--doctor --hooks output did not name the marketplace-clone registration/firing count at all');
208
- const firings = Number(firingsMatch[1]);
209
- if (!(firings > 0)) fail(`expected at least one hook FIRING through the installed registration, got ${firings}`);
210
- log(`OK — ${firings} real hook firing(s) through the installed marketplace-clone registration`);
195
+ log(`OK — installed automatic hooks retired across ${retirement.files.length} package surfaces and the actual Claude registry`);
211
196
 
212
197
  if (fs.existsSync(authorSettings)) fail('installer must never create an author-local settings.json in a virgin image');
213
198
  log('OK — no author-local ~/.claude/settings.json in this virgin image');