ruvnet-brain 4.3.1 → 4.3.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/README.md +12 -13
  2. package/bin/install.mjs +18 -8
  3. package/console/app.js +4 -4
  4. package/data/model-catalog.json +3 -3
  5. package/kb/verify-citation.mjs +32 -5
  6. package/package.json +13 -9
  7. package/plugin/.claude-plugin/plugin.json +1 -1
  8. package/plugin/.codex-plugin/plugin.json +1 -1
  9. package/plugin/mcp/managed-cli-interface.mjs +4 -2
  10. package/plugin/scripts/codex-hook-adapter.mjs +12 -7
  11. package/plugin/scripts/codex-hook-events.mjs +39 -0
  12. package/plugin/scripts/coverage-integrity.mjs +4 -4
  13. package/plugin/scripts/hook-shim.mjs +9 -7
  14. package/plugin/scripts/learn-capture.sh +20 -1
  15. package/plugin/scripts/learn-flush.mjs +7 -1
  16. package/plugin/scripts/lesson-gate.mjs +17 -2
  17. package/plugin/scripts/lesson-hooks.sh +17 -2
  18. package/plugin/scripts/session-start-core.mjs +47 -11
  19. package/plugin/skills/release-proof/SKILL.md +19 -10
  20. package/plugin/skills/release-proof/references/receipt-contract.md +18 -8
  21. package/scripts/brain-stamp-resolve.mjs +22 -0
  22. package/scripts/brain-stamp.mjs +5 -3
  23. package/scripts/build-bundle.mjs +41 -4
  24. package/scripts/candidate-host-evidence.mjs +9 -2
  25. package/scripts/ci/step-watchdog.mjs +86 -0
  26. package/scripts/convergence-manifest.mjs +66 -0
  27. package/scripts/distill-project.mjs +22 -4
  28. package/scripts/eval-brain.mjs +13 -3
  29. package/scripts/execution-policy.mjs +111 -0
  30. package/scripts/execution-preflight.mjs +18 -0
  31. package/scripts/git-hooks/pre-push +16 -0
  32. package/scripts/health-repair.mjs +5 -1
  33. package/scripts/host-install-matrix.mjs +247 -40
  34. package/scripts/integration-evidence.mjs +73 -0
  35. package/scripts/nightly-wrapper.sh +4 -3
  36. package/scripts/onboarding-console.mjs +3 -3
  37. package/scripts/prepublication-evidence.mjs +174 -0
  38. package/scripts/protected-release-invocation.mjs +32 -4
  39. package/scripts/public-verification-aggregate.mjs +27 -54
  40. package/scripts/public-verification-inputs.mjs +5 -3
  41. package/scripts/qa-runner.mjs +72 -0
  42. package/scripts/qe/agentic-qe-4.3.mjs +195 -0
  43. package/scripts/qe/aggregate-4.3.mjs +42 -0
  44. package/scripts/record-lesson.mjs +28 -6
  45. package/scripts/refresh-model-catalog.mjs +32 -3
  46. package/scripts/release-authority.mjs +19 -7
  47. package/scripts/release-convergence-watchdog.mjs +10 -22
  48. package/scripts/release-projection.mjs +76 -32
  49. package/scripts/release-transaction.mjs +1 -1
  50. package/scripts/release.mjs +9 -15
  51. package/scripts/retrieval-canary.mjs +14 -6
  52. package/scripts/set-version.mjs +31 -0
  53. package/scripts/source-coverage.mjs +11 -2
  54. package/scripts/staged-host-verifier.mjs +2 -2
  55. package/scripts/verify-channels.mjs +1 -1
  56. package/scripts/wired-check.mjs +1 -0
