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.
- 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/lesson-command-scope.mjs +133 -0
- package/plugin/scripts/lesson-gate.mjs +401 -0
- package/plugin/scripts/lesson-presentation.mjs +99 -0
- package/plugin/scripts/lesson-store.mjs +452 -0
- package/plugin/scripts/route-dispatch.sh +1 -0
- package/plugin/scripts/session-start-core.mjs +28 -2
- package/plugin/scripts/verify-interface.sh +1 -0
- 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/learning-replay-cli.mjs +236 -0
- package/scripts/learning-replay-contract.mjs +255 -0
- package/scripts/learning-replay-execution.mjs +193 -0
- package/scripts/learning-replay-fixture.mjs +380 -0
- package/scripts/learning-replay-proof.mjs +459 -0
- package/scripts/learning-replay.mjs +10 -1565
- package/scripts/lesson-gate.mjs +3 -679
- package/scripts/lesson-store.mjs +4 -447
- package/scripts/memory-doctor.mjs +80 -9
- package/scripts/nightly-wrapper.sh +20 -28
- package/scripts/onboarding-console.mjs +302 -95
- 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-vector.mjs +49 -25
- 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,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:
|
|
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
|
|
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:
|
|
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
|
|
114
|
-
|
|
115
|
-
|
|
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
|
|
122
|
-
|
|
123
|
-
|
|
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 {
|
|
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
|
|
211
|
-
|
|
212
|
-
|
|
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) };
|