ruvnet-brain 4.0.2 → 4.0.5

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 (46) hide show
  1. package/README.md +1 -1
  2. package/bin/install.mjs +215 -31
  3. package/docs/RELEASE-NOTES-4.0.md +1 -1
  4. package/package.json +1 -1
  5. package/plugin/.claude-plugin/plugin.json +1 -1
  6. package/plugin/.codex-plugin/plugin.json +1 -1
  7. package/plugin/commands/whats-new.md +6 -6
  8. package/plugin/docs/RELEASE-NOTES-4.0.md +88 -0
  9. package/plugin/mcp/server.mjs +70 -33
  10. package/plugin/scripts/hook-shim.mjs +27 -22
  11. package/plugin/scripts/lesson-command-scope.mjs +133 -0
  12. package/plugin/scripts/lesson-gate.mjs +401 -0
  13. package/plugin/scripts/lesson-presentation.mjs +99 -0
  14. package/plugin/scripts/lesson-store.mjs +452 -0
  15. package/plugin/scripts/route-dispatch.sh +1 -0
  16. package/plugin/scripts/session-start-core.mjs +28 -2
  17. package/plugin/scripts/verify-interface.sh +1 -0
  18. package/plugin/scripts/whats-new.mjs +42 -0
  19. package/plugin/skills/release-proof/SKILL.md +21 -4
  20. package/plugin/skills/release-proof/references/receipt-contract.md +8 -2
  21. package/plugin/skills/release-proof/scripts/release-proof.mjs +77 -1
  22. package/plugin/skills/ruvnet-brain/SKILL.md +22 -7
  23. package/plugin/skills/whats-new/SKILL.md +4 -4
  24. package/scripts/build-bundle.mjs +34 -25
  25. package/scripts/fix-workstream.mjs +291 -0
  26. package/scripts/health-repair.mjs +14 -27
  27. package/scripts/issue-fix.mjs +135 -216
  28. package/scripts/learning-replay-cli.mjs +236 -0
  29. package/scripts/learning-replay-contract.mjs +255 -0
  30. package/scripts/learning-replay-execution.mjs +193 -0
  31. package/scripts/learning-replay-fixture.mjs +380 -0
  32. package/scripts/learning-replay-proof.mjs +459 -0
  33. package/scripts/learning-replay.mjs +10 -1565
  34. package/scripts/lesson-gate.mjs +3 -679
  35. package/scripts/lesson-store.mjs +4 -447
  36. package/scripts/memory-doctor.mjs +80 -9
  37. package/scripts/nightly-wrapper.sh +20 -28
  38. package/scripts/onboarding-console.mjs +302 -95
  39. package/scripts/protected-release-invocation.mjs +76 -0
  40. package/scripts/publication-receipt.mjs +307 -0
  41. package/scripts/release-authority.mjs +93 -0
  42. package/scripts/release-vector.mjs +49 -25
  43. package/scripts/release.mjs +55 -11
  44. package/scripts/self-update.mjs +15 -227
  45. package/scripts/stabilization-receipt.mjs +108 -0
  46. package/scripts/wired-check.mjs +3 -0