@@ -0,0 +1,195 @@
1
+ #!/usr/bin/env node
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { spawnSync } from 'node:child_process';
6
+ import { createHash } from 'node:crypto';
7
+
8
+ const ROOT = path.resolve(import.meta.dirname, '../..');
9
+ const RECEIPT_VERSION = 1;
10
+ const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm';
11
+ const vitestBin = path.join(ROOT, 'node_modules', 'vitest', 'vitest.mjs');
12
+ const FORBIDDEN_SPEND_KEYS = ['ANTHROPIC_API_KEY', 'OPENAI_API_KEY', 'OPENROUTER_API_KEY'];
13
+ const vitest = (files, timeoutMs = 180_000) => ({
14
+ kind: 'vitest', command: process.execPath, args: [vitestBin, 'run', ...files], timeoutMs,
15
+ });
16
+ const windowsInstallImportProbe = {
17
+ kind: 'command', command: process.execPath,
18
+ args: ['--input-type=module', '-e', "process.env.RUVNET_BRAIN_IMPORT_ONLY='1'; await import('./bin/install.mjs')"],
19
+ timeoutMs: 30_000,
20
+ };
21
+
22
+ // Deterministic slices only. Agentic-QE's grounded quality-gate contract names correctness,
23
+ // safety, reliability, test adequacy, parity, performance, distribution, and release evidence.
24
+ // The fleet is intentionally not invoked here: it is an on-demand generator, never release proof.
25
+ const LANES = Object.freeze({
26
+ // Fail design/provenance issues before any long behavioral or platform lane starts.
27
+ preflight: [
28
+ { kind: 'command', command: process.execPath, args: ['--check', 'scripts/qe/agentic-qe-4.3.mjs'], timeoutMs: 10_000 },
29
+ { kind: 'command', command: process.execPath, args: ['--check', 'scripts/qe/aggregate-4.3.mjs'], timeoutMs: 10_000 },
30
+ { kind: 'command', command: npm, args: ['run', 'version:check'], timeoutMs: 60_000 },
31
+ vitest(['tests/qe/gpt56/critical-risk-map.test.mjs'], 60_000),
32
+ ],
33
+ check: [
34
+ vitest([
35
+ 'tests/qe/gpt56/critical-risk-map.test.mjs',
36
+ 'tests/unit/npm-tarball-codex.test.mjs',
37
+ 'tests/unit/codex-lifecycle-hooks.test.mjs',
38
+ 'tests/unit/codex-console-invocation.test.mjs',
39
+ 'tests/unit/console-advocacy-dial.test.mjs',
40
+ 'tests/unit/console-advocacy-precision.test.mjs',
41
+ 'tests/unit/mcp-timeout-outage.test.mjs',
42
+ ]),
43
+ ],
44
+ integration: [
45
+ vitest(['tests/integration/hook-conformance-both-hosts.test.mjs'], 240_000),
46
+ vitest(['tests/integration/managed-cli-mcp.test.mjs'], 180_000),
47
+ ],
48
+ release: [
49
+ vitest([
50
+ 'tests/qe/release/packed-clean-install.test.mjs',
51
+ 'tests/qe/release/release-publish-contract.test.mjs',
52
+ 'tests/qe/release/stable-spine-recovery.test.mjs',
53
+ 'tests/qe/security/release-abuse-cases.test.mjs',
54
+ ], 240_000),
55
+ ],
56
+ resources: [
57
+ vitest([
58
+ 'tests/qe/gpt56/worker-concurrency-retirement.test.mjs',
59
+ 'tests/unit/mcp-timeout-outage.test.mjs',
60
+ ], 180_000),
61
+ ],
62
+ 'windows-unit': [
63
+ windowsInstallImportProbe,
64
+ vitest([
65
+ 'tests/qe/gpt56/critical-risk-map.test.mjs',
66
+ 'tests/unit/codex-console-invocation.test.mjs',
67
+ 'tests/unit/console-advocacy-dial.test.mjs',
68
+ 'tests/unit/console-advocacy-precision.test.mjs',
69
+ 'tests/unit/mcp-timeout-outage.test.mjs',
70
+ ], 120_000),
71
+ vitest(['tests/unit/npm-tarball-codex.test.mjs'], 180_000),
72
+ vitest(['tests/unit/codex-lifecycle-hooks.test.mjs'], 180_000),
73
+ ],
74
+ });
75
+
76
+ const sha256 = (value) => createHash('sha256').update(value).digest('hex');
77
+ const now = () => new Date().toISOString();
78
+ const arg = (name) => {
79
+ const i = process.argv.indexOf(name);
80
+ return i < 0 ? null : process.argv[i + 1];
81
+ };
82
+
83
+ function enforceZeroSpend() {
84
+ const exposed = FORBIDDEN_SPEND_KEYS.filter((key) => process.env[key]);
85
+ if (exposed.length || process.env.RUVNET_ALLOW_METERED_SPEND === '1') {
86
+ throw new Error(`refusing QE: metered API credentials exposed (${exposed.join(', ') || 'override'})`);
87
+ }
88
+ }
89
+
90
+ function gitSha() {
91
+ if (process.env.GITHUB_SHA) return process.env.GITHUB_SHA;
92
+ const result = spawnSync('git', ['rev-parse', 'HEAD'], { cwd: ROOT, encoding: 'utf8' });
93
+ return result.status === 0 ? result.stdout.trim() : 'unknown';
94
+ }
95
+
96
+ function outputDir() {
97
+ const dir = path.resolve(ROOT, process.env.QE_RECEIPT_DIR || '.qe/receipts');
98
+ fs.mkdirSync(dir, { recursive: true });
99
+ return dir;
100
+ }
101
+
102
+ function runStep(step, index, dir) {
103
+ const startedAt = now();
104
+ const report = path.join(dir, `vitest-${index}.json`);
105
+ const args = step.kind === 'vitest'
106
+ ? [...step.args, '--reporter=json', '--reporter=verbose', `--outputFile=${report}`]
107
+ : step.args;
108
+ const result = spawnSync(step.command, args, {
109
+ cwd: ROOT,
110
+ env: { ...process.env, CI: '1' },
111
+ encoding: 'utf8',
112
+ timeout: step.timeoutMs,
113
+ maxBuffer: 4 * 1024 * 1024,
114
+ });
115
+ const stdout = String(result.stdout || '');
116
+ const stderr = String(result.stderr || '');
117
+ const stepReceipt = {
118
+ index,
119
+ command: [step.command, ...args].join(' '),
120
+ startedAt,
121
+ endedAt: now(),
122
+ exitCode: result.status,
123
+ spawnError: result.error?.message || null,
124
+ timedOut: Boolean(result.error?.code === 'ETIMEDOUT'),
125
+ stdoutSha256: sha256(stdout),
126
+ stderrSha256: sha256(stderr),
127
+ stdoutTail: stdout.slice(-4000),
128
+ stderrTail: stderr.slice(-4000),
129
+ };
130
+ if (step.kind === 'vitest') {
131
+ try {
132
+ const json = JSON.parse(fs.readFileSync(report, 'utf8'));
133
+ stepReceipt.tests = {
134
+ total: json.numTotalTests ?? 0,
135
+ passed: json.numPassedTests ?? 0,
136
+ failed: json.numFailedTests ?? 0,
137
+ skipped: json.numPendingTests ?? 0,
138
+ suitesFailed: json.numFailedTestSuites ?? 0,
139
+ };
140
+ stepReceipt.failedTests = (json.testResults || []).flatMap((suite) =>
141
+ (suite.assertionResults || [])
142
+ .filter((test) => test.status === 'failed')
143
+ .map((test) => ({ file: suite.name, name: test.fullName })));
144
+ stepReceipt.skippedTests = (json.testResults || []).flatMap((suite) =>
145
+ (suite.assertionResults || [])
146
+ .filter((test) => test.status === 'pending' || test.status === 'skipped')
147
+ .map((test) => ({ file: suite.name, name: test.fullName })));
148
+ stepReceipt.suiteErrors = (json.testResults || [])
149
+ .filter((suite) => suite.status === 'failed' || suite.message)
150
+ .map((suite) => ({ file: suite.name, message: suite.message || 'suite failed' }));
151
+ } catch (error) { stepReceipt.reportError = error.message; }
152
+ }
153
+ stepReceipt.status = result.error || result.status !== 0 ? 'FAIL' : 'PASS';
154
+ if (stepReceipt.tests && (stepReceipt.tests.total === 0 || stepReceipt.tests.skipped > 0)) {
155
+ stepReceipt.status = 'UNKNOWN';
156
+ }
157
+ return stepReceipt;
158
+ }
159
+
160
+ function main() {
161
+ enforceZeroSpend();
162
+ const requestedLane = arg('--lane');
163
+ const lane = requestedLane;
164
+ if (!requestedLane || !LANES[lane]) throw new Error(`unknown lane: ${requestedLane || '<missing>'}`);
165
+ const dir = outputDir();
166
+ fs.rmSync(path.join(dir, `${requestedLane}.json`), { force: true });
167
+ const runDir = path.join(dir, `.run-${requestedLane}-${process.pid}-${Date.now()}`);
168
+ fs.mkdirSync(runDir, { recursive: true });
169
+ const steps = [];
170
+ for (const [index, step] of LANES[lane].entries()) {
171
+ const result = runStep(step, index, runDir);
172
+ steps.push(result);
173
+ if (result.status !== 'PASS') break;
174
+ }
175
+ const status = steps.every((step) => step.status === 'PASS') ? 'PASS' : 'FAIL';
176
+ const receipt = {
177
+ schema: 'ruvnet-brain.agentic-qe.receipt', receiptVersion: RECEIPT_VERSION,
178
+ contract: 'agentic-qe-4.3', lane: requestedLane, sha: gitSha(),
179
+ runId: process.env.GITHUB_RUN_ID || `local-${gitSha()}`, host: `${os.platform()}-${os.arch()}`,
180
+ startedAt: steps[0]?.startedAt || now(), endedAt: steps.at(-1)?.endedAt || now(), status, steps,
181
+ };
182
+ const file = path.join(dir, `${requestedLane}.json`);
183
+ fs.writeFileSync(file, `${JSON.stringify(receipt, null, 2)}\n`);
184
+ fs.rmSync(runDir, { recursive: true, force: true });
185
+ console.log(JSON.stringify({
186
+ lane: requestedLane, status, receipt: file, sha: receipt.sha,
187
+ failedTests: steps.flatMap((step) => step.failedTests || []),
188
+ }));
189
+ process.exitCode = status === 'PASS' ? 0 : 1;
190
+ }
191
+
192
+ try { main(); } catch (error) {
193
+ console.error(`agentic-qe-4.3: ${error.message}`);
194
+ process.exitCode = 2;
195
+ }
@@ -0,0 +1,42 @@
1
+ #!/usr/bin/env node
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+
5
+ const ROOT = path.resolve(import.meta.dirname, '../..');
6
+ const dir = path.resolve(ROOT, process.env.QE_RECEIPT_DIR || '.qe/receipts');
7
+ const required = ['preflight', 'check', 'integration', 'release', 'resources', 'windows-unit'];
8
+ const failures = [];
9
+ const receipts = required.map((lane) => {
10
+ const file = path.join(dir, `${lane}.json`);
11
+ if (!fs.existsSync(file)) { failures.push(`${lane}: receipt missing`); return null; }
12
+ try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch (error) {
13
+ failures.push(`${lane}: invalid receipt (${error.message})`); return null;
14
+ }
15
+ });
16
+ const shas = new Set(receipts.filter(Boolean).map((receipt) => receipt.sha));
17
+ if (shas.size !== 1) failures.push(`receipts are not bound to one SHA: ${[...shas].join(', ') || '<none>'}`);
18
+ const runIds = new Set(receipts.filter(Boolean).map((receipt) => receipt.runId));
19
+ if (runIds.size !== 1) failures.push(`receipts are not bound to one run: ${[...runIds].join(', ') || '<none>'}`);
20
+ for (const [index, receipt] of receipts.entries()) {
21
+ const lane = required[index];
22
+ if (!receipt) continue;
23
+ if (receipt.schema !== 'ruvnet-brain.agentic-qe.receipt' || receipt.contract !== 'agentic-qe-4.3') {
24
+ failures.push(`${lane}: wrong receipt contract`);
25
+ }
26
+ if (receipt.lane !== lane || receipt.status !== 'PASS') failures.push(`${lane}: status ${receipt.status || '<missing>'}`);
27
+ for (const step of receipt.steps || []) {
28
+ if (step.status !== 'PASS') failures.push(`${lane} step ${step.index}: ${step.status}`);
29
+ if (step.tests && (step.tests.total === 0 || step.tests.skipped > 0)) {
30
+ failures.push(`${lane} step ${step.index}: skipped or empty test result`);
31
+ }
32
+ }
33
+ }
34
+ const result = {
35
+ schema: 'ruvnet-brain.agentic-qe.aggregate', contract: 'agentic-qe-4.3',
36
+ sha: shas.size === 1 ? [...shas][0] : null, required,
37
+ status: failures.length ? 'FAIL' : 'PASS', failures, receipts,
38
+ };
39
+ fs.mkdirSync(dir, { recursive: true });
40
+ fs.writeFileSync(path.join(dir, 'aggregate.json'), `${JSON.stringify(result, null, 2)}\n`);
41
+ console.log(JSON.stringify({ status: result.status, sha: result.sha, failures }));
42
+ process.exitCode = failures.length ? 1 : 0;
@@ -19,6 +19,7 @@
19
19
  import { execFileSync } from 'node:child_process';
