ruvnet-brain 4.3.9 → 4.3.10

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 (83) hide show
  1. package/README.md +26 -9
  2. package/bin/install.mjs +646 -366
  3. package/bin/nightly-refresh.mjs +115 -0
  4. package/kb/brain-profile.mjs +118 -32
  5. package/kb/lifecycle-evidence-retention.mjs +237 -0
  6. package/kb/refresh-run.mjs +367 -0
  7. package/kb/retrieval-result.mjs +39 -0
  8. package/kb/update-storage-transaction.mjs +439 -0
  9. package/package.json +11 -2
  10. package/plugin/.claude-plugin/plugin.json +1 -1
  11. package/plugin/.codex-plugin/plugin.json +1 -1
  12. package/plugin/hooks/codex-hooks.json +2 -165
  13. package/plugin/hooks/hook-contracts.json +4 -65
  14. package/plugin/hooks/hooks.json +2 -208
  15. package/plugin/scripts/adr-currency-gate.mjs +29 -19
  16. package/plugin/scripts/capability-registry.mjs +10 -83
  17. package/plugin/scripts/codex-hook-adapter.mjs +62 -9
  18. package/plugin/scripts/codex-hook-wrapper.mjs +19 -1
  19. package/plugin/scripts/continuation-gate.mjs +30 -28
  20. package/plugin/scripts/continuation-objective.mjs +34 -0
  21. package/plugin/scripts/development-maintenance.mjs +49 -0
  22. package/plugin/scripts/hook-shim.mjs +3 -0
  23. package/plugin/scripts/learn-flush.mjs +6 -1
  24. package/plugin/scripts/lesson-gate.mjs +5 -2
  25. package/plugin/scripts/lesson-presentation.mjs +5 -0
  26. package/plugin/scripts/md-stamp.mjs +109 -6
  27. package/plugin/scripts/memory-doctor.mjs +3 -7
  28. package/plugin/scripts/nightly-controller.mjs +9 -27
  29. package/plugin/scripts/nightly-scheduler.mjs +472 -0
  30. package/plugin/scripts/project-progression-contract.mjs +4 -4
  31. package/plugin/scripts/project-progression-store.mjs +35 -6
  32. package/plugin/scripts/ruflo-bin.mjs +20 -0
  33. package/plugin/scripts/session-snapshot-contract.mjs +6 -1
  34. package/plugin/scripts/version-bump-gate.sh +14 -2
  35. package/plugin/skills/ruvnet-brain/PLAYBOOK.md +15 -23
  36. package/scripts/build-bundle.mjs +8 -0
  37. package/scripts/build-primer.mjs +2 -3
  38. package/scripts/candidate-host-evidence.mjs +59 -41
  39. package/scripts/ci/build-fixture-kb.mjs +59 -2
  40. package/scripts/ci/mutate-hook-timeout.mjs +24 -47
  41. package/scripts/ci/stranger-scenario.mjs +13 -28
  42. package/scripts/claims-verify.mjs +219 -21
  43. package/scripts/console-engine.mjs +4 -18
  44. package/scripts/console-runtime-identity.mjs +6 -0
  45. package/scripts/development-maintenance.mjs +47 -0
  46. package/scripts/development-push-check.mjs +35 -0
  47. package/scripts/distill-project.mjs +24 -47
  48. package/scripts/doc-currency.mjs +91 -19
  49. package/scripts/git-hooks/pre-push +4 -162
  50. package/scripts/health-repair.mjs +22 -4
  51. package/scripts/hook-retirement-check.mjs +22 -0
  52. package/scripts/host-install-matrix.mjs +125 -61
  53. package/scripts/integration-evidence.mjs +11 -6
  54. package/scripts/learning-replay-fixture.mjs +23 -2
  55. package/scripts/nightly-two-run-proof.mjs +415 -0
  56. package/scripts/nightly-watchdog.mjs +17 -2
  57. package/scripts/npm-invocation.mjs +20 -0
  58. package/scripts/prepublication-evidence.mjs +42 -6
  59. package/scripts/primer-grounding.mjs +20 -0
  60. package/scripts/product-integrity-contract.mjs +22 -2
  61. package/scripts/public-verification-abandon.mjs +101 -0
  62. package/scripts/public-verification-aggregate.mjs +53 -19
  63. package/scripts/public-verification-finalizer.mjs +8 -2
  64. package/scripts/public-verification-lane.mjs +63 -8
  65. package/scripts/publication-receipt.mjs +109 -45
  66. package/scripts/published-surface-probe.mjs +10 -2
  67. package/scripts/qa-contract.mjs +57 -0
  68. package/scripts/qa-lanes.mjs +40 -0
  69. package/scripts/qa-runner.mjs +54 -64
  70. package/scripts/qe/ux-suite.mjs +10 -34
  71. package/scripts/qualified-candidate-check.mjs +124 -0
  72. package/scripts/release-abort-stale.mjs +2 -2
  73. package/scripts/release-projection.mjs +20 -1
  74. package/scripts/release-qualification-contract.mjs +87 -0
  75. package/scripts/release-qualification.mjs +135 -0
  76. package/scripts/release-transaction.mjs +81 -1
  77. package/scripts/remedy-registry.mjs +6 -13
  78. package/scripts/retrieval-canary.mjs +91 -18
  79. package/scripts/selfcheck.mjs +12 -4
  80. package/scripts/snapshot-freshness.mjs +58 -0
  81. package/scripts/stack-sync.mjs +41 -43
  82. package/scripts/staged-host-verifier.mjs +63 -14
  83. package/scripts/wired-check.mjs +31 -2
