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.
- package/README.md +26 -9
- package/bin/install.mjs +646 -366
- package/bin/nightly-refresh.mjs +115 -0
- package/kb/brain-profile.mjs +118 -32
- package/kb/lifecycle-evidence-retention.mjs +237 -0
- package/kb/refresh-run.mjs +367 -0
- package/kb/retrieval-result.mjs +39 -0
- package/kb/update-storage-transaction.mjs +439 -0
- package/package.json +11 -2
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/hooks/codex-hooks.json +2 -165
- package/plugin/hooks/hook-contracts.json +4 -65
- package/plugin/hooks/hooks.json +2 -208
- package/plugin/scripts/adr-currency-gate.mjs +29 -19
- package/plugin/scripts/capability-registry.mjs +10 -83
- package/plugin/scripts/codex-hook-adapter.mjs +62 -9
- package/plugin/scripts/codex-hook-wrapper.mjs +19 -1
- package/plugin/scripts/continuation-gate.mjs +30 -28
- package/plugin/scripts/continuation-objective.mjs +34 -0
- package/plugin/scripts/development-maintenance.mjs +49 -0
- package/plugin/scripts/hook-shim.mjs +3 -0
- package/plugin/scripts/learn-flush.mjs +6 -1
- package/plugin/scripts/lesson-gate.mjs +5 -2
- package/plugin/scripts/lesson-presentation.mjs +5 -0
- package/plugin/scripts/md-stamp.mjs +109 -6
- package/plugin/scripts/memory-doctor.mjs +3 -7
- package/plugin/scripts/nightly-controller.mjs +9 -27
- package/plugin/scripts/nightly-scheduler.mjs +472 -0
- package/plugin/scripts/project-progression-contract.mjs +4 -4
- package/plugin/scripts/project-progression-store.mjs +35 -6
- package/plugin/scripts/ruflo-bin.mjs +20 -0
- package/plugin/scripts/session-snapshot-contract.mjs +6 -1
- package/plugin/scripts/version-bump-gate.sh +14 -2
- package/plugin/skills/ruvnet-brain/PLAYBOOK.md +15 -23
- package/scripts/build-bundle.mjs +8 -0
- package/scripts/build-primer.mjs +2 -3
- package/scripts/candidate-host-evidence.mjs +59 -41
- package/scripts/ci/build-fixture-kb.mjs +59 -2
- package/scripts/ci/mutate-hook-timeout.mjs +24 -47
- package/scripts/ci/stranger-scenario.mjs +13 -28
- package/scripts/claims-verify.mjs +219 -21
- package/scripts/console-engine.mjs +4 -18
- package/scripts/console-runtime-identity.mjs +6 -0
- package/scripts/development-maintenance.mjs +47 -0
- package/scripts/development-push-check.mjs +35 -0
- package/scripts/distill-project.mjs +24 -47
- package/scripts/doc-currency.mjs +91 -19
- package/scripts/git-hooks/pre-push +4 -162
- package/scripts/health-repair.mjs +22 -4
- package/scripts/hook-retirement-check.mjs +22 -0
- package/scripts/host-install-matrix.mjs +125 -61
- package/scripts/integration-evidence.mjs +11 -6
- package/scripts/learning-replay-fixture.mjs +23 -2
- package/scripts/nightly-two-run-proof.mjs +415 -0
- package/scripts/nightly-watchdog.mjs +17 -2
- package/scripts/npm-invocation.mjs +20 -0
- package/scripts/prepublication-evidence.mjs +42 -6
- package/scripts/primer-grounding.mjs +20 -0
- package/scripts/product-integrity-contract.mjs +22 -2
- package/scripts/public-verification-abandon.mjs +101 -0
- package/scripts/public-verification-aggregate.mjs +53 -19
- package/scripts/public-verification-finalizer.mjs +8 -2
- package/scripts/public-verification-lane.mjs +63 -8
- package/scripts/publication-receipt.mjs +109 -45
- package/scripts/published-surface-probe.mjs +10 -2
- package/scripts/qa-contract.mjs +57 -0
- package/scripts/qa-lanes.mjs +40 -0
- package/scripts/qa-runner.mjs +54 -64
- package/scripts/qe/ux-suite.mjs +10 -34
- package/scripts/qualified-candidate-check.mjs +124 -0
- package/scripts/release-abort-stale.mjs +2 -2
- package/scripts/release-projection.mjs +20 -1
- package/scripts/release-qualification-contract.mjs +87 -0
- package/scripts/release-qualification.mjs +135 -0
- package/scripts/release-transaction.mjs +81 -1
- package/scripts/remedy-registry.mjs +6 -13
- package/scripts/retrieval-canary.mjs +91 -18
- package/scripts/selfcheck.mjs +12 -4
- package/scripts/snapshot-freshness.mjs +58 -0
- package/scripts/stack-sync.mjs +41 -43
- package/scripts/staged-host-verifier.mjs +63 -14
- 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,
|
|
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 (
|
|
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
|
-
//
|
|
107
|
-
//
|
|
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:
|
|
113
|
-
summary: "mine this project's stored memories into reusable patterns (snapshots first;
|
|
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
|
-
|
|
121
|
-
|
|
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',
|