ruvnet-brain 4.3.9 → 4.3.10

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 (83) 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 +11 -2
  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 +472 -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/ruvnet-brain/PLAYBOOK.md +15 -23
  36. package/scripts/build-bundle.mjs +8 -0
  37. package/scripts/build-primer.mjs +2 -3
  38. package/scripts/candidate-host-evidence.mjs +59 -41
  39. package/scripts/ci/build-fixture-kb.mjs +59 -2
  40. package/scripts/ci/mutate-hook-timeout.mjs +24 -47
  41. package/scripts/ci/stranger-scenario.mjs +13 -28
  42. package/scripts/claims-verify.mjs +219 -21
  43. package/scripts/console-engine.mjs +4 -18
  44. package/scripts/console-runtime-identity.mjs +6 -0
  45. package/scripts/development-maintenance.mjs +47 -0
  46. package/scripts/development-push-check.mjs +35 -0
  47. package/scripts/distill-project.mjs +24 -47
  48. package/scripts/doc-currency.mjs +91 -19
  49. package/scripts/git-hooks/pre-push +4 -162
  50. package/scripts/health-repair.mjs +22 -4
  51. package/scripts/hook-retirement-check.mjs +22 -0
  52. package/scripts/host-install-matrix.mjs +125 -61
  53. package/scripts/integration-evidence.mjs +11 -6
  54. package/scripts/learning-replay-fixture.mjs +23 -2
  55. package/scripts/nightly-two-run-proof.mjs +415 -0
  56. package/scripts/nightly-watchdog.mjs +17 -2
  57. package/scripts/npm-invocation.mjs +20 -0
  58. package/scripts/prepublication-evidence.mjs +42 -6
  59. package/scripts/primer-grounding.mjs +20 -0
  60. package/scripts/product-integrity-contract.mjs +22 -2
  61. package/scripts/public-verification-abandon.mjs +101 -0
  62. package/scripts/public-verification-aggregate.mjs +53 -19
  63. package/scripts/public-verification-finalizer.mjs +8 -2
  64. package/scripts/public-verification-lane.mjs +63 -8
  65. package/scripts/publication-receipt.mjs +109 -45
  66. package/scripts/published-surface-probe.mjs +10 -2
  67. package/scripts/qa-contract.mjs +57 -0
  68. package/scripts/qa-lanes.mjs +40 -0
  69. package/scripts/qa-runner.mjs +54 -64
  70. package/scripts/qe/ux-suite.mjs +10 -34
  71. package/scripts/qualified-candidate-check.mjs +124 -0
  72. package/scripts/release-abort-stale.mjs +2 -2
  73. package/scripts/release-projection.mjs +20 -1
  74. package/scripts/release-qualification-contract.mjs +87 -0
  75. package/scripts/release-qualification.mjs +135 -0
  76. package/scripts/release-transaction.mjs +81 -1
  77. package/scripts/remedy-registry.mjs +6 -13
  78. package/scripts/retrieval-canary.mjs +91 -18
  79. package/scripts/selfcheck.mjs +12 -4
  80. package/scripts/snapshot-freshness.mjs +58 -0
  81. package/scripts/stack-sync.mjs +41 -43
  82. package/scripts/staged-host-verifier.mjs +63 -14
  83. 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,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');