@@ -0,0 +1,124 @@
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 crypto from 'node:crypto';
6
+ import { execFileSync } from 'node:child_process';
7
+ import { fileURLToPath } from 'node:url';
8
+ import { extractZip } from '../kb/zip-extract.mjs';
9
+ import { evaluateCandidateReceipt } from './release-proof.mjs';
10
+ import { aggregateEvidence } from './release-evidence-aggregate.mjs';
11
+ import { payloadIdFor } from './release-payload.mjs';
12
+ import { canonicalJson } from './release-transaction.mjs';
13
+
14
+ const REPO = 'stuinfla/ruvnet-brain';
15
+ const WORKFLOW = '.github/workflows/release-candidate-preflight.yml';
16
+ const positive = (n) => Number.isSafeInteger(n) && n > 0;
17
+ const sha256 = (bytes) => crypto.createHash('sha256').update(bytes).digest('hex');
18
+ export function trustedQualificationRun(run, workflow, sha) {
19
+ return positive(run?.id) && run.workflow_id === workflow.id && run.path === WORKFLOW
20
+ && run.event === 'push' && run.head_sha === sha && /^release\/.+/.test(run.head_branch || '')
21
+ && positive(run.repository?.id) && run.repository.full_name === REPO
22
+ && run.head_repository?.id === run.repository.id && run.head_repository.full_name === REPO;
23
+ }
24
+ function trustedArtifact(artifact, run, name) {
25
+ return positive(artifact?.id) && artifact.name === name && artifact.expired === false
26
+ && /^sha256:[a-f0-9]{64}$/.test(artifact.digest || '') && positive(artifact.size_in_bytes)
27
+ && artifact.workflow_run?.id === run.id && artifact.workflow_run.head_sha === run.head_sha
28
+ && artifact.workflow_run.head_branch === run.head_branch
29
+ && artifact.workflow_run.repository_id === run.repository.id
30
+ && artifact.workflow_run.head_repository_id === run.repository.id;
31
+ }
32
+ export function validateQualificationReceipt(receipt, { sha, run, artifact, requiredCheck }) {
33
+ if (!['canonical-qa', 'integration'].includes(requiredCheck)) throw new Error('unsupported required check');
34
+ if (receipt?.schemaVersion !== 1 || receipt.kind !== 'ruvnet-brain-qualified-candidate'
35
+ || receipt.sha !== sha || receipt.runId !== run.id
36
+ || canonicalJson(receipt.artifact) !== canonicalJson({ id: artifact.id, name: artifact.name,
37
+ digest: artifact.digest, size: artifact.size_in_bytes })) throw new Error('qualification receipt provenance differs');
38
+ const candidate = receipt.candidateReceipt;
39
+ if (evaluateCandidateReceipt(candidate).verdict !== 'PASS' || candidate.sha !== sha) throw new Error('candidate seal failed');
40
+ const manifest = receipt.payloadManifest;
41
+ if (manifest?.schemaVersion !== 1 || manifest.candidateSha !== sha || manifest.version !== candidate.version
42
+ || manifest.tag !== candidate.tag || !Array.isArray(manifest.members) || !manifest.members.length
43
+ || new Set(manifest.members.map(({ name }) => name)).size !== manifest.members.length
44
+ || manifest.members.some((m) => typeof m.name !== 'string' || path.basename(m.name) !== m.name
45
+ || !positive(m.size) || !/^[a-f0-9]{64}$/.test(m.sha256 || ''))) throw new Error('payload manifest identity differs');
46
+ const packageName = String(candidate.artifact.path).replaceAll('\\', '/').split('/').at(-1);
47
+ if (!manifest.members.some((m) => m.name === packageName
48
+ && m.sha256 === String(candidate.artifact.sha256).replace(/^sha256:/, ''))) throw new Error('candidate package is outside payload');
49
+ const envelope = receipt.aggregateEnvelope;
50
+ const rebuilt = aggregateEvidence({ sha, payloadId: payloadIdFor(manifest), leaves: envelope?.leaves });
51
+ if (canonicalJson(rebuilt) !== canonicalJson(envelope)
52
+ || envelope.leaves.some((leaf) => leaf.runId !== run.id)) throw new Error('aggregate evidence digest or run differs');
53
+ return { verdict: 'PASS', sha, requiredCheck, runId: run.id, artifactId: artifact.id,
54
+ payloadId: rebuilt.payloadId, evidenceDigest: rebuilt.evidenceDigest };
55
+ }
56
+ const api = (endpoint) => JSON.parse(execFileSync('gh', ['api', `repos/${REPO}/${endpoint}`], {
57
+ encoding: 'utf8', timeout: 30000, maxBuffer: 16 * 1024 * 1024 }));
58
+ export async function downloadQualificationReceipt(artifact, fetchArchive = (id) => execFileSync('gh',
59
+ ['api', `repos/${REPO}/actions/artifacts/${id}/zip`], { timeout: 60000, maxBuffer: 2 * 1024 * 1024 })) {
60
+ if (artifact.size_in_bytes > 2 * 1024 * 1024) throw new Error('qualification receipt artifact oversized');
61
+ const bytes = await fetchArchive(artifact.id);
62
+ if (`sha256:${sha256(bytes)}` !== artifact.digest) throw new Error('qualification artifact digest differs');
63
+ const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'qualified-candidate-'));
64
+ try {
65
+ const zip = path.join(temp, 'receipt.zip');
66
+ fs.writeFileSync(zip, bytes);
67
+ const out = path.join(temp, 'contents');
68
+ const extracted = await extractZip(zip, out);
69
+ if (canonicalJson(extracted.entryNames) !== canonicalJson(['qualification-receipt.json'])) throw new Error('unexpected qualification artifact members');
70
+ const file = path.join(out, 'qualification-receipt.json');
71
+ if (fs.statSync(file).size > 1024 * 1024) throw new Error('qualification receipt oversized');
72
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
73
+ } finally { fs.rmSync(temp, { recursive: true, force: true }); }
74
+ }
75
+ export async function checkQualifiedCandidate({ sha, requiredCheck, timeoutMs = 7200000, pollMs = 30000,
76
+ readApi = api, readReceipt = downloadQualificationReceipt, now = Date.now,
77
+ pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) } = {}) {
78
+ if (!/^[a-f0-9]{40}$/.test(sha || '') || !['canonical-qa', 'integration'].includes(requiredCheck)
79
+ || !positive(timeoutMs) || timeoutMs > 7200000 || !positive(pollMs) || pollMs < 30000 || pollMs > 60000) throw new Error('invalid bounded qualification request');
80
+ const workflow = await readApi('actions/workflows/release-candidate-preflight.yml');
81
+ if (!positive(workflow.id) || workflow.path !== WORKFLOW) throw new Error('untrusted qualification workflow');
82
+ const deadline = now() + timeoutMs;
83
+ for (;;) {
84
+ const list = await readApi(`actions/workflows/${workflow.id}/runs?event=push&head_sha=${sha}&per_page=100`);
85
+ if (list.total_count > 100) throw new Error('qualification run listing truncated');
86
+ const selected = (list.workflow_runs || []).filter((r) => trustedQualificationRun(r, workflow, sha)).sort((a,b) => b.id-a.id)[0];
87
+ if (selected) {
88
+ const run = await readApi(`actions/runs/${selected.id}`);
89
+ if (run.id !== selected.id || !trustedQualificationRun(run, workflow, sha)) throw new Error('qualification run provenance changed');
90
+ if (run.status === 'completed') {
91
+ if (run.conclusion !== 'success') throw new Error(`qualification failed: ${run.conclusion}`);
92
+ const listed = await readApi(`actions/runs/${run.id}/artifacts?per_page=100`);
93
+ if (listed.total_count > 100) throw new Error('qualification artifact listing truncated');
94
+ const select = async (name) => {
95
+ const rows = (listed.artifacts || []).filter((a) => a.name === name);
96
+ if (rows.length !== 1) throw new Error('missing or duplicate qualification artifact');
97
+ const artifact = await readApi(`actions/artifacts/${rows[0].id}`);
98
+ if (artifact.id !== rows[0].id || !trustedArtifact(artifact, run, name)) throw new Error('qualification artifact provenance changed');
99
+ return artifact;
100
+ };
101
+ const artifact = await select(`release-candidate-${sha}`);
102
+ const small = await select(`qualification-receipt-${sha}`);
103
+ return validateQualificationReceipt(await readReceipt(small), { sha, run, artifact, requiredCheck });
104
+ }
105
+ }
106
+ const remaining = deadline - now();
107
+ if (remaining <= 0) throw new Error('timed out waiting for exact-SHA qualification');
108
+ process.stderr.write('Waiting for exact-SHA release qualification\n');
109
+ await pause(Math.min(pollMs, remaining));
110
+ }
111
+ }
112
+ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
113
+ try {
114
+ if (process.env.GITHUB_REPOSITORY !== REPO) throw new Error('unexpected qualification repository');
115
+ const args = process.argv.slice(2), options = {};
116
+ for (let i=0;i<args.length;i+=2) {
117
+ if (!['--sha','--required-check','--timeout-ms','--poll-ms'].includes(args[i]) || !args[i+1]) throw new Error('invalid qualification arguments');
118
+ options[args[i]] = args[i+1];
119
+ }
120
+ console.log(JSON.stringify(await checkQualifiedCandidate({ sha: options['--sha'], requiredCheck: options['--required-check'],
121
+ ...(options['--timeout-ms'] ? {timeoutMs:Number(options['--timeout-ms'])} : {}),
122
+ ...(options['--poll-ms'] ? {pollMs:Number(options['--poll-ms'])} : {}) })));
123
+ } catch(error) { console.error(error.message); process.exitCode=1; }
124
+ }
@@ -34,7 +34,7 @@ import fs from 'node:fs';
34
34
  import os from 'node:os';
