ruvnet-brain 4.0.6 → 4.0.8

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 (47) hide show
  1. package/README.md +3 -3
  2. package/bin/install.mjs +118 -9
  3. package/config/model-router/catalog.template.json +17 -2
  4. package/console/app.js +32 -14
  5. package/console/architecture.html +1 -1
  6. package/data/model-catalog.json +54 -0
  7. package/package.json +2 -1
  8. package/plugin/.claude-plugin/plugin.json +1 -1
  9. package/plugin/.codex-plugin/plugin.json +1 -1
  10. package/plugin/hooks/codex-hooks.json +17 -17
  11. package/plugin/hooks/hooks.json +34 -1
  12. package/plugin/scripts/codex-hook-wrapper.mjs +72 -1
  13. package/plugin/scripts/ground-ruvnet.sh +2 -1
  14. package/plugin/scripts/hook-shim.mjs +13 -6
  15. package/plugin/scripts/lesson-provenance.mjs +21 -0
  16. package/plugin/scripts/lesson-store.mjs +33 -3
  17. package/plugin/scripts/route-dispatch.sh +23 -49
  18. package/plugin/scripts/session-snapshot-contract.mjs +100 -0
  19. package/plugin/scripts/session-snapshot-hook.mjs +34 -0
  20. package/plugin/scripts/session-start-core.mjs +25 -7
  21. package/plugin/scripts/swarm-slot-recycler.mjs +89 -0
  22. package/plugin/skills/release-proof/SKILL.md +4 -4
  23. package/plugin/skills/release-proof/scripts/release-proof.mjs +12 -5
  24. package/plugin/skills/ruvnet-brain/PLAYBOOK.md +19 -4
  25. package/scripts/candidate-host-evidence.mjs +49 -0
  26. package/scripts/capability-registry.mjs +3 -3
  27. package/scripts/ci/learning-replay-codex-adapter.mjs +13 -1
  28. package/scripts/learning-replay-contract.mjs +14 -4
  29. package/scripts/learning-replay-execution.mjs +4 -3
  30. package/scripts/learning-replay-fixture.mjs +23 -2
  31. package/scripts/lesson-lifecycle.mjs +2 -1
  32. package/scripts/lesson-ratify.mjs +6 -3
  33. package/scripts/lesson-seed.mjs +21 -16
  34. package/scripts/model-router-catalog.mjs +16 -0
  35. package/scripts/model-router-engine.mjs +4 -1
  36. package/scripts/onboarding-console.mjs +52 -14
  37. package/scripts/provider-availability.mjs +12 -0
  38. package/scripts/publication-receipt.mjs +126 -34
  39. package/scripts/release-authority.mjs +14 -2
  40. package/scripts/release-evidence-aggregate.mjs +55 -0
  41. package/scripts/release-payload.mjs +100 -0
  42. package/scripts/release-transaction-provider.mjs +413 -0
  43. package/scripts/release-transaction.mjs +404 -0
  44. package/scripts/release.mjs +97 -280
  45. package/scripts/selfcheck.mjs +20 -5
  46. package/scripts/session-snapshot-contract.mjs +1 -0
  47. package/scripts/staged-host-verifier.mjs +103 -0
@@ -8,22 +8,21 @@
8
8
  // and only prints "SHIPPED" when every channel a user touches is proven current and working. There is
9
9
  // no "I think it's fine" — there is pass or fail.
10
10
  //
11
- // It is idempotent and safe to re-run. Each step verifies the REAL artifact (registry, live URL, the
12
- // actual command), never the repo state. Repo state != user experience (the whole lesson).
11
+ // Check-only mode evaluates source. Publish mode consumes a CI-sealed package and receipt, then
12
+ // performs only the staged transaction and public verification. It never rebuilds or retests the
13
+ // source: the immutable artifact is the evidence boundary.
13
14
  //
14
15
  // Usage:
15
16
  // node scripts/release.mjs --check # run every gate READ-ONLY (no publish) — the pre-flight
16
- // node scripts/release.mjs --publish # sync version, npm publish, then run every gate
17
+ // node scripts/release.mjs --publish # publish the exact CI-sealed artifact
17
18
  // node scripts/release.mjs # same as --check
18
19
  //
19
20
  // The gates, in order (fail fast):
20
21
  // A. version single-source-of-truth agrees (sync-version --check)
21
22
  // B. full test suite green (npm test — the 60/60)
22
23
  // C. narrative + unit gates (vitest) incl. the tag/entity-aware "What's new" check
23
- // C+. [--publish only] push to origin/main — ONLY now that A–C are green (a red tree can't reach GitHub)
24
- // D. [--publish only] build + sign bundle, create/update the exact-SHA GitHub Release,
25
- // then npm publish + force `latest` to the shipping version
26
- // E. verify-channels — the LIVE walk of npm / self-update manifest / release bundle+sig / explainer / git
24
+ // D. [--publish only] stage and promote the exact package plus signed RVF bundle
25
+ // E. [--check only] verify current public channels; publish verifies them inside transaction finalization
27
26
 
28
27
  import path from 'node:path';
29
28
  import { fileURLToPath } from 'node:url';
@@ -31,6 +30,10 @@ import { spawnSync, execFileSync } from 'node:child_process';
31
30
  import fs from 'node:fs';
