ruvnet-brain 4.0.5 → 4.0.7
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.
- package/README.md +3 -3
- package/bin/install.mjs +112 -7
- package/config/model-router/catalog.template.json +17 -2
- package/console/app.js +32 -14
- package/console/architecture.html +1 -1
- package/data/model-catalog.json +54 -0
- package/package.json +2 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/hooks/codex-hooks.json +17 -17
- package/plugin/hooks/hooks.json +34 -1
- package/plugin/scripts/codex-hook-wrapper.mjs +36 -4
- package/plugin/scripts/ground-ruvnet.sh +2 -1
- package/plugin/scripts/hook-shim.mjs +13 -6
- package/plugin/scripts/lesson-provenance.mjs +21 -0
- package/plugin/scripts/lesson-store.mjs +33 -3
- package/plugin/scripts/route-dispatch.sh +23 -49
- package/plugin/scripts/session-snapshot-contract.mjs +100 -0
- package/plugin/scripts/session-snapshot-hook.mjs +34 -0
- package/plugin/scripts/session-start-core.mjs +25 -7
- package/plugin/scripts/swarm-slot-recycler.mjs +89 -0
- package/plugin/skills/release-proof/SKILL.md +4 -4
- package/plugin/skills/ruvnet-brain/PLAYBOOK.md +19 -4
- package/scripts/capability-registry.mjs +3 -3
- package/scripts/lesson-lifecycle.mjs +2 -1
- package/scripts/lesson-ratify.mjs +6 -3
- package/scripts/lesson-seed.mjs +21 -16
- package/scripts/model-router-catalog.mjs +16 -0
- package/scripts/model-router-engine.mjs +4 -1
- package/scripts/onboarding-console.mjs +52 -14
- package/scripts/provider-availability.mjs +12 -0
- package/scripts/release-authority.mjs +14 -2
- package/scripts/release-transaction-provider.mjs +236 -0
- package/scripts/release-transaction.mjs +214 -0
- package/scripts/release.mjs +67 -279
- package/scripts/session-snapshot-contract.mjs +1 -0
- package/scripts/stabilization-receipt.mjs +1 -1
- package/scripts/staged-host-verifier.mjs +106 -0
package/scripts/release.mjs
CHANGED
|
@@ -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
|
-
//
|
|
12
|
-
//
|
|
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 #
|
|
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
|
-
//
|
|
24
|
-
//
|
|
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,9 @@ 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';
|
|
34
36
|
|
|
35
37
|
const ROOT = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
|
|
36
38
|
const PUBLISH = process.argv.includes('--publish');
|
|
@@ -51,36 +53,6 @@ function runOrDie(label, cmd, args, opts = {}) {
|
|
|
51
53
|
}
|
|
52
54
|
}
|
|
53
55
|
|
|
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
56
|
console.log(`\n${c.b('RuvNet Brain — release / definition-of-done')} ${c.dim('· ' + (PUBLISH ? 'PUBLISH' : 'check-only') + ' · shipping ' + V())}\n`);
|
|
85
57
|
|
|
86
58
|
// The local CLI remains useful as a read-only preflight, but publication authority lives only in
|
|
@@ -115,10 +87,12 @@ if (initialDirty) {
|
|
|
115
87
|
process.exit(1);
|
|
116
88
|
}
|
|
117
89
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
90
|
+
if (!PUBLISH) {
|
|
91
|
+
// Source gates belong to candidate CI/check-only mode. The protected publisher has already
|
|
92
|
+
// validated their exact-SHA receipt and the sealed bytes before reaching this process.
|
|
93
|
+
step('A', 'version single-source-of-truth agrees across every surface');
|
|
94
|
+
runOrDie('version sync', process.execPath, ['scripts/sync-version.mjs', '--check']);
|
|
95
|
+
runOrDie('one protected publisher', process.execPath, ['scripts/release-authority.mjs']);
|
|
122
96
|
|
|
123
97
|
// WIRED-CHECK — refuses to ship a module with zero callers.
|
|
124
98
|
//
|
|
@@ -130,23 +104,23 @@ runOrDie('one protected publisher', process.execPath, ['scripts/release-authorit
|
|
|
130
104
|
//
|
|
131
105
|
// Seven repetitions of one mistake is not a discipline problem; discipline is what failed. So it
|
|
132
106
|
// 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']);
|
|
107
|
+
runOrDie('wired (no orphan modules)', process.execPath, ['scripts/wired-check.mjs', '--check']);
|
|
134
108
|
|
|
135
109
|
// THE NORTH-STAR PROMOTION VECTOR — strict/check-only releases may not average one broken or
|
|
136
110
|
// unknown invariant into a pass. The separately authorized stabilization class makes no 95 claim;
|
|
137
111
|
// it retains every safety, test, artifact, publication, and post-publication gate below while the
|
|
138
112
|
// promotion program remains open. Derive this only from the already-validated sealed receipt, never
|
|
139
113
|
// from a free-standing environment toggle.
|
|
140
|
-
if (protectedReleaseMode === 'strict') {
|
|
141
|
-
|
|
114
|
+
if (protectedReleaseMode === 'strict') {
|
|
115
|
+
runOrDie('release vector (all critical invariants PASS)', process.execPath, ['scripts/release-vector.mjs']);
|
|
142
116
|
|
|
143
117
|
// The Top-100 corpus spans naive through expert prompts and grades semantic clauses, citations,
|
|
144
118
|
// abstention, and latency. A manual-only benchmark is a report; a strict release-path benchmark
|
|
145
119
|
// is a guarantee. The benchmark itself fails closed unless all 100 canonical questions run.
|
|
146
|
-
|
|
147
|
-
} else {
|
|
148
|
-
|
|
149
|
-
}
|
|
120
|
+
runOrDie('Top-100 source-grounded recall contract', process.execPath, ['scripts/top100-benchmark.mjs', '--no-write']);
|
|
121
|
+
} else {
|
|
122
|
+
console.log(c.y(' strict >=95 promotion gates: NOT CLAIMED (sealed stabilization; scoreClaimed:false)'));
|
|
123
|
+
}
|
|
150
124
|
|
|
151
125
|
// A2. Stable Spine restart classifier (ADR-023, red-team finding 18): diff the boot-frozen SHELL
|
|
152
126
|
// (hooks.json, hook-shim, MCP server, .mcp.json, skills/, commands/) against the previous release
|
|
@@ -154,8 +128,8 @@ if (protectedReleaseMode === 'strict') {
|
|
|
154
128
|
// remembered — the same shellDiff logic runs client-side in update-apply.mjs at every flip, so the
|
|
155
129
|
// user-facing nag stays honest even if this print is ignored. Informational at ship time; the
|
|
156
130
|
// releasing human sees exactly which shell files changed.
|
|
157
|
-
step('A2', 'Stable Spine — does this release change the boot-frozen shell? (requiresRestart classifier)');
|
|
158
|
-
{
|
|
131
|
+
step('A2', 'Stable Spine — does this release change the boot-frozen shell? (requiresRestart classifier)');
|
|
132
|
+
{
|
|
159
133
|
const { execFileSync } = await import('node:child_process');
|
|
160
134
|
const SHELL = ['plugin/hooks/hooks.json', 'plugin/scripts/hook-shim.mjs', 'plugin/mcp/server.mjs', 'plugin/.mcp.json', 'plugin/skills', 'plugin/commands'];
|
|
161
135
|
let prevTag = '';
|
|
@@ -176,98 +150,36 @@ step('A2', 'Stable Spine — does this release change the boot-frozen shell? (re
|
|
|
176
150
|
console.log(` ${c.g('requiresRestart: false')} — no shell change vs ${prevTag}; this release goes fully live with zero restarts.`);
|
|
177
151
|
}
|
|
178
152
|
}
|
|
179
|
-
}
|
|
153
|
+
}
|
|
180
154
|
|
|
181
155
|
// B. the full brain test suite (the 60/60)
|
|
182
|
-
step('B', 'full test suite (npm test)');
|
|
183
|
-
runOrDie('npm test', 'npm', ['test']);
|
|
156
|
+
step('B', 'full test suite (npm test)');
|
|
157
|
+
runOrDie('npm test', 'npm', ['test']);
|
|
184
158
|
|
|
185
159
|
// 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']);
|
|
160
|
+
step('C', 'unit gates (vitest) — narrative version, claims, guards');
|
|
161
|
+
runOrDie('vitest unit', 'npx', ['vitest', 'run', 'tests/unit']);
|
|
226
162
|
}
|
|
227
163
|
|
|
228
|
-
// D.
|
|
229
|
-
//
|
|
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.
|
|
164
|
+
// D. One remotely durable, staged release transaction (ADR-062 / DDD-0015). GitHub remains a draft
|
|
165
|
+
// and npm remains on a non-default candidate tag until exact bytes and all host fixtures pass.
|
|
233
166
|
if (PUBLISH) {
|
|
234
167
|
const v = V();
|
|
235
168
|
const tag = `v${v}`;
|
|
236
169
|
const zip = path.join(ROOT, 'dist', 'ruvnet-brain.zip');
|
|
237
|
-
const assets = [zip, `${zip}.sig`, `${zip}.sha256`, sealedPackageArtifact];
|
|
238
170
|
const head = execFileSync('git', ['-C', ROOT, 'rev-parse', 'HEAD'], { encoding: 'utf8' }).trim();
|
|
239
|
-
const
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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) {
|
|
171
|
+
const buildArgs = ['scripts/build-bundle.mjs', '--version', tag];
|
|
172
|
+
if (process.env.RUVNET_RELEASE_ASSETS) buildArgs.push('--assets', process.env.RUVNET_RELEASE_ASSETS);
|
|
173
|
+
step('D', 'prepare, stage, promote, and reconcile one signed remote transaction');
|
|
174
|
+
runOrDie('build release bundle', process.execPath, buildArgs);
|
|
175
|
+
runOrDie('sign release bundle', process.execPath, ['scripts/sign-bundle.mjs', '--bundle', zip]);
|
|
176
|
+
const assets = {
|
|
177
|
+
bundlePath: zip,
|
|
178
|
+
bundleSignaturePath: `${zip}.sig`,
|
|
179
|
+
bundleDigestPath: `${zip}.sha256`,
|
|
180
|
+
packagePath: sealedPackageArtifact,
|
|
181
|
+
};
|
|
182
|
+
for (const asset of Object.values(assets)) {
|
|
271
183
|
if (!fs.existsSync(asset)) {
|
|
272
184
|
console.error(`\n${c.r('✗ GATE FAILED: signed release asset missing')} ${c.dim(asset)}`);
|
|
273
185
|
process.exit(1);
|
|
@@ -279,157 +191,33 @@ if (PUBLISH) {
|
|
|
279
191
|
process.exit(1);
|
|
280
192
|
}
|
|
281
193
|
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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);
|
|
349
|
-
}
|
|
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 });
|
|
194
|
+
const packageIntegrity = `sha512-${crypto.createHash('sha512').update(fs.readFileSync(sealedPackageArtifact)).digest('base64')}`;
|
|
195
|
+
const identity = {
|
|
196
|
+
repository: 'stuinfla/ruvnet-brain', package: 'ruvnet-brain', version: v, tag,
|
|
197
|
+
candidateSha: head, packageIntegrity, bundleSha256,
|
|
198
|
+
};
|
|
199
|
+
const privatePem = process.env.RUVNET_SIGNING_KEY;
|
|
200
|
+
if (!privatePem) throw new Error('RUVNET_SIGNING_KEY is required for signed transaction receipts');
|
|
201
|
+
const finalReceipt = await runReleaseTransaction({
|
|
202
|
+
identity, assets, adapter: liveReleaseProvider({
|
|
203
|
+
root: ROOT,
|
|
204
|
+
candidateReceipt: process.env.RUVNET_CANDIDATE_RECEIPT,
|
|
205
|
+
publicationReceipt: process.env.RUVNET_PUBLICATION_RECEIPT,
|
|
206
|
+
}),
|
|
207
|
+
privateKey: crypto.createPrivateKey(privatePem),
|
|
208
|
+
publicKey: crypto.createPublicKey(fs.readFileSync(path.join(ROOT, 'keys/ruvnet-brain-signing.pub.pem'), 'utf8')),
|
|
209
|
+
hostVerifier: stagedHostVerifier({ assets, identity }),
|
|
210
|
+
});
|
|
211
|
+
if (finalReceipt.state !== 'channels-converged') throw new Error(`release transaction stopped at ${finalReceipt.state}`);
|
|
361
212
|
} else {
|
|
362
|
-
step('D', '
|
|
213
|
+
step('D', 'remote staged release transaction — SKIPPED (check-only; pass --publish to publish)');
|
|
363
214
|
}
|
|
364
215
|
|
|
365
|
-
//
|
|
366
|
-
//
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
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)'));
|
|
412
|
-
}
|
|
413
|
-
|
|
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
|
-
});
|
|
216
|
+
// Check-only diagnoses the currently public channels. During publication, transaction finalization
|
|
217
|
+
// performs this walk once, then creates and verifies the publication receipt before convergence.
|
|
218
|
+
if (!PUBLISH) {
|
|
219
|
+
step('E', 'verify-channels — the live walk of every user path');
|
|
220
|
+
runOrDie('verify-channels', process.execPath, ['scripts/verify-channels.mjs']);
|
|
433
221
|
}
|
|
434
222
|
|
|
435
223
|
if (PUBLISH) {
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from '../plugin/scripts/session-snapshot-contract.mjs';
|
|
@@ -47,7 +47,7 @@ export function createStabilizationReceipt({ root, artifactPath, qe, audit }) {
|
|
|
47
47
|
schemaVersion: 1,
|
|
48
48
|
phase: 'stabilization-candidate',
|
|
49
49
|
mode: 'stabilization',
|
|
50
|
-
reason:
|
|
50
|
+
reason: `Owner-authorized ${manifest.version} stabilization release; >=95 promotion remains open work`,
|
|
51
51
|
targetScore: 95,
|
|
52
52
|
scoreClaimed: false,
|
|
53
53
|
sha,
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { execFileSync, spawnSync } from 'node:child_process';
|
|
2
|
+
import crypto from 'node:crypto';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
|
|
7
|
+
const sha256 = (file) => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
|
|
8
|
+
const locate = (name) => {
|
|
9
|
+
try { return execFileSync('which', [name], { encoding: 'utf8' }).trim(); } catch { return null; }
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const run = (name, args, options) => {
|
|
13
|
+
const result = spawnSync(name, args, { encoding: 'utf8', ...options });
|
|
14
|
+
if (result.error || result.status !== 0) {
|
|
15
|
+
const detail = String(result.stderr || result.stdout || result.error?.message);
|
|
16
|
+
throw new Error(`${path.basename(name)} ${args.join(' ')} failed: ${detail.slice(-5000)}`);
|
|
17
|
+
}
|
|
18
|
+
return result;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export function classifyDoctorResult(result) {
|
|
22
|
+
const output = `${result.stdout || ''}\n${result.stderr || ''}`;
|
|
23
|
+
if (!result.error && result.status === 0) return { accepted: true, status: 'PASS', output };
|
|
24
|
+
const pendingReview = result.status === 1
|
|
25
|
+
&& /Codex installed the Brain, but \d+ lifecycle hooks await review/i.test(output)
|
|
26
|
+
&& /Grounding PROVEN/i.test(output)
|
|
27
|
+
&& !/reader MISSING|search_ruvnet MISSING|host convergence incomplete|receipt is invalid/i.test(output);
|
|
28
|
+
return { accepted: pendingReview, status: pendingReview ? 'PENDING_REVIEW' : 'FAIL', output };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const preparePackage = ({ packagePath, bundlePath }) => {
|
|
32
|
+
const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'ruvnet-staged-host-'));
|
|
33
|
+
run('tar', ['-xzf', packagePath, '-C', temp]);
|
|
34
|
+
const packageRoot = path.join(temp, 'package');
|
|
35
|
+
const bundleRoot = path.join(packageRoot, 'dist', 'ruvnet-brain');
|
|
36
|
+
fs.mkdirSync(bundleRoot, { recursive: true });
|
|
37
|
+
run('unzip', ['-q', bundlePath, '-d', bundleRoot]);
|
|
38
|
+
const nested = path.join(bundleRoot, 'ruvnet-brain');
|
|
39
|
+
if (fs.existsSync(nested)) {
|
|
40
|
+
for (const name of fs.readdirSync(nested)) fs.renameSync(path.join(nested, name), path.join(bundleRoot, name));
|
|
41
|
+
fs.rmdirSync(nested);
|
|
42
|
+
}
|
|
43
|
+
return { temp, packageRoot };
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const fixturePath = (mode, temp) => {
|
|
47
|
+
const bin = path.join(temp, `bin-${mode}`);
|
|
48
|
+
fs.mkdirSync(bin);
|
|
49
|
+
const hosts = mode === 'claude' ? ['claude'] : mode === 'codex' ? ['codex'] : ['claude', 'codex'];
|
|
50
|
+
const desired = ['node', 'npm', ...hosts];
|
|
51
|
+
for (const name of desired) {
|
|
52
|
+
const target = locate(name);
|
|
53
|
+
if (!target) throw new Error(`${name} CLI unavailable for ${mode} host fixture`);
|
|
54
|
+
fs.symlinkSync(target, path.join(bin, name));
|
|
55
|
+
}
|
|
56
|
+
return `${bin}:/usr/bin:/bin`;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export function stagedHostVerifier({ assets, identity }) {
|
|
60
|
+
return {
|
|
61
|
+
async verify({ source, assets: observedAssets = assets }) {
|
|
62
|
+
const prepared = preparePackage(observedAssets);
|
|
63
|
+
const results = {};
|
|
64
|
+
try {
|
|
65
|
+
for (const mode of ['claude', 'codex', 'dual']) {
|
|
66
|
+
const home = path.join(prepared.temp, `home-${mode}`);
|
|
67
|
+
const brainHome = path.join(home, '.cache', 'ruvnet-brain');
|
|
68
|
+
fs.mkdirSync(path.join(home, '.claude'), { recursive: true });
|
|
69
|
+
if (mode !== 'claude') fs.mkdirSync(path.join(home, '.codex'), { recursive: true });
|
|
70
|
+
const env = {
|
|
71
|
+
...process.env,
|
|
72
|
+
HOME: home,
|
|
73
|
+
CODEX_HOME: path.join(home, '.codex'),
|
|
74
|
+
RUVNET_BRAIN_HOME: brainHome,
|
|
75
|
+
RUVNET_BRAIN_KB: path.join(brainHome, 'kb'),
|
|
76
|
+
RUVNET_CLAUDE_MARKETPLACE_SOURCE: prepared.packageRoot,
|
|
77
|
+
CI: 'true',
|
|
78
|
+
PATH: fixturePath(mode, prepared.temp),
|
|
79
|
+
};
|
|
80
|
+
const installer = path.join(prepared.packageRoot, 'bin', 'install.mjs');
|
|
81
|
+
run(process.execPath, [installer, '--local', '--yes', '--force', '--no-nightly-prompt',
|
|
82
|
+
'--no-telemetry', '--no-stack', '--no-enhance', '--no-statusline', '--no-selfcheck'], {
|
|
83
|
+
cwd: prepared.packageRoot, env, timeout: 1_200_000,
|
|
84
|
+
});
|
|
85
|
+
const doctor = spawnSync(process.execPath, [installer, '--doctor'], {
|
|
86
|
+
cwd: prepared.packageRoot, env, timeout: 180_000,
|
|
87
|
+
});
|
|
88
|
+
const classified = classifyDoctorResult(doctor);
|
|
89
|
+
if (!classified.accepted) {
|
|
90
|
+
throw new Error(`doctor failed for ${mode}: ${classified.output.slice(-5000) || doctor.error?.message}`);
|
|
91
|
+
}
|
|
92
|
+
results[mode] = {
|
|
93
|
+
status: classified.status,
|
|
94
|
+
doctorExit: doctor.status,
|
|
95
|
+
version: identity.version,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
return { verdict: 'PASS', source, artifactSha256: sha256(observedAssets.packagePath), fixtures: results };
|
|
99
|
+
} catch (error) {
|
|
100
|
+
return { verdict: 'FAIL', error: error.message, fixtures: results };
|
|
101
|
+
} finally {
|
|
102
|
+
fs.rmSync(prepared.temp, { recursive: true, force: true });
|
|
103
|
+
}
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
}
|