35
35
  import path from 'node:path';
36
36
  import {
37
- RECEIPT_PREFIX, TERMINAL_STATES, ALLOWED_TRANSITIONS, canonicalJson, digestReceipt, signReceipt,
37
+ RECEIPT_PREFIX, isClosedReceipt, ALLOWED_TRANSITIONS, canonicalJson, digestReceipt, signReceipt,
38
38
  } from './release-transaction.mjs';
39
39
 
40
40
  const REPO = 'stuinfla/ruvnet-brain';
@@ -65,7 +65,7 @@ const receipts = receiptAssets.map(fetchAsset).sort((a, b) => a.sequence - b.seq
65
65
  const last = receipts.at(-1);
66
66
  log(`${TAG}: ${receipts.length} receipt(s), latest seq=${last.sequence} state=${last.state} txn=${String(last.transactionId).slice(0, 16)}…`);
67
67
 
68
- if (TERMINAL_STATES.has(last.state)) {
68
+ if (isClosedReceipt(last)) {
69
69
  log(`already terminal (${last.state}) — nothing to do.`);
70
70
  process.exit(0);
71
71
  }
@@ -6,7 +6,7 @@ import fs from 'node:fs';
6
6
  import path from 'node:path';
7
7
  import { fileURLToPath } from 'node:url';
8
8
  import { digest, generationLedgerBytes, projectPublicGenerationLedger,
9
- releaseCoverageGenerationFor } from '../plugin/scripts/coverage-integrity.mjs';
9
+ releaseCoverageGenerationFor, validateCoverageDirectory } from '../plugin/scripts/coverage-integrity.mjs';
10
10
  import { validatePublicInventory } from './public-inventory.mjs';
11
11
  import { readRvfGenerations } from './rvf-generation.mjs';
12
12
 
@@ -15,6 +15,25 @@ const arg = (name, fallback = null) => { const i = process.argv.indexOf(name); r
15
15
  const readJson = (file) => JSON.parse(fs.readFileSync(file, 'utf8'));
16
16
  const sha256 = (bytes) => crypto.createHash('sha256').update(bytes).digest('hex');
17
17
 
18
+ // The first assembly supplies seed bytes to the projection producer. Only the final, projected
19
+ // assembly has a release-bound public ledger; its runtime ledger must describe that same set.
20
+ export function bindAssembledReleaseProjection({ assetsDir, version, sourceSnapshot }) {
21
+ const ledger = readJson(path.join(assetsDir, 'PUBLIC-RVF-GENERATIONS.json'));
22
+ if (!/^[a-f0-9]{40}$/.test(sourceSnapshot || '') || ledger.schemaVersion !== 2
23
+ || ledger.kind !== 'ruvnet-brain-public-generation-ledger'
24
+ || ledger.brainVersion !== version || ledger.releaseTag !== `v${version}`
25
+ || ledger.sourceSnapshot !== sourceSnapshot) {
26
+ throw new Error('assembled public ledger does not bind this release source');
27
+ }
28
+ fs.writeFileSync(path.join(assetsDir, 'RVF-GENERATIONS.json'), `${JSON.stringify({
29
+ ...ledger, kind: 'ruvnet-brain-runtime-generation-ledger',
30
+ }, null, 2)}\n`);
31
+ const result = validateCoverageDirectory(assetsDir, { expectedVersion: version,
32
+ expectedSourceSnapshot: sourceSnapshot, requireCompleteProfile: true });
33
+ if (!result.valid) throw new Error(`assembled release projection rejected: ${result.failures.join('; ')}`);
34
+ return result;
35
+ }
36
+
18
37
  function seedCompatibleGistReceipt() {
19
38
  const source = readJson(path.join(ROOT, 'kb', 'ruv-gists.sources.json'));
20
39
  const gists = {};
@@ -0,0 +1,87 @@
1
+ // Reviewed stabilization release boundaries. Legacy tests remain developer diagnostics.
2
+ export const RELEASE_REQUIREMENTS = Object.freeze({
3
+ "source": [
4
+ {
5
+ "id": "signed-artifacts",
6
+ "reason": "Signature verification and exact assembled coverage reject changed bytes",
7
+ "files": [
8
+ "tests/unit/sign-verify-roundtrip.test.mjs",
9
+ "tests/unit/assembled-release-projection.test.mjs"
10
+ ]
11
+ },
12
+ {
13
+ "id": "protected-publication",
14
+ "reason": "Only owner-authorized exact-source artifacts enter publication",
15
+ "files": [
16
+ "tests/unit/protected-release-invocation.test.mjs",
17
+ "tests/unit/release-identity-invariants.test.mjs",
18
+ "tests/unit/release-transaction.test.mjs",
19
+ "tests/unit/prepublication-evidence.test.mjs",
20
+ "tests/unit/integration-evidence.test.mjs",
21
+ "tests/unit/qualified-candidate-check.test.mjs",
22
+ "tests/unit/release-qualification.test.mjs",
23
+ "tests/unit/development-push-boundary.test.mjs"
24
+ ]
25
+ },
26
+ {
27
+ "id": "public-verification",
28
+ "reason": "Public bytes, native update evidence and terminal receipts are bound to the actual candidate and run",
29
+ "files": [
30
+ "tests/unit/public-verification-aggregate.test.mjs",
31
+ "tests/unit/public-verification-finalizer.test.mjs",
32
+ "tests/unit/public-verification-lane.test.mjs",
33
+ "tests/unit/public-verification-abandon.test.mjs",
34
+ "tests/unit/publication-receipt-producer.test.mjs",
35
+ "tests/unit/recovery-candidate-source.test.mjs"
36
+ ]
37
+ },
38
+ {
39
+ "id": "safe-install-update",
40
+ "reason": "Installation and update preserve prior and private state and reject invalid dependencies",
41
+ "files": [
42
+ "tests/unit/automatic-hook-retirement.test.mjs",
43
+ "tests/unit/install-activation-rollback.test.mjs",
44
+ "tests/unit/forge-update-apply-rollback.test.mjs"
45
+ ]
46
+ },
47
+ {
48
+ "id": "native-scheduler",
49
+ "reason": "Owned scheduler lifecycle, execution identity, measured no-op and signed installed coverage",
50
+ "files": [
51
+ "tests/unit/nightly-scheduler.test.mjs",
52
+ "tests/unit/nightly-refresh-launcher.test.mjs",
53
+ "tests/unit/nightly-two-run-proof.test.mjs"
54
+ ]
55
+ }
56
+ ],
57
+ "integration": [
58
+ {
59
+ "id": "native-explicit-interface",
60
+ "reason": "Real MCP subprocess readiness, command policy and literal argv safety",
61
+ "files": [
62
+ "tests/integration/managed-cli-mcp.test.mjs"
63
+ ]
64
+ },
65
+ {
66
+ "id": "private-bundle-boundary",
67
+ "reason": "Actual bundle builder rejects absent private fence and incomplete public payloads",
68
+ "files": [
69
+ "tests/integration/build-bundle-fence.test.mjs"
70
+ ]
71
+ },
72
+ {
73
+ "id": "native-codex-discovery",
74
+ "reason": "Actual Codex plugin discovery and repair preserve explicit disabled state",
75
+ "files": [
76
+ "tests/integration/codex-skill-discovery.test.mjs"
77
+ ]
78
+ },
79
+ {
80
+ "id": "owned-uninstall",
81
+ "reason": "Actual offline uninstall preserves unrelated files and user guidance",
82
+ "files": [
83
+ "tests/integration/uninstall-footprint.test.mjs"
84
+ ]
85
+ }
86
+ ]
87
+ });
@@ -0,0 +1,135 @@
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 { fileURLToPath } from 'node:url';
7
+ import { digest } from './coverage-integrity.mjs';
8
+ import { sourceIdentity } from './qa-contract.mjs';
9
+ import { RELEASE_REQUIREMENTS } from './release-qualification-contract.mjs';
10
+
11
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
12
+ const PLATFORM = { linux: 'linux', darwin: 'macos', win32: 'windows' }[process.platform];
13
+
14
+ export function qualificationPlan(suite = 'source') {
15
+ const requirements = RELEASE_REQUIREMENTS[suite];
16
+ if (!requirements?.length) throw new Error('unknown qualification suite');
17
+ const files = requirements.flatMap((entry) => entry.files);
18
+ if (new Set(files).size !== files.length || files.some((file) => !file.startsWith('tests/')
19
+ || !file.endsWith('.test.mjs') || file.includes('..'))) throw new Error('invalid qualification inventory');
20
+ return { requirements, files, contractSha256: digest(requirements) };
21
+ }
22
+
23
+ export function assessTestReport(report, files, root = ROOT, platform = PLATFORM) {
24
+ if (!['linux', 'macos', 'windows'].includes(platform)) throw new Error('invalid test report platform');
25
+ const paths = platform === 'windows' ? path.win32 : path.posix;
26
+ if (typeof root !== 'string' || !paths.isAbsolute(root)) throw new Error('test report checkout root must be absolute');
27
+ const results = report?.testResults;
28
+ if (!Array.isArray(results) || results.length !== files.length) throw new Error('test report file inventory differs');
29
+ const expected = new Set(files.map((file) => paths.resolve(root, file)));
30
+ if (results.some(file => typeof file.name !== 'string' || !paths.isAbsolute(file.name))) throw new Error('test report file inventory requires absolute names');
31
+ const names = results.map((file) => paths.resolve(file.name));
32
+ if (new Set(names).size !== files.length || names.some((name) => !expected.has(name))) {
33
+ throw new Error('test report contains missing, duplicate, or unexpected files');
34
+ }
35
+ const cases = results.flatMap((file) => file.assertionResults || []);
36
+ if (!cases.length || results.some((file) => file.status !== 'passed' || !file.assertionResults?.length)
37
+ || cases.some((test) => test.status !== 'passed') || report.success !== true
38
+ || report.numFailedTests !== 0 || report.numFailedTestSuites !== 0
39
+ || report.numPendingTests !== 0 || (report.numTodoTests || 0) !== 0
40
+ || report.numTotalTests !== cases.length || report.numPassedTests !== cases.length) {
41
+ throw new Error('reviewed release cases did not all execute and pass');
42
+ }
43
+ return { total: cases.length, passed: cases.length, failed: 0, skipped: 0 };
44
+ }
45
+
46
+ export function validateQualificationReceipt(report, { suite, platform, sourceSha }) {
47
+ const plan = qualificationPlan(suite);
48
+ const { receiptSha256, ...payload } = report || {};
49
+ if (report?.schemaVersion !== 1 || report.kind !== 'ruvnet-brain-release-qualification'
50
+ || report.suite !== suite || report.platform !== platform || report.status !== 'PASS'
51
+ || digest(payload) !== receiptSha256 || report.contractSha256 !== plan.contractSha256
52
+ || digest(report.files) !== digest(plan.files)
53
+ || digest(report.requirements) !== digest(plan.requirements.map(row => row.id))) {
54
+ throw new Error('qualification receipt identity, digest, or reviewed contract differs');
55
+ }
56
+ if (!/^[a-f0-9]{40}$/.test(String(sourceSha)) || report.source?.sha !== sourceSha
57
+ || report.sourceAfter?.sha !== sourceSha || report.source.dirty !== false || report.sourceAfter.dirty !== false
58
+ || !/^[a-f0-9]{64}$/.test(report.source.digest || '') || report.source.digest !== report.sourceAfter.digest
59
+ || report.sourceStable !== true) throw new Error('qualification source is not clean and unchanged');
60
+ if (!Array.isArray(report.results) || !report.results.length
61
+ || report.results.some(row => row.status !== 'PASS' || row.exitCode !== 0)) {
62
+ throw new Error('qualification command did not complete successfully');
63
+ }
64
+ if (typeof report.checkoutRoot !== 'string' || !report.checkoutRoot) throw new Error('qualification checkout root is missing');
65
+ const tests = assessTestReport(report.testReport, plan.files, report.checkoutRoot, platform);
66
+ if (digest(tests) !== digest(report.tests)) throw new Error('qualification test summary differs from executed cases');
67
+ return tests;
68
+ }
69
+
70
+ export function runQualification({ suite = 'source', platform = PLATFORM, report: output, root = ROOT } = {}) {
71
+ if (platform !== PLATFORM) throw new Error(`cannot produce ${platform} evidence on ${PLATFORM}`);
72
+ if (!output || fs.existsSync(output)) throw new Error('a new --report path is required');
73
+ const plan = qualificationPlan(suite);
74
+ for (const file of plan.files) if (!fs.statSync(path.resolve(root, file)).isFile()) throw new Error(`missing test ${file}`);
75
+ const source = sourceIdentity(root);
76
+ const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'release-qualification-'));
77
+ const results = [];
78
+ let testReport = null;
79
+ let tests = { total: 0, passed: 0, failed: 0, skipped: 0 };
80
+ const execute = (name, args) => {
81
+ const run = spawnSync(process.execPath, ['scripts/ci/step-watchdog.mjs', '--name', name,
82
+ '--timeout-ms', '900000', '--receipt-dir', temp, '--', process.execPath, ...args],
83
+ { cwd: root, env: { ...process.env, ...(suite === 'integration' ? { RUVNET_REQUIRE_CODEX_DISCOVERY: '1' } : {}) },
84
+ stdio: 'inherit' });
85
+ const ok = !run.error && run.status === 0;
86
+ results.push({ name, status: ok ? 'PASS' : 'FAIL', exitCode: run.status });
87
+ if (!ok) throw new Error(`${name} failed`);
88
+ };
89
+ let failure = null;
90
+ try {
91
+ if (suite === 'source') {
92
+ execute('release-version', ['scripts/sync-version.mjs', '--check']);
93
+ execute('release-source-identity', ['scripts/convergence-manifest.mjs']);
94
+ execute('automatic-hook-retirement', ['scripts/hook-retirement-check.mjs']);
95
+ }
96
+ const testPath = path.join(temp, 'tests.json');
97
+ execute(`release-${suite}-${platform}`, ['node_modules/vitest/vitest.mjs', 'run', ...plan.files,
98
+ '--reporter=json', '--outputFile', testPath, '--maxWorkers=1']);
99
+ testReport = JSON.parse(fs.readFileSync(testPath, 'utf8'));
100
+ tests = assessTestReport(testReport, plan.files, root);
101
+ } catch (error) {
102
+ failure = error.message;
103
+ const testPath = path.join(temp, 'tests.json');
104
+ if (fs.existsSync(testPath)) {
105
+ try {
106
+ testReport = JSON.parse(fs.readFileSync(testPath, 'utf8'));
107
+ tests = { total: testReport.numTotalTests || 0, passed: testReport.numPassedTests || 0,
108
+ failed: testReport.numFailedTests || 0,
109
+ skipped: (testReport.numPendingTests || 0) + (testReport.numTodoTests || 0) };
110
+ } catch { /* failed report remains a failure */ }
111
+ }
112
+ }
113
+ const sourceAfter = sourceIdentity(root);
114
+ const sourceStable = source.digest === sourceAfter.digest;
115
+ const receipt = { schemaVersion: 1, kind: 'ruvnet-brain-release-qualification', suite, platform, checkoutRoot: path.resolve(root),
116
+ source, sourceAfter, sourceStable, status: !failure && sourceStable ? 'PASS' : 'FAIL',
117
+ requirements: plan.requirements.map((entry) => entry.id), contractSha256: plan.contractSha256,
118
+ files: plan.files, tests, testReport, results, failure, observedAt: new Date().toISOString() };
119
+ receipt.receiptSha256 = digest(receipt);
120
+ fs.mkdirSync(path.dirname(path.resolve(output)), { recursive: true });
121
+ fs.writeFileSync(output, JSON.stringify(receipt, null, 2) + '\n', { flag: 'wx', mode: 0o600 });
122
+ fs.rmSync(temp, { recursive: true, force: true });
123
+ return receipt;
124
+ }
125
+
126
+ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
127
+ const args = process.argv.slice(2);
128
+ const value = (flag, fallback) => { const i = args.indexOf(flag); return i < 0 ? fallback : args[i + 1]; };
129
+ try {
130
+ const receipt = runQualification({ suite: value('--suite', 'source'), platform: value('--platform', PLATFORM),
131
+ report: value('--report') });
132
+ console.log(JSON.stringify({ status: receipt.status, tests: receipt.tests, receiptSha256: receipt.receiptSha256 }));
133
+ process.exitCode = receipt.status === 'PASS' ? 0 : 1;
134
+ } catch (error) { console.error(error.message); process.exitCode = 1; }
135
+ }
@@ -68,7 +68,7 @@ export const ALLOWED_TRANSITIONS = Object.freeze({
68
68
  compensated: ['github-promote-intent', 'npm-promote-intent', 'manual-intervention-required', ...ABORTABLE],
69
69
  'defaults-promoted': ['finalize-intent', 'manual-intervention-required', ...ABORTABLE],
70
70
  'finalize-intent': ['channels-converged', 'manual-intervention-required', ...ABORTABLE],
71
- 'manual-intervention-required': [],
71
+ 'manual-intervention-required': [...ABORTABLE],
72
72
  'channels-converged': ['install-verified', 'abandoned', 'superseded'],
73
73
  'install-verified': [],
74
74
  abandoned: [],
@@ -506,11 +506,18 @@ export async function abortReleaseTransaction({ identity, receipts, reason, auth
506
506
  export async function finalizeReleaseTransaction({
507
507
  identity,
508
508
  aggregate,
509
+ verifierSha,
510
+ workflowRunId,
509
511
  adapter,
510
512
  privateKey,
511
513
  publicKey,
512
514
  aggregatePublicKey,
513
515
  } = {}) {
516
+ if (!/^[1-9][0-9]*$/.test(String(workflowRunId || ''))) throw new Error('expected workflow run ID is required');
517
+ if (verifierSha !== undefined && (!/^[a-f0-9]{40}$/.test(String(verifierSha))
518
+ || aggregate?.identity?.verifierSha !== verifierSha)) {
519
+ throw new Error('public verification verifier SHA differs from the expected verifier');
520
+ }
514
521
  if (!adapter || typeof adapter.discover !== 'function' || typeof adapter.observeSnapshot !== 'function'
515
522
  || typeof adapter.materializePublicVerificationAggregate !== 'function'
516
523
  || typeof adapter.appendReceipt !== 'function' || typeof adapter.readReceipt !== 'function') {
@@ -518,6 +525,7 @@ export async function finalizeReleaseTransaction({
518
525
  }
519
526
  const expectedAggregateIdentity = {
520
527
  ...aggregate?.identity,
528
+ workflowRunId: String(workflowRunId),
521
529
  sourceSha: identity?.candidateSha,
522
530
  version: identity?.version,
523
531
  tag: identity?.tag,
@@ -560,6 +568,7 @@ export async function finalizeReleaseTransaction({
560
568
  verdict: 'INSTALL_VERIFIED',
561
569
  publicVerification: {
562
570
  aggregateSha256: aggregate.aggregateSha256,
571
+ ...(aggregate.identity.verifierSha === undefined ? {} : { verifierSha: aggregate.identity.verifierSha }),
563
572
  aggregateAssetSha256,
564
573
  signatureAssetSha256,
565
574
  lanes: aggregate.metrics.leaves,
@@ -580,3 +589,74 @@ export async function finalizeReleaseTransaction({
580
589
  validateReceiptChain([...chain, persisted], identity, publicKey);
581
590
  return persisted;
582
591
  }
592
+
593
+ // Close an unsuccessful public verification attempt without changing public bytes or truth.
594
+ export async function abandonPublicVerificationTransaction({ identity, expected, reason, authorization,
595
+ recovery, failure, adapter, privateKey, publicKey } = {}) {
596
+ const hex = (value, length) => new RegExp(`^[a-f0-9]{${length}}$`).test(value || '');
597
+ const bounded = (value, max) => typeof value === 'string' && value.trim().length > 0 && value.length <= max;
598
+ if (!expected || expected.transactionId !== transactionIdFor(identity)
599
+ || !Number.isSafeInteger(expected.sequence) || expected.sequence < 0 || !hex(expected.receiptDigest, 64)) {
600
+ throw new Error('abandonment requires exact expected transaction, sequence, and digest');
601
+ }
602
+ if (!bounded(reason, 1024) || !bounded(authorization?.actor, 100) || !bounded(authorization?.reference, 1024)
603
+ || authorization.event !== 'repository_dispatch' || authorization.action !== 'abandon-public-verification'
604
+ || !Number.isSafeInteger(authorization.dispatchRunId) || authorization.dispatchRunId <= 0
605
+ || !hex(authorization.verifierSha, 40)) throw new Error('explicit abandonment authorization provenance is required');
606
+ if (recovery?.event !== 'repository_dispatch' || !Number.isSafeInteger(recovery.workflowId) || recovery.workflowId <= 0
607
+ || !Number.isSafeInteger(recovery.repositoryId) || recovery.repositoryId <= 0
608
+ || recovery.repository !== identity.repository || recovery.workflow !== '.github/workflows/recover-public-verification.yml'
609
+ || recovery.conclusion !== 'failure' || !Number.isSafeInteger(recovery.runId) || recovery.runId <= 0
610
+ || !Number.isSafeInteger(recovery.artifactId) || recovery.artifactId <= 0 || !hex(recovery.verifierSha, 40)
611
+ || recovery.artifactName !== `recovered-public-verification-${failure?.os}-${identity.candidateSha}-${recovery.runId}`) {
612
+ throw new Error('failed recovery provenance differs from the candidate');
613
+ }
614
+ const { failureSha256, ...failurePayload } = failure || {};
615
+ if (Buffer.byteLength(canonicalJson(failure || {})) > 131072 || failure?.schemaVersion !== 1
616
+ || failure.kind !== 'ruvnet-brain-public-verification-failure' || !['linux', 'macos', 'windows'].includes(failure.os)
617
+ || failure.sourceSha !== identity.candidateSha || failure.artifactSha256 !== identity.packageSha256
618
+ || failure.bundleSha256 !== identity.bundleSha256 || !Array.isArray(failure.failures)
619
+ || failure.failures.length < 1 || failure.failures.length > 3
620
+ || failure.failures.some((entry) => !bounded(entry.reason, 4096))
621
+ || (failure.verifierSha !== undefined && failure.verifierSha !== recovery.verifierSha)
622
+ || !hex(failureSha256, 64)
623
+ || crypto.createHash('sha256').update(canonicalJson(failurePayload)).digest('hex') !== failureSha256) {
624
+ throw new Error('bounded failed recovery evidence is invalid or belongs to another candidate');
625
+ }
626
+ const intent = { expected, reason, authorization: { actor: authorization.actor, reference: authorization.reference }, recovery, failureSha256 };
627
+ const intentSha256 = crypto.createHash('sha256').update(canonicalJson(intent)).digest('hex');
628
+ if (!adapter || ['discover', 'observeSnapshot', 'appendReceipt', 'readReceipt'].some((key) => typeof adapter[key] !== 'function')) {
629
+ throw new Error('abandonment adapter is incomplete');
630
+ }
631
+ const discovered = await adapter.discover(identity);
632
+ const chain = validateReceiptChain(discovered.receipts || [], identity, publicKey);
633
+ const current = chain.at(-1);
634
+ if (current?.state === 'abandoned') {
635
+ if (current.observation?.abandonment?.intentSha256 !== intentSha256) throw new Error('conflicting abandonment intent');
636
+ return current;
637
+ }
638
+ if (current?.schemaVersion !== 3 || current.state !== 'channels-converged'
639
+ || current.observation?.verdict !== 'PUBLISHED_NOT_VERIFIED'
640
+ || current.sequence !== expected.sequence || current.receiptDigest !== expected.receiptDigest) {
641
+ throw new Error('abandonment expected receipt is not the current published-unverified transaction');
642
+ }
643
+ const snapshot = await adapter.observeSnapshot(identity, discovered.matchingDrafts?.[0], { forceAssets: true });
644
+ if (snapshot.readError || !npmLatestIsB(snapshot, identity) || !npmCandidateExact(snapshot, identity)
645
+ || !githubIsB(snapshot, identity) || snapshot.github?.published !== true || snapshot.github?.latest !== true
646
+ || snapshot.github?.assetsExact !== true
647
+ || canonicalJson(snapshot.npm) !== canonicalJson(current.observation.npm)
648
+ || canonicalJson(snapshot.github) !== canonicalJson(current.observation.github)) {
649
+ throw new Error('public artifacts or channels changed before abandonment');
650
+ }
651
+ const receipt = stateReceipt({ identity, prior: current, state: 'abandoned', privateKey,
652
+ observation: { verdict: 'PUBLISHED_NOT_VERIFIED', abandonment: { intentSha256, ...intent, authorization, failure },
653
+ npm: snapshot.npm, github: snapshot.github } });
654
+ verifyReceipt(receipt, publicKey);
655
+ const name = `${RECEIPT_PREFIX}${String(receipt.sequence).padStart(4, '0')}.json`;
656
+ await adapter.appendReceipt(discovered.matchingDrafts?.[0] || null, receipt, name);
657
+ const persisted = await adapter.readReceipt(discovered.matchingDrafts?.[0] || null, receipt.sequence);
658
+ verifyReceipt(persisted, publicKey);
659
+ if (canonicalJson(persisted) !== canonicalJson(receipt)) throw new Error('abandoned receipt readback differs');
660
+ validateReceiptChain([...chain, persisted], identity, publicKey);
661
+ return persisted;
662
+ }
@@ -103,26 +103,19 @@ export const REMEDIES = [
103
103
  inverse: () => ({ kind: K.RESTORE_STORE_BACKUPS }),
104
104
  },
105
105
  {
106
- // THE CAPABILITY BRIDGE'S ONLY CURRENT MEMBER. buildCapabilityRecommendations() in
107
- // console-engine.mjs offers `enable:memory-distillation` only while the capability is OFF; this
108
- // is the executor behind it. See distill-project.mjs's header for why THIS script and not bare
109
- // `ruflo memory distill run`: the wrapper snapshots first, fails closed on a receipt-write
110
- // failure, and its `--restore` is the tested inverse (proven 644→648→644→648, 2026-07-24).
106
+ // Retained for explicit execution and existing receipts, not offered as reversible.
107
+ // The restore command refuses unsafe replacement; historical roundtrips do not authorize it.
111
108
  key: 'enable-memory-distillation',
112
- autoEligible: true,
113
- summary: "mine this project's stored memories into reusable patterns (snapshots first; reversible)",
109
+ autoEligible: false,
110
+ summary: "mine this project's stored memories into reusable patterns (snapshots first; automatic undo unavailable)",
114
111
  match: (id) => (id === 'enable:memory-distillation' ? {} : null),
115
112
  // This console instance is always scoped to ONE project — the directory it was started in — the
116
113
  // same assumption the memory-index/learning-flush/learning-train remedies above already make.
117
114
  // `usesServerProject` asks onboarding-console.mjs (impure, process-aware) to supply that directory
118
115
  // at call time; this file stays pure and never reads process.cwd() itself (see header).
119
116
  plan: () => ({ script: 'scripts/distill-project.mjs', args: [], usesServerProject: true }),
120
- // `--restore` with no path argument uses distill-project.mjs's OWN newestSnapshot() lookup inside
121
- // this project's `.swarm/backups` — the exact mechanism its header proves end to end. Re-deriving
122
- // which snapshot to restore here, instead of asking the tool that took it, is the kind of
123
- // duplicate implementation this project has already been burned by once (ADR-047's rejected
124
- // "offered command and promised undo live on different execution paths" bug).
125
- inverse: () => ({ kind: K.RESTORE_PROJECT_DISTILL }),
117
+ inverse: () => ({ kind: K.RESTORE_PROJECT_DISTILL, available: false,
118
+ human: 'automatic undo is unavailable; retain the snapshot and coordinate offline SQLite recovery with all database users stopped' }),
126
119
  },
127
120
  {
128
121
  key: 'stack-sync',