32
31
  import crypto from 'node:crypto';
33
32
  import { validateProtectedPublishInvocation } from './protected-release-invocation.mjs';
33
+ import { runReleaseTransaction } from './release-transaction.mjs';
34
+ import { liveReleaseProvider } from './release-transaction-provider.mjs';
35
+ import { stagedHostVerifier } from './staged-host-verifier.mjs';
36
+ import { verifyPayload } from './release-payload.mjs';
34
37
 
35
38
  const ROOT = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
36
39
  const PUBLISH = process.argv.includes('--publish');
@@ -38,6 +41,8 @@ let protectedCandidate = null;
38
41
  let sealedPackageArtifact = null;
39
42
  let publicationReceiptPath = null;
40
43
  let protectedReleaseMode = 'strict';
44
+ let verifiedPayload = null;
45
+ let aggregateEnvelope = null;
41
46
  const c = { g: (s) => `\x1b[32m${s}\x1b[0m`, r: (s) => `\x1b[31m${s}\x1b[0m`, y: (s) => `\x1b[33m${s}\x1b[0m`, b: (s) => `\x1b[1m${s}\x1b[0m`, dim: (s) => `\x1b[2m${s}\x1b[0m` };
42
47
  const V = () => JSON.parse(fs.readFileSync(path.join(ROOT, 'plugin/.claude-plugin/plugin.json'), 'utf8')).version;
43
48
 
@@ -51,36 +56,6 @@ function runOrDie(label, cmd, args, opts = {}) {
51
56
  }
52
57
  }
53
58
 
54
- function remoteTagCommit(tag) {
55
- const out = execFileSync('git', [
56
- 'ls-remote', 'origin', `refs/tags/${tag}`, `refs/tags/${tag}^{}`,
57
- ], { cwd: ROOT, encoding: 'utf8' }).trim();
58
- if (!out) return '';
59
- const rows = out.split('\n').map((line) => line.trim().split(/\s+/));
60
- return rows.find(([, ref]) => ref?.endsWith('^{}'))?.[0] || rows[0]?.[0] || '';
61
- }
62
-
63
- function recordReleaseTransaction(state, data) {
64
- const file = path.join(ROOT, 'dist', 'release-transaction.json');
65
- fs.mkdirSync(path.dirname(file), { recursive: true });
66
- const next = {
67
- state,
68
- updatedAt: new Date().toISOString(),
69
- ...data,
70
- };
71
- const tmp = `${file}.tmp-${process.pid}`;
72
- fs.writeFileSync(tmp, `${JSON.stringify(next, null, 2)}\n`);
73
- fs.renameSync(tmp, file);
74
- }
75
-
76
- function readReleaseTransaction() {
77
- try {
78
- return JSON.parse(fs.readFileSync(path.join(ROOT, 'dist', 'release-transaction.json'), 'utf8'));
79
- } catch {
80
- return null;
81
- }
82
- }
83
-
84
59
  console.log(`\n${c.b('RuvNet Brain — release / definition-of-done')} ${c.dim('· ' + (PUBLISH ? 'PUBLISH' : 'check-only') + ' · shipping ' + V())}\n`);
85
60
 
86
61
  // The local CLI remains useful as a read-only preflight, but publication authority lives only in
@@ -103,6 +78,23 @@ if (PUBLISH) {
103
78
  console.error(`\n${c.r('✗ PROTECTED RELEASE GATE FAILED')}\n publication receipt output must be a new file inside release-evidence\n`);
104
79
  process.exit(1);
105
80
  }
81
+ const payloadManifestPath = path.resolve(ROOT, process.env.RUVNET_CANDIDATE_PAYLOAD || '');
82
+ const payloadSignaturePath = path.resolve(ROOT, process.env.RUVNET_CANDIDATE_PAYLOAD_SIGNATURE || '');
83
+ const aggregatePath = path.resolve(ROOT, process.env.RUVNET_AGGREGATE_ENVELOPE || '');
84
+ if (![payloadManifestPath, payloadSignaturePath, aggregatePath].every((file) => file.startsWith(`${evidenceRoot}${path.sep}`) && fs.existsSync(file))) {
85
+ throw new Error('protected publication requires persisted payload manifest, signature, and aggregate envelope');
86
+ }
87
+ verifiedPayload = verifyPayload({
88
+ manifest: JSON.parse(fs.readFileSync(payloadManifestPath, 'utf8')),
89
+ signature: fs.readFileSync(payloadSignaturePath, 'utf8'),
90
+ publicKey: crypto.createPublicKey(fs.readFileSync(path.join(ROOT, 'keys/ruvnet-brain-signing.pub.pem'), 'utf8')),
91
+ root: path.dirname(payloadManifestPath),
92
+ });
93
+ aggregateEnvelope = JSON.parse(fs.readFileSync(aggregatePath, 'utf8'));
94
+ if (aggregateEnvelope.verdict !== 'PASS' || aggregateEnvelope.sha !== protectedCandidate.sha
95
+ || aggregateEnvelope.payloadId !== verifiedPayload.payloadId) {
96
+ throw new Error('aggregate envelope does not bind the protected candidate payload');
97
+ }
106
98
  }
107
99
 
108
100
  // A verdict is only about the exact committed candidate. Check-only used to permit a dirty tree