20
20
  import fs from 'node:fs';
21
21
  import path from 'node:path';
22
+ import { resolveRuflo, RUFLO_MISSING } from '../plugin/scripts/ruflo-bin.mjs';
22
23
 
23
24
  const arg = (name, def = '') => {
24
25
  const i = process.argv.indexOf(`--${name}`);
@@ -46,6 +47,12 @@ if (!fs.existsSync(db)) {
46
47
  process.exit(2);
47
48
  }
48
49
 
50
+ const RUFLO = resolveRuflo();
51
+ if (!RUFLO) {
52
+ console.error(`ERROR: ${RUFLO_MISSING}`);
53
+ process.exit(2);
54
+ }
55
+
49
56
  const key = `lesson-${slug}`;
50
57
  const value = [
51
58
  `TASK: ${task}`,
@@ -56,21 +63,36 @@ const value = [
56
63
  ].filter(Boolean).join(' ');
57
64
 
58
65
  const ruflo = (args) =>
59
- execFileSync('ruflo', args, { cwd: dir, encoding: 'utf8', timeout: 60000 });
66
+ execFileSync(RUFLO, args, { cwd: dir, encoding: 'utf8', timeout: 60000,
67
+ shell: process.platform === 'win32' });
60
68
 
61
69
  console.log(`\nRecording lesson into ${path.basename(dir)}/.swarm/memory.db (namespace: ${ns})`);
62
70
  console.log(` key: ${key}`);
63
71
 
64
- // 1. STORE (native, signal namespace) — L1 content + L2 embedding
65
- let stored = false;
72
+ // 1a. STORE (native, signal namespace) — L1 content + L2 embedding
66
73
  try {
67
- const out = ruflo(['memory', 'store', '-k', key, '-n', ns, '--value', value]);
68
- stored = /OK|stored/i.test(out);
74
+ ruflo(['memory', 'store', '-k', key, '-n', ns, '--value', value]);
69
75
  } catch (e) {
70
76
  console.error(' store FAILED:', String(e.stdout || e.message).split('\n')[0]);
71
77
  process.exit(1);
72
78
  }
73
- console.log(` 1. store -> ${stored ? 'OK' : '?'}`);
79
+
80
+ // 1b. PROVE THE WRITE (exact-key round trip, ADR-063). `ruflo memory store` printing "[OK] Data
81
+ // stored successfully" is not evidence of a write — that exact line was on stdout throughout the
82
+ // 2026-08-13 incident that left three days of memory unrecoverable (rowcount 0, no store-side
83
+ // error). The only accepted proof in this repo is retrieving the SAME key back through the managed
84
+ // interface and reading the VALUE, the pattern `degradation-watch.mjs`'s `proveMemoryDurable()` and
85
+ // `learning-replay-fixture.mjs`'s `retrieveExact()` already establish — never the store command's
86
+ // own claimed-success wording, and never its exit status (the CLI can exit 0 while printing
87
+ // `[ERROR]`).
88
+ let stored = false;
89
+ try {
90
+ const back = ruflo(['memory', 'retrieve', '-k', key, '-n', ns, '--value-only', '--path', db]);
91
+ stored = String(back).includes(value);
92
+ } catch (e) {
93
+ console.error(' round-trip FAILED:', String(e.stdout || e.message).split('\n')[0]);
94
+ }
95
+ console.log(` 1. store -> ${stored ? 'OK (round-trip verified)' : 'store reported no error, but retrieve did not return the value'}`);
74
96
 
75
97
  // 2. REFINE (native) — L3 patterns + L4 episodes
76
98
  let batchEpisodes = '?';
@@ -1,8 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  // scripts/refresh-model-catalog.mjs — pulls the LIVE OpenRouter model catalog and writes the committed
3
- // snapshot data/openrouter-catalog-snapshot.json (with a pulledAt stamp). verify-model-catalog.mjs then
3
+ // snapshot data/openrouter-catalog-snapshot.json (with a pulledAt stamp) and synchronizes the catalog's
4
+ // factual prices. verify-model-catalog.mjs then
4
5
  // enforces, offline in CI, that every model in data/model-catalog.json exists and is priced correctly
5
- // against this snapshot — and that the snapshot is fresh. Run nightly (the anti-rot mechanism) + on demand.
6
+ // against this snapshot — and that the snapshot is fresh. Run weekly (the anti-rot mechanism) + on demand.
6
7
  // ADR-0016.
7
8
  //
8
9
  // It ALSO flags drift so a new flagship (e.g. a GPT-5.6-class release) surfaces instead of rotting:
@@ -45,6 +46,30 @@ export function detectDrift(catalog, models) {
45
46
  return missing;
46
47
  }
47
48
 
49
+ /** Synchronize factual catalog prices from the same live map that backs the snapshot. */
50
+ export function syncCatalogPrices(catalog, models, pulledAt) {
51
+ const changes = [];
52
+ for (const [provider, entry] of Object.entries(catalog.providers || {})) {
53
+ if (entry.aliasOf) continue;
54
+ for (const tier of ['frontier', 'mid', 'cheap']) {
55
+ const candidate = entry[tier];
56
+ if (!candidate?.model) continue;
57
+ const live = models[candidate.model]
58
+ || (!candidate.model.includes('/') ? models[`anthropic/${candidate.model}`] : null);
59
+ if (!live) continue;
60
+ if (candidate.in !== live.in || candidate.out !== live.out) {
61
+ changes.push({ provider, tier, model: candidate.model, from: { in: candidate.in, out: candidate.out }, to: live });
62
+ candidate.in = live.in;
63
+ candidate.out = live.out;
64
+ }
65
+ }
66
+ }
67
+ if (catalog._meta?.sources) {
68
+ catalog._meta.sources.prices = `OpenRouter /api/v1/models live catalog, pulled ${pulledAt.slice(0, 10)} (in/out USD per Mtok).`;
69
+ }
70
+ return changes;
71
+ }
72
+
48
73
  export function shapeSnapshot(models, pulledAt) {
49
74
  return { _meta: { source: 'OpenRouter /api/v1/models (live metadata)', pulledAt, modelCount: Object.keys(models).length }, models };
50
75
  }
@@ -73,7 +98,8 @@ async function main() {
73
98
 
74
99
  const catalog = JSON.parse(fs.readFileSync(CATALOG, 'utf8'));
75
100
  const missing = detectDrift(catalog, models);
76
- const snapshot = shapeSnapshot(models, new Date().toISOString());
101
+ const pulledAt = new Date().toISOString();
102
+ const snapshot = shapeSnapshot(models, pulledAt);
77
103
  const next = JSON.stringify(snapshot, null, 2) + '\n';
78
104
 
79
105
  if (check) {
@@ -86,8 +112,11 @@ async function main() {
86
112
  return;
87
113
  }
88
114
 
115
+ const priceChanges = syncCatalogPrices(catalog, models, pulledAt);
89
116
  fs.writeFileSync(SNAPSHOT, next);
117
+ fs.writeFileSync(CATALOG, JSON.stringify(catalog, null, 2) + '\n');
90
118
  console.log(`✓ wrote ${SNAPSHOT.replace(ROOT + '/', '')} — ${Object.keys(models).length} live models, pulledAt ${snapshot._meta.pulledAt}`);
119
+ console.log(`✓ synchronized ${priceChanges.length} catalog price field(s) from the same live response`);
91
120
  if (missing.length) {
92
121
  console.log(`\n⚠ DRIFT: ${missing.length} catalog model(s) no longer in the live catalog — update data/model-catalog.json:`);
93
122
  for (const m of missing) console.log(' - ' + m);
@@ -11,7 +11,7 @@ const CANONICAL_PUBLISHERS = new Set([
11
11
  'scripts/release.mjs',
12
12
  'scripts/release-transaction-provider.mjs',
13
13
  ]);
14
- const SOURCE_EXTENSIONS = new Set(['.mjs', '.js', '.cjs', '.sh']);
14
+ const SOURCE_EXTENSIONS = new Set(['.mjs', '.js', '.cjs', '.sh', '.yml', '.yaml']);
15
15
 
16
16
  function executableSource(source) {
17
17
  const withoutBlocks = source.replace(/\/\*[\s\S]*?\*\//g, '');
@@ -28,7 +28,7 @@ const ACTIONS = [
28
28
  jsPatterns: [
29
29
  /\[\s*['"]release['"]\s*,\s*['"]create['"]/,
30
30
  ],
31
- shellPatterns: [/^\s*gh\s+release\s+create\b/m],
31
+ shellPatterns: [/^\s*(?:-\s*run:\s*)?gh\s+release\s+create\b/m],
32
32
  },
33
33
  {
34
34
  action: 'github-release-update',
@@ -36,7 +36,7 @@ const ACTIONS = [
36
36
  /['"]PATCH['"][\s\S]{0,120}releases\//,
37
37
  /\[\s*['"]release['"]\s*,\s*['"]edit['"]/,
38
38
  ],
39
- shellPatterns: [/^\s*gh\s+(?:release\s+edit|api\s+.*releases\/.*PATCH)\b/m],
39
+ shellPatterns: [/^\s*(?:-\s*run:\s*)?gh\s+(?:release\s+edit|api\s+.*releases\/.*PATCH)\b/m],
40
40
  },
41
41
  {
42
42
  action: 'npm-publish',
@@ -44,7 +44,7 @@ const ACTIONS = [
44
44
  /(?:execFileSync|spawnSync)\(\s*['"]npm['"]\s*,\s*\[\s*['"]publish['"]/,
45
45
  /runOrDie\(\s*['"]npm publish['"]/,
46
46
  ],
47
- shellPatterns: [/^\s*npm\s+publish\b/m],
47
+ shellPatterns: [/^\s*(?:-\s*run:\s*)?npm\s+publish\b/m],
48
48
  },
49
49
  {
50
50
  action: 'npm-dist-tag',
@@ -52,7 +52,7 @@ const ACTIONS = [
52
52
  /(?:execFileSync|spawnSync)\(\s*['"]npm['"]\s*,\s*\[\s*['"]dist-tag['"]\s*,\s*['"]add['"]/,
53
53
  /runOrDie\(\s*['"]npm dist-tag[^'"]*['"]/,
54
54
  ],
55
- shellPatterns: [/^\s*npm\s+dist-tag\s+add\b/m],
55
+ shellPatterns: [/^\s*(?:-\s*run:\s*)?npm\s+dist-tag\s+add\b/m],
56
56
  },
57
57
  ];
58
58
 
@@ -60,7 +60,10 @@ export function detectPublisherActions(file, source) {
60
60
  const relative = file.split(path.sep).join('/');
61
61
  if (CANONICAL_PUBLISHERS.has(relative)) return [];
62
62
  const executable = executableSource(source);
63
- const kind = path.extname(relative) === '.sh' ? 'shellPatterns' : 'jsPatterns';
63
+ const extension = path.extname(relative);
64
+ const kind = ['.sh', '.yml', '.yaml'].includes(extension) || relative.startsWith('package.json#scripts.')
65
+ ? 'shellPatterns'
66
+ : 'jsPatterns';
64
67
  return ACTIONS
65
68
  .filter((action) => action[kind].some((pattern) => pattern.test(executable)))
66
69
  .map(({ action }) => ({ file: relative, action }));
@@ -79,14 +82,23 @@ function sourceFiles(root) {
79
82
  visit(path.join(root, 'scripts'));
80
83
  visit(path.join(root, 'deploy'));
81
84
  visit(path.join(root, 'bin'));
85
+ visit(path.join(root, '.github', 'workflows'));
82
86
  return files;
83
87
  }
84
88
 
85
89
  export function findUnauthorizedPublishers(root = ROOT) {
86
- return sourceFiles(root).flatMap((absolute) => {
90
+ const findings = sourceFiles(root).flatMap((absolute) => {
87
91
  const relative = path.relative(root, absolute).split(path.sep).join('/');
88
92
  return detectPublisherActions(relative, fs.readFileSync(absolute, 'utf8'));
89
93
  });
94
+ const packageFile = path.join(root, 'package.json');
95
+ if (fs.existsSync(packageFile)) {
96
+ const pkg = JSON.parse(fs.readFileSync(packageFile, 'utf8'));
97
+ for (const [name, command] of Object.entries(pkg.scripts || {})) {
98
+ findings.push(...detectPublisherActions(`package.json#scripts.${name}`, String(command)));
99
+ }
100
+ }
101
+ return findings;
90
102
  }
91
103
 
92
104
  export function main(root = ROOT) {
@@ -11,18 +11,16 @@
11
11
  * for a month.
12
12
  *
13
13
  * WHAT IT WILL NOT DO. It does not publish. `scripts/self-update.mjs:56` refuses `--publish` and
14
- * only `protected-release.yml` may create a release, publish npm, or move a dist-tag. This script
15
- * DISPATCHES that workflow — the sanctioned path — and never substitutes for it. Every safety gate
16
- * (exact-SHA evidence, clean worktree, release-proof, host verification, post-publication seal)
17
- * still runs inside the workflow exactly as designed. Bypassing them to "get it done while nobody
18
- * is looking" would be the worst possible reading of an instruction to finish the job.
14
+ * `release-candidate-preflight.yml` owns the one-time long lanes and `protected-release.yml` is the
15
+ * sole publication controller. This watchdog is report-only: it never dispatches either phase and
16
+ * cannot manufacture typed candidate evidence.
19
17
  *
20
18
  * IT IS A NO-OP UNLESS EVERY PRECONDITION HOLDS. It runs from the nightly, unattended, for weeks.
21
19
  * A watchdog that acts on a partial picture is worse than no watchdog, so it refuses on anything
22
20
  * unexpected and says why. The default outcome is "nothing happened, here is the reason".
23
21
  *
24
22
  * node scripts/release-convergence-watchdog.mjs # report only, never acts
25
- * node scripts/release-convergence-watchdog.mjs --dispatch # act, but only if ALL gates pass
23
+ * node scripts/release-convergence-watchdog.mjs --dispatch # compatibility flag; still reports only
26
24
  */
27
25
  import { execFileSync } from 'node:child_process';
28
26
  import fs from 'node:fs';
@@ -99,21 +97,11 @@ const runsFor = (wf) => JSON.parse(tryShy(() => sh('gh', ['run', 'list', '--repo
99
97
  const greenAt = (wf) => runsFor(wf).find((r) => r.headSha === originMain && r.conclusion === 'success');
100
98
 
101
99
  const ci = greenAt('ci');
102
- const aggregate = greenAt('release-aggregate');
103
100
  if (!ci) stand_down(`no successful exact-SHA \`ci\` run at ${originMain.slice(0, 7)} yet`);
104
- if (!aggregate) stand_down(`no successful exact-SHA \`release-aggregate\` run at ${originMain.slice(0, 7)} yet`);
105
101
 
106
- log(`ALL GATES PASS — candidate=${originMain.slice(0, 7)} version=${version} ci=${ci.databaseId} aggregate=${aggregate.databaseId}`);
107
- if (!DISPATCH) {
108
- log('report-only mode; pass --dispatch to actually invoke the protected release workflow.');
109
- process.exit(0);
110
- }
111
-
112
- // ── 6. Dispatch THE SANCTIONED PUBLISHER. Nothing here publishes; the workflow does. ─────────────
113
- sh('gh', ['workflow', 'run', 'protected-release.yml', '--repo', REPO,
114
- '-f', `candidate_sha=${originMain}`,
115
- '-f', `version=${version}`,
116
- '-f', `release_qe_run_id=${ci.databaseId}`,
117
- '-f', `aggregate_run_id=${aggregate.databaseId}`]);
118
- log(`DISPATCHED protected-release for ${version}. The workflow owns every safety gate from here.`);
119
- log('Verify afterwards with: node scripts/published-surface-probe.mjs (D-version-coherence must PASS).');
102
+ log(`PRECONDITIONS VISIBLE — candidate=${originMain.slice(0, 7)} version=${version} ci=${ci.databaseId}`);
103
+ // ── 6. Never dispatch a publisher from the watchdog. ───────────────────────────────────────────
104
+ // Only protected-release may derive the complete typed evidence chain and cross Production.
105
+ // This unattended observer cannot turn visible preconditions into release authority.
106
+ log(`PRECONDITIONS ONLY — no candidate artifact inferred and no action taken for ${version} at ${originMain}`);
107
+ log('Run release-candidate-preflight on release/** once; fast-forward that exact SHA to main; then protected-release imports release-candidate-<SHA> without a run ID.');
@@ -1,7 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  // Build the release-bound coverage projection from an observed corpus ledger and exact bundle bytes.
3
- // This is intentionally a producer, not a validator: public-verification-inputs.mjs remains the
4
- // independent consumer that proves the emitted files agree with the archive.
3
+ // This is a producer; public-verification-inputs.mjs independently proves the emitted files.
5
4
  import crypto from 'node:crypto';
6
5
  import fs from 'node:fs';
7
6
  import path from 'node:path';
@@ -15,7 +14,20 @@ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
15
14
  const arg = (name, fallback = null) => { const i = process.argv.indexOf(name); return i >= 0 ? process.argv[i + 1] : fallback; };
16
15
  const readJson = (file) => JSON.parse(fs.readFileSync(file, 'utf8'));
17
16
  const sha256 = (bytes) => crypto.createHash('sha256').update(bytes).digest('hex');
18
- const storeName = (value) => String(value || '').toLowerCase().replace(/\.big\.rvf$|\.rvf$/i, '');
17
+
18
+ function seedCompatibleGistReceipt() {
19
+ const source = readJson(path.join(ROOT, 'kb', 'ruv-gists.sources.json'));
20
+ const gists = {};
21
+ for (const [id, row] of Object.entries(source.gists || {})) {
22
+ const files = row.files;
23
+ gists[id] = { versionSha: row.versionSha, files,
24
+ contentDigest: crypto.createHash('sha256').update(JSON.stringify(files)).digest('hex') };
25
+ }
26
+ // The v4.2.1 seed predates the schema-3 source-byte binding. Keep the release proof honest:
27
+ // schema 2 proves the exact gist identity/file inventory, while CORPUS-COVERAGE retains the
28
+ // newer source observation separately. Do not relabel the old seed as current-source evidence.
29
+ return { schemaVersion: 2, owner: source.owner, generated: source.generated, gists };
30
+ }
19
31
 
20
32
  export function createReleaseProjection({ corpusCoverage, assetsDir, version, sourceSnapshot,
21
33
  corpusSeed, baselineReceiptSha256, outDir }) {
@@ -30,23 +42,48 @@ export function createReleaseProjection({ corpusCoverage, assetsDir, version, so
30
42
  }
31
43
  const assets = path.resolve(assetsDir);
32
44
  const sourceLedger = readRvfGenerations(assets);
33
- // The coverage ledger describes the full upstream universe. A release candidate contains
34
- // only the immutable seed's asset set, so absent-but-eligible upstream rows are not release
35
- // blockers; shipped stores must still all be CURRENT.
36
- const assetStores = fs.readdirSync(assets)
37
- .filter((name) => /\.rvf$/i.test(name)).map(storeName);
38
- const availableStores = new Set([
39
- ...Object.keys(sourceLedger.stores || {}).map(storeName),
40
- ...assetStores,
41
- ]);
42
- const eligible = corpusCoverage.rows.filter((row) => row.disposition === 'eligible'
43
- && availableStores.has(storeName(row.artifact?.store)));
44
- if (!eligible.length || eligible.some((row) => row.status !== 'CURRENT')) throw new Error('eligible corpus rows are not all CURRENT');
45
- const publicStores = [...new Set(eligible.map((row) => String(row.artifact.store).toLowerCase()))];
46
- // Derived public stores are not corpus rows, but they are still part of the
47
- // exact shipped generation ledger when the candidate asset set contains them.
48
- if (sourceLedger.stores?.concepts && !publicStores.includes('concepts')) publicStores.push('concepts');
49
- const ledger = projectPublicGenerationLedger({ ledger: sourceLedger, publicStores, version, sourceSnapshot });
45
+ // Source observation and release assets are different evidence planes. The observation is
46
+ // generated against the maintainer checkout, where RVF binaries are intentionally absent;
47
+ // the seed is the immutable release input. Build the release-scoped rows from the actual seed
48
+ // files and its byte-bound ledger, while preserving the complete observation in CORPUS-COVERAGE.
49
+ const availableStores = new Set(fs.readdirSync(assets)
50
+ .filter((name) => /^.+\.big\.rvf$/.test(name)).map((name) => name.slice(0, -'.big.rvf'.length).toLowerCase()));
51
+ const seededRows = corpusCoverage.rows.filter((row) => row.disposition === 'eligible'
52
+ && availableStores.has(String(row.artifact?.store || '').toLowerCase()));
53
+ if (!seededRows.length) throw new Error('immutable seed contains no eligible corpus stores');
54
+ const seededExcludedRows = corpusCoverage.rows.filter((row) => row.disposition !== 'eligible'
55
+ && availableStores.has(String(row.artifact?.store || '').toLowerCase()));
56
+ const rows = [...seededRows, ...seededExcludedRows].map((row) => {
57
+ if (row.disposition !== 'eligible') return { ...row };
58
+ const store = String(row.artifact.store);
59
+ const generation = sourceLedger.stores[store];
60
+ if (!generation) throw new Error(`immutable seed ledger is missing public store ${store}`);
61
+ return { ...row, status: 'CURRENT', artifact: { ...row.artifact,
62
+ sourceCommit: generation.sourceCommit, rvfSha256: generation.sha256 } };
63
+ });
64
+ // The release ledger is the shipped public set, not a catalog of policy-excluded rows.
65
+ // Excluded rows remain in COVERAGE.json as auditable policy evidence, but must not be
66
+ // inserted into PUBLIC-RVF-GENERATIONS.json or counted as installed public stores.
67
+ const publicStores = [...new Set(rows.filter((row) => row.disposition === 'eligible')
68
+ .map((row) => String(row.artifact.store).toLowerCase()))];
69
+ const classesFile = path.join(assets, 'public-store-classes.json');
70
+ const derivedStores = fs.existsSync(classesFile)
71
+ ? readJson(classesFile).derived.map((entry) => String(entry.store).toLowerCase()) : [];
72
+ const ledgerStores = [...new Set([...publicStores, ...derivedStores])];
73
+ const projectedCoverage = { ...corpusCoverage, rows,
74
+ totals: { ...corpusCoverage.totals, rows: rows.length,
75
+ repositories: rows.filter((row) => row.kind === 'repository').length,
76
+ gists: rows.filter((row) => row.kind === 'gist').length,
77
+ byStatus: Object.fromEntries([...new Set(rows.map((row) => row.status))].sort()
78
+ .map((status) => [status, rows.filter((row) => row.status === status).length])) } };
79
+ const projectedEnumerationReceipt = { ...corpusCoverage.enumerationReceipt,
80
+ repositories: { ...corpusCoverage.enumerationReceipt.repositories,
81
+ expected: projectedCoverage.totals.repositories },
82
+ gists: { ...corpusCoverage.enumerationReceipt.gists,
83
+ expected: projectedCoverage.totals.gists } };
84
+ const ledger = projectPublicGenerationLedger({ ledger: sourceLedger, publicStores: ledgerStores, version, sourceSnapshot });
85
+ const ledgerBytes = generationLedgerBytes(ledger);
86
+ const corpusBytes = Buffer.from(`${JSON.stringify(corpusCoverage, null, 2)}\n`);
50
87
  const releaseBase = {
51
88
  schemaVersion: 1, kind: 'ruvnet-brain-release-coverage', owner: corpusCoverage.owner,
52
89
  observedAt: corpusCoverage.observedAt, generatorSourceSha: corpusCoverage.generatorSourceSha,
@@ -54,22 +91,29 @@ export function createReleaseProjection({ corpusCoverage, assetsDir, version, so
54
91
  releaseIdentity: { version, tag: `v${version}`, sourceSnapshot },
55
92
  corpusSeed: { tag: corpusSeed.tag, archiveSha256: corpusSeed.archiveSha256,
56
93
  archiveBytes: corpusSeed.archiveBytes, receiptSha256: baselineReceiptSha256 },
57
- corpusCoverage: { sha256: sha256(Buffer.from(`${JSON.stringify(corpusCoverage, null, 2)}\n`)), coverageGeneration: corpusCoverage.coverageGeneration },
58
- generationLedger: { file: 'PUBLIC-RVF-GENERATIONS.json', sha256: sha256(generationLedgerBytes(ledger)),
59
- bytes: generationLedgerBytes(ledger).length, storeCount: Object.keys(ledger.stores).length },
60
- installedProjectionSchema: 2, policy: corpusCoverage.policy, enumerationReceipt: corpusCoverage.enumerationReceipt,
61
- rows: corpusCoverage.rows, totals: corpusCoverage.totals,
94
+ corpusCoverage: { sha256: sha256(corpusBytes), coverageGeneration: corpusCoverage.coverageGeneration },
95
+ generationLedger: { file: 'PUBLIC-RVF-GENERATIONS.json', sha256: sha256(ledgerBytes),
96
+ bytes: ledgerBytes.length, storeCount: Object.keys(ledger.stores).length },
97
+ installedProjectionSchema: 2, policy: corpusCoverage.policy, enumerationReceipt: projectedEnumerationReceipt,
98
+ rows: projectedCoverage.rows, totals: projectedCoverage.totals,
62
99
  };
100
+ const gistReceipt = seedCompatibleGistReceipt();
101
+ // Keep the projected receipt in the same asset root that the final archive
102
+ // validator reads, so both partition hashes include identical evidence.
103
+ fs.writeFileSync(path.join(assets, 'ruv-gists.sources.json'), `${JSON.stringify(gistReceipt, null, 2)}\n`);
63
104
  const inventory = validatePublicInventory({ assetsDir: assets, coverage: releaseBase, ledger });
64
105
  releaseBase.publicInventoryPartitionSha256 = inventory.partitionSha256;
65
106
  releaseBase.releaseCoverageGeneration = releaseCoverageGenerationFor(releaseBase);
66
107
  const out = path.resolve(outDir);
67
108
  fs.mkdirSync(out, { recursive: true });
68
- const corpusBytes = Buffer.from(`${JSON.stringify(corpusCoverage, null, 2)}\n`);
69
- const coverageBytes = Buffer.from(`${JSON.stringify(releaseBase, null, 2)}\n`);
70
109
  fs.writeFileSync(path.join(out, 'CORPUS-COVERAGE.json'), corpusBytes);
71
- fs.writeFileSync(path.join(out, 'COVERAGE.json'), coverageBytes);
72
- fs.writeFileSync(path.join(out, 'PUBLIC-RVF-GENERATIONS.json'), generationLedgerBytes(ledger));
110
+ fs.writeFileSync(path.join(out, 'COVERAGE.json'), `${JSON.stringify(releaseBase, null, 2)}\n`);
111
+ fs.writeFileSync(path.join(out, 'PUBLIC-RVF-GENERATIONS.json'), ledgerBytes);
112
+ fs.writeFileSync(path.join(out, 'ruv-gists.sources.json'), `${JSON.stringify(gistReceipt, null, 2)}\n`);
113
+ for (const file of ['public-store-classes.json', 'concepts.sources.json']) {
114
+ const source = path.join(assets, file);
115
+ if (fs.existsSync(source)) fs.copyFileSync(source, path.join(out, file));
116
+ }
73
117
  return releaseBase;
74
118
  }
75
119
 
@@ -77,9 +121,9 @@ if (path.resolve(process.argv[1] || '') === fileURLToPath(import.meta.url)) {
77
121
  try {
78
122
  const corpusFile = path.resolve(arg('--corpus', path.join(ROOT, 'data', 'source-coverage.json')));
79
123
  const corpus = readJson(corpusFile);
80
- const projection = createReleaseProjection({ corpusCoverage: corpus,
81
- assetsDir: arg('--assets'), outDir: arg('--out', 'release-evidence'), version: arg('--version'),
82
- sourceSnapshot: arg('--source-snapshot'), baselineReceiptSha256: arg('--baseline-receipt-sha256'),
124
+ const projection = createReleaseProjection({ corpusCoverage: corpus, assetsDir: arg('--assets'),
125
+ outDir: arg('--out', 'release-evidence'), version: arg('--version'), sourceSnapshot: arg('--source-snapshot'),
126
+ baselineReceiptSha256: arg('--baseline-receipt-sha256'),
83
127
  corpusSeed: { tag: arg('--seed-tag'), archiveSha256: arg('--seed-sha256'), archiveBytes: Number(arg('--seed-bytes')) } });
84
128
  console.log(JSON.stringify({ ok: true, releaseCoverageGeneration: projection.releaseCoverageGeneration }));
85
129
  } catch (error) { console.error(`[release-projection] ${error.message}`); process.exitCode = 1; }