ruvnet-brain 4.0.1 → 4.0.2
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/.claude-plugin/marketplace.json +1 -0
- package/README.md +4 -4
- package/bin/install.mjs +100 -5
- package/console/CONTRACT.md +172 -0
- package/console/activity.js +753 -0
- package/console/app.js +4189 -0
- package/console/architecture.html +1221 -0
- package/console/assets/depth-1.webp +0 -0
- package/console/assets/depth-2.webp +0 -0
- package/console/assets/depth-3.webp +0 -0
- package/console/assets/harness-vs-plain.svg +259 -0
- package/console/assets/hero.webp +0 -0
- package/console/assets/memory.webp +0 -0
- package/console/assets/metaharness.svg +247 -0
- package/console/index.html +777 -0
- package/console/install-architecture.html +162 -0
- package/console/install-mockup.html +543 -0
- package/console/style.css +2144 -0
- package/console/tips.css +926 -0
- package/console/tips.html +858 -0
- package/console/tips.js +128 -0
- package/docs/RELEASE-NOTES-4.0.md +88 -0
- package/kb/model-requirements.mjs +37 -6
- package/keys/ruvnet-brain-signing.pub.pem +3 -0
- package/package.json +8 -22
- package/plugin/.claude-plugin/marketplace.json +1 -0
- package/plugin/.claude-plugin/plugin.json +2 -3
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/commands/brain-console.md +2 -2
- package/plugin/commands/configure.md +3 -2
- package/plugin/commands/rvbc.md +4 -3
- package/plugin/commands/rvcb.md +2 -2
- package/plugin/hooks/hooks.json +1 -2
- package/plugin/mcp/managed-cli-interface.mjs +47 -4
- package/plugin/mcp/server.mjs +21 -0
- package/plugin/scripts/detach.mjs +14 -0
- package/plugin/scripts/first-session-worker.mjs +38 -0
- package/plugin/scripts/ground-ruvnet.sh +16 -6
- package/plugin/scripts/hook-shim.mjs +7 -7
- package/plugin/scripts/learn-capture.sh +22 -3
- package/plugin/scripts/learn-flush.mjs +21 -4
- package/plugin/scripts/runtime-preferences.mjs +269 -0
- package/plugin/scripts/session-start-core.mjs +477 -0
- package/plugin/scripts/session-start.sh +3 -858
- package/plugin/skills/brain-console/SKILL.md +4 -2
- package/plugin/skills/release-proof/SKILL.md +81 -0
- package/plugin/skills/release-proof/agents/openai.yaml +4 -0
- package/plugin/skills/release-proof/references/receipt-contract.md +38 -0
- package/plugin/skills/release-proof/scripts/release-proof.mjs +210 -0
- package/plugin/skills/ruvnet-brain/PLAYBOOK.md +5 -1
- package/plugin/skills/rvbc/SKILL.md +9 -6
- package/scripts/adr-backfill.mjs +107 -0
- package/scripts/advocacy-outcomes.mjs +808 -0
- package/scripts/agentdb-context.mjs +216 -0
- package/scripts/agentdb-fleet-doctor.mjs +101 -0
- package/scripts/ascii-drift.mjs +236 -0
- package/scripts/behavioral-l1-l4.mjs +210 -0
- package/scripts/brain-capability-check.mjs +72 -0
- package/scripts/brain-grade-groundtruth.mjs +100 -0
- package/scripts/brain-latency-50.mjs +227 -0
- package/scripts/brain-novice-50.mjs +189 -0
- package/scripts/brain-stamp.mjs +94 -0
- package/scripts/brain-state.mjs +212 -0
- package/scripts/build-bundle.mjs +522 -0
- package/scripts/build-concepts.mjs +132 -0
- package/scripts/build-l2.mjs +71 -0
- package/scripts/build-primer.mjs +73 -0
- package/scripts/build-symbols.mjs +68 -0
- package/scripts/calibrate-router.mjs +97 -0
- package/scripts/capability-audit.mjs +321 -0
- package/scripts/capability-registry.mjs +876 -0
- package/scripts/check-indexation.mjs +108 -0
- package/scripts/check-legibility.mjs +189 -0
- package/scripts/ci/build-fixture-kb.mjs +67 -0
- package/scripts/ci/learning-replay-codex-adapter.mjs +62 -0
- package/scripts/ci/learning-replay-recorder.mjs +59 -0
- package/scripts/ci/mutate-hook-timeout.mjs +70 -0
- package/scripts/ci/stranger-fixture-stage.mjs +17 -0
- package/scripts/ci/stranger-scenario.mjs +228 -0
- package/scripts/ci/stranger-timeout.mjs +25 -0
- package/scripts/ci-verdict.mjs +29 -0
- package/scripts/claims-verify.mjs +710 -0
- package/scripts/clear-claude-tmp.sh +31 -0
- package/scripts/console-engine.mjs +434 -0
- package/scripts/console-engine.test.mjs +125 -0
- package/scripts/corpus-qa.mjs +250 -0
- package/scripts/correction-detect-embed.mjs +346 -0
- package/scripts/correction-detect-measure.mjs +270 -0
- package/scripts/correction-detect.mjs +686 -0
- package/scripts/count-chunks.mjs +54 -0
- package/scripts/described-questions.json +30 -0
- package/scripts/design-grade.mjs +58 -0
- package/scripts/dev-plugin-link.sh +105 -0
- package/scripts/distill-project.mjs +200 -0
- package/scripts/doc-currency.mjs +801 -0
- package/scripts/eval-brain.mjs +244 -0
- package/scripts/fix-metaharness-memretrieve.mjs +121 -0
- package/scripts/full-hints.mjs +87 -0
- package/scripts/gate.sh +39 -0
- package/scripts/gates.mjs +146 -0
- package/scripts/gen-console-images.mjs +54 -0
- package/scripts/gen-images.mjs +47 -0
- package/scripts/git-clone-refresh.mjs +52 -0
- package/scripts/git-hooks/pre-push +126 -0
- package/scripts/goal-match.mjs +398 -0
- package/scripts/goldie-research.mjs +223 -0
- package/scripts/goldie-weekly.sh +67 -0
- package/scripts/health-repair.mjs +250 -0
- package/scripts/helix-scenario-questions.json +10 -0
- package/scripts/ingest-gists.mjs +230 -0
- package/scripts/ingest-meeting.mjs +115 -0
- package/scripts/ingest-repo.mjs +79 -0
- package/scripts/install-npx-witness.sh +49 -0
- package/scripts/issue-fix.mjs +639 -0
- package/scripts/issue-watch.mjs +276 -0
- package/scripts/issue4-close-note.md +31 -0
- package/scripts/key-canary.mjs +91 -0
- package/scripts/latency-to-surface.mjs +233 -0
- package/scripts/learning-enable.mjs +380 -0
- package/scripts/learning-replay.mjs +1570 -0
- package/scripts/learnings.mjs +62 -0
- package/scripts/lesson-gate.mjs +680 -0
- package/scripts/lesson-lifecycle.mjs +449 -0
- package/scripts/lesson-promote.mjs +262 -0
- package/scripts/lesson-ratify.mjs +98 -0
- package/scripts/lesson-seed.mjs +252 -0
- package/scripts/lesson-store.mjs +447 -0
- package/scripts/loop-checkpoint.mjs +86 -0
- package/scripts/memdb-health.sh +14 -0
- package/scripts/memory-doctor.mjs +271 -0
- package/scripts/model-catalog.mjs +79 -0
- package/scripts/nightly-controller.mjs +66 -0
- package/scripts/nightly-gists.sh +72 -0
- package/scripts/nightly-wrapper.sh +180 -0
- package/scripts/notify.sh +12 -0
- package/scripts/npx-witness.sh +56 -0
- package/scripts/onboarding-console.mjs +2749 -0
- package/scripts/private-fence.mjs +69 -0
- package/scripts/proactivity-metrics.mjs +118 -0
- package/scripts/proof-questions.json +56 -0
- package/scripts/prove.mjs +95 -0
- package/scripts/proxy/claude-proxied.sh +57 -0
- package/scripts/proxy/proxy-revert.sh +59 -0
- package/scripts/proxy/proxy-up.sh +60 -0
- package/scripts/proxy/proxy-verify.mjs +142 -0
- package/scripts/published-surface-probe.mjs +241 -0
- package/scripts/qe/card-lane-gate.mjs +162 -0
- package/scripts/qe/session-start-gate.mjs +229 -0
- package/scripts/qe/ux-suite.mjs +323 -0
- package/scripts/reconcile-project.mjs +0 -0
- package/scripts/record-lesson.mjs +113 -0
- package/scripts/refresh-model-catalog.mjs +99 -0
- package/scripts/release-proof.mjs +9 -0
- package/scripts/release-vector.mjs +281 -0
- package/scripts/release.mjs +395 -0
- package/scripts/remedy-registry.mjs +247 -0
- package/scripts/rerank-cap-eval.mjs +265 -0
- package/scripts/rerank-cap-warm-ab.mjs +129 -0
- package/scripts/route-cheap.mjs +20 -15
- package/scripts/router-utilization.mjs +182 -0
- package/scripts/routing-flywheel.mjs +596 -0
- package/scripts/rvf-generation.mjs +104 -0
- package/scripts/rvf-index-audit.mjs +138 -0
- package/scripts/self-update.mjs +508 -0
- package/scripts/selfcheck.mjs +7 -1
- package/scripts/sign-bundle.mjs +69 -0
- package/scripts/signal-watch.mjs +171 -0
- package/scripts/stack-sync.mjs +469 -0
- package/scripts/stamp-existing-rvf-generations.mjs +53 -0
- package/scripts/stamp-sweep.mjs +144 -0
- package/scripts/status-honesty.mjs +102 -0
- package/scripts/sync-version.mjs +217 -0
- package/scripts/token-report.mjs +102 -0
- package/scripts/top100-benchmark.mjs +479 -0
- package/scripts/top100-corpus.mjs +112 -0
- package/scripts/top100-semantic-assertions.mjs +449 -0
- package/scripts/update-apply.mjs +9 -0
- package/scripts/upgrade-notice.mjs +14 -0
- package/scripts/verify-bundle.mjs +51 -0
- package/scripts/verify-channels.mjs +184 -0
- package/scripts/verify-model-catalog.mjs +104 -0
- package/scripts/verify-nightly-close-issue4.sh +31 -0
- package/scripts/version.mjs +40 -0
- package/scripts/wired-check.mjs +864 -0
- package/plugin/scripts/finalize-token-meter.mjs +0 -25
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// scripts/release.mjs — the DEFINITION OF DONE. The only path to the word "shipped."
|
|
3
|
+
//
|
|
4
|
+
// WHY (2026-07-17, Stuart): "You should be able to take the applied knowledge and build it into a set
|
|
5
|
+
// of criteria that you always use, not a bunch of suggestions you choose to ignore." Every failure
|
|
6
|
+
// this session was an ASSUMPTION that survived because the check was a suggestion, not a gate. This
|
|
7
|
+
// script turns the checklist into a gate: it runs the criteria in order, STOPS on the first failure,
|
|
8
|
+
// and only prints "SHIPPED" when every channel a user touches is proven current and working. There is
|
|
9
|
+
// no "I think it's fine" — there is pass or fail.
|
|
10
|
+
//
|
|
11
|
+
// It is idempotent and safe to re-run. Each step verifies the REAL artifact (registry, live URL, the
|
|
12
|
+
// actual command), never the repo state. Repo state != user experience (the whole lesson).
|
|
13
|
+
//
|
|
14
|
+
// Usage:
|
|
15
|
+
// node scripts/release.mjs --check # run every gate READ-ONLY (no publish) — the pre-flight
|
|
16
|
+
// node scripts/release.mjs --publish # sync version, npm publish, then run every gate
|
|
17
|
+
// node scripts/release.mjs # same as --check
|
|
18
|
+
//
|
|
19
|
+
// The gates, in order (fail fast):
|
|
20
|
+
// A. version single-source-of-truth agrees (sync-version --check)
|
|
21
|
+
// B. full test suite green (npm test — the 60/60)
|
|
22
|
+
// C. narrative + unit gates (vitest) incl. the tag/entity-aware "What's new" check
|
|
23
|
+
// C+. [--publish only] push to origin/main — ONLY now that A–C are green (a red tree can't reach GitHub)
|
|
24
|
+
// D. [--publish only] build + sign bundle, create/update the exact-SHA GitHub Release,
|
|
25
|
+
// then npm publish + force `latest` to the shipping version
|
|
26
|
+
// E. verify-channels — the LIVE walk of npm / self-update manifest / release bundle+sig / explainer / git
|
|
27
|
+
|
|
28
|
+
import path from 'node:path';
|
|
29
|
+
import { fileURLToPath } from 'node:url';
|
|
30
|
+
import { spawnSync, execFileSync } from 'node:child_process';
|
|
31
|
+
import fs from 'node:fs';
|
|
32
|
+
import crypto from 'node:crypto';
|
|
33
|
+
|
|
34
|
+
const ROOT = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
|
|
35
|
+
const PUBLISH = process.argv.includes('--publish');
|
|
36
|
+
const c = { g: (s) => `\x1b[32m${s}\x1b[0m`, r: (s) => `\x1b[31m${s}\x1b[0m`, y: (s) => `\x1b[33m${s}\x1b[0m`, b: (s) => `\x1b[1m${s}\x1b[0m`, dim: (s) => `\x1b[2m${s}\x1b[0m` };
|
|
37
|
+
const V = () => JSON.parse(fs.readFileSync(path.join(ROOT, 'plugin/.claude-plugin/plugin.json'), 'utf8')).version;
|
|
38
|
+
|
|
39
|
+
function step(n, label) { process.stdout.write(`\n${c.b('▸ ' + n)} ${label}\n`); }
|
|
40
|
+
function runOrDie(label, cmd, args, opts = {}) {
|
|
41
|
+
const r = spawnSync(cmd, args, { cwd: ROOT, stdio: 'inherit', ...opts });
|
|
42
|
+
if (r.error || r.status !== 0) {
|
|
43
|
+
console.error(`\n${c.r('✗ GATE FAILED: ' + label)} ${c.dim('(' + cmd + ' ' + args.join(' ') + ' → ' + (r.error ? r.error.message : 'exit ' + r.status) + ')')}`);
|
|
44
|
+
console.error(`${c.r(' NOT shipped. Fix this, then re-run. No assumptions past a red gate.')}\n`);
|
|
45
|
+
process.exit(1);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function remoteTagCommit(tag) {
|
|
50
|
+
const out = execFileSync('git', [
|
|
51
|
+
'ls-remote', 'origin', `refs/tags/${tag}`, `refs/tags/${tag}^{}`,
|
|
52
|
+
], { cwd: ROOT, encoding: 'utf8' }).trim();
|
|
53
|
+
if (!out) return '';
|
|
54
|
+
const rows = out.split('\n').map((line) => line.trim().split(/\s+/));
|
|
55
|
+
return rows.find(([, ref]) => ref?.endsWith('^{}'))?.[0] || rows[0]?.[0] || '';
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function recordReleaseTransaction(state, data) {
|
|
59
|
+
const file = path.join(ROOT, 'dist', 'release-transaction.json');
|
|
60
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
61
|
+
const next = {
|
|
62
|
+
state,
|
|
63
|
+
updatedAt: new Date().toISOString(),
|
|
64
|
+
...data,
|
|
65
|
+
};
|
|
66
|
+
const tmp = `${file}.tmp-${process.pid}`;
|
|
67
|
+
fs.writeFileSync(tmp, `${JSON.stringify(next, null, 2)}\n`);
|
|
68
|
+
fs.renameSync(tmp, file);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function readReleaseTransaction() {
|
|
72
|
+
try {
|
|
73
|
+
return JSON.parse(fs.readFileSync(path.join(ROOT, 'dist', 'release-transaction.json'), 'utf8'));
|
|
74
|
+
} catch {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
console.log(`\n${c.b('RuvNet Brain — release / definition-of-done')} ${c.dim('· ' + (PUBLISH ? 'PUBLISH' : 'check-only') + ' · shipping ' + V())}\n`);
|
|
80
|
+
|
|
81
|
+
// A verdict is only about the exact committed candidate. Check-only used to permit a dirty tree
|
|
82
|
+
// while publish checked cleanliness much later, so preflight could certify bytes that would never
|
|
83
|
+
// ship. Both modes now bind to the same committed tree before any expensive gate runs.
|
|
84
|
+
const initialDirty = execFileSync('git', ['-C', ROOT, 'status', '--porcelain'], { encoding: 'utf8' }).trim();
|
|
85
|
+
if (initialDirty) {
|
|
86
|
+
console.error(`\n${c.r('✗ GATE FAILED: working tree not clean')} ${c.dim('— preflight and publish both certify committed bytes only.')}`);
|
|
87
|
+
console.error(initialDirty.split('\n').slice(0, 10).map((l) => ' ' + l).join('\n'));
|
|
88
|
+
process.exit(1);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// A. version single source of truth
|
|
92
|
+
step('A', 'version single-source-of-truth agrees across every surface');
|
|
93
|
+
runOrDie('version sync', process.execPath, ['scripts/sync-version.mjs', '--check']);
|
|
94
|
+
|
|
95
|
+
// WIRED-CHECK — refuses to ship a module with zero callers.
|
|
96
|
+
//
|
|
97
|
+
// Added 2026-07-22 after this project shipped built-tested-unwired code SEVEN times in one session
|
|
98
|
+
// (capability-registry, capability-audit, lesson-gate's five triggers, anticipate.sh,
|
|
99
|
+
// advocacy-outcomes, lesson-promote's demotion, continuation-gate's global path). Every one had
|
|
100
|
+
// passing tests, because a test imports the module directly — the one caller that proves nothing
|
|
101
|
+
// about whether the product uses it. Every one was found by a human running grep, hours later.
|
|
102
|
+
//
|
|
103
|
+
// Seven repetitions of one mistake is not a discipline problem; discipline is what failed. So it
|
|
104
|
+
// becomes a gate, on the ship path, where this repo's gates run 8/8 against prose's 0/6.
|
|
105
|
+
runOrDie('wired (no orphan modules)', process.execPath, ['scripts/wired-check.mjs', '--check']);
|
|
106
|
+
|
|
107
|
+
// THE NORTH-STAR VECTOR — a release may not average one broken/unknown invariant into a pass.
|
|
108
|
+
// This is the real ship path, not an npm alias or a test import: every check-only preflight and
|
|
109
|
+
// every publish attempt executes the eight D1-D8 detectors on the candidate SHA.
|
|
110
|
+
runOrDie('release vector (all critical invariants PASS)', process.execPath, ['scripts/release-vector.mjs']);
|
|
111
|
+
|
|
112
|
+
// The Top-100 corpus spans naive through expert prompts and grades semantic clauses, citations,
|
|
113
|
+
// abstention, and latency. A manual-only benchmark is a report; a release-path benchmark is a
|
|
114
|
+
// guarantee. The benchmark itself fails closed unless all 100 canonical questions run.
|
|
115
|
+
runOrDie('Top-100 source-grounded recall contract', process.execPath, ['scripts/top100-benchmark.mjs', '--no-write']);
|
|
116
|
+
|
|
117
|
+
// A2. Stable Spine restart classifier (ADR-023, red-team finding 18): diff the boot-frozen SHELL
|
|
118
|
+
// (hooks.json, hook-shim, MCP server, .mcp.json, skills/, commands/) against the previous release
|
|
119
|
+
// tag and SAY OUT LOUD whether this release needs a restart. The classification is computed, never
|
|
120
|
+
// remembered — the same shellDiff logic runs client-side in update-apply.mjs at every flip, so the
|
|
121
|
+
// user-facing nag stays honest even if this print is ignored. Informational at ship time; the
|
|
122
|
+
// releasing human sees exactly which shell files changed.
|
|
123
|
+
step('A2', 'Stable Spine — does this release change the boot-frozen shell? (requiresRestart classifier)');
|
|
124
|
+
{
|
|
125
|
+
const { execFileSync } = await import('node:child_process');
|
|
126
|
+
const SHELL = ['plugin/hooks/hooks.json', 'plugin/scripts/hook-shim.mjs', 'plugin/mcp/server.mjs', 'plugin/.mcp.json', 'plugin/skills', 'plugin/commands'];
|
|
127
|
+
let prevTag = '';
|
|
128
|
+
try { prevTag = execFileSync('git', ['describe', '--tags', '--abbrev=0'], { encoding: 'utf8' }).trim(); } catch { /* no tags yet */ }
|
|
129
|
+
if (!prevTag) {
|
|
130
|
+
console.log(c.dim(' no previous release tag — classifier has no baseline (first spine release: requiresRestart=true by definition)'));
|
|
131
|
+
} else {
|
|
132
|
+
let changed = [];
|
|
133
|
+
try {
|
|
134
|
+
const out = execFileSync('git', ['diff', '--name-only', `${prevTag}..HEAD`, '--', ...SHELL], { encoding: 'utf8' }).trim();
|
|
135
|
+
changed = out ? out.split('\n') : [];
|
|
136
|
+
} catch { /* diff failure = unknown; say so, never guess green */ changed = ['(diff failed — treat as changed)']; }
|
|
137
|
+
if (changed.length) {
|
|
138
|
+
console.log(` ${c.y('requiresRestart: TRUE')} — shell changed vs ${prevTag}:`);
|
|
139
|
+
for (const f of changed) console.log(` · ${f}`);
|
|
140
|
+
console.log(c.dim(' users get ONE honest restart notice (session-start reads active.json.shellChanged); everything else is live.'));
|
|
141
|
+
} else {
|
|
142
|
+
console.log(` ${c.g('requiresRestart: false')} — no shell change vs ${prevTag}; this release goes fully live with zero restarts.`);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// B. the full brain test suite (the 60/60)
|
|
148
|
+
step('B', 'full test suite (npm test)');
|
|
149
|
+
runOrDie('npm test', 'npm', ['test']);
|
|
150
|
+
|
|
151
|
+
// C. unit gates — narrative-version (tag/entity aware), claims, etc.
|
|
152
|
+
step('C', 'unit gates (vitest) — narrative version, claims, guards');
|
|
153
|
+
runOrDie('vitest unit', 'npx', ['vitest', 'run', 'tests/unit']);
|
|
154
|
+
|
|
155
|
+
// C+. PUSH — only now that A–C are green (publish only). Pushing AFTER the local gates is the fix
|
|
156
|
+
// for the drift that bit on 2026-07-18: a commit was pushed FIRST, then release.mjs's gate B caught a
|
|
157
|
+
// failing plugin-battery test, leaving GitHub at 3.4.10-dev while npm sat at 3.4.9-dev — the exact
|
|
158
|
+
// "pushed but didn't finish" split. The pre-push git hook only checks version/manifest (fast, always),
|
|
159
|
+
// so tests must gate the push HERE. A red tree can no longer reach origin ahead of npm.
|
|
160
|
+
if (PUBLISH) {
|
|
161
|
+
// C++. REMOTE CI IS A SHIP GATE (ADR-053 §5). Between 2026-07-21 and 07-26 the `ci` workflow was
|
|
162
|
+
// red for ~70 consecutive runs — six releases shipped right past it, because nothing on the ship
|
|
163
|
+
// path ever ASKED the remote verdict. Local gates prove this machine; only CI proves ubuntu and
|
|
164
|
+
// windows. So the latest COMPLETED run on origin/main must be green before we add commits on top
|
|
165
|
+
// and publish. (The current commit's own run starts after the push — this gate is "never build on
|
|
166
|
+
// a known-broken main", not "wait for my own run".) Escape hatch for a genuine hotfix:
|
|
167
|
+
// --ci-override "<reason>" — printed into the release log, never silent.
|
|
168
|
+
step('C++', 'remote CI on origin/main is green (the ubuntu+windows verdict this machine cannot produce)');
|
|
169
|
+
{
|
|
170
|
+
const { fetchLatestCiVerdict, assessCiGate } = await import('./ci-verdict.mjs');
|
|
171
|
+
const OVERRIDE_IX = process.argv.indexOf('--ci-override');
|
|
172
|
+
const overrideReason = OVERRIDE_IX >= 0 ? (process.argv[OVERRIDE_IX + 1] || '(no reason given)') : null;
|
|
173
|
+
const { verdict, sha } = await fetchLatestCiVerdict();
|
|
174
|
+
const gate = assessCiGate(verdict, overrideReason);
|
|
175
|
+
if (gate === 'ship') {
|
|
176
|
+
console.log(c.dim(` latest completed ci run on origin/main: success (${sha})`));
|
|
177
|
+
} else if (gate === 'override') {
|
|
178
|
+
console.log(` ${c.y('! CI gate OVERRIDDEN')} — verdict was ${verdict ?? 'unknown'} (${sha || 'no run found'}); reason: ${overrideReason}`);
|
|
179
|
+
} else {
|
|
180
|
+
console.error(`\n${c.r('✗ GATE FAILED: remote CI on origin/main is ' + (verdict ?? 'unknown'))} ${c.dim('(' + (sha || 'no completed run found') + ')')}`);
|
|
181
|
+
console.error(`${c.r(' A red or unknown main does not get shipped on top of. Fix CI first (gh run list --workflow ci.yml),')}`);
|
|
182
|
+
console.error(`${c.r(' or for a genuine hotfix: --ci-override "<reason>" (the reason is printed into the release log).')}\n`);
|
|
183
|
+
process.exit(1);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
step('C+', 'push to origin/main — safe now that A–C passed');
|
|
188
|
+
let ahead = '0';
|
|
189
|
+
try { ahead = execFileSync('git', ['-C', ROOT, 'rev-list', '--count', 'origin/main..HEAD'], { encoding: 'utf8' }).trim(); } catch { /* origin/main ref missing — push will resolve */ ahead = '?'; }
|
|
190
|
+
if (ahead === '0') console.log(c.dim(' nothing to push — HEAD already on origin/main'));
|
|
191
|
+
else runOrDie('git push', 'git', ['-C', ROOT, 'push', 'origin', 'main']);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// D. Publish BOTH delivery channels. This used to advance npm without creating the GitHub Release
|
|
195
|
+
// that verify-channels immediately required, making the sanctioned manual ship path impossible to
|
|
196
|
+
// complete. Build and sign first, then create/update the exact-SHA Release before npm advances.
|
|
197
|
+
// Re-runs are idempotent: a matching Release gets its three assets replaced; a tag bound to any
|
|
198
|
+
// other commit is a hard provenance failure.
|
|
199
|
+
if (PUBLISH) {
|
|
200
|
+
const v = V();
|
|
201
|
+
const tag = `v${v}`;
|
|
202
|
+
const zip = path.join(ROOT, 'dist', 'ruvnet-brain.zip');
|
|
203
|
+
const assets = [zip, `${zip}.sig`, `${zip}.sha256`];
|
|
204
|
+
const head = execFileSync('git', ['-C', ROOT, 'rev-parse', 'HEAD'], { encoding: 'utf8' }).trim();
|
|
205
|
+
const priorTxn = readReleaseTransaction();
|
|
206
|
+
const unfinished = priorTxn && priorTxn.state !== 'channels-converged';
|
|
207
|
+
const samePendingCandidate = unfinished && priorTxn.tag === tag && priorTxn.head === head;
|
|
208
|
+
|
|
209
|
+
step('D', 'build + sign bundle and publish matching GitHub/npm channels');
|
|
210
|
+
if (unfinished && !samePendingCandidate) {
|
|
211
|
+
console.error(`\n${c.r('✗ GATE FAILED: unfinished release transaction requires reconciliation')}`);
|
|
212
|
+
console.error(c.dim(` ${priorTxn.state}: ${priorTxn.tag || '?'} @ ${priorTxn.head || '?'}; refusing to overwrite it with ${tag} @ ${head}`));
|
|
213
|
+
process.exit(1);
|
|
214
|
+
}
|
|
215
|
+
if (samePendingCandidate) {
|
|
216
|
+
if (!assets.every((asset) => fs.existsSync(asset))) {
|
|
217
|
+
console.error(`\n${c.r('✗ GATE FAILED: pending release assets are missing; reconcile before retrying')}`);
|
|
218
|
+
process.exit(1);
|
|
219
|
+
}
|
|
220
|
+
const declared = fs.readFileSync(`${zip}.sha256`, 'utf8').trim().split(/\s+/)[0];
|
|
221
|
+
const actual = crypto.createHash('sha256').update(fs.readFileSync(zip)).digest('hex');
|
|
222
|
+
const verify = spawnSync(process.execPath, ['scripts/verify-bundle.mjs', zip, `${zip}.sig`], {
|
|
223
|
+
cwd: ROOT, encoding: 'utf8',
|
|
224
|
+
});
|
|
225
|
+
if (declared !== priorTxn.bundleSha256 || actual !== declared || verify.status !== 0) {
|
|
226
|
+
console.error(`\n${c.r('✗ GATE FAILED: pending release assets do not match their signed transaction')}`);
|
|
227
|
+
process.exit(1);
|
|
228
|
+
}
|
|
229
|
+
console.log(c.dim(' resume existing signed release assets for the pending candidate'));
|
|
230
|
+
} else {
|
|
231
|
+
runOrDie('build release bundle', process.execPath, ['scripts/build-bundle.mjs', '--version', tag]);
|
|
232
|
+
runOrDie('sign release bundle', process.execPath, ['scripts/sign-bundle.mjs', '--bundle', zip]);
|
|
233
|
+
}
|
|
234
|
+
for (const asset of assets) {
|
|
235
|
+
if (!fs.existsSync(asset)) {
|
|
236
|
+
console.error(`\n${c.r('✗ GATE FAILED: signed release asset missing')} ${c.dim(asset)}`);
|
|
237
|
+
process.exit(1);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
const bundleSha256 = fs.readFileSync(`${zip}.sha256`, 'utf8').trim().split(/\s+/)[0];
|
|
241
|
+
if (!/^[a-f0-9]{64}$/i.test(bundleSha256)) {
|
|
242
|
+
console.error(`\n${c.r('✗ GATE FAILED: release digest is not a SHA-256 value')}`);
|
|
243
|
+
process.exit(1);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
let remoteTagSha = '';
|
|
247
|
+
try {
|
|
248
|
+
remoteTagSha = remoteTagCommit(tag);
|
|
249
|
+
} catch (e) {
|
|
250
|
+
console.error(`\n${c.r('✗ GATE FAILED: could not verify remote release tag')} ${c.dim(String(e.message || e).split('\n')[0])}`);
|
|
251
|
+
process.exit(1);
|
|
252
|
+
}
|
|
253
|
+
if (remoteTagSha && remoteTagSha !== head) {
|
|
254
|
+
console.error(`\n${c.r('✗ GATE FAILED: release tag already identifies different bytes')}`);
|
|
255
|
+
console.error(c.dim(` ${tag} -> ${remoteTagSha}; candidate HEAD -> ${head}`));
|
|
256
|
+
process.exit(1);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Cross-provider publication cannot be truly atomic. Persist the exact convergence state before
|
|
260
|
+
// the first remote mutation so a failed npm publish is recoverable and the next run can converge
|
|
261
|
+
// the SAME tag/HEAD instead of guessing which channel moved.
|
|
262
|
+
if (priorTxn && !['channels-converged'].includes(priorTxn.state)) {
|
|
263
|
+
const sameCandidate = priorTxn.tag === tag
|
|
264
|
+
&& priorTxn.head === head
|
|
265
|
+
&& priorTxn.bundleSha256 === bundleSha256;
|
|
266
|
+
if (!sameCandidate) {
|
|
267
|
+
console.error(`\n${c.r('✗ GATE FAILED: unfinished release transaction requires reconciliation')}`);
|
|
268
|
+
if (priorTxn.tag === tag && priorTxn.head === head && priorTxn.bundleSha256 !== bundleSha256) {
|
|
269
|
+
console.error(c.dim(' release transaction artifact digest changed for the same tag and HEAD'));
|
|
270
|
+
}
|
|
271
|
+
console.error(c.dim(` ${priorTxn.state}: ${priorTxn.tag || '?'} @ ${priorTxn.head || '?'}; refusing to overwrite it with ${tag} @ ${head}`));
|
|
272
|
+
process.exit(1);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
recordReleaseTransaction('prepared', { version: v, tag, head, bundleSha256 });
|
|
276
|
+
|
|
277
|
+
let releaseExists = false;
|
|
278
|
+
try {
|
|
279
|
+
execFileSync('gh', ['release', 'view', tag, '--repo', 'stuinfla/ruvnet-brain'], {
|
|
280
|
+
cwd: ROOT, stdio: ['ignore', 'ignore', 'ignore'],
|
|
281
|
+
});
|
|
282
|
+
releaseExists = true;
|
|
283
|
+
} catch { /* absent is the expected first-publish state */ }
|
|
284
|
+
|
|
285
|
+
if (releaseExists) {
|
|
286
|
+
if (!remoteTagSha) {
|
|
287
|
+
console.error(`\n${c.r('✗ GATE FAILED: GitHub Release exists without a verifiable matching tag')} ${c.dim(tag)}`);
|
|
288
|
+
process.exit(1);
|
|
289
|
+
}
|
|
290
|
+
runOrDie('replace signed GitHub Release assets', 'gh', [
|
|
291
|
+
'release', 'upload', tag, ...assets, '--clobber', '--repo', 'stuinfla/ruvnet-brain',
|
|
292
|
+
]);
|
|
293
|
+
} else {
|
|
294
|
+
runOrDie('create signed GitHub Release', 'gh', [
|
|
295
|
+
'release', 'create', tag, ...assets,
|
|
296
|
+
'--repo', 'stuinfla/ruvnet-brain',
|
|
297
|
+
'--target', head,
|
|
298
|
+
'--title', `${tag} — verified release`,
|
|
299
|
+
'--generate-notes',
|
|
300
|
+
'--latest',
|
|
301
|
+
]);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Confirm the tag GitHub created/retained points to the candidate before touching npm.
|
|
305
|
+
let publishedTagSha = '';
|
|
306
|
+
try {
|
|
307
|
+
publishedTagSha = remoteTagCommit(tag);
|
|
308
|
+
} catch { /* handled by the mismatch below */ }
|
|
309
|
+
if (publishedTagSha !== head) {
|
|
310
|
+
console.error(`\n${c.r('✗ GATE FAILED: published GitHub Release tag is not candidate HEAD')}`);
|
|
311
|
+
console.error(c.dim(` ${tag} -> ${publishedTagSha || '(missing)'}; candidate HEAD -> ${head}`));
|
|
312
|
+
process.exit(1);
|
|
313
|
+
}
|
|
314
|
+
recordReleaseTransaction('github-published-npm-pending', { version: v, tag, head, bundleSha256 });
|
|
315
|
+
|
|
316
|
+
let already = '';
|
|
317
|
+
try { already = execFileSync('npm', ['view', `ruvnet-brain@${v}`, 'version'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); } catch { /* not published yet */ }
|
|
318
|
+
if (already === v) console.log(c.dim(` ${v} already on npm — skipping publish, just re-asserting the tag`));
|
|
319
|
+
// npm requires an explicit tag for prerelease versions. This project intentionally serves its
|
|
320
|
+
// `-dev` release from `latest`, so make that policy explicit on the publish command itself.
|
|
321
|
+
else runOrDie('npm publish', 'npm', ['publish', '--tag', 'latest']);
|
|
322
|
+
// npm does NOT auto-move `latest` to a prerelease (x.y.z-dev) — force it, or `@latest` stays stale.
|
|
323
|
+
runOrDie('npm dist-tag latest', 'npm', ['dist-tag', 'add', `ruvnet-brain@${v}`, 'latest']);
|
|
324
|
+
recordReleaseTransaction('publish-complete-verification-pending', { version: v, tag, head, bundleSha256 });
|
|
325
|
+
} else {
|
|
326
|
+
step('D', 'GitHub Release + npm publish — SKIPPED (check-only; pass --publish to publish)');
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// D+. THE DEPLOY-SURFACE SWEEP (owner standing order, 2026-07-27): "ALWAYS check GitHub CLI and
|
|
330
|
+
// Vercel CLI for gotchas with anything you're pushing. This needs to be part of the protocol you use
|
|
331
|
+
// whenever you deploy. I don't want to have to tell you this again."
|
|
332
|
+
//
|
|
333
|
+
// Gate C++ already asks whether CI passed. That is one surface. This asks the two CLIs what the
|
|
334
|
+
// PLATFORMS think — failing workflows other than our own ci, security advisories, and whether the
|
|
335
|
+
// production deployment that serves the explainer is actually Ready. Each is a question a human
|
|
336
|
+
// would otherwise have to remember to ask, which is the definition of a check that eventually
|
|
337
|
+
// doesn't happen.
|
|
338
|
+
//
|
|
339
|
+
// ADVISORY BY DESIGN, LOUD BY CONTRACT: this prints findings and does not exit non-zero, because a
|
|
340
|
+
// GitHub-side hiccup must not wedge a correct release — EXCEPT where it overlaps a hard gate that
|
|
341
|
+
// already exists (C++ for ci, E for the live explainer). Anything it finds is printed in full so it
|
|
342
|
+
// cannot be a diagnostic nobody reads.
|
|
343
|
+
step('D+', 'deploy-surface sweep — what GitHub and Vercel think about what we are pushing');
|
|
344
|
+
{
|
|
345
|
+
const sh = (cmd, args) => { try { return execFileSync(cmd, args, { encoding: 'utf8', stdio: ['ignore','pipe','ignore'], timeout: 45000 }); } catch { return null; } };
|
|
346
|
+
|
|
347
|
+
// 1. Failing workflow runs that are NOT our ci (ci is gate C++'s job). issue-watch exits 1 BY
|
|
348
|
+
// DESIGN on an SLA breach, so it is reported as an SLA signal, never as a broken pipeline —
|
|
349
|
+
// conflating the two is how a permanently-red workflow trains everyone to ignore red.
|
|
350
|
+
const runs = sh('gh', ['run','list','--repo','stuinfla/ruvnet-brain','--limit','15','--json','name,conclusion,headBranch']);
|
|
351
|
+
if (runs) {
|
|
352
|
+
let bad = [];
|
|
353
|
+
try { bad = JSON.parse(runs).filter((r) => r.conclusion && r.conclusion !== 'success' && r.name !== 'ci'); } catch { /* unparseable — reported below */ }
|
|
354
|
+
const sla = bad.filter((r) => r.name === 'issue-watch');
|
|
355
|
+
const real = bad.filter((r) => r.name !== 'issue-watch');
|
|
356
|
+
if (sla.length) console.log(` ${c.y('! issue-watch red x' + sla.length)} ${c.dim('— by design: an open issue is past its 4h SLA. Answer the issue, do not fix the workflow.')}`);
|
|
357
|
+
if (real.length) console.log(` ${c.y('! non-ci workflows failing:')} ${real.map((r) => r.name).join(', ')}`);
|
|
358
|
+
if (!sla.length && !real.length) console.log(c.dim(' no failing workflows outside ci'));
|
|
359
|
+
} else console.log(c.dim(' gh unavailable — workflow sweep SKIPPED (not a pass)'));
|
|
360
|
+
|
|
361
|
+
// 2. Security advisories against what we ship.
|
|
362
|
+
const dep = sh('gh', ['api','repos/stuinfla/ruvnet-brain/dependabot/alerts','--jq','[.[]|select(.state=="open")]|length']);
|
|
363
|
+
if (dep !== null) {
|
|
364
|
+
const n = parseInt(dep.trim(), 10);
|
|
365
|
+
console.log(n > 0 ? ` ${c.r('! ' + n + ' open dependabot alert(s)')}` : c.dim(' 0 open dependabot alerts'));
|
|
366
|
+
} else console.log(c.dim(' dependabot query unavailable — SKIPPED (not a pass)'));
|
|
367
|
+
|
|
368
|
+
// 3. Vercel: the explainer is a shipped surface; a Ready production deployment is the precondition
|
|
369
|
+
// for gate E's live check meaning anything.
|
|
370
|
+
const vc = sh('vercel', ['ls','--yes']);
|
|
371
|
+
if (vc) {
|
|
372
|
+
const prod = vc.split('\n').find((l) => l.includes('Production'));
|
|
373
|
+
const ready = prod && /●\s*Ready/.test(prod);
|
|
374
|
+
console.log(ready ? c.dim(' vercel: latest production deployment Ready') : ` ${c.y('! vercel: latest production deployment is NOT Ready')} ${c.dim((prod||'').trim().slice(0,90))}`);
|
|
375
|
+
} else console.log(c.dim(' vercel CLI unavailable/not logged in — SKIPPED (not a pass)'));
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// E. the live channel walk — THE gate that would have caught the stale-2.9.1 + 404
|
|
379
|
+
step('E', 'verify-channels — the live walk of every user path');
|
|
380
|
+
runOrDie('verify-channels', process.execPath, ['scripts/verify-channels.mjs']);
|
|
381
|
+
if (PUBLISH) {
|
|
382
|
+
const txn = readReleaseTransaction();
|
|
383
|
+
recordReleaseTransaction('channels-converged', {
|
|
384
|
+
version: V(),
|
|
385
|
+
tag: `v${V()}`,
|
|
386
|
+
head: execFileSync('git', ['-C', ROOT, 'rev-parse', 'HEAD'], { encoding: 'utf8' }).trim(),
|
|
387
|
+
bundleSha256: txn?.bundleSha256 ?? null,
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
if (PUBLISH) {
|
|
392
|
+
console.log(`\n${c.g(c.b('✓✓✓ SHIPPED'))} — every gate passed and every live channel is current. ${c.dim('A user on any path (npm, npx, explainer, --update) gets the working, current build.')}\n`);
|
|
393
|
+
} else {
|
|
394
|
+
console.log(`\n${c.g(c.b('✓✓✓ PREFLIGHT PASS — NOT PUBLISHED'))} — the committed candidate passed every check-only gate.\n`);
|
|
395
|
+
}
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
// remedy-registry.mjs — ONE object per recommendation id, owning detector ⇄ executor ⇄ inverse.
|
|
2
|
+
//
|
|
3
|
+
// WHY THIS EXISTS. Before this file, a recommendation's id, the code that ran it, and the code that
|
|
4
|
+
// reversed it lived in three different places that nothing forced to agree. All three drifted, and
|
|
5
|
+
// every drift was invisible until someone clicked the button:
|
|
6
|
+
//
|
|
7
|
+
// 1. `learning:enable-fleet` was constructed, validated, and offered — with NO executor at all.
|
|
8
|
+
// It fell through apply()'s if/else to `Unknown recommendation id`. The single most important
|
|
9
|
+
// recommendation in the product (ADR-027's North Star case) was a dead button.
|
|
10
|
+
// 2. `repair:memory-index` journalled `kind:'restore-memory-backup'`, and undo() had no branch for
|
|
11
|
+
// it. It hit the default arm and reported "nothing to undo (the change reverses itself
|
|
12
|
+
// automatically)" — while the recommendation had promised "restore the backup taken immediately
|
|
13
|
+
// before the repair." The undo did not exist. The promise was a lie.
|
|
14
|
+
// 3. `repair:memory-index` also satisfies `startsWith('repair:')`, so ONE reordering of an if/else
|
|
15
|
+
// chain silently routed a database repair into a global npm sync. That was caught by review,
|
|
16
|
+
// but only by review — nothing structural prevented it.
|
|
17
|
+
//
|
|
18
|
+
// The common shape: a chain of `if (id.startsWith(...))` cannot be audited, because the set of ids
|
|
19
|
+
// it handles is not a value anything can inspect. So it becomes a value here. Each Remedy owns its
|
|
20
|
+
// id, the executor as DATA (not a spawn), and a DECLARED inverse. `assertRegistryClosure()` then
|
|
21
|
+
// proves, in a test, that every id the builders can construct resolves to exactly one remedy with a
|
|
22
|
+
// real undo handler behind it — so a dead button fails CI instead of failing a user.
|
|
23
|
+
//
|
|
24
|
+
// PURITY: no I/O, no spawn, no fs — same discipline as console-engine.mjs (DDD context 4). A remedy
|
|
25
|
+
// RETURNS a description of what to run; onboarding-console.mjs is the only thing that runs it. That
|
|
26
|
+
// is what lets the closure test check every path without touching the machine.
|
|
27
|
+
|
|
28
|
+
// ── Undo kinds ───────────────────────────────────────────────────────────────────────────────────
|
|
29
|
+
// The set of inverses the console can actually perform. A remedy may not name a kind outside this
|
|
30
|
+
// set, and every kind here MUST have a live branch in onboarding-console.undo(). Both directions are
|
|
31
|
+
// enforced by test, because a missing branch does not throw — it silently returns "nothing to undo",
|
|
32
|
+
// which is the most dangerous possible answer: it reads like success.
|
|
33
|
+
//
|
|
34
|
+
// NONE is a real, declared value, not an absence. "This genuinely has no inverse" and "nobody wrote
|
|
35
|
+
// one" must never look the same, which is exactly the bug that made #2 above invisible.
|
|
36
|
+
export const UNDO_KINDS = Object.freeze({
|
|
37
|
+
NONE: 'none',
|
|
38
|
+
REINSTALL_VERSION: 'reinstall-version',
|
|
39
|
+
RESTORE_BACKUP: 'restore-backup',
|
|
40
|
+
RESTORE_MEMORY_BACKUP: 'restore-memory-backup',
|
|
41
|
+
RESTORE_STORE_BACKUPS: 'restore-store-backups',
|
|
42
|
+
AUTO_REBUILD: 'auto-rebuild',
|
|
43
|
+
// distill-project.mjs's OWN `--restore` (see its header: a tested inverse, not a re-implementation
|
|
44
|
+
// of one). Distinct from RESTORE_MEMORY_BACKUP/RESTORE_STORE_BACKUPS because those restore backups
|
|
45
|
+
// *this server* located and named; this one hands the restore entirely to the same script that took
|
|
46
|
+
// the snapshot, which already knows where its own backups live.
|
|
47
|
+
RESTORE_PROJECT_DISTILL: 'restore-project-distill',
|
|
48
|
+
});
|
|
49
|
+
const K = UNDO_KINDS;
|
|
50
|
+
|
|
51
|
+
// Ids that are exact, reserved words. A parameterized matcher (`repair:<pkg>`) must never capture
|
|
52
|
+
// one of these — see the ambiguity throw in resolveRemedy().
|
|
53
|
+
const RESERVED = new Set(['repair:memory-index', 'purge:shadows']);
|
|
54
|
+
|
|
55
|
+
// ── The registry ─────────────────────────────────────────────────────────────────────────────────
|
|
56
|
+
// match(id) → params object if this remedy owns the id, else null.
|
|
57
|
+
// plan(p) → { script, args } — the executor, as data.
|
|
58
|
+
// inverse(p) → { kind, ...params } — journalled BEFORE the change is made.
|
|
59
|
+
export const REMEDIES = [
|
|
60
|
+
{
|
|
61
|
+
key: 'memory-index',
|
|
62
|
+
autoEligible: true,
|
|
63
|
+
summary: 'REINDEX a corrupt AgentDB store',
|
|
64
|
+
match: (id) => (id === 'repair:memory-index' ? {} : null),
|
|
65
|
+
plan: () => ({ script: 'scripts/health-repair.mjs', args: ['--repair-memory'] }),
|
|
66
|
+
// health-repair.mjs takes an sqlite `.backup` of the store immediately before REINDEX (never a
|
|
67
|
+
// cp — that silently truncates a live WAL database, a standing lesson proven by experiment).
|
|
68
|
+
// The inverse is restoring it. This is the branch whose absence made the promise a lie.
|
|
69
|
+
inverse: () => ({ kind: K.RESTORE_MEMORY_BACKUP }),
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
key: 'learning-flush',
|
|
73
|
+
summary: 'drain the capture queue into the learner',
|
|
74
|
+
match: (id) => (id === 'learning:flush' ? {} : null),
|
|
75
|
+
plan: () => ({ script: 'scripts/health-repair.mjs', args: ['--flush-learning'] }),
|
|
76
|
+
// Genuinely additive: it moves already-captured local events into the learner. Declared NONE on
|
|
77
|
+
// purpose, and the human string says what a user would actually do instead.
|
|
78
|
+
inverse: () => ({ kind: K.NONE, human: 'nothing to reverse — this only adds observations the learner already had queued; learned state can be reset separately' }),
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
key: 'learning-train',
|
|
82
|
+
summary: 'run one training cycle',
|
|
83
|
+
match: (id) => (id === 'learning:train' ? {} : null),
|
|
84
|
+
plan: () => ({ script: 'scripts/health-repair.mjs', args: ['--train-learning'] }),
|
|
85
|
+
inverse: () => ({ kind: K.NONE, human: 'nothing to reverse here — learned state is reset with `ruflo hooks intelligence --reset`, which is a separate, deliberate action' }),
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
// THE ONE THAT HAD NO EXECUTOR. See ADR-027's North Star case: stores full of memories that
|
|
89
|
+
// teach nothing. The remedy is not ours to invent — memory-doctor.mjs has printed the exact fix
|
|
90
|
+
// since the day it was written ("embedded but never distilled — run: ruflo memory distill run"),
|
|
91
|
+
// and the console simply never said it out loud. This wires that sentence to a button.
|
|
92
|
+
key: 'distill-fleet',
|
|
93
|
+
summary: 'distill embedded-but-never-distilled stores into reusable patterns',
|
|
94
|
+
match: (id) => (id === 'learning:distill-fleet' ? {} : null),
|
|
95
|
+
// needsReceipt: this remedy touches a SET of stores discovered at run time, so the inverse
|
|
96
|
+
// cannot be described up front. The executor writes down exactly which stores it snapshotted
|
|
97
|
+
// and where; the inverse reads that receipt. Without it, "restore the backups" would be a hope
|
|
98
|
+
// rather than an instruction — and a hope is what made the memory-index undo a lie.
|
|
99
|
+
plan: () => ({ script: 'scripts/health-repair.mjs', args: ['--distill-fleet'], needsReceipt: true }),
|
|
100
|
+
// Distillation WRITES (reasoning_patterns, episodes, causal_edges), so it needs a real inverse.
|
|
101
|
+
// health-repair snapshots each store with `ruflo memory backup` (rUv's own WAL-safe, rotated
|
|
102
|
+
// snapshotter) before distilling it; the inverse restores those snapshots.
|
|
103
|
+
inverse: () => ({ kind: K.RESTORE_STORE_BACKUPS }),
|
|
104
|
+
},
|
|
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).
|
|
111
|
+
key: 'enable-memory-distillation',
|
|
112
|
+
autoEligible: true,
|
|
113
|
+
summary: "mine this project's stored memories into reusable patterns (snapshots first; reversible)",
|
|
114
|
+
match: (id) => (id === 'enable:memory-distillation' ? {} : null),
|
|
115
|
+
// This console instance is always scoped to ONE project — the directory it was started in — the
|
|
116
|
+
// same assumption the memory-index/learning-flush/learning-train remedies above already make.
|
|
117
|
+
// `usesServerProject` asks onboarding-console.mjs (impure, process-aware) to supply that directory
|
|
118
|
+
// at call time; this file stays pure and never reads process.cwd() itself (see header).
|
|
119
|
+
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 }),
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
key: 'stack-sync',
|
|
129
|
+
summary: 'install/repair a global package to its target version',
|
|
130
|
+
match: (id) => {
|
|
131
|
+
if (RESERVED.has(id)) return null; // `repair:memory-index` is NOT a package repair
|
|
132
|
+
const m = /^(?:sync|repair):(.+)$/.exec(id);
|
|
133
|
+
return m ? { pkg: m[1] } : null;
|
|
134
|
+
},
|
|
135
|
+
plan: () => ({ script: 'scripts/stack-sync.mjs', args: ['--sync'] }),
|
|
136
|
+
// The inverse of a version bump is the version that was on disk a moment ago — which only the
|
|
137
|
+
// caller can read, so it is filled in at journal time. Declaring it here is what makes the
|
|
138
|
+
// closure test able to check that undo() can honour it.
|
|
139
|
+
inverse: ({ pkg }) => ({ kind: K.REINSTALL_VERSION, pkg }),
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
key: 'purge-shadows',
|
|
143
|
+
summary: 'delete stale duplicate copies from the npx cache',
|
|
144
|
+
match: (id) => (id === 'purge:shadows' ? {} : null),
|
|
145
|
+
plan: () => ({ script: 'scripts/stack-sync.mjs', args: ['--sync'] }),
|
|
146
|
+
inverse: () => ({ kind: K.AUTO_REBUILD, human: 'the temporary cache re-fills itself on next use; no manual step needed' }),
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
key: 'reconcile-project',
|
|
150
|
+
autoEligible: true,
|
|
151
|
+
summary: 'rewire a project from npx to the global binary',
|
|
152
|
+
match: (id) => {
|
|
153
|
+
const m = /^reconcile:(.+)$/.exec(id);
|
|
154
|
+
return m ? { project: m[1] } : null;
|
|
155
|
+
},
|
|
156
|
+
plan: ({ project }) => ({ script: 'scripts/reconcile-project.mjs', args: ['--apply', '--project', project], resolveProject: true }),
|
|
157
|
+
inverse: ({ project }) => ({ kind: K.RESTORE_BACKUP, project }),
|
|
158
|
+
},
|
|
159
|
+
];
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Resolve an id to EXACTLY ONE remedy.
|
|
163
|
+
*
|
|
164
|
+
* Ambiguity throws rather than picking a winner. Silently preferring the first match is precisely
|
|
165
|
+
* how `repair:memory-index` once routed into a global npm sync while telling the user their database
|
|
166
|
+
* had been repaired — a wrong action reported as the right one. A throw is a loud developer error;
|
|
167
|
+
* a silent misroute is a user's data.
|
|
168
|
+
*/
|
|
169
|
+
export function resolveRemedy(id) {
|
|
170
|
+
const hits = [];
|
|
171
|
+
for (const r of REMEDIES) {
|
|
172
|
+
const params = r.match(id);
|
|
173
|
+
if (params) hits.push({ remedy: r, params });
|
|
174
|
+
}
|
|
175
|
+
if (hits.length > 1) {
|
|
176
|
+
throw new Error(`Remedy id "${id}" is ambiguous — claimed by: ${hits.map((h) => h.remedy.key).join(', ')}. Exactly one remedy must own an id.`);
|
|
177
|
+
}
|
|
178
|
+
return hits[0] ?? null;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** The full plan for an id: what to run, and what reverses it. Null when nothing owns the id. */
|
|
182
|
+
export function planFor(id) {
|
|
183
|
+
const hit = resolveRemedy(id);
|
|
184
|
+
if (!hit) return null;
|
|
185
|
+
const { remedy, params } = hit;
|
|
186
|
+
return {
|
|
187
|
+
key: remedy.key,
|
|
188
|
+
summary: remedy.summary,
|
|
189
|
+
autoEligible: remedy.autoEligible === true,
|
|
190
|
+
exec: remedy.plan(params),
|
|
191
|
+
undo: remedy.inverse(params),
|
|
192
|
+
params,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* THE CLOSURE PROOF. Every id that can be OFFERED must be runnable and reversible.
|
|
198
|
+
*
|
|
199
|
+
* Takes the ids the recommendation builders actually constructed (never a hand-typed list — a
|
|
200
|
+
* hand-typed list drifts from the builders, which is the whole failure mode) plus the undo kinds
|
|
201
|
+
* onboarding-console.undo() implements, and returns every gap it finds.
|
|
202
|
+
*
|
|
203
|
+
* @param {string[]} offeredIds ids from buildHealth/Stack/WiringRecommendations
|
|
204
|
+
* @param {string[]} handledUndoKinds kinds undo() has a real branch for
|
|
205
|
+
* @returns {{orphanIds:string[], ambiguousIds:string[], unhandledUndoKinds:string[], deadKinds:string[]}}
|
|
206
|
+
*/
|
|
207
|
+
export function assertRegistryClosure(offeredIds = [], handledUndoKinds = []) {
|
|
208
|
+
const orphanIds = [];
|
|
209
|
+
const ambiguousIds = [];
|
|
210
|
+
const unhandledUndoKinds = [];
|
|
211
|
+
const handled = new Set(handledUndoKinds);
|
|
212
|
+
const usedKinds = new Set();
|
|
213
|
+
|
|
214
|
+
for (const id of offeredIds) {
|
|
215
|
+
let plan = null;
|
|
216
|
+
try { plan = planFor(id); } catch { ambiguousIds.push(id); continue; }
|
|
217
|
+
if (!plan) { orphanIds.push(id); continue; } // offered with no executor — the dead-button bug
|
|
218
|
+
usedKinds.add(plan.undo.kind);
|
|
219
|
+
// NONE is self-handling by definition, but it must still be DECLARED (see UNDO_KINDS).
|
|
220
|
+
if (plan.undo.kind !== K.NONE && !handled.has(plan.undo.kind)) unhandledUndoKinds.push(`${id} → ${plan.undo.kind}`);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// The other direction: a kind the registry declares that undo() cannot perform is a broken promise
|
|
224
|
+
// waiting to happen, even if no builder currently emits that id.
|
|
225
|
+
const declared = new Set();
|
|
226
|
+
for (const r of REMEDIES) {
|
|
227
|
+
try { declared.add(r.inverse(r.match(sampleIdFor(r)) || {}).kind); } catch { /* sampling is best-effort */ }
|
|
228
|
+
}
|
|
229
|
+
const deadKinds = [...declared].filter((k) => k !== K.NONE && !handled.has(k));
|
|
230
|
+
|
|
231
|
+
return { orphanIds, ambiguousIds, unhandledUndoKinds, deadKinds };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** A representative id for each remedy, so closure can sample parameterized matchers too. */
|
|
235
|
+
export function sampleIdFor(remedy) {
|
|
236
|
+
switch (remedy.key) {
|
|
237
|
+
case 'memory-index': return 'repair:memory-index';
|
|
238
|
+
case 'learning-flush': return 'learning:flush';
|
|
239
|
+
case 'learning-train': return 'learning:train';
|
|
240
|
+
case 'distill-fleet': return 'learning:distill-fleet';
|
|
241
|
+
case 'enable-memory-distillation': return 'enable:memory-distillation';
|
|
242
|
+
case 'stack-sync': return 'sync:ruflo';
|
|
243
|
+
case 'purge-shadows': return 'purge:shadows';
|
|
244
|
+
case 'reconcile-project': return 'reconcile:example';
|
|
245
|
+
default: return `__unknown:${remedy.key}`;
|
|
246
|
+
}
|
|
247
|
+
}
|