ruvnet-brain 4.0.5 → 4.0.7
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 +3 -3
- package/bin/install.mjs +112 -7
- package/config/model-router/catalog.template.json +17 -2
- package/console/app.js +32 -14
- package/console/architecture.html +1 -1
- package/data/model-catalog.json +54 -0
- package/package.json +2 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/hooks/codex-hooks.json +17 -17
- package/plugin/hooks/hooks.json +34 -1
- package/plugin/scripts/codex-hook-wrapper.mjs +36 -4
- package/plugin/scripts/ground-ruvnet.sh +2 -1
- package/plugin/scripts/hook-shim.mjs +13 -6
- package/plugin/scripts/lesson-provenance.mjs +21 -0
- package/plugin/scripts/lesson-store.mjs +33 -3
- package/plugin/scripts/route-dispatch.sh +23 -49
- package/plugin/scripts/session-snapshot-contract.mjs +100 -0
- package/plugin/scripts/session-snapshot-hook.mjs +34 -0
- package/plugin/scripts/session-start-core.mjs +25 -7
- package/plugin/scripts/swarm-slot-recycler.mjs +89 -0
- package/plugin/skills/release-proof/SKILL.md +4 -4
- package/plugin/skills/ruvnet-brain/PLAYBOOK.md +19 -4
- package/scripts/capability-registry.mjs +3 -3
- package/scripts/lesson-lifecycle.mjs +2 -1
- package/scripts/lesson-ratify.mjs +6 -3
- package/scripts/lesson-seed.mjs +21 -16
- package/scripts/model-router-catalog.mjs +16 -0
- package/scripts/model-router-engine.mjs +4 -1
- package/scripts/onboarding-console.mjs +52 -14
- package/scripts/provider-availability.mjs +12 -0
- package/scripts/release-authority.mjs +14 -2
- package/scripts/release-transaction-provider.mjs +236 -0
- package/scripts/release-transaction.mjs +214 -0
- package/scripts/release.mjs +67 -279
- package/scripts/session-snapshot-contract.mjs +1 -0
- package/scripts/stabilization-receipt.mjs +1 -1
- package/scripts/staged-host-verifier.mjs +106 -0
|
@@ -41,6 +41,8 @@ import { loadCatalog as engineCatalog, catalogSource as engineCatalogSource, loa
|
|
|
41
41
|
import { effectivePrices, loadLabelledRows, MIN_LABELS, OUTCOMES } from './metaharness-router.mjs';
|
|
42
42
|
import { utilization } from './router-utilization.mjs';
|
|
43
43
|
import { loadCatalog, detectProvider, frontierFor } from './model-catalog.mjs';
|
|
44
|
+
import { providerAvailability } from './provider-availability.mjs';
|
|
45
|
+
import { inspectSessionSnapshots } from './session-snapshot-contract.mjs';
|
|
44
46
|
import { learnings } from './learnings.mjs';
|
|
45
47
|
import { gatesSurvey } from './gates.mjs';
|
|
46
48
|
// The write-safety primitives, borrowed rather than re-implemented. See saveConfig for why.
|
|
@@ -59,7 +61,7 @@ import {
|
|
|
59
61
|
} from '../kb/brain-profile.mjs';
|
|
60
62
|
// Lessons: read model + the two user verbs. Every mutation goes through lesson-store's own
|
|
61
63
|
// updateLessons/ratify/demote/restore — this file adds a SURFACE, never a second writer.
|
|
62
|
-
import { loadLessons, updateLessons, ratify, demote, restore, pending, weightOf, TRIGGERS, ENFORCEMENT, ORIGIN, STATUS } from './lesson-store.mjs';
|
|
64
|
+
import { loadLessons, updateLessons, ratify, demote, restore, pending, weightOf, TRIGGERS, ENFORCEMENT, ORIGIN, SOURCE_CLASS, STATUS } from './lesson-store.mjs';
|
|
63
65
|
import {
|
|
64
66
|
openRouterCredentialStatus,
|
|
65
67
|
saveOpenRouterCredential,
|
|
@@ -421,8 +423,19 @@ function probeMemory(projectDir) {
|
|
|
421
423
|
const db = path.join(projectDir, '.swarm/memory.db');
|
|
422
424
|
const probes = {};
|
|
423
425
|
// compaction survival + session surfacing are filesystem facts, always checkable
|
|
424
|
-
const
|
|
425
|
-
|
|
426
|
+
const snapshot = inspectSessionSnapshots(projectDir);
|
|
427
|
+
const snapshotDetail = snapshot.kind === 'canonical'
|
|
428
|
+
? 'a fresh versioned PreCompact snapshot is present in .swarm/agentdb-sessions.jsonl'
|
|
429
|
+
: snapshot.kind === 'legacy'
|
|
430
|
+
? 'a fresh supported Ruflo session snapshot is present (legacy path; migration recommended)'
|
|
431
|
+
: snapshot.kind === 'malformed'
|
|
432
|
+
? 'snapshot files were found but none matched the supported schema'
|
|
433
|
+
: snapshot.kind !== 'absent'
|
|
434
|
+
? 'the newest supported snapshot is stale'
|
|
435
|
+
: 'no supported PreCompact snapshot found for this project yet';
|
|
436
|
+
probes.compactionSurvival = snapshot.fresh
|
|
437
|
+
? { status: 'ok', detail: snapshotDetail, artifact: snapshot.kind }
|
|
438
|
+
: { status: 'warn', detail: snapshotDetail, artifact: snapshot.kind };
|
|
426
439
|
probes.sessionSurfacing = sessionHookExists() ? { status: 'ok', detail: 'the global SessionStart hook surfaces project state at launch' } : { status: 'warn', detail: 'no SessionStart recall hook found' };
|
|
427
440
|
// recall quality: honestly NOT probed at render (a true probe needs an embedding query; left for an explicit deep test)
|
|
428
441
|
probes.recallQuality = { status: 'notTested', detail: 'not checked this session — a real recall probe needs an embedding round-trip, which render deliberately avoids' };
|
|
@@ -989,7 +1002,15 @@ function gatherLessons() {
|
|
|
989
1002
|
const lessons = all.map((l) => {
|
|
990
1003
|
const trig = TRIGGER_BY_KEY.get(l.trigger);
|
|
991
1004
|
const meaning = ENFORCEMENT_MEANING[l.enforcement] || { label: l.enforcement, detail: '' };
|
|
992
|
-
const userStated = l.origin === ORIGIN.USER_STATED;
|
|
1005
|
+
const userStated = l.origin === ORIGIN.USER_STATED && l.sourceClass === SOURCE_CLASS.CURRENT_USER;
|
|
1006
|
+
const quarantined = l.sourceClass === SOURCE_CLASS.IMPORTED_OWNER || l.sourceClass === SOURCE_CLASS.DEMONSTRATION;
|
|
1007
|
+
const origin = l.sourceClass === SOURCE_CLASS.CURRENT_USER
|
|
1008
|
+
? 'you taught me this'
|
|
1009
|
+
: l.sourceClass === SOURCE_CLASS.IMPORTED_OWNER
|
|
1010
|
+
? 'imported maintainer history — not yours'
|
|
1011
|
+
: l.sourceClass === SOURCE_CLASS.DEMONSTRATION
|
|
1012
|
+
? 'demonstration data — not personal policy'
|
|
1013
|
+
: 'I inferred this from what happened';
|
|
993
1014
|
return {
|
|
994
1015
|
id: l.id,
|
|
995
1016
|
statement: l.statement,
|
|
@@ -1001,8 +1022,10 @@ function gatherLessons() {
|
|
|
1001
1022
|
enforcementLabel: meaning.label,
|
|
1002
1023
|
enforcementDetail: meaning.detail,
|
|
1003
1024
|
// Provenance drives trust, so it is stated plainly and never flattened into a badge colour.
|
|
1004
|
-
origin
|
|
1025
|
+
origin,
|
|
1026
|
+
sourceClass: l.sourceClass,
|
|
1005
1027
|
userStated,
|
|
1028
|
+
quarantined,
|
|
1006
1029
|
taughtCount: l.repeatCount || 0,
|
|
1007
1030
|
projects: Array.isArray(l.projects) ? l.projects : [],
|
|
1008
1031
|
evidence: l.evidence || null,
|
|
@@ -1011,10 +1034,11 @@ function gatherLessons() {
|
|
|
1011
1034
|
ratified: l.status === STATUS.RATIFIED || l.status === STATUS.ACTIVE,
|
|
1012
1035
|
demoted: !!l.demoted,
|
|
1013
1036
|
// The one thing the user is being ASKED, as opposed to merely shown.
|
|
1014
|
-
awaitingYou: l.status === STATUS.CANDIDATE && !l.demoted,
|
|
1037
|
+
awaitingYou: l.status === STATUS.CANDIDATE && !l.demoted && !quarantined,
|
|
1015
1038
|
// Honest ceiling: ratifying a model-inferred lesson can NOT raise it to block
|
|
1016
1039
|
// (lesson-store.mjs:380). Say so before they click, not after.
|
|
1017
1040
|
canReachBlock: userStated,
|
|
1041
|
+
canRatify: !quarantined,
|
|
1018
1042
|
intendedEnforcement: l.intendedEnforcement || null,
|
|
1019
1043
|
};
|
|
1020
1044
|
});
|
|
@@ -1038,6 +1062,7 @@ function gatherLessons() {
|
|
|
1038
1062
|
active: lessons.filter((l) => l.ratified && !l.demoted).length,
|
|
1039
1063
|
awaitingYou: lessons.filter((l) => l.awaitingYou).length,
|
|
1040
1064
|
off: lessons.filter((l) => l.demoted).length,
|
|
1065
|
+
quarantined: lessons.filter((l) => l.quarantined).length,
|
|
1041
1066
|
blocking: lessons.filter((l) => l.enforcement === 'block' && l.ratified && !l.demoted).length,
|
|
1042
1067
|
},
|
|
1043
1068
|
// TASK 2: this endpoint bypasses serveCached entirely and had NO timestamp of any kind. It is
|
|
@@ -1068,6 +1093,9 @@ function setLesson(body) {
|
|
|
1068
1093
|
}
|
|
1069
1094
|
const before = loadLessons().find((l) => l.id === id);
|
|
1070
1095
|
if (!before) return { ok: false, log: `nothing changed — no lesson with id ${id}` };
|
|
1096
|
+
if (action === 'ratify' && (before.sourceClass === SOURCE_CLASS.IMPORTED_OWNER || before.sourceClass === SOURCE_CLASS.DEMONSTRATION)) {
|
|
1097
|
+
return { ok: false, log: 'nothing changed — imported or demonstration history cannot become personal policy' };
|
|
1098
|
+
}
|
|
1071
1099
|
|
|
1072
1100
|
try {
|
|
1073
1101
|
updateLessons((fresh) => spec.fn(id, fresh));
|
|
@@ -1695,7 +1723,10 @@ function gatherRouterEngine() {
|
|
|
1695
1723
|
// user's Settings choice is now the single source of truth, via the SAME detectProvider() the
|
|
1696
1724
|
// savings.utilization frontier calc already uses (config → env → catalog default) — so this and
|
|
1697
1725
|
// the frontier calc can never disagree either.
|
|
1698
|
-
|
|
1726
|
+
const subscriptions = detectSubscriptions();
|
|
1727
|
+
let house;
|
|
1728
|
+
let providerKeys = providerAvailability(null, subscriptions);
|
|
1729
|
+
let providerCatalog = { status: 'degraded', detail: 'provider catalog was not loaded; native boolean detections are shown' };
|
|
1699
1730
|
try {
|
|
1700
1731
|
const hcat = loadCatalog();
|
|
1701
1732
|
house = detectProvider(hcat, { provider: cfg.provider });
|
|
@@ -1704,11 +1735,12 @@ function gatherRouterEngine() {
|
|
|
1704
1735
|
// key found". Read each provider's real detect_env vars — minus the CLAUDECODE / CLAUDE_CODE_ENTRYPOINT
|
|
1705
1736
|
// run-context markers (which are not credentials), exactly as detectProvider() itself filters them —
|
|
1706
1737
|
// so the UI's "key found / not found" is now true instead of decorative.
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
}
|
|
1711
|
-
|
|
1738
|
+
providerKeys = providerAvailability(hcat, subscriptions);
|
|
1739
|
+
providerCatalog = { status: 'ok', detail: 'verified provider catalog loaded' };
|
|
1740
|
+
} catch (error) {
|
|
1741
|
+
house = { provider: cfg.provider && cfg.provider !== 'auto' ? cfg.provider : 'anthropic', source: 'default' };
|
|
1742
|
+
providerCatalog = { status: 'degraded', detail: `provider catalog unavailable: ${String(error?.message || error)}` };
|
|
1743
|
+
}
|
|
1712
1744
|
return {
|
|
1713
1745
|
engine: {
|
|
1714
1746
|
package: '@metaharness/router', installed,
|
|
@@ -1717,12 +1749,13 @@ function gatherRouterEngine() {
|
|
|
1717
1749
|
outcomesLog: OUTCOMES.replace(os.homedir(), '~'),
|
|
1718
1750
|
},
|
|
1719
1751
|
keys: { openrouter: openrouterKey, ...providerKeys },
|
|
1752
|
+
providerCatalog,
|
|
1720
1753
|
// Paid seats, found at USER level. `keys` above is env-var API keys only, which is exactly why a
|
|
1721
1754
|
// user with ChatGPT Max and Claude Max read as "auto" — neither plan puts a key in the
|
|
1722
1755
|
// environment. These two fields are what let the UI say "you already have this" instead of
|
|
1723
1756
|
// asking someone to paste a credential they are already paying not to need.
|
|
1724
|
-
subscriptions
|
|
1725
|
-
preferredSeat: preferredSeat(
|
|
1757
|
+
subscriptions,
|
|
1758
|
+
preferredSeat: preferredSeat(subscriptions),
|
|
1726
1759
|
profile: { present: !!profile, path: PROFILE_PATH.replace(os.homedir(), '~') },
|
|
1727
1760
|
catalogSource: engineCatalogSource(), // 'catalog' | 'built-in-fallback' — so the UI never calls the stub a real catalog
|
|
1728
1761
|
house,
|
|
@@ -1833,6 +1866,10 @@ async function fetchReleaseDigest() {
|
|
|
1833
1866
|
// in the heading of the console"). Plugin-cache dir first — the truth on installed machines — then
|
|
1834
1867
|
// the repo's plugin.json for dev checkouts. null hides the chip rather than guessing.
|
|
1835
1868
|
function brainVersionOnDisk() {
|
|
1869
|
+
const runtimeIdentity = readJSON(path.join(REPO, 'runtime-identity.json'));
|
|
1870
|
+
if (typeof runtimeIdentity?.runtimeVersion === 'string' && runtimeIdentity.runtimeVersion) {
|
|
1871
|
+
return runtimeIdentity.runtimeVersion.replace(/^v/, '');
|
|
1872
|
+
}
|
|
1836
1873
|
try { const v = readInstallChannel().version; if (v) return String(v).replace(/^v/, ''); } catch { /* fall through */ }
|
|
1837
1874
|
try { return getVersion(); } catch { return null; }
|
|
1838
1875
|
}
|
|
@@ -2948,6 +2985,7 @@ export {
|
|
|
2948
2985
|
saveBrainPower,
|
|
2949
2986
|
gatherBrainProfile,
|
|
2950
2987
|
saveBrainProfile,
|
|
2988
|
+
gatherRouterEngine,
|
|
2951
2989
|
autoEligibleIds,
|
|
2952
2990
|
};
|
|
2953
2991
|
// Exported for the cross-project cache-isolation test (console-cache-scope.test.mjs). serveCached's
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
const IGNORED_ENV = new Set(['CLAUDECODE', 'CLAUDE_CODE_ENTRYPOINT']);
|
|
2
|
+
|
|
3
|
+
// Boolean-only by construction: no credential value can cross the Console API boundary.
|
|
4
|
+
export function providerAvailability(catalog, subscriptions = {}, env = process.env) {
|
|
5
|
+
if (catalog?.providers && typeof catalog.providers === 'object') {
|
|
6
|
+
return Object.fromEntries(Object.entries(catalog.providers).map(([name, provider]) => [
|
|
7
|
+
name,
|
|
8
|
+
(provider?.detect_env || []).some((key) => !IGNORED_ENV.has(key) && Boolean(env[key])),
|
|
9
|
+
]));
|
|
10
|
+
}
|
|
11
|
+
return Object.fromEntries(Object.entries(subscriptions).map(([name, value]) => [name, Boolean(value?.apiKey)]));
|
|
12
|
+
}
|
|
@@ -7,7 +7,10 @@ import path from 'node:path';
|
|
|
7
7
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
8
8
|
|
|
9
9
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
10
|
-
const
|
|
10
|
+
const CANONICAL_PUBLISHERS = new Set([
|
|
11
|
+
'scripts/release.mjs',
|
|
12
|
+
'scripts/release-transaction-provider.mjs',
|
|
13
|
+
]);
|
|
11
14
|
const SOURCE_EXTENSIONS = new Set(['.mjs', '.js', '.cjs', '.sh']);
|
|
12
15
|
|
|
13
16
|
function executableSource(source) {
|
|
@@ -27,6 +30,14 @@ const ACTIONS = [
|
|
|
27
30
|
],
|
|
28
31
|
shellPatterns: [/^\s*gh\s+release\s+create\b/m],
|
|
29
32
|
},
|
|
33
|
+
{
|
|
34
|
+
action: 'github-release-update',
|
|
35
|
+
jsPatterns: [
|
|
36
|
+
/['"]PATCH['"][\s\S]{0,120}releases\//,
|
|
37
|
+
/\[\s*['"]release['"]\s*,\s*['"]edit['"]/,
|
|
38
|
+
],
|
|
39
|
+
shellPatterns: [/^\s*gh\s+(?:release\s+edit|api\s+.*releases\/.*PATCH)\b/m],
|
|
40
|
+
},
|
|
30
41
|
{
|
|
31
42
|
action: 'npm-publish',
|
|
32
43
|
jsPatterns: [
|
|
@@ -47,7 +58,7 @@ const ACTIONS = [
|
|
|
47
58
|
|
|
48
59
|
export function detectPublisherActions(file, source) {
|
|
49
60
|
const relative = file.split(path.sep).join('/');
|
|
50
|
-
if (relative
|
|
61
|
+
if (CANONICAL_PUBLISHERS.has(relative)) return [];
|
|
51
62
|
const executable = executableSource(source);
|
|
52
63
|
const kind = path.extname(relative) === '.sh' ? 'shellPatterns' : 'jsPatterns';
|
|
53
64
|
return ACTIONS
|
|
@@ -67,6 +78,7 @@ function sourceFiles(root) {
|
|
|
67
78
|
};
|
|
68
79
|
visit(path.join(root, 'scripts'));
|
|
69
80
|
visit(path.join(root, 'deploy'));
|
|
81
|
+
visit(path.join(root, 'bin'));
|
|
70
82
|
return files;
|
|
71
83
|
}
|
|
72
84
|
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import { execFileSync, spawnSync } from 'node:child_process';
|
|
2
|
+
import crypto from 'node:crypto';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { RECEIPT_PREFIX, TERMINAL_STATES } from './release-transaction.mjs';
|
|
7
|
+
|
|
8
|
+
const REPO = 'stuinfla/ruvnet-brain';
|
|
9
|
+
const PACKAGE = 'ruvnet-brain';
|
|
10
|
+
|
|
11
|
+
const command = (name, args, options = {}) => execFileSync(name, args, {
|
|
12
|
+
encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], ...options,
|
|
13
|
+
}).trim();
|
|
14
|
+
const json = (name, args, options) => JSON.parse(command(name, args, options));
|
|
15
|
+
const maybe = (callback, fallback = null) => {
|
|
16
|
+
try { return callback(); } catch { return fallback; }
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const tagSha = (tag, root) => {
|
|
20
|
+
const rows = command('git', ['ls-remote', 'origin', `refs/tags/${tag}`, `refs/tags/${tag}^{}`], { cwd: root })
|
|
21
|
+
.split('\n').filter(Boolean).map((line) => line.split(/\s+/));
|
|
22
|
+
return rows.find(([, ref]) => ref?.endsWith('^{}'))?.[0] || rows[0]?.[0] || '';
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const assetBytes = (asset) => {
|
|
26
|
+
const result = spawnSync('gh', ['api', asset.url, '-H', 'Accept: application/octet-stream'], {
|
|
27
|
+
encoding: 'buffer', stdio: ['ignore', 'pipe', 'pipe'],
|
|
28
|
+
});
|
|
29
|
+
if (result.error || result.status !== 0) throw new Error(`cannot download transaction asset ${asset.name}`);
|
|
30
|
+
return Buffer.from(result.stdout);
|
|
31
|
+
};
|
|
32
|
+
const assetReceipt = (asset) => JSON.parse(assetBytes(asset).toString('utf8'));
|
|
33
|
+
const sha256 = (bytes) => crypto.createHash('sha256').update(bytes).digest('hex');
|
|
34
|
+
|
|
35
|
+
export function liveReleaseProvider({
|
|
36
|
+
root = process.cwd(), candidateReceipt = null, publicationReceipt = null,
|
|
37
|
+
} = {}) {
|
|
38
|
+
let releases = [];
|
|
39
|
+
let activeDraft = null;
|
|
40
|
+
const refresh = () => {
|
|
41
|
+
releases = json('gh', ['api', `repos/${REPO}/releases?per_page=100`]);
|
|
42
|
+
return releases;
|
|
43
|
+
};
|
|
44
|
+
const releaseById = (id) => json('gh', ['api', `repos/${REPO}/releases/${id}`]);
|
|
45
|
+
const receiptsFor = (release) => (release?.assets || [])
|
|
46
|
+
.filter(({ name }) => name.startsWith(RECEIPT_PREFIX) && name.endsWith('.json'))
|
|
47
|
+
.map(assetReceipt);
|
|
48
|
+
const materializeRemoteAssets = (draft, identity) => {
|
|
49
|
+
const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'ruvnet-release-staged-'));
|
|
50
|
+
try {
|
|
51
|
+
const release = releaseById(draft.id);
|
|
52
|
+
const required = [
|
|
53
|
+
['bundlePath', 'ruvnet-brain.zip'],
|
|
54
|
+
['bundleSignaturePath', 'ruvnet-brain.zip.sig'],
|
|
55
|
+
['bundleDigestPath', 'ruvnet-brain.zip.sha256'],
|
|
56
|
+
];
|
|
57
|
+
const assets = {};
|
|
58
|
+
for (const [key, name] of required) {
|
|
59
|
+
const remote = release.assets?.find((asset) => asset.name === name);
|
|
60
|
+
if (!remote) throw new Error(`staged GitHub asset missing: ${name}`);
|
|
61
|
+
const file = path.join(temp, name);
|
|
62
|
+
fs.writeFileSync(file, assetBytes(remote), { flag: 'wx', mode: 0o600 });
|
|
63
|
+
assets[key] = file;
|
|
64
|
+
}
|
|
65
|
+
if (sha256(fs.readFileSync(assets.bundlePath)) !== identity.bundleSha256) {
|
|
66
|
+
throw new Error('staged GitHub bundle digest does not match transaction identity');
|
|
67
|
+
}
|
|
68
|
+
const packed = json('npm', [
|
|
69
|
+
'pack', `${PACKAGE}@candidate-v${identity.version}`, '--json', '--pack-destination', temp,
|
|
70
|
+
]);
|
|
71
|
+
if (!Array.isArray(packed) || packed.length !== 1 || !packed[0].filename) {
|
|
72
|
+
throw new Error('npm candidate pack did not return exactly one artifact');
|
|
73
|
+
}
|
|
74
|
+
assets.packagePath = path.join(temp, packed[0].filename);
|
|
75
|
+
const integrity = `sha512-${crypto.createHash('sha512').update(fs.readFileSync(assets.packagePath)).digest('base64')}`;
|
|
76
|
+
if (integrity !== identity.packageIntegrity) throw new Error('staged npm package integrity mismatch');
|
|
77
|
+
return { assets, cleanup: () => fs.rmSync(temp, { recursive: true, force: true }) };
|
|
78
|
+
} catch (error) {
|
|
79
|
+
fs.rmSync(temp, { recursive: true, force: true });
|
|
80
|
+
throw error;
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
async discover(identity) {
|
|
86
|
+
const all = refresh();
|
|
87
|
+
const matchingDrafts = all.filter((release) => release.tag_name === identity.tag
|
|
88
|
+
&& release.target_commitish === identity.candidateSha).map((release) => ({
|
|
89
|
+
id: release.id, tag: release.tag_name, sha: release.target_commitish, draft: release.draft,
|
|
90
|
+
}));
|
|
91
|
+
activeDraft = matchingDrafts[0] || null;
|
|
92
|
+
const receipts = matchingDrafts.length === 1 ? receiptsFor(releaseById(matchingDrafts[0].id)) : [];
|
|
93
|
+
const latestByTransaction = new Map();
|
|
94
|
+
for (const receipt of all.flatMap((release) => receiptsFor(release))) {
|
|
95
|
+
const prior = latestByTransaction.get(receipt.transactionId);
|
|
96
|
+
if (!prior || receipt.sequence > prior.sequence) latestByTransaction.set(receipt.transactionId, receipt);
|
|
97
|
+
}
|
|
98
|
+
const pending = [...latestByTransaction.values()]
|
|
99
|
+
.filter((receipt) => !TERMINAL_STATES.has(receipt.state));
|
|
100
|
+
const npmLatest = maybe(() => command('npm', ['view', `${PACKAGE}@latest`, 'version']), null);
|
|
101
|
+
const githubLatest = maybe(() => json('gh', ['api', `repos/${REPO}/releases/latest`]).tag_name, null);
|
|
102
|
+
return { matchingDrafts, receipts, pending, prior: { npmLatest, githubLatest } };
|
|
103
|
+
},
|
|
104
|
+
|
|
105
|
+
async createDraft(identity) {
|
|
106
|
+
const release = json('gh', [
|
|
107
|
+
'api', '-X', 'POST', `repos/${REPO}/releases`,
|
|
108
|
+
'-f', `tag_name=${identity.tag}`, '-f', `target_commitish=${identity.candidateSha}`,
|
|
109
|
+
'-f', `name=${identity.tag} staged candidate`, '-F', 'draft=true', '-F', 'prerelease=false',
|
|
110
|
+
]);
|
|
111
|
+
activeDraft = { id: release.id, tag: release.tag_name, sha: release.target_commitish };
|
|
112
|
+
return activeDraft;
|
|
113
|
+
},
|
|
114
|
+
|
|
115
|
+
async appendReceipt(draft, receipt, name) {
|
|
116
|
+
const anchor = draft || activeDraft;
|
|
117
|
+
if (!anchor) throw new Error('no draft anchor for transaction receipt');
|
|
118
|
+
const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'ruvnet-release-receipt-'));
|
|
119
|
+
const file = path.join(temp, name);
|
|
120
|
+
try {
|
|
121
|
+
fs.writeFileSync(file, `${JSON.stringify(receipt, null, 2)}\n`, { flag: 'wx', mode: 0o600 });
|
|
122
|
+
command('gh', ['release', 'upload', anchor.tag, file, '--repo', REPO]);
|
|
123
|
+
} finally {
|
|
124
|
+
fs.rmSync(temp, { recursive: true, force: true });
|
|
125
|
+
}
|
|
126
|
+
},
|
|
127
|
+
|
|
128
|
+
async readReceipt(draft, sequence) {
|
|
129
|
+
const name = `${RECEIPT_PREFIX}${String(sequence).padStart(4, '0')}.json`;
|
|
130
|
+
const release = releaseById((draft || activeDraft).id);
|
|
131
|
+
const asset = release.assets?.find((item) => item.name === name);
|
|
132
|
+
if (!asset) throw new Error(`remote receipt ${name} is missing after upload`);
|
|
133
|
+
return assetReceipt(asset);
|
|
134
|
+
},
|
|
135
|
+
|
|
136
|
+
async uploadAssets(draft, assets) {
|
|
137
|
+
const paths = [assets.bundlePath, assets.bundleSignaturePath, assets.bundleDigestPath, assets.packagePath];
|
|
138
|
+
for (const file of paths) {
|
|
139
|
+
const release = releaseById(draft.id);
|
|
140
|
+
const existing = release.assets?.find(({ name }) => name === path.basename(file));
|
|
141
|
+
if (existing) {
|
|
142
|
+
if (sha256(assetBytes(existing)) !== sha256(fs.readFileSync(file))) {
|
|
143
|
+
throw new Error(`refusing to replace staged asset with different bytes: ${existing.name}`);
|
|
144
|
+
}
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
command('gh', ['release', 'upload', draft.tag, file, '--repo', REPO]);
|
|
148
|
+
}
|
|
149
|
+
},
|
|
150
|
+
|
|
151
|
+
async materializeStagedAssets(draft, identity) {
|
|
152
|
+
return materializeRemoteAssets(draft, identity);
|
|
153
|
+
},
|
|
154
|
+
|
|
155
|
+
async stageNpm(identity, packagePath) {
|
|
156
|
+
const existing = maybe(() => command('npm', ['view', `${PACKAGE}@${identity.version}`, 'dist.integrity']), null);
|
|
157
|
+
if (existing) return;
|
|
158
|
+
command('npm', ['publish', packagePath, '--tag', `candidate-v${identity.version}`]);
|
|
159
|
+
},
|
|
160
|
+
|
|
161
|
+
async observeNpmCandidate(identity) {
|
|
162
|
+
const metadata = json('npm', ['view', `${PACKAGE}@candidate-v${identity.version}`, '--json']);
|
|
163
|
+
return { version: metadata.version, integrity: metadata.dist?.integrity, tag: `candidate-v${identity.version}` };
|
|
164
|
+
},
|
|
165
|
+
|
|
166
|
+
async publishDraftNonLatest(draft) {
|
|
167
|
+
command('gh', ['api', '-X', 'PATCH', `repos/${REPO}/releases/${draft.id}`, '-F', 'draft=false', '-f', 'make_latest=false']);
|
|
168
|
+
},
|
|
169
|
+
|
|
170
|
+
async observeGithub(identity) {
|
|
171
|
+
const release = json('gh', ['api', `repos/${REPO}/releases/tags/${identity.tag}`]);
|
|
172
|
+
const latest = maybe(() => json('gh', ['api', `repos/${REPO}/releases/latest`]).tag_name, null);
|
|
173
|
+
return { tag: release.tag_name, sha: tagSha(identity.tag, root), latest: latest === identity.tag };
|
|
174
|
+
},
|
|
175
|
+
|
|
176
|
+
async promoteNpm(identity) {
|
|
177
|
+
command('npm', ['dist-tag', 'add', `${PACKAGE}@${identity.version}`, 'latest']);
|
|
178
|
+
},
|
|
179
|
+
async observeNpmLatest() {
|
|
180
|
+
return { version: command('npm', ['view', `${PACKAGE}@latest`, 'version']) };
|
|
181
|
+
},
|
|
182
|
+
async makeGithubLatest(draft) {
|
|
183
|
+
command('gh', ['api', '-X', 'PATCH', `repos/${REPO}/releases/${draft.id}`, '-f', 'make_latest=true']);
|
|
184
|
+
},
|
|
185
|
+
async observeGithubLatest() {
|
|
186
|
+
return { tag: json('gh', ['api', `repos/${REPO}/releases/latest`]).tag_name };
|
|
187
|
+
},
|
|
188
|
+
async restoreNpmLatest(prior, expected) {
|
|
189
|
+
const current = command('npm', ['view', `${PACKAGE}@latest`, 'version']);
|
|
190
|
+
if (current !== expected) throw new Error(`refusing compensation: npm latest is ${current}, expected ${expected}`);
|
|
191
|
+
command('npm', ['dist-tag', 'add', `${PACKAGE}@${prior}`, 'latest']);
|
|
192
|
+
},
|
|
193
|
+
async finalize(identity, receipt, hostVerifier) {
|
|
194
|
+
const staged = materializeRemoteAssets(activeDraft, identity);
|
|
195
|
+
let hosts;
|
|
196
|
+
try {
|
|
197
|
+
hosts = await hostVerifier.verify({ source: 'final', identity, assets: staged.assets });
|
|
198
|
+
} finally {
|
|
199
|
+
staged.cleanup();
|
|
200
|
+
}
|
|
201
|
+
if (hosts.verdict !== 'PASS') return { verdict: 'FAIL', hosts };
|
|
202
|
+
if (!candidateReceipt || !publicationReceipt) {
|
|
203
|
+
throw new Error('final convergence requires candidate and publication receipt paths');
|
|
204
|
+
}
|
|
205
|
+
if (!fs.existsSync(path.resolve(root, publicationReceipt))) {
|
|
206
|
+
const publication = spawnSync(process.execPath, [
|
|
207
|
+
'scripts/publication-receipt.mjs', '--candidate', candidateReceipt, '--out', publicationReceipt,
|
|
208
|
+
], { cwd: root, encoding: 'utf8', timeout: 1_200_000 });
|
|
209
|
+
if (publication.error || publication.status !== 0) {
|
|
210
|
+
return { verdict: 'FAIL', hosts, publicationError: String(publication.stderr || publication.error?.message) };
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
const seal = spawnSync(process.execPath, [
|
|
214
|
+
'scripts/release-proof.mjs', '--candidate', candidateReceipt, '--publication', publicationReceipt,
|
|
215
|
+
], { cwd: root, encoding: 'utf8', timeout: 300_000 });
|
|
216
|
+
if (seal.error || seal.status !== 0) {
|
|
217
|
+
return { verdict: 'FAIL', hosts, sealError: String(seal.stderr || seal.error?.message) };
|
|
218
|
+
}
|
|
219
|
+
const channels = spawnSync(process.execPath, ['scripts/verify-channels.mjs'], {
|
|
220
|
+
cwd: root, encoding: 'utf8', timeout: 600_000,
|
|
221
|
+
});
|
|
222
|
+
if (channels.error || channels.status !== 0) {
|
|
223
|
+
return { verdict: 'FAIL', hosts, channelError: String(channels.stderr || channels.error?.message) };
|
|
224
|
+
}
|
|
225
|
+
const probe = spawnSync(process.execPath, ['scripts/published-surface-probe.mjs', '--json'], {
|
|
226
|
+
cwd: root, encoding: 'utf8', timeout: 600_000,
|
|
227
|
+
});
|
|
228
|
+
let observed = null;
|
|
229
|
+
try { observed = JSON.parse(probe.stdout || ''); } catch {}
|
|
230
|
+
return {
|
|
231
|
+
verdict: probe.status === 0 && observed?.verdict === 'PASS' ? 'PASS' : 'FAIL',
|
|
232
|
+
hosts, surface: observed, publicationReceipt, previousReceiptDigest: receipt.receiptDigest,
|
|
233
|
+
};
|
|
234
|
+
},
|
|
235
|
+
};
|
|
236
|
+
}
|