@@ -0,0 +1,76 @@
1
+ #!/usr/bin/env node
2
+ // Local publication is deliberately impossible. The one publisher may mutate public channels only
3
+ // when the reviewer-protected GitHub workflow carries a fully valid candidate seal into it.
4
+ import crypto from 'node:crypto';
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import { evaluateCandidateReceipt } from './release-proof.mjs';
8
+ import { getVersion } from './version.mjs';
9
+
10
+ export const PROTECTED_WORKFLOW = 'protected-release';
11
+ export const PROTECTED_VERSION = getVersion();
12
+
13
+ const hex = (value, length) => new RegExp(`^[0-9a-f]{${length}}$`).test(String(value || ''));
14
+ const digestOf = (receipt) => String(receipt?.artifact?.sha256 || '').replace(/^sha256:/, '');
15
+
16
+ export function validateProtectedPublishInvocation({ root = process.cwd(), env = process.env } = {}) {
17
+ const failures = [];
18
+ let releaseMode = 'unknown';
19
+ if (env.GITHUB_ACTIONS !== 'true' || env.GITHUB_WORKFLOW !== PROTECTED_WORKFLOW) {
20
+ failures.push('publication is allowed only inside the protected-release GitHub workflow');
21
+ }
22
+
23
+ const expectedSha = String(env.RUVNET_EXPECTED_SHA || '');
24
+ const expectedDigest = String(env.RUVNET_EXPECTED_ARTIFACT_SHA256 || '').replace(/^sha256:/, '');
25
+ const expectedVersion = String(env.RUVNET_EXPECTED_VERSION || '');
26
+ if (!hex(expectedSha, 40)) failures.push('expected candidate SHA is missing or malformed');
27
+ if (!hex(expectedDigest, 64)) failures.push('expected artifact digest is missing or malformed');
28
+ if (expectedVersion !== PROTECTED_VERSION) failures.push(`release generation must be ${PROTECTED_VERSION}`);
29
+
30
+ const receiptRelative = String(env.RUVNET_CANDIDATE_RECEIPT || '');
31
+ const evidenceRoot = path.resolve(root, 'release-evidence');
32
+ const receiptPath = path.resolve(root, receiptRelative || '.missing-candidate-receipt');
33
+ if (!receiptRelative || !(receiptPath === evidenceRoot || receiptPath.startsWith(`${evidenceRoot}${path.sep}`))) {
34
+ failures.push('candidate receipt must be inside release-evidence');
35
+ }
36
+
37
+ let receipt = null;
38
+ try {
39
+ receipt = JSON.parse(fs.readFileSync(receiptPath, 'utf8'));
40
+ } catch (error) {
41
+ failures.push(`candidate receipt unavailable: ${error.message}`);
42
+ }
43
+
44
+ if (receipt) {
45
+ const seal = evaluateCandidateReceipt(receipt);
46
+ if (seal.verdict !== 'PASS') failures.push(`candidate seal failed: ${seal.failures.map(({ code }) => code).join(',')}`);
47
+ const receiptDigest = digestOf(receipt);
48
+ if (receipt.sha !== expectedSha) failures.push('candidate receipt SHA mismatch');
49
+ if (receiptDigest !== expectedDigest) failures.push('candidate artifact digest mismatch');
50
+ if (receipt.version !== expectedVersion) failures.push('candidate version mismatch');
51
+ const expectedMode = receipt.phase === 'stabilization-candidate' ? 'stabilization' : 'strict';
52
+ releaseMode = expectedMode;
53
+ if (env.RUVNET_RELEASE_MODE !== expectedMode) failures.push(`release mode must be ${expectedMode} for this receipt`);
54
+
55
+ const artifactPath = path.resolve(root, String(receipt.artifact?.path || '.missing-artifact'));
56
+ if (!(artifactPath === evidenceRoot || artifactPath.startsWith(`${evidenceRoot}${path.sep}`))) {
57
+ failures.push('candidate artifact must be inside release-evidence');
58
+ } else {
59
+ try {
60
+ const actual = crypto.createHash('sha256').update(fs.readFileSync(artifactPath)).digest('hex');
61
+ if (actual !== expectedDigest) failures.push('candidate artifact bytes do not match the seal');
62
+ } catch (error) {
63
+ failures.push(`candidate artifact unavailable: ${error.message}`);
64
+ }
65
+ }
66
+ }
67
+
68
+ return {
69
+ verdict: failures.length === 0 ? 'PASS' : 'FAIL',
70
+ sha: expectedSha,
71
+ artifactSha256: expectedDigest,
72
+ version: expectedVersion,
73
+ mode: releaseMode,
74
+ failures,
75
+ };
76
+ }
@@ -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
+ }
@@ -33,9 +33,9 @@ export function verdictOf(results) {
33
33
  return results.reduce((worst, r) => (RANK.indexOf(r.state) < RANK.indexOf(worst) ? r.state : worst), 'PASS');
34
34
  }
35
35
 