@@ -115,10 +107,12 @@ if (initialDirty) {
115
107
  process.exit(1);
116
108
  }
117
109
 
118
- // A. version single source of truth
119
- step('A', 'version single-source-of-truth agrees across every surface');
120
- runOrDie('version sync', process.execPath, ['scripts/sync-version.mjs', '--check']);
121
- runOrDie('one protected publisher', process.execPath, ['scripts/release-authority.mjs']);
110
+ if (!PUBLISH) {
111
+ // Source gates belong to candidate CI/check-only mode. The protected publisher has already
112
+ // validated their exact-SHA receipt and the sealed bytes before reaching this process.
113
+ step('A', 'version single-source-of-truth agrees across every surface');
114
+ runOrDie('version sync', process.execPath, ['scripts/sync-version.mjs', '--check']);
115
+ runOrDie('one protected publisher', process.execPath, ['scripts/release-authority.mjs']);
122
116
 
123
117
  // WIRED-CHECK — refuses to ship a module with zero callers.
124
118
  //
@@ -130,23 +124,23 @@ runOrDie('one protected publisher', process.execPath, ['scripts/release-authorit
130
124
  //
131
125
  // Seven repetitions of one mistake is not a discipline problem; discipline is what failed. So it
132
126
  // becomes a gate, on the ship path, where this repo's gates run 8/8 against prose's 0/6.
133
- runOrDie('wired (no orphan modules)', process.execPath, ['scripts/wired-check.mjs', '--check']);
127
+ runOrDie('wired (no orphan modules)', process.execPath, ['scripts/wired-check.mjs', '--check']);
134
128
 
135
129
  // THE NORTH-STAR PROMOTION VECTOR — strict/check-only releases may not average one broken or
136
130
  // unknown invariant into a pass. The separately authorized stabilization class makes no 95 claim;
137
131
  // it retains every safety, test, artifact, publication, and post-publication gate below while the
138
132
  // promotion program remains open. Derive this only from the already-validated sealed receipt, never
139
133
  // from a free-standing environment toggle.
140
- if (protectedReleaseMode === 'strict') {
141
- runOrDie('release vector (all critical invariants PASS)', process.execPath, ['scripts/release-vector.mjs']);
134
+ if (protectedReleaseMode === 'strict') {
135
+ runOrDie('release vector (all critical invariants PASS)', process.execPath, ['scripts/release-vector.mjs']);
142
136
 
143
137
  // The Top-100 corpus spans naive through expert prompts and grades semantic clauses, citations,
144
138
  // abstention, and latency. A manual-only benchmark is a report; a strict release-path benchmark
145
139
  // is a guarantee. The benchmark itself fails closed unless all 100 canonical questions run.
146
- runOrDie('Top-100 source-grounded recall contract', process.execPath, ['scripts/top100-benchmark.mjs', '--no-write']);
147
- } else {
148
- console.log(c.y(' strict >=95 promotion gates: NOT CLAIMED (sealed stabilization; scoreClaimed:false)'));
149
- }
140
+ runOrDie('Top-100 source-grounded recall contract', process.execPath, ['scripts/top100-benchmark.mjs', '--no-write']);
141
+ } else {
142
+ console.log(c.y(' strict >=95 promotion gates: NOT CLAIMED (sealed stabilization; scoreClaimed:false)'));
143
+ }
150
144
 
151
145
  // A2. Stable Spine restart classifier (ADR-023, red-team finding 18): diff the boot-frozen SHELL
152
146
  // (hooks.json, hook-shim, MCP server, .mcp.json, skills/, commands/) against the previous release
@@ -154,8 +148,8 @@ if (protectedReleaseMode === 'strict') {
154
148
  // remembered — the same shellDiff logic runs client-side in update-apply.mjs at every flip, so the
155
149
  // user-facing nag stays honest even if this print is ignored. Informational at ship time; the
156
150
  // releasing human sees exactly which shell files changed.
157
- step('A2', 'Stable Spine — does this release change the boot-frozen shell? (requiresRestart classifier)');
158
- {
151
+ step('A2', 'Stable Spine — does this release change the boot-frozen shell? (requiresRestart classifier)');
152
+ {
159
153
  const { execFileSync } = await import('node:child_process');
160
154
  const SHELL = ['plugin/hooks/hooks.json', 'plugin/scripts/hook-shim.mjs', 'plugin/mcp/server.mjs', 'plugin/.mcp.json', 'plugin/skills', 'plugin/commands'];
161
155
  let prevTag = '';
@@ -176,260 +170,83 @@ step('A2', 'Stable Spine — does this release change the boot-frozen shell? (re
176
170
  console.log(` ${c.g('requiresRestart: false')} — no shell change vs ${prevTag}; this release goes fully live with zero restarts.`);
177
171
  }
178
172
  }
179
- }
173
+ }
180
174
 
181
175
  // B. the full brain test suite (the 60/60)
182
- step('B', 'full test suite (npm test)');
183
- runOrDie('npm test', 'npm', ['test']);
176
+ step('B', 'full test suite (npm test)');
177
+ runOrDie('npm test', 'npm', ['test']);
184
178
 
185
179
  // C. unit gates — narrative-version (tag/entity aware), claims, etc.
186
- step('C', 'unit gates (vitest) — narrative version, claims, guards');
187
- runOrDie('vitest unit', 'npx', ['vitest', 'run', 'tests/unit']);
188
-
189
- // C+. PUSH — only now that A–C are green (publish only). Pushing AFTER the local gates is the fix
190
- // for the drift that bit on 2026-07-18: a commit was pushed FIRST, then release.mjs's gate B caught a
191
- // failing plugin-battery test, leaving GitHub at 3.4.10-dev while npm sat at 3.4.9-dev — the exact
192
- // "pushed but didn't finish" split. The pre-push git hook only checks version/manifest (fast, always),
193
- // so tests must gate the push HERE. A red tree can no longer reach origin ahead of npm.
194
- if (PUBLISH) {
195
- // C++. REMOTE CI IS A SHIP GATE (ADR-053 §5). Between 2026-07-21 and 07-26 the `ci` workflow was
196
- // red for ~70 consecutive runs — six releases shipped right past it, because nothing on the ship
197
- // path ever ASKED the remote verdict. Local gates prove this machine; only CI proves ubuntu and
198
- // windows. So the latest COMPLETED run on origin/main must be green before we add commits on top
199
- // and publish. (The current commit's own run starts after the push — this gate is "never build on
200
- // a known-broken main", not "wait for my own run".) Escape hatch for a genuine hotfix:
201
- // --ci-override "<reason>" — printed into the release log, never silent.
202
- step('C++', 'remote CI on origin/main is green (the ubuntu+windows verdict this machine cannot produce)');
203
- {
204
- const { fetchLatestCiVerdict, assessCiGate } = await import('./ci-verdict.mjs');
205
- const OVERRIDE_IX = process.argv.indexOf('--ci-override');
206
- const overrideReason = OVERRIDE_IX >= 0 ? (process.argv[OVERRIDE_IX + 1] || '(no reason given)') : null;
207
- const { verdict, sha } = await fetchLatestCiVerdict();
208
- const gate = assessCiGate(verdict, overrideReason);
209
- if (gate === 'ship') {
210
- console.log(c.dim(` latest completed ci run on origin/main: success (${sha})`));
211
- } else if (gate === 'override') {
212
- console.log(` ${c.y('! CI gate OVERRIDDEN')} — verdict was ${verdict ?? 'unknown'} (${sha || 'no run found'}); reason: ${overrideReason}`);
213
- } else {
214
- console.error(`\n${c.r('✗ GATE FAILED: remote CI on origin/main is ' + (verdict ?? 'unknown'))} ${c.dim('(' + (sha || 'no completed run found') + ')')}`);
215
- console.error(`${c.r(' A red or unknown main does not get shipped on top of. Fix CI first (gh run list --workflow ci.yml),')}`);
216
- console.error(`${c.r(' or for a genuine hotfix: --ci-override "<reason>" (the reason is printed into the release log).')}\n`);
217
- process.exit(1);
218
- }
219
- }
220
-
221
- step('C+', 'push to origin/main — safe now that A–C passed');
222
- let ahead = '0';
223
- try { ahead = execFileSync('git', ['-C', ROOT, 'rev-list', '--count', 'origin/main..HEAD'], { encoding: 'utf8' }).trim(); } catch { /* origin/main ref missing — push will resolve */ ahead = '?'; }
224
- if (ahead === '0') console.log(c.dim(' nothing to push — HEAD already on origin/main'));
225
- else runOrDie('git push', 'git', ['-C', ROOT, 'push', 'origin', 'main']);
180
+ step('C', 'unit gates (vitest) — narrative version, claims, guards');
181
+ runOrDie('vitest unit', 'npx', ['vitest', 'run', 'tests/unit']);
226
182
  }
227
183
 
228
- // D. Publish BOTH delivery channels. This used to advance npm without creating the GitHub Release
229
- // that verify-channels immediately required, making the sanctioned manual ship path impossible to
230
- // complete. Build and sign first, then create/update the exact-SHA Release before npm advances.
231
- // Re-runs are idempotent: a matching Release gets its three assets replaced; a tag bound to any
232
- // other commit is a hard provenance failure.
184
+ // D. One remotely durable, staged release transaction (ADR-062 / DDD-0015). GitHub remains a draft
185
+ // and npm remains on a non-default candidate tag until exact bytes and all host fixtures pass.
233
186
  if (PUBLISH) {
234
187
  const v = V();
235
188
  const tag = `v${v}`;
236
- const zip = path.join(ROOT, 'dist', 'ruvnet-brain.zip');
237
- const assets = [zip, `${zip}.sig`, `${zip}.sha256`, sealedPackageArtifact];
238
189
  const head = execFileSync('git', ['-C', ROOT, 'rev-parse', 'HEAD'], { encoding: 'utf8' }).trim();
239
- const priorTxn = readReleaseTransaction();
240
- const unfinished = priorTxn && priorTxn.state !== 'channels-converged';
241
- const samePendingCandidate = unfinished && priorTxn.tag === tag && priorTxn.head === head;
242
-
243
- step('D', 'build + sign bundle and publish matching GitHub/npm channels');
244
- if (unfinished && !samePendingCandidate) {
245
- console.error(`\n${c.r('✗ GATE FAILED: unfinished release transaction requires reconciliation')}`);
246
- console.error(c.dim(` ${priorTxn.state}: ${priorTxn.tag || '?'} @ ${priorTxn.head || '?'}; refusing to overwrite it with ${tag} @ ${head}`));
247
- process.exit(1);
248
- }
249
- if (samePendingCandidate) {
250
- if (!assets.every((asset) => fs.existsSync(asset))) {
251
- console.error(`\n${c.r('✗ GATE FAILED: pending release assets are missing; reconcile before retrying')}`);
252
- process.exit(1);
253
- }
254
- const declared = fs.readFileSync(`${zip}.sha256`, 'utf8').trim().split(/\s+/)[0];
255
- const actual = crypto.createHash('sha256').update(fs.readFileSync(zip)).digest('hex');
256
- const verify = spawnSync(process.execPath, ['scripts/verify-bundle.mjs', zip, `${zip}.sig`], {
257
- cwd: ROOT, encoding: 'utf8',
258
- });
259
- if (declared !== priorTxn.bundleSha256 || actual !== declared || verify.status !== 0) {
260
- console.error(`\n${c.r('✗ GATE FAILED: pending release assets do not match their signed transaction')}`);
261
- process.exit(1);
262
- }
263
- console.log(c.dim(' resume existing signed release assets for the pending candidate'));
264
- } else {
265
- const buildArgs = ['scripts/build-bundle.mjs', '--version', tag];
266
- if (process.env.RUVNET_RELEASE_ASSETS) buildArgs.push('--assets', process.env.RUVNET_RELEASE_ASSETS);
267
- runOrDie('build release bundle', process.execPath, buildArgs);
268
- runOrDie('sign release bundle', process.execPath, ['scripts/sign-bundle.mjs', '--bundle', zip]);
269
- }
270
- for (const asset of assets) {
190
+ step('D', 'verify, stage, promote, and reconcile one persisted signed payload');
191
+ const payloadRoot = verifiedPayload.root;
192
+ const byRole = new Map(verifiedPayload.manifest.members.map((member) => [member.role, path.join(payloadRoot, member.name)]));
193
+ const assets = {
194
+ bundlePath: byRole.get('bundle'),
195
+ bundleSignaturePath: path.join(payloadRoot, 'ruvnet-brain.zip.sig'),
196
+ bundleDigestPath: path.join(payloadRoot, 'ruvnet-brain.zip.sha256'),
197
+ packagePath: byRole.get('npm'),
198
+ };
199
+ for (const asset of Object.values(assets)) {
271
200
  if (!fs.existsSync(asset)) {
272
201
  console.error(`\n${c.r('✗ GATE FAILED: signed release asset missing')} ${c.dim(asset)}`);
273
202
  process.exit(1);
274
203
  }
275
204
  }
276
- const bundleSha256 = fs.readFileSync(`${zip}.sha256`, 'utf8').trim().split(/\s+/)[0];
205
+ const bundleSha256 = fs.readFileSync(assets.bundleDigestPath, 'utf8').trim().split(/\s+/)[0];
277
206
  if (!/^[a-f0-9]{64}$/i.test(bundleSha256)) {
278
207
  console.error(`\n${c.r('✗ GATE FAILED: release digest is not a SHA-256 value')}`);
279
208
  process.exit(1);
280
209
  }
281
210
 
282
- let remoteTagSha = '';
283
- try {
284
- remoteTagSha = remoteTagCommit(tag);
285
- } catch (e) {
286
- console.error(`\n${c.r('✗ GATE FAILED: could not verify remote release tag')} ${c.dim(String(e.message || e).split('\n')[0])}`);
287
- process.exit(1);
288
- }
289
- if (remoteTagSha && remoteTagSha !== head) {
290
- console.error(`\n${c.r('✗ GATE FAILED: release tag already identifies different bytes')}`);
291
- console.error(c.dim(` ${tag} -> ${remoteTagSha}; candidate HEAD -> ${head}`));
292
- process.exit(1);
293
- }
294
-
295
- // Cross-provider publication cannot be truly atomic. Persist the exact convergence state before
296
- // the first remote mutation so a failed npm publish is recoverable and the next run can converge
297
- // the SAME tag/HEAD instead of guessing which channel moved.
298
- if (priorTxn && !['channels-converged'].includes(priorTxn.state)) {
299
- const sameCandidate = priorTxn.tag === tag
300
- && priorTxn.head === head
301
- && priorTxn.bundleSha256 === bundleSha256;
302
- if (!sameCandidate) {
303
- console.error(`\n${c.r('✗ GATE FAILED: unfinished release transaction requires reconciliation')}`);
304
- if (priorTxn.tag === tag && priorTxn.head === head && priorTxn.bundleSha256 !== bundleSha256) {
305
- console.error(c.dim(' release transaction artifact digest changed for the same tag and HEAD'));
306
- }
307
- console.error(c.dim(` ${priorTxn.state}: ${priorTxn.tag || '?'} @ ${priorTxn.head || '?'}; refusing to overwrite it with ${tag} @ ${head}`));
308
- process.exit(1);
309
- }
310
- }
311
- recordReleaseTransaction('prepared', { version: v, tag, head, bundleSha256 });
312
-
313
- let releaseExists = false;
314
- try {
315
- execFileSync('gh', ['release', 'view', tag, '--repo', 'stuinfla/ruvnet-brain'], {
316
- cwd: ROOT, stdio: ['ignore', 'ignore', 'ignore'],
317
- });
318
- releaseExists = true;
319
- } catch { /* absent is the expected first-publish state */ }
320
-
321
- if (releaseExists) {
322
- if (!remoteTagSha) {
323
- console.error(`\n${c.r('✗ GATE FAILED: GitHub Release exists without a verifiable matching tag')} ${c.dim(tag)}`);
324
- process.exit(1);
325
- }
326
- runOrDie('replace signed GitHub Release assets', 'gh', [
327
- 'release', 'upload', tag, ...assets, '--clobber', '--repo', 'stuinfla/ruvnet-brain',
328
- ]);
329
- } else {
330
- runOrDie('create signed GitHub Release', 'gh', [
331
- 'release', 'create', tag, ...assets,
332
- '--repo', 'stuinfla/ruvnet-brain',
333
- '--target', head,
334
- '--title', `${tag} — verified release`,
335
- '--generate-notes',
336
- '--latest',
337
- ]);
338
- }
339
-
340
- // Confirm the tag GitHub created/retained points to the candidate before touching npm.
341
- let publishedTagSha = '';
342
- try {
343
- publishedTagSha = remoteTagCommit(tag);
344
- } catch { /* handled by the mismatch below */ }
345
- if (publishedTagSha !== head) {
346
- console.error(`\n${c.r('✗ GATE FAILED: published GitHub Release tag is not candidate HEAD')}`);
347
- console.error(c.dim(` ${tag} -> ${publishedTagSha || '(missing)'}; candidate HEAD -> ${head}`));
348
- process.exit(1);
211
+ const packageIntegrity = `sha512-${crypto.createHash('sha512').update(fs.readFileSync(sealedPackageArtifact)).digest('base64')}`;
212
+ if (assets.packagePath !== sealedPackageArtifact
213
+ && !fs.readFileSync(assets.packagePath).equals(fs.readFileSync(sealedPackageArtifact))) {
214
+ throw new Error('candidate receipt package and payload package bytes differ');
349
215
  }
350
- recordReleaseTransaction('github-published-npm-pending', { version: v, tag, head, bundleSha256 });
351
-
352
- let already = '';
353
- try { already = execFileSync('npm', ['view', `ruvnet-brain@${v}`, 'version'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); } catch { /* not published yet */ }
354
- if (already === v) console.log(c.dim(` ${v} already on npm — skipping publish, just re-asserting the tag`));
355
- // npm requires an explicit tag for prerelease versions. This project intentionally serves its
356
- // `-dev` release from `latest`, so make that policy explicit on the publish command itself.
357
- else runOrDie('npm publish', 'npm', ['publish', sealedPackageArtifact, '--tag', 'latest']);
358
- // npm does NOT auto-move `latest` to a prerelease (x.y.z-dev) — force it, or `@latest` stays stale.
359
- runOrDie('npm dist-tag latest', 'npm', ['dist-tag', 'add', `ruvnet-brain@${v}`, 'latest']);
360
- recordReleaseTransaction('publish-complete-verification-pending', { version: v, tag, head, bundleSha256 });
216
+ const identity = {
217
+ repository: 'stuinfla/ruvnet-brain', package: 'ruvnet-brain', version: v, tag,
218
+ candidateSha: head,
219
+ payloadId: verifiedPayload.payloadId,
220
+ evidenceDigest: aggregateEnvelope.evidenceDigest,
221
+ packageIntegrity,
222
+ packageSha256: crypto.createHash('sha256').update(fs.readFileSync(assets.packagePath)).digest('hex'),
223
+ packageAssetName: path.basename(assets.packagePath),
224
+ bundleSha256,
225
+ bundleSignatureSha256: crypto.createHash('sha256').update(fs.readFileSync(assets.bundleSignaturePath)).digest('hex'),
226
+ bundleDigestSha256: crypto.createHash('sha256').update(fs.readFileSync(assets.bundleDigestPath)).digest('hex'),
227
+ };
228
+ const privatePem = process.env.RUVNET_SIGNING_KEY;
229
+ if (!privatePem) throw new Error('RUVNET_SIGNING_KEY is required for signed transaction receipts');
230
+ const finalReceipt = await runReleaseTransaction({
231
+ identity, assets, adapter: liveReleaseProvider({
232
+ root: ROOT,
233
+ candidateReceipt: process.env.RUVNET_CANDIDATE_RECEIPT,
234
+ publicationReceipt: process.env.RUVNET_PUBLICATION_RECEIPT,
235
+ }),
236
+ privateKey: crypto.createPrivateKey(privatePem),
237
+ publicKey: crypto.createPublicKey(fs.readFileSync(path.join(ROOT, 'keys/ruvnet-brain-signing.pub.pem'), 'utf8')),
238
+ hostVerifier: stagedHostVerifier({ assets, identity }),
239
+ });
240
+ if (finalReceipt.state !== 'channels-converged') throw new Error(`release transaction stopped at ${finalReceipt.state}`);
361
241
  } else {
362
- step('D', 'GitHub Release + npm publish — SKIPPED (check-only; pass --publish to publish)');
363
- }
364
-
365
- // D+. THE DEPLOY-SURFACE SWEEP (owner standing order, 2026-07-27): "ALWAYS check GitHub CLI and
366
- // Vercel CLI for gotchas with anything you're pushing. This needs to be part of the protocol you use
367
- // whenever you deploy. I don't want to have to tell you this again."
368
- //
369
- // Gate C++ already asks whether CI passed. That is one surface. This asks the two CLIs what the
370
- // PLATFORMS think — failing workflows other than our own ci, security advisories, and whether the
371
- // production deployment that serves the explainer is actually Ready. Each is a question a human
372
- // would otherwise have to remember to ask, which is the definition of a check that eventually
373
- // doesn't happen.
374
- //
375
- // ADVISORY BY DESIGN, LOUD BY CONTRACT: this prints findings and does not exit non-zero, because a
376
- // GitHub-side hiccup must not wedge a correct release — EXCEPT where it overlaps a hard gate that
377
- // already exists (C++ for ci, E for the live explainer). Anything it finds is printed in full so it
378
- // cannot be a diagnostic nobody reads.
379
- step('D+', 'deploy-surface sweep — what GitHub and Vercel think about what we are pushing');
380
- {
381
- const sh = (cmd, args) => { try { return execFileSync(cmd, args, { encoding: 'utf8', stdio: ['ignore','pipe','ignore'], timeout: 45000 }); } catch { return null; } };
382
-
383
- // 1. Failing workflow runs that are NOT our ci (ci is gate C++'s job). issue-watch exits 1 BY
384
- // DESIGN on an SLA breach, so it is reported as an SLA signal, never as a broken pipeline —
385
- // conflating the two is how a permanently-red workflow trains everyone to ignore red.
386
- const runs = sh('gh', ['run','list','--repo','stuinfla/ruvnet-brain','--limit','15','--json','name,conclusion,headBranch']);
387
- if (runs) {
388
- let bad = [];
389
- try { bad = JSON.parse(runs).filter((r) => r.conclusion && r.conclusion !== 'success' && r.name !== 'ci'); } catch { /* unparseable — reported below */ }
390
- const sla = bad.filter((r) => r.name === 'issue-watch');
391
- const real = bad.filter((r) => r.name !== 'issue-watch');
392
- if (sla.length) console.log(` ${c.y('! issue-watch red x' + sla.length)} ${c.dim('— by design: an open issue is past its 4h SLA. Answer the issue, do not fix the workflow.')}`);
393
- if (real.length) console.log(` ${c.y('! non-ci workflows failing:')} ${real.map((r) => r.name).join(', ')}`);
394
- if (!sla.length && !real.length) console.log(c.dim(' no failing workflows outside ci'));
395
- } else console.log(c.dim(' gh unavailable — workflow sweep SKIPPED (not a pass)'));
396
-
397
- // 2. Security advisories against what we ship.
398
- const dep = sh('gh', ['api','repos/stuinfla/ruvnet-brain/dependabot/alerts','--jq','[.[]|select(.state=="open")]|length']);
399
- if (dep !== null) {
400
- const n = parseInt(dep.trim(), 10);
401
- console.log(n > 0 ? ` ${c.r('! ' + n + ' open dependabot alert(s)')}` : c.dim(' 0 open dependabot alerts'));
402
- } else console.log(c.dim(' dependabot query unavailable — SKIPPED (not a pass)'));
403
-
404
- // 3. Vercel: the explainer is a shipped surface; a Ready production deployment is the precondition
405
- // for gate E's live check meaning anything.
406
- const vc = sh('vercel', ['ls','--yes']);
407
- if (vc) {
408
- const prod = vc.split('\n').find((l) => l.includes('Production'));
409
- const ready = prod && /●\s*Ready/.test(prod);
410
- console.log(ready ? c.dim(' vercel: latest production deployment Ready') : ` ${c.y('! vercel: latest production deployment is NOT Ready')} ${c.dim((prod||'').trim().slice(0,90))}`);
411
- } else console.log(c.dim(' vercel CLI unavailable/not logged in — SKIPPED (not a pass)'));
242
+ step('D', 'remote staged release transaction — SKIPPED (check-only; pass --publish to publish)');
412
243
  }
413
244
 
414
- // E. the live channel walk — THE gate that would have caught the stale-2.9.1 + 404
415
- step('E', 'verify-channels — the live walk of every user path');
416
- runOrDie('verify-channels', process.execPath, ['scripts/verify-channels.mjs']);
417
- if (PUBLISH) {
418
- runOrDie('publication receipt', process.execPath, [
419
- 'scripts/publication-receipt.mjs', '--candidate', process.env.RUVNET_CANDIDATE_RECEIPT,
420
- '--out', process.env.RUVNET_PUBLICATION_RECEIPT,
421
- ]);
422
- runOrDie('publication seal', process.execPath, [
423
- 'scripts/release-proof.mjs', '--candidate', process.env.RUVNET_CANDIDATE_RECEIPT,
424
- '--publication', process.env.RUVNET_PUBLICATION_RECEIPT,
425
- ]);
426
- const txn = readReleaseTransaction();
427
- recordReleaseTransaction('channels-converged', {
428
- version: V(),
429
- tag: `v${V()}`,
430
- head: execFileSync('git', ['-C', ROOT, 'rev-parse', 'HEAD'], { encoding: 'utf8' }).trim(),
431
- bundleSha256: txn?.bundleSha256 ?? null,
432
- });
245
+ // Check-only diagnoses the currently public channels. During publication, transaction finalization
246
+ // performs this walk once, then creates and verifies the publication receipt before convergence.
247
+ if (!PUBLISH) {
248
+ step('E', 'verify-channels — the live walk of every user path');
249
+ runOrDie('verify-channels', process.execPath, ['scripts/verify-channels.mjs']);
433
250
  }
434
251
 
435
252
  if (PUBLISH) {
@@ -159,17 +159,31 @@ export function resolveInstalledSurface({ home = os.homedir(), repo = null } = {
159
159
  for (const v of fs.readdirSync(cache)) {
160
160
  const root = path.join(cache, v);
161
161
  if (fs.existsSync(path.join(root, 'hooks', 'hooks.json'))) {
162
- candidates.push({ root, source: `installed:${v}`, mtime: fs.statSync(path.join(root, 'hooks', 'hooks.json')).mtimeMs });
162
+ candidates.push({ root, source: `installed:${v}`, hooksFile: path.join(root, 'hooks', 'hooks.json'), mtime: fs.statSync(path.join(root, 'hooks', 'hooks.json')).mtimeMs });
163
163
  }
164
164
  }
165
165
  } catch { /* no packed install on this machine */ }
166
166
  candidates.sort((a, b) => b.mtime - a.mtime); // newest generation wins; several can coexist
167
167
  const clone = path.join(home, '.claude', 'plugins', 'marketplaces', 'ruvnet-brain', 'plugin');
168
168
  if (fs.existsSync(path.join(clone, 'hooks', 'hooks.json'))) {
169
- candidates.push({ root: clone, source: 'marketplace-clone', mtime: 0 });
169
+ candidates.push({ root: clone, source: 'marketplace-clone', hooksFile: path.join(clone, 'hooks', 'hooks.json'), mtime: 0 });
170
170
  }
171
+ // Codex installs the same immutable payload under its own cache. A Codex-only machine has no
172
+ // Claude marketplace clone, so treating that layout as "no plugin" made the real installed
173
+ // Codex hooks impossible to self-check (the release host matrix caught this). Keep the same
174
+ // newest-generation rule and read the Codex hook registry from the installed payload itself.
175
+ const codexCache = path.join(home, '.codex', 'plugins', 'cache', 'ruvnet-brain', 'ruvnet-brain');
176
+ try {
177
+ for (const v of fs.readdirSync(codexCache)) {
178
+ const root = path.join(codexCache, v);
179
+ if (fs.existsSync(path.join(root, 'hooks', 'codex-hooks.json'))) {
180
+ candidates.push({ root, source: `codex-installed:${v}`, hooksFile: path.join(root, 'hooks', 'codex-hooks.json'), mtime: fs.statSync(path.join(root, 'hooks', 'codex-hooks.json')).mtimeMs, codex: true });
181
+ }
182
+ }
183
+ } catch { /* no Codex plugin cache on this machine */ }
171
184
  if (repo && fs.existsSync(path.join(repo, 'plugin', 'hooks', 'hooks.json'))) {
172
- candidates.push({ root: path.join(repo, 'plugin'), source: 'checkout', mtime: 0 });
185
+ const root = path.join(repo, 'plugin');
186
+ candidates.push({ root, source: 'checkout', hooksFile: path.join(root, 'hooks', 'hooks.json'), mtime: 0 });
173
187
  }
174
188
  const chosen = candidates[0];
175
189
  if (!chosen) return { ok: false, reason: 'no installed ruvnet-brain plugin payload found on this machine' };
@@ -177,8 +191,9 @@ export function resolveInstalledSurface({ home = os.homedir(), repo = null } = {
177
191
  ok: true,
178
192
  root: chosen.root,
179
193
  source: chosen.source,
180
- hooksFile: path.join(chosen.root, 'hooks', 'hooks.json'),
194
+ hooksFile: chosen.hooksFile,
181
195
  shimFile: path.join(chosen.root, 'scripts', 'hook-shim.mjs'),
196
+ codex: Boolean(chosen.codex),
182
197
  alternates: candidates.slice(1).map((c) => c.source),
183
198
  };
184
199
  }
@@ -480,7 +495,7 @@ export async function runBattery({ home = os.homedir(), repo = null, cwd = os.tm
480
495
  const shimId = reg.shimIdIn(r.command);
481
496
  const entry = shimId ? table[shimId] : null;
482
497
  const contract = entry ? null : contracts.find((c) => reg.contractMatches(c, { layer: 'plugin', event: r.event, matcher: r.matcher, command: r.command }));
483
- const mode = entry?.mode ?? contract?.mode ?? null;
498
+ const mode = entry?.mode ?? contract?.mode ?? (s.codex ? 'advisory' : null);
484
499
  const handler = entry?.file ?? reg.basenamesIn(r.command).filter((b) => b !== 'hook-shim.mjs').pop() ?? null;
485
500
  const rec = { ...r, handler, shimId, mode };
486
501
 
@@ -0,0 +1 @@
1
+ export * from '../plugin/scripts/session-snapshot-contract.mjs';