ruvnet-brain 4.0.2 → 4.0.4
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 +1 -1
- package/bin/install.mjs +215 -31
- package/docs/RELEASE-NOTES-4.0.md +1 -1
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/commands/whats-new.md +6 -6
- package/plugin/docs/RELEASE-NOTES-4.0.md +88 -0
- package/plugin/mcp/server.mjs +70 -33
- package/plugin/scripts/hook-shim.mjs +27 -22
- package/plugin/scripts/session-start-core.mjs +28 -2
- package/plugin/scripts/whats-new.mjs +42 -0
- package/plugin/skills/release-proof/SKILL.md +21 -4
- package/plugin/skills/release-proof/references/receipt-contract.md +8 -2
- package/plugin/skills/release-proof/scripts/release-proof.mjs +77 -1
- package/plugin/skills/ruvnet-brain/SKILL.md +22 -7
- package/plugin/skills/whats-new/SKILL.md +4 -4
- package/scripts/build-bundle.mjs +34 -25
- package/scripts/fix-workstream.mjs +291 -0
- package/scripts/health-repair.mjs +14 -27
- package/scripts/issue-fix.mjs +135 -216
- package/scripts/memory-doctor.mjs +64 -9
- package/scripts/nightly-wrapper.sh +20 -28
- package/scripts/onboarding-console.mjs +226 -53
- package/scripts/protected-release-invocation.mjs +76 -0
- package/scripts/publication-receipt.mjs +307 -0
- package/scripts/release-authority.mjs +93 -0
- package/scripts/release.mjs +55 -11
- package/scripts/self-update.mjs +15 -227
- package/scripts/stabilization-receipt.mjs +108 -0
- package/scripts/wired-check.mjs +3 -0
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Post-publication evidence producer. It only observes public bytes and isolated installs, then
|
|
3
|
+
// writes one append-only receipt. It has no publish, tag, release, push, or deployment capability.
|
|
4
|
+
import crypto from 'node:crypto';
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import os from 'node:os';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { pipeline } from 'node:stream/promises';
|
|
9
|
+
import { Readable } from 'node:stream';
|
|
10
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
11
|
+
import { pathToFileURL } from 'node:url';
|
|
12
|
+
import { evaluateCandidateReceipt, evaluatePublicationReceipt } from './release-proof.mjs';
|
|
13
|
+
|
|
14
|
+
const REPO = 'stuinfla/ruvnet-brain';
|
|
15
|
+
const PACKAGE = 'ruvnet-brain';
|
|
16
|
+
const DEADLINE_MS = 30_000;
|
|
17
|
+
|
|
18
|
+
const sha256 = (file) => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
|
|
19
|
+
const readJson = (file) => JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
20
|
+
const receiptDigest = (candidate) => String(candidate?.artifact?.sha256 || '').replace(/^sha256:/, '');
|
|
21
|
+
|
|
22
|
+
function command(name, args, options = {}) {
|
|
23
|
+
const result = spawnSync(name, args, { encoding: 'utf8', ...options });
|
|
24
|
+
if (result.error || result.status !== 0) {
|
|
25
|
+
const detail = String(result.stderr || result.stdout || result.error?.message || '').trim();
|
|
26
|
+
throw new Error(`${name} ${args.join(' ')} failed: ${detail || `exit ${result.status}`}`);
|
|
27
|
+
}
|
|
28
|
+
return String(result.stdout || '').trim();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function download(url, destination, headers = {}) {
|
|
32
|
+
const response = await fetch(url, {
|
|
33
|
+
redirect: 'follow',
|
|
34
|
+
headers: { 'user-agent': 'ruvnet-brain-publication-receipt', ...headers },
|
|
35
|
+
signal: AbortSignal.timeout(300_000),
|
|
36
|
+
});
|
|
37
|
+
if (!response.ok || !response.body) throw new Error(`download failed: ${url} -> HTTP ${response.status}`);
|
|
38
|
+
await pipeline(Readable.fromWeb(response.body), fs.createWriteStream(destination, { flags: 'wx', mode: 0o600 }));
|
|
39
|
+
return destination;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function tagCommit(root, tag) {
|
|
43
|
+
const rows = command('git', ['ls-remote', 'origin', `refs/tags/${tag}`, `refs/tags/${tag}^{}`], { cwd: root })
|
|
44
|
+
.split('\n').filter(Boolean).map((line) => line.trim().split(/\s+/));
|
|
45
|
+
const sha = rows.find(([, ref]) => ref?.endsWith('^{}'))?.[0] || rows[0]?.[0] || '';
|
|
46
|
+
if (!/^[a-f0-9]{40}$/i.test(sha)) throw new Error(`GitHub tag ${tag} has no full commit SHA`);
|
|
47
|
+
return sha;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function findManifest(root, relative, version) {
|
|
51
|
+
const found = [];
|
|
52
|
+
const visit = (directory, depth = 0) => {
|
|
53
|
+
if (depth > 9 || !fs.existsSync(directory)) return;
|
|
54
|
+
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
|
55
|
+
const child = path.join(directory, entry.name);
|
|
56
|
+
if (entry.isSymbolicLink()) continue;
|
|
57
|
+
if (entry.isDirectory()) visit(child, depth + 1);
|
|
58
|
+
else if (entry.isFile() && child.endsWith(relative)) found.push(child);
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
visit(root);
|
|
62
|
+
const match = found.find((file) => {
|
|
63
|
+
try { return readJson(file).version === version; } catch { return false; }
|
|
64
|
+
});
|
|
65
|
+
if (!match) throw new Error(`installed ${relative} at version ${version} not found in virgin home`);
|
|
66
|
+
return match;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function assertInstalledPayload(sourceRoot, installedRoot) {
|
|
70
|
+
let checked = 0;
|
|
71
|
+
const visit = (source, relative = '') => {
|
|
72
|
+
const stat = fs.lstatSync(source);
|
|
73
|
+
if (stat.isSymbolicLink()) throw new Error(`sealed plugin payload contains symlink: ${relative || '.'}`);
|
|
74
|
+
if (stat.isDirectory()) {
|
|
75
|
+
for (const name of fs.readdirSync(source)) visit(path.join(source, name), path.join(relative, name));
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
if (!stat.isFile()) throw new Error(`sealed plugin payload contains unsupported entry: ${relative}`);
|
|
79
|
+
const installed = path.join(installedRoot, relative);
|
|
80
|
+
let installedStat;
|
|
81
|
+
try { installedStat = fs.lstatSync(installed); } catch { throw new Error(`installed plugin payload is missing ${relative}`); }
|
|
82
|
+
if (!installedStat.isFile() || installedStat.isSymbolicLink()) {
|
|
83
|
+
throw new Error(`installed plugin payload is not a regular file: ${relative}`);
|
|
84
|
+
}
|
|
85
|
+
if (sha256(source) !== sha256(installed)) throw new Error(`installed plugin payload byte mismatch: ${relative}`);
|
|
86
|
+
checked += 1;
|
|
87
|
+
};
|
|
88
|
+
visit(sourceRoot);
|
|
89
|
+
if (checked === 0) throw new Error('sealed plugin payload is empty');
|
|
90
|
+
return checked;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function rpcSearch(server, env, query, timeoutMs = DEADLINE_MS) {
|
|
94
|
+
return new Promise((resolve, reject) => {
|
|
95
|
+
const child = spawn(process.execPath, [server], { env, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
96
|
+
let stdout = '';
|
|
97
|
+
let stderr = '';
|
|
98
|
+
let timer;
|
|
99
|
+
const pending = new Map();
|
|
100
|
+
const finish = (error, value) => {
|
|
101
|
+
clearTimeout(timer);
|
|
102
|
+
child.kill('SIGTERM');
|
|
103
|
+
error ? reject(error) : resolve(value);
|
|
104
|
+
};
|
|
105
|
+
timer = setTimeout(() => finish(new Error(`installed Brain exceeded ${timeoutMs}ms deadline`)), timeoutMs);
|
|
106
|
+
child.stderr.on('data', (chunk) => { stderr += chunk; });
|
|
107
|
+
child.stdout.on('data', (chunk) => {
|
|
108
|
+
stdout += chunk;
|
|
109
|
+
let newline;
|
|
110
|
+
while ((newline = stdout.indexOf('\n')) >= 0) {
|
|
111
|
+
const line = stdout.slice(0, newline).trim();
|
|
112
|
+
stdout = stdout.slice(newline + 1);
|
|
113
|
+
if (!line) continue;
|
|
114
|
+
let message;
|
|
115
|
+
try { message = JSON.parse(line); } catch { continue; }
|
|
116
|
+
const handler = pending.get(message.id);
|
|
117
|
+
if (handler) { pending.delete(message.id); handler(message); }
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
child.on('error', (error) => finish(error));
|
|
121
|
+
child.on('exit', (code) => {
|
|
122
|
+
if (pending.size) finish(new Error(`installed Brain exited ${code}: ${stderr.slice(0, 400)}`));
|
|
123
|
+
});
|
|
124
|
+
const call = (id, method, params = {}) => new Promise((done) => {
|
|
125
|
+
pending.set(id, done);
|
|
126
|
+
child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id, method, params })}\n`);
|
|
127
|
+
});
|
|
128
|
+
(async () => {
|
|
129
|
+
const initialized = await call(1, 'initialize', { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'release-proof', version: '1' } });
|
|
130
|
+
if (initialized.error) throw new Error(`MCP initialize failed: ${JSON.stringify(initialized.error)}`);
|
|
131
|
+
const listed = await call(2, 'tools/list');
|
|
132
|
+
if (!listed.result?.tools?.some((tool) => tool.name === 'search_ruvnet')) throw new Error('installed MCP does not advertise search_ruvnet');
|
|
133
|
+
const started = performance.now();
|
|
134
|
+
const searched = await call(3, 'tools/call', { name: 'search_ruvnet', arguments: { query, k: 5 } });
|
|
135
|
+
const broadMs = Math.round(performance.now() - started);
|
|
136
|
+
const text = (searched.result?.content || []).map((item) => item.text || '').join('\n');
|
|
137
|
+
if (searched.error || searched.result?.isError || /search_ruvnet error:/i.test(text)) throw new Error(`installed Brain search failed: ${text.slice(0, 400)}`);
|
|
138
|
+
if (!/repo=ruvnet-brain/i.test(text) || !/path\s*:/i.test(text)) throw new Error('installed Brain search returned no ruvnet-brain source citation');
|
|
139
|
+
finish(null, { broadMs, text });
|
|
140
|
+
})().catch((error) => finish(error));
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function livePublicationAdapter({ root = process.cwd() } = {}) {
|
|
145
|
+
let installContext = null;
|
|
146
|
+
return {
|
|
147
|
+
async downloadNpm({ version, destination }) {
|
|
148
|
+
const metadata = JSON.parse(command('npm', ['view', `${PACKAGE}@${version}`, '--json']));
|
|
149
|
+
if (metadata.version !== version || !metadata.dist?.tarball) {
|
|
150
|
+
throw new Error('npm metadata is missing exact version or tarball');
|
|
151
|
+
}
|
|
152
|
+
await download(metadata.dist.tarball, destination);
|
|
153
|
+
return { path: destination, version: metadata.version, sha: metadata.gitHead || null };
|
|
154
|
+
},
|
|
155
|
+
|
|
156
|
+
async downloadGithub({ tag, assetName, destination }) {
|
|
157
|
+
const headers = process.env.GITHUB_TOKEN ? { authorization: `Bearer ${process.env.GITHUB_TOKEN}` } : {};
|
|
158
|
+
const response = await fetch(`https://api.github.com/repos/${REPO}/releases/tags/${encodeURIComponent(tag)}`, {
|
|
159
|
+
headers: { 'user-agent': 'ruvnet-brain-publication-receipt', ...headers },
|
|
160
|
+
signal: AbortSignal.timeout(30_000),
|
|
161
|
+
});
|
|
162
|
+
if (!response.ok) throw new Error(`GitHub release ${tag} lookup failed: HTTP ${response.status}`);
|
|
163
|
+
const release = await response.json();
|
|
164
|
+
const asset = release.assets?.find((item) => item.name === assetName);
|
|
165
|
+
if (!asset?.browser_download_url) throw new Error(`GitHub release ${tag} is missing sealed artifact ${assetName}`);
|
|
166
|
+
await download(asset.browser_download_url, destination, headers);
|
|
167
|
+
return { path: destination, tag: release.tag_name, sha: tagCommit(root, tag) };
|
|
168
|
+
},
|
|
169
|
+
|
|
170
|
+
async installHosts({ artifactPath, artifactSha256, version }) {
|
|
171
|
+
const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'ruvnet-public-install-'));
|
|
172
|
+
const packageRoot = path.join(temp, 'package');
|
|
173
|
+
command('tar', ['-xzf', artifactPath, '-C', temp]);
|
|
174
|
+
const home = path.join(temp, 'home');
|
|
175
|
+
const codexHome = path.join(home, '.codex');
|
|
176
|
+
const brainHome = path.join(home, '.cache', 'ruvnet-brain');
|
|
177
|
+
const kb = path.join(brainHome, 'kb');
|
|
178
|
+
fs.mkdirSync(codexHome, { recursive: true });
|
|
179
|
+
const env = {
|
|
180
|
+
...process.env,
|
|
181
|
+
HOME: home,
|
|
182
|
+
CODEX_HOME: codexHome,
|
|
183
|
+
RUVNET_BRAIN_HOME: brainHome,
|
|
184
|
+
RUVNET_BRAIN_KB: kb,
|
|
185
|
+
RUVNET_STRICT_INSTALL: '1',
|
|
186
|
+
RUVNET_BRAIN_PROFILE: 'complete',
|
|
187
|
+
CI: 'true',
|
|
188
|
+
};
|
|
189
|
+
command(process.execPath, [
|
|
190
|
+
path.join(packageRoot, 'bin', 'install.mjs'), '--yes', '--force', '--version', `v${version}`,
|
|
191
|
+
'--no-nightly-prompt', '--no-telemetry', '--no-stack', '--no-enhance', '--no-statusline',
|
|
192
|
+
], { env, cwd: packageRoot, timeout: 1_200_000, maxBuffer: 32 * 1024 * 1024 });
|
|
193
|
+
|
|
194
|
+
const claudeManifest = findManifest(path.join(home, '.claude'), path.join('.claude-plugin', 'plugin.json'), version);
|
|
195
|
+
const codexManifest = findManifest(path.join(home, '.codex'), path.join('.codex-plugin', 'plugin.json'), version);
|
|
196
|
+
const sealedPlugin = path.join(packageRoot, 'plugin');
|
|
197
|
+
const claudeFiles = assertInstalledPayload(sealedPlugin, path.dirname(path.dirname(claudeManifest)));
|
|
198
|
+
const codexFiles = assertInstalledPayload(sealedPlugin, path.dirname(path.dirname(codexManifest)));
|
|
199
|
+
const config = fs.readFileSync(path.join(codexHome, 'config.toml'), 'utf8');
|
|
200
|
+
if (!/\[mcp_servers\.ruvnet-brain\]/.test(config)) throw new Error('virgin Codex home is not wired to ruvnet-brain');
|
|
201
|
+
const source = readJson(path.join(kb, 'SOURCE.json'));
|
|
202
|
+
if (source.brainVersion !== version || source.releaseTag !== `v${version}`) throw new Error('installed public Brain bundle version mismatch');
|
|
203
|
+
installContext = { temp, home, codexHome, brainHome, kb, env, packageRoot };
|
|
204
|
+
return {
|
|
205
|
+
claude: { status: 'PASS', version, artifactSha256, payloadFilesVerified: claudeFiles },
|
|
206
|
+
codex: { status: 'PASS', version, artifactSha256, payloadFilesVerified: codexFiles },
|
|
207
|
+
bundle: { brainVersion: source.brainVersion, releaseTag: source.releaseTag },
|
|
208
|
+
};
|
|
209
|
+
},
|
|
210
|
+
|
|
211
|
+
async probeBrain() {
|
|
212
|
+
if (!installContext) throw new Error('public hosts must be installed before Brain proof');
|
|
213
|
+
const rvfs = fs.readdirSync(installContext.kb).filter((name) => /ruvnet-brain.*\.rvf$/i.test(name));
|
|
214
|
+
if (rvfs.length === 0) throw new Error('installed public Brain has no ruvnet-brain self RVF');
|
|
215
|
+
const server = path.join(installContext.home, '.claude', 'ruvnet-brain', 'mcp', 'server.mjs');
|
|
216
|
+
if (!fs.existsSync(server)) throw new Error('installed persistent MCP server is missing');
|
|
217
|
+
const result = await rpcSearch(server, installContext.env, 'How does RuvNet Brain prove a public release artifact?', DEADLINE_MS);
|
|
218
|
+
const readiness = readJson(path.join(installContext.brainHome, 'mcp-readiness.json'));
|
|
219
|
+
if (readiness.state !== 'ready') throw new Error(`installed Brain readiness is ${readiness.state || 'missing'}`);
|
|
220
|
+
return { status: 'PASS', selfStore: true, broadMs: result.broadMs, deadlineMs: DEADLINE_MS };
|
|
221
|
+
},
|
|
222
|
+
|
|
223
|
+
async probePublishedSurface({ sha }) {
|
|
224
|
+
const head = command('git', ['rev-parse', 'HEAD'], { cwd: root });
|
|
225
|
+
if (head !== sha) throw new Error(`published-surface probe checkout ${head} does not match candidate ${sha}`);
|
|
226
|
+
const result = spawnSync(process.execPath, ['scripts/published-surface-probe.mjs', '--json'], {
|
|
227
|
+
cwd: root, env: process.env, encoding: 'utf8', timeout: 600_000,
|
|
228
|
+
});
|
|
229
|
+
let parsed;
|
|
230
|
+
try { parsed = JSON.parse(String(result.stdout || '')); } catch { throw new Error(`published-surface-probe emitted invalid JSON: ${String(result.stderr || '').slice(0, 300)}`); }
|
|
231
|
+
if (result.status !== 0 || parsed.verdict !== 'PASS') throw new Error(`published-surface-probe is ${parsed.verdict || `exit ${result.status}`}`);
|
|
232
|
+
return { name: 'published-surface-probe', status: 'completed', conclusion: 'success', sha };
|
|
233
|
+
},
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export async function generatePublicationReceipt({
|
|
238
|
+
root = process.cwd(),
|
|
239
|
+
candidatePath,
|
|
240
|
+
outPath,
|
|
241
|
+
adapter = livePublicationAdapter({ root }),
|
|
242
|
+
} = {}) {
|
|
243
|
+
if (!candidatePath || !outPath) throw new Error('candidatePath and outPath are required');
|
|
244
|
+
if (fs.existsSync(outPath)) throw new Error(`refusing to overwrite existing publication receipt: ${outPath}`);
|
|
245
|
+
const candidate = readJson(candidatePath);
|
|
246
|
+
const candidateResult = evaluateCandidateReceipt(candidate);
|
|
247
|
+
if (candidateResult.verdict !== 'PASS') throw new Error(`candidate seal failed: ${candidateResult.failures.map(({ code }) => code).join(',')}`);
|
|
248
|
+
const digest = receiptDigest(candidate);
|
|
249
|
+
const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'ruvnet-public-evidence-'));
|
|
250
|
+
try {
|
|
251
|
+
const assetName = path.basename(candidate.artifact.path);
|
|
252
|
+
const npm = await adapter.downloadNpm({ version: candidate.version, destination: path.join(temp, `npm-${assetName}`) });
|
|
253
|
+
const github = await adapter.downloadGithub({ tag: candidate.tag, assetName, destination: path.join(temp, `github-${assetName}`) });
|
|
254
|
+
const npmDigest = sha256(npm.path);
|
|
255
|
+
const githubDigest = sha256(github.path);
|
|
256
|
+
if (npmDigest !== digest) throw new Error(`npm public byte mismatch: ${npmDigest} != ${digest}`);
|
|
257
|
+
if (githubDigest !== digest) throw new Error(`GitHub public byte mismatch: ${githubDigest} != ${digest}`);
|
|
258
|
+
if (npm.version !== candidate.version || (npm.sha && npm.sha !== candidate.sha)) throw new Error('npm public identity mismatch');
|
|
259
|
+
if (github.tag !== candidate.tag || github.sha !== candidate.sha) throw new Error('GitHub public identity mismatch');
|
|
260
|
+
|
|
261
|
+
const installed = await adapter.installHosts({ artifactPath: npm.path, artifactSha256: digest, version: candidate.version });
|
|
262
|
+
const brain = await adapter.probeBrain({ sha: candidate.sha, artifactSha256: digest, version: candidate.version });
|
|
263
|
+
const surface = await adapter.probePublishedSurface({ sha: candidate.sha, artifactSha256: digest, version: candidate.version });
|
|
264
|
+
const publication = {
|
|
265
|
+
schemaVersion: 1,
|
|
266
|
+
phase: 'publication',
|
|
267
|
+
sha: candidate.sha,
|
|
268
|
+
artifactSha256: digest,
|
|
269
|
+
version: candidate.version,
|
|
270
|
+
// npm does not guarantee gitHead metadata. Exact public-byte equality with the candidate's
|
|
271
|
+
// SHA-bound sealed artifact is the identity proof; a present but conflicting gitHead is red.
|
|
272
|
+
npm: { version: npm.version, sha: candidate.sha, artifactSha256: npmDigest },
|
|
273
|
+
githubRelease: { tag: github.tag, sha: github.sha, artifactSha256: githubDigest },
|
|
274
|
+
bundle: installed.bundle,
|
|
275
|
+
installed: { claude: installed.claude, codex: installed.codex },
|
|
276
|
+
brain,
|
|
277
|
+
postPublicationChecks: [surface],
|
|
278
|
+
};
|
|
279
|
+
const result = evaluatePublicationReceipt(candidate, publication);
|
|
280
|
+
if (result.verdict !== 'PASS') throw new Error(`publication seal failed: ${result.failures.map(({ code }) => code).join(',')}`);
|
|
281
|
+
fs.mkdirSync(path.dirname(outPath), { recursive: true });
|
|
282
|
+
fs.writeFileSync(outPath, `${JSON.stringify(publication, null, 2)}\n`, { flag: 'wx', mode: 0o600 });
|
|
283
|
+
return result;
|
|
284
|
+
} finally {
|
|
285
|
+
fs.rmSync(temp, { recursive: true, force: true });
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function argument(args, flag) {
|
|
290
|
+
const index = args.indexOf(flag);
|
|
291
|
+
return index >= 0 ? args[index + 1] : null;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
export async function main(args = process.argv.slice(2)) {
|
|
295
|
+
try {
|
|
296
|
+
const candidatePath = argument(args, '--candidate');
|
|
297
|
+
const outPath = argument(args, '--out');
|
|
298
|
+
const result = await generatePublicationReceipt({ candidatePath, outPath });
|
|
299
|
+
console.log(JSON.stringify(result, null, 2));
|
|
300
|
+
return 0;
|
|
301
|
+
} catch (error) {
|
|
302
|
+
console.error(`publication-receipt: ${error.message}`);
|
|
303
|
+
return 1;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
if (process.argv[1] && pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url) process.exitCode = await main();
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// One-publisher source gate for issue #77. Rebuild and maintenance jobs may prepare bytes, but
|
|
3
|
+
// only scripts/release.mjs may contain operations that create a GitHub Release, publish npm, or
|
|
4
|
+
// move an npm dist-tag. CI and the canonical release path both execute this check.
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
8
|
+
|
|
9
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
10
|
+
const CANONICAL_PUBLISHER = 'scripts/release.mjs';
|
|
11
|
+
const SOURCE_EXTENSIONS = new Set(['.mjs', '.js', '.cjs', '.sh']);
|
|
12
|
+
|
|
13
|
+
function executableSource(source) {
|
|
14
|
+
const withoutBlocks = source.replace(/\/\*[\s\S]*?\*\//g, '');
|
|
15
|
+
return withoutBlocks
|
|
16
|
+
.split('\n')
|
|
17
|
+
.filter((line) => !line.trimStart().startsWith('#'))
|
|
18
|
+
.map((line) => line.replace(/\/\/.*$/, ''))
|
|
19
|
+
.join('\n');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const ACTIONS = [
|
|
23
|
+
{
|
|
24
|
+
action: 'github-release-create',
|
|
25
|
+
jsPatterns: [
|
|
26
|
+
/\[\s*['"]release['"]\s*,\s*['"]create['"]/,
|
|
27
|
+
],
|
|
28
|
+
shellPatterns: [/^\s*gh\s+release\s+create\b/m],
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
action: 'npm-publish',
|
|
32
|
+
jsPatterns: [
|
|
33
|
+
/(?:execFileSync|spawnSync)\(\s*['"]npm['"]\s*,\s*\[\s*['"]publish['"]/,
|
|
34
|
+
/runOrDie\(\s*['"]npm publish['"]/,
|
|
35
|
+
],
|
|
36
|
+
shellPatterns: [/^\s*npm\s+publish\b/m],
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
action: 'npm-dist-tag',
|
|
40
|
+
jsPatterns: [
|
|
41
|
+
/(?:execFileSync|spawnSync)\(\s*['"]npm['"]\s*,\s*\[\s*['"]dist-tag['"]\s*,\s*['"]add['"]/,
|
|
42
|
+
/runOrDie\(\s*['"]npm dist-tag[^'"]*['"]/,
|
|
43
|
+
],
|
|
44
|
+
shellPatterns: [/^\s*npm\s+dist-tag\s+add\b/m],
|
|
45
|
+
},
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
export function detectPublisherActions(file, source) {
|
|
49
|
+
const relative = file.split(path.sep).join('/');
|
|
50
|
+
if (relative === CANONICAL_PUBLISHER) return [];
|
|
51
|
+
const executable = executableSource(source);
|
|
52
|
+
const kind = path.extname(relative) === '.sh' ? 'shellPatterns' : 'jsPatterns';
|
|
53
|
+
return ACTIONS
|
|
54
|
+
.filter((action) => action[kind].some((pattern) => pattern.test(executable)))
|
|
55
|
+
.map(({ action }) => ({ file: relative, action }));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function sourceFiles(root) {
|
|
59
|
+
const files = [];
|
|
60
|
+
const visit = (absolute) => {
|
|
61
|
+
if (!fs.existsSync(absolute)) return;
|
|
62
|
+
for (const entry of fs.readdirSync(absolute, { withFileTypes: true })) {
|
|
63
|
+
const child = path.join(absolute, entry.name);
|
|
64
|
+
if (entry.isDirectory()) visit(child);
|
|
65
|
+
else if (entry.isFile() && SOURCE_EXTENSIONS.has(path.extname(entry.name))) files.push(child);
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
visit(path.join(root, 'scripts'));
|
|
69
|
+
visit(path.join(root, 'deploy'));
|
|
70
|
+
return files;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function findUnauthorizedPublishers(root = ROOT) {
|
|
74
|
+
return sourceFiles(root).flatMap((absolute) => {
|
|
75
|
+
const relative = path.relative(root, absolute).split(path.sep).join('/');
|
|
76
|
+
return detectPublisherActions(relative, fs.readFileSync(absolute, 'utf8'));
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function main(root = ROOT) {
|
|
81
|
+
const findings = findUnauthorizedPublishers(root);
|
|
82
|
+
if (findings.length === 0) {
|
|
83
|
+
console.log('[release-authority] PASS: scripts/release.mjs is the only publisher');
|
|
84
|
+
return 0;
|
|
85
|
+
}
|
|
86
|
+
console.error('[release-authority] FAIL: publication action found outside scripts/release.mjs');
|
|
87
|
+
for (const finding of findings) console.error(` ${finding.file}: ${finding.action}`);
|
|
88
|
+
return 1;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (process.argv[1] && pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url) {
|
|
92
|
+
process.exitCode = main();
|
|
93
|
+
}
|
package/scripts/release.mjs
CHANGED
|
@@ -30,9 +30,14 @@ import { fileURLToPath } from 'node:url';
|
|
|
30
30
|
import { spawnSync, execFileSync } from 'node:child_process';
|
|
31
31
|
import fs from 'node:fs';
|
|
32
32
|
import crypto from 'node:crypto';
|
|
33
|
+
import { validateProtectedPublishInvocation } from './protected-release-invocation.mjs';
|
|
33
34
|
|
|
34
35
|
const ROOT = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
|
|
35
36
|
const PUBLISH = process.argv.includes('--publish');
|
|
37
|
+
let protectedCandidate = null;
|
|
38
|
+
let sealedPackageArtifact = null;
|
|
39
|
+
let publicationReceiptPath = null;
|
|
40
|
+
let protectedReleaseMode = 'strict';
|
|
36
41
|
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` };
|
|
37
42
|
const V = () => JSON.parse(fs.readFileSync(path.join(ROOT, 'plugin/.claude-plugin/plugin.json'), 'utf8')).version;
|
|
38
43
|
|
|
@@ -78,6 +83,28 @@ function readReleaseTransaction() {
|
|
|
78
83
|
|
|
79
84
|
console.log(`\n${c.b('RuvNet Brain — release / definition-of-done')} ${c.dim('· ' + (PUBLISH ? 'PUBLISH' : 'check-only') + ' · shipping ' + V())}\n`);
|
|
80
85
|
|
|
86
|
+
// The local CLI remains useful as a read-only preflight, but publication authority lives only in
|
|
87
|
+
// the reviewer-protected workflow. Validate the exact candidate receipt and artifact bytes before
|
|
88
|
+
// any command capable of pushing, tagging, releasing, or publishing can run.
|
|
89
|
+
if (PUBLISH) {
|
|
90
|
+
const protectedInvocation = validateProtectedPublishInvocation({ root: ROOT });
|
|
91
|
+
if (protectedInvocation.verdict !== 'PASS') {
|
|
92
|
+
console.error(`\n${c.r('✗ PROTECTED RELEASE GATE FAILED')}`);
|
|
93
|
+
for (const failure of protectedInvocation.failures) console.error(` ${failure}`);
|
|
94
|
+
console.error(`${c.r(' NOT shipped. Run the protected-release workflow with exact candidate evidence.')}\n`);
|
|
95
|
+
process.exit(1);
|
|
96
|
+
}
|
|
97
|
+
protectedReleaseMode = protectedInvocation.mode;
|
|
98
|
+
protectedCandidate = JSON.parse(fs.readFileSync(path.resolve(ROOT, process.env.RUVNET_CANDIDATE_RECEIPT), 'utf8'));
|
|
99
|
+
sealedPackageArtifact = path.resolve(ROOT, protectedCandidate.artifact.path);
|
|
100
|
+
publicationReceiptPath = path.resolve(ROOT, process.env.RUVNET_PUBLICATION_RECEIPT || '.missing-publication-receipt');
|
|
101
|
+
const evidenceRoot = path.join(ROOT, 'release-evidence');
|
|
102
|
+
if (!publicationReceiptPath.startsWith(`${evidenceRoot}${path.sep}`) || fs.existsSync(publicationReceiptPath)) {
|
|
103
|
+
console.error(`\n${c.r('✗ PROTECTED RELEASE GATE FAILED')}\n publication receipt output must be a new file inside release-evidence\n`);
|
|
104
|
+
process.exit(1);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
81
108
|
// A verdict is only about the exact committed candidate. Check-only used to permit a dirty tree
|
|
82
109
|
// while publish checked cleanliness much later, so preflight could certify bytes that would never
|
|
83
110
|
// ship. Both modes now bind to the same committed tree before any expensive gate runs.
|
|
@@ -91,6 +118,7 @@ if (initialDirty) {
|
|
|
91
118
|
// A. version single source of truth
|
|
92
119
|
step('A', 'version single-source-of-truth agrees across every surface');
|
|
93
120
|
runOrDie('version sync', process.execPath, ['scripts/sync-version.mjs', '--check']);
|
|
121
|
+
runOrDie('one protected publisher', process.execPath, ['scripts/release-authority.mjs']);
|
|
94
122
|
|
|
95
123
|
// WIRED-CHECK — refuses to ship a module with zero callers.
|
|
96
124
|
//
|
|
@@ -104,15 +132,21 @@ runOrDie('version sync', process.execPath, ['scripts/sync-version.mjs', '--check
|
|
|
104
132
|
// becomes a gate, on the ship path, where this repo's gates run 8/8 against prose's 0/6.
|
|
105
133
|
runOrDie('wired (no orphan modules)', process.execPath, ['scripts/wired-check.mjs', '--check']);
|
|
106
134
|
|
|
107
|
-
// THE NORTH-STAR VECTOR —
|
|
108
|
-
//
|
|
109
|
-
// every
|
|
110
|
-
|
|
135
|
+
// THE NORTH-STAR PROMOTION VECTOR — strict/check-only releases may not average one broken or
|
|
136
|
+
// unknown invariant into a pass. The separately authorized stabilization class makes no 95 claim;
|
|
137
|
+
// it retains every safety, test, artifact, publication, and post-publication gate below while the
|
|
138
|
+
// promotion program remains open. Derive this only from the already-validated sealed receipt, never
|
|
139
|
+
// from a free-standing environment toggle.
|
|
140
|
+
if (protectedReleaseMode === 'strict') {
|
|
141
|
+
runOrDie('release vector (all critical invariants PASS)', process.execPath, ['scripts/release-vector.mjs']);
|
|
111
142
|
|
|
112
|
-
// The Top-100 corpus spans naive through expert prompts and grades semantic clauses, citations,
|
|
113
|
-
// abstention, and latency. A manual-only benchmark is a report; a release-path benchmark
|
|
114
|
-
// guarantee. The benchmark itself fails closed unless all 100 canonical questions run.
|
|
115
|
-
runOrDie('Top-100 source-grounded recall contract', process.execPath, ['scripts/top100-benchmark.mjs', '--no-write']);
|
|
143
|
+
// The Top-100 corpus spans naive through expert prompts and grades semantic clauses, citations,
|
|
144
|
+
// abstention, and latency. A manual-only benchmark is a report; a strict release-path benchmark
|
|
145
|
+
// 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
|
+
}
|
|
116
150
|
|
|
117
151
|
// A2. Stable Spine restart classifier (ADR-023, red-team finding 18): diff the boot-frozen SHELL
|
|
118
152
|
// (hooks.json, hook-shim, MCP server, .mcp.json, skills/, commands/) against the previous release
|
|
@@ -200,7 +234,7 @@ if (PUBLISH) {
|
|
|
200
234
|
const v = V();
|
|
201
235
|
const tag = `v${v}`;
|
|
202
236
|
const zip = path.join(ROOT, 'dist', 'ruvnet-brain.zip');
|
|
203
|
-
const assets = [zip, `${zip}.sig`, `${zip}.sha256
|
|
237
|
+
const assets = [zip, `${zip}.sig`, `${zip}.sha256`, sealedPackageArtifact];
|
|
204
238
|
const head = execFileSync('git', ['-C', ROOT, 'rev-parse', 'HEAD'], { encoding: 'utf8' }).trim();
|
|
205
239
|
const priorTxn = readReleaseTransaction();
|
|
206
240
|
const unfinished = priorTxn && priorTxn.state !== 'channels-converged';
|
|
@@ -228,7 +262,9 @@ if (PUBLISH) {
|
|
|
228
262
|
}
|
|
229
263
|
console.log(c.dim(' resume existing signed release assets for the pending candidate'));
|
|
230
264
|
} else {
|
|
231
|
-
|
|
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);
|
|
232
268
|
runOrDie('sign release bundle', process.execPath, ['scripts/sign-bundle.mjs', '--bundle', zip]);
|
|
233
269
|
}
|
|
234
270
|
for (const asset of assets) {
|
|
@@ -318,7 +354,7 @@ if (PUBLISH) {
|
|
|
318
354
|
if (already === v) console.log(c.dim(` ${v} already on npm — skipping publish, just re-asserting the tag`));
|
|
319
355
|
// npm requires an explicit tag for prerelease versions. This project intentionally serves its
|
|
320
356
|
// `-dev` release from `latest`, so make that policy explicit on the publish command itself.
|
|
321
|
-
else runOrDie('npm publish', 'npm', ['publish', '--tag', 'latest']);
|
|
357
|
+
else runOrDie('npm publish', 'npm', ['publish', sealedPackageArtifact, '--tag', 'latest']);
|
|
322
358
|
// npm does NOT auto-move `latest` to a prerelease (x.y.z-dev) — force it, or `@latest` stays stale.
|
|
323
359
|
runOrDie('npm dist-tag latest', 'npm', ['dist-tag', 'add', `ruvnet-brain@${v}`, 'latest']);
|
|
324
360
|
recordReleaseTransaction('publish-complete-verification-pending', { version: v, tag, head, bundleSha256 });
|
|
@@ -379,6 +415,14 @@ step('D+', 'deploy-surface sweep — what GitHub and Vercel think about what we
|
|
|
379
415
|
step('E', 'verify-channels — the live walk of every user path');
|
|
380
416
|
runOrDie('verify-channels', process.execPath, ['scripts/verify-channels.mjs']);
|
|
381
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
|
+
]);
|
|
382
426
|
const txn = readReleaseTransaction();
|
|
383
427
|
recordReleaseTransaction('channels-converged', {
|
|
384
428
|
version: V(),
|