36
- export function candidateLineage() {
36
+ export function candidateLineage(root = ROOT) {
37
37
  const git = (args) => {
38
- const r = spawnSync('git', args, { cwd: ROOT, encoding: 'utf8' });
38
+ const r = spawnSync('git', args, { cwd: root, encoding: 'utf8' });
39
39
  return r.status === 0 ? r.stdout.trim() : '';
40
40
  };
41
41
  return {
@@ -49,11 +49,8 @@ export function verdictWithLineage(results, lineage) {
49
49
  return lineage?.dirty ? 'FAIL' : verdictOf(results);
50
50
  }
51
51
 
52
- const exists = (rel) => fs.existsSync(path.join(ROOT, rel));
53
- const read = (rel) => { try { return fs.readFileSync(path.join(ROOT, rel), 'utf8'); } catch { return null; } };
54
-
55
52
  /** Run a command; UNKNOWN (not FAIL) when the runner itself could not execute. */
56
- function run(cmd, args, timeoutMs = 120_000, env = process.env) {
53
+ function runAt(root, cmd, args, timeoutMs = 120_000, env = process.env) {
57
54
  // npm installs `npm.cmd` / `npx.cmd` command shims on Windows. Direct spawn does not resolve the
58
55
  // extension through PATHEXT, and .cmd files require cmd.exe, so a healthy detector otherwise
59
56
  // reads UNKNOWN with ENOENT. Invoke the command interpreter explicitly rather than `shell:true`;
@@ -62,7 +59,7 @@ function run(cmd, args, timeoutMs = 120_000, env = process.env) {
62
59
  const executable = windowsCommandShim ? (process.env.ComSpec || 'cmd.exe') : cmd;
63
60
  const spawnArgs = windowsCommandShim ? ['/d', '/c', `${cmd}.cmd`, ...args] : args;
64
61
  const r = spawnSync(executable, spawnArgs, {
65
- cwd: ROOT,
62
+ cwd: root,
66
63
  encoding: 'utf8',
67
64
  timeout: timeoutMs,
68
65
  env,
@@ -71,6 +68,18 @@ function run(cmd, args, timeoutMs = 120_000, env = process.env) {
71
68
  return { state: r.status === 0 ? 'PASS' : 'FAIL', why: r.status === 0 ? 'exit 0' : `exit ${r.status}`, out: r.stdout };
72
69
  }
73
70
 
71
+ /** An explicit detector root makes destructive mutants isolated and concurrency-safe. */
72
+ function detectorScope({ root = ROOT, runCommand = null } = {}) {
73
+ return {
74
+ root,
75
+ exists: (relative) => fs.existsSync(path.join(root, relative)),
76
+ read: (relative) => {
77
+ try { return fs.readFileSync(path.join(root, relative), 'utf8'); } catch { return null; }
78
+ },
79
+ run: runCommand || ((cmd, args, timeoutMs, env) => runAt(root, cmd, args, timeoutMs, env)),
80
+ };
81
+ }
82
+
74
83
  // ─── the eight invariants ────────────────────────────────────────────────────────────────────
75
84
  // Each names the dimension it gates and the dated failure it descends from. A detector with no
76
85
  // incident behind it is a checkbox; every one of these was bought with a real defect.
@@ -80,7 +89,8 @@ export const INVARIANTS = [
80
89
  name: 'INSTALL-FAILS-LOUD',
81
90
  dimension: 'D8',
82
91
  incident: 'verifyInstall() warned and continued; a stranger scored 18/100 on an install that could not fail',
83
- detect() {
92
+ detect(options) {
93
+ const { exists, read } = detectorScope(options);
84
94
  if (!exists('.github/workflows/stranger-matrix.yml')) return { state: 'FAIL', why: 'no stranger matrix' };
85
95
  const inst = read('bin/install.mjs');
86
96
  if (inst === null) return { state: 'UNKNOWN', why: 'bin/install.mjs unreadable' };
@@ -95,7 +105,8 @@ export const INVARIANTS = [
95
105
  name: 'INTERFACE-CORPUS',
96
106
  dimension: 'D7',
97
107
  incident: 'shell-parsing defects recurred across issues #12/#13/#41/#44, then three stale ADRs reached main because document currency was outside every release gate',
98
- detect() {
108
+ detect(options) {
109
+ const { exists, run } = detectorScope(options);
99
110
  if (!exists('tests/regression/interface-gate-corpus.test.mjs')) return { state: 'FAIL', why: 'no incident corpus' };
100
111
  if (!exists('scripts/doc-currency.mjs')) return { state: 'FAIL', why: 'no document-currency gate' };
101
112
  const corpus = run('npx', ['vitest', 'run', 'tests/regression/interface-gate-corpus.test.mjs', '--reporter=dot']);
@@ -110,23 +121,29 @@ export const INVARIANTS = [
110
121
  name: 'LATENCY-DECISION-LANE',
111
122
  dimension: 'D6',
112
123
  incident: 'latency breaches only warned; timing regressions did not block shipping',
113
- detect: () => (exists('scripts/qe/card-lane-gate.mjs')
114
- ? run('node', ['scripts/qe/card-lane-gate.mjs'])
115
- : { state: 'FAIL', why: 'no decision-lane gate' }),
124
+ detect(options) {
125
+ const { exists, run } = detectorScope(options);
126
+ return exists('scripts/qe/card-lane-gate.mjs')
127
+ ? run('node', ['scripts/qe/card-lane-gate.mjs'])
128
+ : { state: 'FAIL', why: 'no decision-lane gate' };
129
+ },
116
130
  },
117
131
  {
118
132
  name: 'COEXIST-BYTE-EQUAL',
119
133
  dimension: 'D5',
120
134
  incident: 'the merged-registry lint found 63 findings across 42 registrations; the prior suite saw 15',
121
- detect: () => (exists('tests/mesh/coexistence.test.mjs')
122
- ? run('npx', ['vitest', 'run', 'tests/mesh', '--reporter=dot'])
123
- : { state: 'FAIL', why: 'no coexistence suite' }),
135
+ detect(options) {
136
+ const { exists, run } = detectorScope(options);
137
+ return exists('tests/mesh/coexistence.test.mjs')
138
+ ? run('npx', ['vitest', 'run', 'tests/mesh', '--reporter=dot'])
139
+ : { state: 'FAIL', why: 'no coexistence suite' };
140
+ },
124
141
  },
125
142
  {
126
143
  name: 'LEARNING-REPLAY',
127
144
  dimension: 'D4',
128
145
  incident: 'L4 asserted the hook\'s own prose contained words — it proved the brain SPOKE, never that anything LISTENED',
129
- async detect() {
146
+ async detect(options = {}) {
130
147
  // DELEGATED to scripts/learning-replay.mjs's own checkArtifact(), not reimplemented here.
131
148
  //
132
149
  // This detector originally required `artifact.sha === HEAD`, which is WRONG in a way worth
@@ -141,7 +158,9 @@ export const INVARIANTS = [
141
158
  try { mod = await import(new URL('./learning-replay.mjs', import.meta.url).href); }
142
159
  catch { return { state: 'UNKNOWN', why: 'counterfactual replay harness absent' }; }
143
160
  let r;
144
- try { r = mod.checkPortfolio(); } catch (e) { return { state: 'UNKNOWN', why: `replay portfolio unreadable: ${e.message}` }; }
161
+ try {
162
+ r = options.checkReplay ? options.checkReplay() : mod.checkPortfolio({ repo: options.root || ROOT });
163
+ } catch (e) { return { state: 'UNKNOWN', why: `replay portfolio unreadable: ${e.message}` }; }
145
164
  // `.status` is the field checkArtifact() actually returns — read, not assumed. Reaching for
146
165
  // `.verdict` (the name used INSIDE the nested artifact) silently yielded undefined and made a
147
166
  // real PASS read UNKNOWN: the delegation "worked" while reporting the opposite of the truth.
@@ -157,7 +176,8 @@ export const INVARIANTS = [
157
176
  name: 'SIGNAL-WATCH-FIRES',
158
177
  dimension: 'D3',
159
178
  incident: 'the OWNER had to report that CI was red — every local surface stayed calm',
160
- detect() {
179
+ detect(options) {
180
+ const { exists, read, run } = detectorScope(options);
161
181
  if (!exists('scripts/signal-watch.mjs')) return { state: 'FAIL', why: 'no signal watcher' };
162
182
  const raw = read('plugin/hooks/hooks.json');
163
183
  if (raw === null) return { state: 'UNKNOWN', why: 'plugin/hooks/hooks.json unreadable' };
@@ -207,15 +227,19 @@ export const INVARIANTS = [
207
227
  name: 'SCENARIOS-CURRENT',
208
228
  dimension: 'D2',
209
229
  incident: 'ADR-053 specified a mental-model scenario list; it was simply absent',
210
- detect: () => (exists('tests/experience/report.mjs')
211
- ? run('node', ['tests/experience/report.mjs'])
212
- : { state: 'FAIL', why: 'no scenario report' }),
230
+ detect(options) {
231
+ const { exists, run } = detectorScope(options);
232
+ return exists('tests/experience/report.mjs')
233
+ ? run('node', ['tests/experience/report.mjs'])
234
+ : { state: 'FAIL', why: 'no scenario report' };
235
+ },
213
236
  },
214
237
  {
215
238
  name: 'GUARANTEE-RUNS',
216
239
  dimension: 'D1',
217
240
  incident: 'the product guarantee skipped on every CI runner; REQUIRE_BRAIN=1 was set nowhere in the repo',
218
- detect() {
241
+ detect(options) {
242
+ const { read, run } = detectorScope(options);
219
243
  const ci = read('.github/workflows/ci.yml');
220
244
  if (ci === null) return { state: 'UNKNOWN', why: 'ci.yml unreadable' };
221
245
  if (!/REQUIRE_BRAIN/.test(ci)) {
@@ -247,12 +271,12 @@ export function headSha() {
247
271
  /** Strings a non-PASS verdict mechanically bans from release surfaces (ADR-058). */
248
272
  export const BANNED_WHEN_DEGRADED = ['healthy', 'proven', 'all pass'];
249
273
 
250
- export async function evaluate(invariants = INVARIANTS) {
251
- const lineage = candidateLineage();
274
+ export async function evaluate(invariants = INVARIANTS, options = {}) {
275
+ const lineage = candidateLineage(options.root || ROOT);
252
276
  const sha = lineage.sha;
253
277
  const results = [];
254
278
  for (const inv of invariants) {
255
- const r = await inv.detect();
279
+ const r = await inv.detect(options);
256
280
  results.push({ name: inv.name, dimension: inv.dimension, state: r.state, why: r.why, sha });
257
281
  }
258
282
  return { sha, lineage, results, verdict: verdictWithLineage(results, lineage) };