ruvnet-brain 4.7.22 → 4.7.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/README.md +1 -1
  2. package/bin/install.mjs +59 -1
  3. package/kb/lifecycle-evidence-retention.mjs +1 -1
  4. package/kb/update-storage-transaction.mjs +161 -1
  5. package/package.json +2 -1
  6. package/plugin/.claude-plugin/plugin.json +1 -1
  7. package/plugin/.codex-plugin/plugin.json +1 -1
  8. package/plugin/scripts/brain-footprint.mjs +5 -1
  9. package/plugin/scripts/learn-capture.mjs +26 -3
  10. package/scripts/generation-census.mjs +220 -0
  11. package/scripts/issue-autofix-agents.mjs +172 -0
  12. package/scripts/issue-autofix-comment.mjs +224 -0
  13. package/scripts/issue-autofix-diff.mjs +108 -0
  14. package/scripts/issue-autofix-exec.mjs +128 -0
  15. package/scripts/issue-autofix-fix.mjs +104 -0
  16. package/scripts/issue-autofix-gate.mjs +137 -0
  17. package/scripts/issue-autofix-night.mjs +164 -0
  18. package/scripts/issue-autofix-notes.mjs +346 -0
  19. package/scripts/issue-autofix-policy.mjs +349 -0
  20. package/scripts/issue-autofix-prompts.mjs +105 -0
  21. package/scripts/issue-autofix-release-ship.mjs +226 -0
  22. package/scripts/issue-autofix-release-util.mjs +141 -0
  23. package/scripts/issue-autofix-release.mjs +272 -0
  24. package/scripts/issue-autofix-report.mjs +121 -0
  25. package/scripts/issue-autofix-runner.mjs +75 -0
  26. package/scripts/issue-autofix-sandbox.mjs +108 -0
  27. package/scripts/issue-autofix-security.mjs +346 -0
  28. package/scripts/issue-autofix-selftest.mjs +101 -0
  29. package/scripts/issue-autofix-state.mjs +61 -0
  30. package/scripts/issue-autofix.mjs +382 -0
  31. package/scripts/issue-fix-lock.mjs +40 -0
  32. package/scripts/issue-fix.mjs +33 -20
  33. package/scripts/no-silent-substitution.mjs +1 -1
  34. package/scripts/ntfy-inbox.mjs +15 -0
  35. package/scripts/onboarding-console.mjs +6 -1
  36. package/scripts/release-prepush-checks.mjs +251 -0
  37. package/scripts/release-prepush.mjs +168 -0
  38. package/scripts/release-qualification-contract.mjs +32 -0
  39. package/scripts/wired-check.mjs +11 -5
@@ -0,0 +1,382 @@
1
+ // scripts/issue-autofix.mjs — the daily unattended issue fixer (ADR-107). Reached only through
2
+ // `node scripts/issue-fix.mjs --mode autofix`, started by launchd (com.ruvnet.issue-autofix); no editor, terminal or
3
+ // Claude Code session is involved. This module owns the run's skeleton: kill switch, lock, S13b follow-up (which runs
4
+ // even while halted), resuming an unfinished release, the release window, selection and the injection scan. One night's
5
+ // work (fix every eligible issue, one batched release, one note per person) lives in issue-autofix-night.mjs.
6
+ //
7
+ // Every git/gh/npm call goes through issue-autofix-exec.mjs (no force, no merge, never the primary checkout); code a
8
+ // model wrote only runs through issue-autofix-sandbox.mjs; the kill switch is re-read before every stage and every
9
+ // irreversible step, and an unreadable switch counts as OFF.
10
+ import fs from 'node:fs';
11
+ import os from 'node:os';
12
+ import path from 'node:path';
13
+ import { fileURLToPath } from 'node:url';
14
+ import { BOT_MARKER } from './issue-watch.mjs';
15
+ import { AutofixAbort, readKillSwitch, selectCandidates, judgeEligibility, judgeReleaseWindow, etDay, isPersonIssue } from './issue-autofix-policy.mjs';
16
+ import { runAgent, STAGE_CONFIG } from './issue-autofix-agents.mjs';
17
+ import * as realRelease from './issue-autofix-release.mjs';
18
+ import { acquireLock, releaseLock } from './issue-fix-lock.mjs';
19
+ import { makeExec, resolveWorkPath, defaultNotify, keepAwake } from './issue-autofix-exec.mjs';
20
+ import { makeSandboxRunner } from './issue-autofix-sandbox.mjs';
21
+ import { PATHS, STATE_NS, readNs, patchNs, patchIssue, writeAtomic, clearAwaiting } from './issue-autofix-state.mjs';
22
+ import { notifyPeople, recheckAwaiting, viewIssue } from './issue-autofix-report.mjs';
23
+ import { scanIssueText } from './issue-autofix-gate.mjs';
24
+ import { fixAll, releaseBatch, shipAndSettle, haltNight, releaseWorktreeExists } from './issue-autofix-night.mjs';
25
+ import { mustGit, must, queryNpmLatest, queryReleasesLatest, resolveRuntime } from './issue-autofix-release-util.mjs';
26
+ import { advanceRunner } from './issue-autofix-runner.mjs';
27
+
28
+ export { PATHS, STATE_NS, resolveWorkPath, makeExec };
29
+
30
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
31
+ const REPO = 'stuinfla/ruvnet-brain';
32
+ const ORIGIN_URL = `https://github.com/${REPO}.git`;
33
+ const IDENTITY = { name: 'RuvNet Brain Autofix', email: 'stuinfla@users.noreply.github.com' };
34
+ const HOUR = 3_600_000;
35
+ export const JOB_BUDGET_MS = 9.5 * HOUR; // JOB_MAX_SECONDS in the plist is 10 h; the heartbeat kills at 10 h
36
+ export const RELEASE_RESERVE_MS = 3.5 * HOUR; // preflight + PR checks + protected-release + verification, measured 1.2-2.6 h
37
+ const REPLY_LABELS = ['enhancement', 'design', 'question', 'discussion', 'needs-owner'];
38
+ const iso = (ms) => new Date(ms).toISOString();
39
+
40
+ function assertSimulateAllowed({ dryRun, ghBin, claudeBin, origin }) {
41
+ const bad = [];
42
+ if (!dryRun) bad.push('--dry-run is not set');
43
+ if (!ghBin || path.basename(ghBin) === 'gh') bad.push('GH_BIN is the real gh');
44
+ if (!claudeBin || path.basename(claudeBin) === 'claude') bad.push('CLAUDE_BIN is the real claude');
45
+ if (!String(origin || '').startsWith('file://')) bad.push('the origin is not a file:// path');
46
+ if (bad.length) throw new Error(`--simulate refused: ${bad.join('; ')}`);
47
+ }
48
+
49
+ const labelsOf = (i) => (i.labels || []).map((l) => String(typeof l === 'string' ? l : l?.name).toLowerCase());
50
+ /** Issues that get a personal reply instead of a fix: a person's request, question or design point (ADR-107). */
51
+ export function replyCandidates(issues, { state, now }) {
52
+ return issues.filter((i) => isPersonIssue(i) && now - Date.parse(i.createdAt) >= HOUR
53
+ && labelsOf(i).some((l) => REPLY_LABELS.includes(l)) && !labelsOf(i).some((l) => ['security', 'release-blocker', 'wontfix', 'autofix:skip'].includes(l))
54
+ && !(state[i.number]?.outcomes || []).some((o) => o.kind === 'reply')
55
+ && !(i.comments || []).some((c) => String(c.author?.login).toLowerCase() === 'stuinfla' && !String(c.body || '').trimStart().startsWith(BOT_MARKER)));
56
+ }
57
+
58
+ // rehearse: { base?: sha, only?: number[] } — the real model stages, gate and sandbox on real issues, with every write
59
+ // that would reach GitHub refused by the exec layer, a separate state file, no pages and no release. It proves the
60
+ // night end to end before the job is armed (ADR-107); the would-be comments are in the receipt.
61
+ export async function runAutofix({ now = Date.now(), dryRun = false, simulate = false, rehearse = null, env = process.env, deps = {} } = {}) {
62
+ const home = env.HOME || os.homedir();
63
+ const ghBin = deps.ghBin || env.GH_BIN || 'gh';
64
+ const claudeBin = deps.claudeBin || env.CLAUDE_BIN || 'claude';
65
+ const originUrl = simulate ? env.AUTOFIX_SIMULATE_ORIGIN : ORIGIN_URL;
66
+ if (simulate) assertSimulateAllowed({ dryRun, ghBin, claudeBin, origin: originUrl });
67
+ const basePaths = deps.paths || PATHS(home);
68
+ // Each rehearsal starts from a clean state of its own: an earlier rehearsal's attempts never feed this one's breaker.
69
+ const paths = rehearse ? { ...basePaths, state: path.join(basePaths.receipts, `rehearsal-state-${now}.json`), runner: null } : basePaths;
70
+ const live = !dryRun || simulate; // a plain --dry-run executes no write and persists nothing
71
+ const t0 = Date.now();
72
+ const nowFn = () => now + (Date.now() - t0);
73
+ const rel = simulate && deps.release ? { ...realRelease, ...deps.release } : realRelease;
74
+
75
+ const transcript = [];
76
+ const note = (l) => { transcript.push(l); deps.onCommand?.(l); };
77
+ const rcp = { schemaVersion: 2, job: 'com.ruvnet.issue-autofix', startedAt: iso(now), endedAt: null, status: 'running', dryRun, simulate,
78
+ killSwitch: null, window: null, candidates: [], skipped: [], replies: [], dropped: [], stages: [], followUp: [], release: null,
79
+ securityGate: null, gates: null, comments: [], pages: [], abort: null, exitCode: null };
80
+ const runId = rcp.startedAt.replace(/[:.]/g, '-');
81
+ const rcpFile = path.join(paths.receipts, `${runId}.json`);
82
+ const runDir = path.join(paths.receipts, runId);
83
+ const flush = () => { if (live) writeAtomic(rcpFile, rcp); };
84
+ const settle = (status, exitCode) => {
85
+ Object.assign(rcp, { status, exitCode, endedAt: iso(nowFn()) });
86
+ if (live) { flush(); fs.writeFileSync(path.join(paths.receipts, `${runId}.commands.log`), `${transcript.join('\n')}\n`); }
87
+ return rcp;
88
+ };
89
+
90
+ const exec = deps.exec || makeExec({ dryRun: !live, rehearse: !!rehearse, log: note, primary: paths.primary, ghBin, env });
91
+ const notifyFn = rehearse ? () => 2 : deps.notify || defaultNotify(ROOT);
92
+ const page = (title, body = '', priority = 'default') => { const exit = live ? notifyFn(title, body, priority) : null; rcp.pages.push({ title, exit }); return exit; };
93
+ const ns = () => readNs(paths.state);
94
+ const bump = (n, patch) => { if (live && n) patchIssue(paths.state, n, patch); };
95
+
96
+ const killState = () => {
97
+ const off = fs.existsSync(paths.offFile); const halt = fs.existsSync(paths.haltFile);
98
+ let variable = { ok: false, value: null };
99
+ if (!off) {
100
+ const v = exec(ghBin, ['variable', 'get', 'ISSUE_AUTOFIX', '--repo', REPO]);
101
+ const unset = v.status !== 0 && /HTTP 404|not found/i.test(v.stderr); // gh's answer for a variable that does not exist
102
+ variable = { ok: v.status === 0 || unset, value: v.status === 0 ? v.stdout.trim() : null };
103
+ }
104
+ return { ...readKillSwitch({ variable, offFile: off, haltFile: halt }), variableOn: readKillSwitch({ variable, offFile: off, haltFile: false }).armed };
105
+ };
106
+ const guard = (stage, { allowHalted = false } = {}) => {
107
+ exec.setStage?.(stage);
108
+ if (rehearse) return; // a rehearsal cannot reach GitHub with a write (makeExec rehearse); there is nothing to stop
109
+ const ks = killState();
110
+ rcp.killSwitch = { armed: ks.armed, reason: ks.reason };
111
+ if (ks.armed || (allowHalted && ks.reason === 'halted' && ks.variableOn)) return;
112
+ throw new AutofixAbort(stage, ks.reason === 'halted' ? 'halted' : 'kill-switch-off', ks.reason);
113
+ };
114
+
115
+ const ctx = { repo: REPO, clone: paths.clone, worktrees: paths.worktrees, holdFile: paths.holdFile, exec, ghBin, now: nowFn,
116
+ sleep: deps.sleep || ((ms) => new Promise((r) => setTimeout(r, ms))), identity: IDENTITY, runDir, paths,
117
+ log: (l) => transcript.push(`# ${l}`), gates: simulate ? deps.gates : undefined, page, env,
118
+ evaluateDoc: simulate ? deps.evaluateDoc : undefined, evaluateReview: simulate ? deps.evaluateReview : undefined,
119
+ saveJournal: (j) => { if (live) patchNs(paths.state, { _release: { ...j } }); } };
120
+ const lastShippedDay = () => { const d = ns()._daily; return d?.shipped ? d.day : undefined; };
121
+ async function checkWindow({ exclude, requiredMs } = {}) {
122
+ const facts = await rel.gatherWindowFacts(ctx);
123
+ if (exclude) { // only OUR release branch and its PR; every other release in flight still closes the window
124
+ facts.remoteReleaseBranches = facts.remoteReleaseBranches.filter((b) => b.name !== exclude);
125
+ facts.openReleasePrs = facts.openReleasePrs.filter((n) => facts.openReleasePrHeads?.[n] !== exclude);
126
+ }
127
+ return { facts, verdict: judgeReleaseWindow(facts, { now: nowFn(), requiredMs, today: etDay(nowFn()), lastShippedDay: exclude ? undefined : lastShippedDay() }) };
128
+ }
129
+ ctx.checkpoint = async (name) => {
130
+ const stage = name.startsWith('gate:') ? 'S6' : name;
131
+ guard(stage);
132
+ if (['S7', 'S9', 'S10'].includes(name)) {
133
+ const own = ns()._release?.branch;
134
+ const { verdict } = await checkWindow({ exclude: own, requiredMs: name === 'S7' ? 3 * HOUR : 2 * HOUR });
135
+ if (!verdict.open) throw new AutofixAbort(stage, 'window-closed', verdict.reasons.join(', '));
136
+ }
137
+ };
138
+
139
+ const rt = { exec, ghBin, repo: REPO, paths, ctx, page, bump, note, rel, receipt: rcp, runDir, tmpRoot: path.join(runDir, 'tmp'),
140
+ now: nowFn, deadlineAt: now + (deps.deadlineMs ?? JOB_BUDGET_MS), releaseReserveMs: deps.releaseReserveMs ?? RELEASE_RESERVE_MS,
141
+ current: {}, tonightNotes: [], nodeModules: deps.nodeModules || path.join(paths.clone, 'node_modules'), batchGate: simulate ? deps.batchGate : undefined,
142
+ resolveWorkPath: (p) => resolveWorkPath(p, { primary: paths.primary }),
143
+ runSandboxed: deps.runSandboxed || makeSandboxRunner({ exec, tmpRoot: path.join(runDir, 'sbx'), home }),
144
+ saveJournal: ctx.saveJournal, mainVersion: null };
145
+ let stageEntry = null;
146
+ rt.begin = (id) => { guard(id); stageEntry = { stage: id, startedAt: iso(nowFn()), durationMs: 0, ok: null, resolvedModel: null, code: null }; rcp.stages.push(stageEntry); flush(); stageEntry.t = Date.now(); };
147
+ rt.end = (ok, extra = {}) => { if (stageEntry) Object.assign(stageEntry, { ok, durationMs: Date.now() - stageEntry.t, ...extra }); };
148
+ rt.agentIssue = (i) => ({ number: i.number, title: i.title, body: i.body, labels: labelsOf(i), comments: (i.comments || []).map((c) => ({ author: c.author?.login ?? '', body: c.body })) });
149
+ rt.agent = async (name, id, cwd, input) => {
150
+ const cfg = STAGE_CONFIG[name];
151
+ if (['plan', 'build', 'verify'].includes(name) && cfg.timeoutMs + rt.releaseReserveMs > rt.deadlineAt - nowFn()) throw new AutofixAbort(id, 'deadline', `${name} needs ${cfg.timeoutMs} ms plus the release reserve`);
152
+ rt.begin(id);
153
+ fs.mkdirSync(rt.tmpRoot, { recursive: true });
154
+ const logPath = path.join(runDir, `${name}-${rt.current.issue?.number ?? 'night'}-${rcp.stages.length}.log`);
155
+ let r;
156
+ try { r = await (deps.runAgentImpl || runAgent)({ stage: name, cwd, input, claudeBin, logPath, root: ROOT }); }
157
+ catch (e) { rt.end(false, { code: e.code }); if (e instanceof AutofixAbort) e.stage = id; throw e; }
158
+ rt.end(r.ok, { resolvedModel: r.resolvedModel, logPath, code: r.ok ? null : (r.timedOut ? 'agent-timeout' : 'agent-failed') });
159
+ if (!r.ok) throw new AutofixAbort(id, r.timedOut ? 'agent-timeout' : 'agent-failed', r.error || '');
160
+ return r;
161
+ };
162
+
163
+ // The clone at origin/main with its own node_modules (installed only when the lockfile changed). It is the trusted
164
+ // base every worktree branches from and links node_modules to; the sandbox forbids writes into it.
165
+ async function ensureClone() {
166
+ resolveWorkPath(paths.clone, { primary: paths.primary });
167
+ if (!fs.existsSync(path.join(paths.clone, '.git'))) {
168
+ fs.mkdirSync(path.dirname(paths.clone), { recursive: true });
169
+ await must(ctx, 'S2', 'transient', 'git', ['clone', '--no-checkout', originUrl, paths.clone]);
170
+ } else if (simulate) {
171
+ const o = exec('git', ['-C', paths.clone, 'remote', 'get-url', 'origin']);
172
+ if (!o.stdout.trim().startsWith('file://')) throw new Error(`--simulate refused: the clone's origin is ${o.stdout.trim()}, not a file:// path`);
173
+ }
174
+ await mustGit(ctx, 'S0', 'transient', paths.clone, ['fetch', 'origin', 'main']);
175
+ await mustGit(ctx, 'S0', 'transient', paths.clone, ['checkout', '-q', '--detach', 'origin/main']);
176
+ if (deps.nodeModules) return;
177
+ const lock = exec('git', ['-C', paths.clone, 'rev-parse', 'origin/main:package-lock.json']).stdout.trim();
178
+ const marker = path.join(paths.clone, 'node_modules', '.autofix-lock');
179
+ if (!fs.existsSync(marker) || fs.readFileSync(marker, 'utf8').trim() !== lock) {
180
+ await must(ctx, 'S0', 'transient', 'npm', ['ci', '--no-audit', '--no-fund'], { cwd: paths.clone, timeoutMs: 30 * 60_000 });
181
+ if (live) fs.writeFileSync(marker, lock);
182
+ }
183
+ }
184
+ async function governedSet(wt) {
185
+ if (simulate && deps.governed0103) return new Set(deps.governed0103);
186
+ const { evaluateDoc } = await import('../plugin/scripts/doc-currency.mjs');
187
+ const { GOVERNING_ADR } = await import('./architecture-review-lock.mjs');
188
+ const set = new Set((evaluateDoc(wt, GOVERNING_ADR, { checkWiring: false }).governed || []).filter((g) => g.resolved).map((g) => g.path));
189
+ if (!set.size) throw new AutofixAbort('S2', 'protected-path', 'cannot derive the ADR-0103 governed set');
190
+ return set;
191
+ }
192
+ function listIssues() {
193
+ const r = exec(ghBin, ['issue', 'list', '--repo', REPO, '--state', 'open', '--limit', '200', '--json', 'number,title,author,labels,createdAt,updatedAt,comments,body']);
194
+ if (r.status !== 0) return { error: `gh issue list exit ${r.status}` };
195
+ try { return { issues: JSON.parse(r.stdout || '[]') }; } catch { return { error: 'gh issue list returned bad JSON' }; }
196
+ }
197
+ function select(issues) {
198
+ const state = ns(); const linked = {};
199
+ for (const i of issues) {
200
+ if (!judgeEligibility(i, { record: state[i.number], now: nowFn(), botMarker: BOT_MARKER }).eligible) continue;
201
+ const p = exec(ghBin, ['pr', 'list', '--repo', REPO, '--state', 'open', '--search', `${i.number} in:body`, '--json', 'number', '--limit', '20']);
202
+ try { linked[i.number] = p.status === 0 ? JSON.parse(p.stdout || '[]').map((x) => x.number) : [0]; } catch { linked[i.number] = [0]; }
203
+ }
204
+ const sel = selectCandidates(issues, { state, now: nowFn(), linkedPrsByIssue: linked, botMarker: BOT_MARKER });
205
+ const held = sel.candidates.filter((c) => state[c.number]?.securityHold && !labelsOf(c).includes('autofix:go'));
206
+ // A security hold outranks every other reason in the receipt: it is the one the owner must act on.
207
+ const skipped = sel.skipped.map((x) => (state[x.number]?.securityHold && !labelsOf(issues.find((i) => i.number === x.number) || {}).includes('autofix:go') ? { number: x.number, reason: 'security-hold' } : x));
208
+ return { ...sel, candidates: sel.candidates.filter((c) => !held.includes(c)), skipped: [...skipped, ...held.map((c) => ({ number: c.number, reason: 'security-hold' }))] };
209
+ }
210
+ async function screen(candidates) {
211
+ const clean = [];
212
+ for (const c of candidates) {
213
+ const s = await (deps.scanIssueText || scanIssueText)(c);
214
+ if (s.safe) { clean.push(c); continue; }
215
+ rcp.skipped.push({ number: c.number, reason: `injection:${s.threats.slice(0, 3).join(',')}` });
216
+ if (!ns()[c.number]?.injectionPagedAt) {
217
+ page(`AUTOFIX NEEDS OWNER #${c.number}: issue text flagged as prompt injection`, `AIDefence: ${s.threats.join(', ')}. Not attempted, nothing posted. Label autofix:skip to silence, or remove the text.`, 'high');
218
+ bump(c.number, { injectionPagedAt: iso(nowFn()) });
219
+ }
220
+ }
221
+ return clean;
222
+ }
223
+ /** Tell everyone, then clear what was said. The kill switch (and, unless following up, the halt) is re-read per post. */
224
+ async function tell(items, { allowHalted = false } = {}) {
225
+ const full = [];
226
+ for (const it of items) { const v = it.issue?.comments ? it.issue : viewIssue(rt, it.n ?? it.issue?.number); if (v) full.push({ ...it, issue: v }); }
227
+ const res = await notifyPeople(rt, full, { checkpoint: async () => guard('S13', { allowHalted }) });
228
+ rcp.comments.push(...res);
229
+ for (const r of res) if (['fixed'].includes(r.kind) && (r.commented || r.deduped) && r.closed !== false) clearAwaiting(paths.state, r.number);
230
+ return res;
231
+ }
232
+
233
+ async function rehearseNight() {
234
+ await ensureClone();
235
+ rt.base = rehearse.base ? exec('git', ['-C', paths.clone, 'rev-parse', '--verify', `${rehearse.base}^{commit}`]).stdout.trim() : null;
236
+ if (rehearse.base && !rt.base) throw new AutofixAbort('S0', 'transient', `unknown base ${rehearse.base}`);
237
+ const listed = listIssues();
238
+ if (listed.error) throw new AutofixAbort('S1', 'transient', listed.error);
239
+ const sel = select(listed.issues);
240
+ const picked = sel.candidates.filter((c) => !rehearse.only?.length || rehearse.only.includes(c.number));
241
+ rcp.candidates = picked.map((c) => ({ number: c.number, reason: 'eligible' }));
242
+ rcp.skipped = sel.skipped;
243
+ rt.mainVersion = JSON.parse(exec('git', ['-C', paths.clone, 'show', 'origin/main:plugin/.claude-plugin/plugin.json']).stdout).version;
244
+ const candidates = await screen(picked);
245
+ const { ready, items } = candidates.length ? await fixAll(rt, candidates, { governed: await governedSet(paths.clone) }) : { ready: [], items: [] };
246
+ const version = rt.mainVersion.replace(/\d+$/, (z) => String(Number(z) + 1));
247
+ rcp.ready = ready.map((r) => ({ number: r.number, fixSha: r.fixSha, wt: r.wt, diff: r.diff, testValidity: r.testValidity, verify: r.verify }));
248
+ await tell([...ready.map((r) => ({ issue: r.issue, kind: 'fixed', version, sha: r.fixSha, testPath: r.testPath, platformCaveat: r.platformCaveat,
249
+ facts: { verified: [...r.facts.verified, 'install-verified on Linux, macOS and Windows (would be, after the release)'], notVerified: r.facts.notVerified },
250
+ fallback: { whatWasWrong: String(r.plan?.rootCause?.explanation || r.summary), whatChanged: String(r.plan?.summary || '').slice(0, 400) } })), ...items]);
251
+ return ['rehearsed'];
252
+ }
253
+
254
+ let lockOwned = false;
255
+ try {
256
+ if (live) fs.mkdirSync(runDir, { recursive: true });
257
+ flush();
258
+ const ks0 = killState();
259
+ rcp.killSwitch = { armed: ks0.armed, reason: ks0.reason };
260
+ const halted = ks0.reason === 'halted' && ks0.variableOn;
261
+ // A plain --dry-run executes no write and spawns no model, so it still evaluates the window and the selection while
262
+ // disarmed: that is how the job is checked before it is armed. Everything else stops at the switch.
263
+ const readOnly = (dryRun && !simulate) || !!rehearse;
264
+ if (!ks0.armed && !halted && !readOnly) return settle(ks0.reason === 'halted' ? 'halted' : 'disabled', 0);
265
+ if (live) {
266
+ const lk = acquireLock(paths.lock, { now: nowFn(), mode: 'autofix' });
267
+ lockOwned = lk.acquired;
268
+ if (!lk.acquired && lk.holder?.pid !== process.pid) { rcp.abort = { stage: 'S0', code: 'lock-held', detail: `held by pid ${lk.holder?.pid}`, halt: false }; return settle('stood-down', 75); }
269
+ }
270
+ const cloneReady = live || fs.existsSync(path.join(paths.clone, '.git'));
271
+ if (cloneReady && live && rehearse) return settle(...await rehearseNight(), 0);
272
+ if (cloneReady && live) {
273
+ guard('S0', { allowHalted: true }); await ensureClone();
274
+ rcp.followUp = await recheckAwaiting(rt, ctx, { checkpoint: async () => guard('S13b', { allowHalted: true }) });
275
+ }
276
+ if (halted && !readOnly) return settle('halted', 0); // the halt was paged when it was written; only the follow-up runs
277
+ if (cloneReady && !readOnly) guard('S0');
278
+
279
+ // An unfinished release from an earlier run is finished first; nothing new starts until it is.
280
+ const j = ns()._release;
281
+ if (live && j && ['ready', 'paused'].includes(j.phase)) {
282
+ if (!releaseWorktreeExists(j)) { page(`AUTOFIX NEEDS OWNER: release/${j.version} journal without its worktree`, JSON.stringify(j).slice(0, 600), 'urgent'); return settle('stood-down', 1); }
283
+ rt.mainVersion = j.version;
284
+ const out = await shipAndSettle(rt, j);
285
+ if (out.items.length) await tell(out.items);
286
+ if (out.halt) haltNight(rt, j, out.halt);
287
+ return settle(out.status, out.exitCode);
288
+ }
289
+
290
+ let window = null;
291
+ if (cloneReady) {
292
+ window = await checkWindow().catch((e) => {
293
+ if (e instanceof AutofixAbort && !['kill-switch-off', 'halted'].includes(e.code)) return { facts: null, verdict: { open: false, reasons: [e.detail || e.code] } };
294
+ throw e;
295
+ });
296
+ rcp.window = window.verdict;
297
+ }
298
+ const listed = listIssues();
299
+ if (listed.error) { rcp.abort = { stage: 'S1', code: 'no-eligible', detail: listed.error, halt: false }; return settle('stood-down', 1); }
300
+ const sel = select(listed.issues);
301
+ rcp.candidates = sel.candidates.map((c) => ({ number: c.number, reason: 'eligible' }));
302
+ rcp.skipped = sel.skipped;
303
+ const replies = replyCandidates(listed.issues, { state: ns(), now: nowFn() });
304
+ rcp.replies = replies.map((i) => i.number);
305
+
306
+ if (!cloneReady || (dryRun && !simulate)) {
307
+ note(`# kill switch: ${ks0.armed ? 'armed' : `NOT armed (${ks0.reason})`}`);
308
+ note(`# would fix ${sel.candidates.map((c) => `#${c.number}`).join(', ') || 'nothing'}; would reply to ${replies.map((c) => `#${c.number}`).join(', ') || 'nobody'}${window ? `; window ${window.verdict.open ? 'open' : `closed (${window.verdict.reasons.join(', ')})`}` : ' (window not evaluated: no clone yet)'}`);
309
+ return settle(sel.candidates.length || replies.length ? 'dry-run' : 'nothing-eligible', 0);
310
+ }
311
+ if (!window.verdict.open) {
312
+ const reasons = window.verdict.reasons;
313
+ const dailyOnly = reasons.every((r) => r === 'daily-cap');
314
+ const st = ns()._standDownStreak || { count: 0, since: null };
315
+ const today = etDay(nowFn());
316
+ const counted = sel.candidates.length > 0 && !dailyOnly;
317
+ const count = (st.count || 0) + (counted && st.lastDay !== today ? 1 : 0);
318
+ patchNs(paths.state, { _standDownStreak: { count, since: count ? (st.since || iso(nowFn())) : null, lastReason: reasons[0], lastDay: counted ? today : st.lastDay } });
319
+ if (!dailyOnly) page(`AUTOFIX STANDING DOWN: ${reasons[0].replace(/^release-blocker:(.*)$/, (m, l) => `release-blocker ${l.split(',').map((x) => `#${x}`).join(' ')}`)}`, reasons.join('; '), 'low');
320
+ rcp.abort = { stage: 'S0', code: dailyOnly ? 'daily-cap' : 'window-closed', detail: reasons.join('; '), halt: false };
321
+ if (replies.length) await tell(replies.map((issue) => ({ issue, kind: 'reply', facts: null, fallback: { whatWasWrong: 'I read what you asked.', whatItNeeds: 'a maintainer’s decision.' } })));
322
+ return settle('stood-down', count >= 3 && sel.candidates.length ? 1 : 0);
323
+ }
324
+ if ((ns()._standDownStreak?.count || 0) > 0) patchNs(paths.state, { _standDownStreak: { count: 0, since: null, lastReason: null } });
325
+ rt.mainVersion = window.facts.mainVersion;
326
+
327
+ const candidates = await screen(sel.candidates);
328
+ const { ready, items } = candidates.length ? await fixAll(rt, candidates, { governed: await governedSet(paths.clone) }) : { ready: [], items: [] };
329
+ items.push(...replies.map((issue) => ({ issue, kind: 'reply', facts: null, fallback: { whatWasWrong: 'I read what you asked.', whatItNeeds: 'a maintainer’s decision.' } })));
330
+ let out = { items: [], status: ready.length || items.length ? 'replied' : 'nothing-eligible', exitCode: 0 };
331
+ const journal = await releaseBatch(rt, ready);
332
+ if (journal?.aborted) out = { items: [], status: 'aborted', exitCode: 1 };
333
+ else if (journal) out = await shipAndSettle(rt, journal);
334
+ await tell([...out.items, ...items]);
335
+ if (out.halt) haltNight(rt, journal, out.halt);
336
+ return settle(out.status, out.exitCode);
337
+ } catch (e) {
338
+ const a = e instanceof AutofixAbort ? e : new AutofixAbort('S0', 'transient', e?.stack || String(e));
339
+ rcp.abort = { stage: a.stage, code: a.code, detail: a.detail, halt: false };
340
+ if (['kill-switch-off', 'halted'].includes(a.code)) { page(`AUTOFIX STOPPED at ${a.stage}`, `Kill switch: ${a.detail}.`, 'low'); return settle('disabled', 0); }
341
+ page(`AUTOFIX ABORTED at ${a.stage}`, `${a.code}: ${a.detail}`.slice(0, 900), 'high');
342
+ return settle('aborted', 1);
343
+ } finally {
344
+ // The job's own code moves to the newest install-verified release for the NEXT run (issue-autofix-runner.mjs).
345
+ if (live && lockOwned && paths.runner) {
346
+ try {
347
+ const npm = await queryNpmLatest(ctx); const latest = await queryReleasesLatest(ctx);
348
+ const verified = !!npm && latest === `v${npm}` && await resolveRuntime(ctx, npm, path.join(runDir, 'runner-runtime.json'));
349
+ rcp.runner = await advanceRunner(rt, { latestTag: npm ? `v${npm}` : null, installVerified: verified });
350
+ flush();
351
+ } catch (e) { rcp.runner = { moved: false, reason: e.message }; }
352
+ }
353
+ if (lockOwned) releaseLock(paths.lock);
354
+ }
355
+ }
356
+
357
+ export async function main(argv = process.argv.slice(2)) {
358
+ if (argv.includes('--rehearse')) {
359
+ keepAwake();
360
+ const at = (f) => (argv.includes(f) ? argv[argv.indexOf(f) + 1] : undefined);
361
+ const only = (at('--only') || '').split(',').filter(Boolean).map(Number);
362
+ const r = await runAutofix({ rehearse: { base: at('--base'), only }, env: process.env, deps: { onCommand: (l) => console.log(l) } });
363
+ console.log(`issue-autofix: ${r.status}${r.abort ? ` (${r.abort.stage}:${r.abort.code})` : ''} [rehearsal: nothing reached GitHub]`);
364
+ for (const c of r.comments || []) console.log(`\n===== would post on #${c.number} (${c.kind}) =====\n${c.body || '(none)'}`);
365
+ return r.exitCode;
366
+ }
367
+ if (argv.includes('--self-test')) {
368
+ const { selfTest } = await import('./issue-autofix-selftest.mjs');
369
+ return selfTest({ env: process.env, notify: defaultNotify(ROOT) });
370
+ }
371
+ const dryRun = argv.includes('--dry-run');
372
+ const simulate = argv.includes('--simulate');
373
+ if (!simulate && process.platform === 'darwin') keepAwake(); // also for a live dry run: proves caffeinate works under launchd
374
+ try {
375
+ const r = await runAutofix({ dryRun, simulate, env: process.env, deps: dryRun && !simulate ? { onCommand: (l) => console.log(l) } : {} });
376
+ console.log(`issue-autofix: ${r.status}${r.abort ? ` (${r.abort.stage}:${r.abort.code})` : ''}${dryRun && !simulate ? ' [dry-run: no write executed]' : ''}`);
377
+ return r.exitCode;
378
+ } catch (err) {
379
+ console.error(`issue-autofix: ${err.message}`);
380
+ return 1;
381
+ }
382
+ }
@@ -0,0 +1,40 @@
1
+ // scripts/issue-fix-lock.mjs — the single-writer lock shared by every issue-fix mode (ADR-050, ADR-107).
2
+ // Exclusive create (link(), the same EEXIST guarantee as O_EXCL) makes "who holds it" one atomic filesystem decision; the old exists-then-write pattern
3
+ // let two launches both see "no lock" and both write one.
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+
7
+ const readHolder = (lockPath) => { try { return JSON.parse(fs.readFileSync(lockPath, 'utf8')); } catch { return null; } };
8
+ const alive = (pid) => {
9
+ if (!Number.isInteger(pid) || pid <= 0) return false;
10
+ try { process.kill(pid, 0); return true; } catch (err) { return err.code === 'EPERM'; }
11
+ };
12
+
13
+ /** @returns {{acquired:true}|{acquired:false, holder:{pid:number,startedAt:string,mode:string}}} */
14
+ export function acquireLock(lockPath, { now = Date.now(), pid = process.pid, mode = 'supervised' } = {}) {
15
+ fs.mkdirSync(path.dirname(lockPath), { recursive: true });
16
+ // The lock appears complete or not at all: write a private file, then link() it into place. link() fails with
17
+ // EEXIST exactly like open(O_EXCL) but, unlike open-then-write, never exposes an empty file that a racing
18
+ // process could mistake for a dead holder and delete.
19
+ const mine = `${lockPath}.${pid}.${now}.tmp`;
20
+ fs.writeFileSync(mine, JSON.stringify({ pid, startedAt: new Date(now).toISOString(), mode }), { flag: 'wx' });
21
+ try {
22
+ for (let attempt = 0; attempt < 2; attempt++) {
23
+ try { fs.linkSync(mine, lockPath); return { acquired: true }; } catch (err) { if (err.code !== 'EEXIST') throw err; }
24
+ const holder = readHolder(lockPath);
25
+ if (holder && alive(holder.pid)) return { acquired: false, holder };
26
+ if (attempt === 0) {
27
+ const again = readHolder(lockPath); // only remove the very lock we judged dead
28
+ if (JSON.stringify(again) === JSON.stringify(holder)) { try { fs.unlinkSync(lockPath); } catch { /* reclaimed by another */ } }
29
+ }
30
+ }
31
+ return { acquired: false, holder: readHolder(lockPath) || { pid: 0, startedAt: '', mode: 'unknown' } };
32
+ } finally { try { fs.unlinkSync(mine); } catch { /* already gone */ } }
33
+ }
34
+
35
+ /** Removes the lock only when this pid holds it. */
36
+ export function releaseLock(lockPath, { pid = process.pid } = {}) {
37
+ const holder = readHolder(lockPath);
38
+ if (!holder || holder.pid !== pid) return false;
39
+ try { fs.unlinkSync(lockPath); return true; } catch { return false; }
40
+ }
@@ -1,10 +1,12 @@
1
1
  #!/usr/bin/env node
2
- // scripts/issue-fix.mjs — GitHub issue triage + supervised candidate preparation.
2
+ // scripts/issue-fix.mjs — GitHub issue triage, supervised candidate preparation, and the daily autofix entry.
3
3
  //
4
4
  // Stuart's mandate: "look for any open issues and fix as soon as they hit." scripts/issue-watch.mjs
5
5
  // already DETECTS and ALERTS on SLA breaches (>4h no owner response). This script has two explicit
6
6
  // authority modes. Scheduled `unattended` runs are read-only triage. On-demand `supervised` runs may
7
7
  // prepare and test a candidate in an isolated worktree, but cannot push, comment, merge, or promote.
8
+ // The daily `autofix` mode (ADR-107) takes the shared single-writer lock and delegates to
9
+ // scripts/issue-autofix.mjs, which ships a person's fix only through the protected release gates.
8
10
  //
9
11
  // House patterns followed (read before touching this file):
10
12
  // - State file: scripts/issue-watch.mjs's ~/.claude/ruvnet-brain/issue-watch-state.json, EXTENDED
@@ -33,6 +35,7 @@
33
35
  // Usage:
34
36
  // node scripts/issue-fix.mjs # safe default: unattended read-only triage
35
37
  // node scripts/issue-fix.mjs --mode supervised # prepare local isolated candidates for review
38
+ // node scripts/issue-fix.mjs --mode autofix # daily unattended fix + release (ADR-107; kill-switch gated)
36
39
  // node scripts/issue-fix.mjs --dry-run # print the plan for each candidate; NOTHING
37
40
  // # is spawned, pushed, commented, or written
38
41
  // node scripts/issue-fix.mjs --dry-run --simulate 16
@@ -48,6 +51,7 @@ import { spawn, spawnSync } from 'node:child_process';
48
51
  import { fileURLToPath, pathToFileURL } from 'node:url';
49
52
  import { BOT_MARKER, OWNER_LOGIN, ghJsonWithRetry } from './issue-watch.mjs';
50
53
  import { isDirectInvocation } from '../plugin/scripts/entrypoint.mjs';
54
+ import { acquireLock as acquireLockFile, releaseLock as releaseLockFile } from './issue-fix-lock.mjs';
51
55
 
52
56
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
53
57
  const REPO = 'stuinfla/ruvnet-brain';
@@ -116,6 +120,12 @@ export function executionPolicy(mode = 'unattended') {
116
120
  promote: false,
117
121
  };
118
122
  }
123
+ if (mode === 'autofix') {
124
+ return {
125
+ mode, readOnlyTriage: false, prepareWorktree: true, spawnFixer: true, publicComment: 'outcome-only',
126
+ pushBranch: 'release/*', promote: 'fast-forward-main', dispatch: 'protected-release:code', publish: false,
127
+ };
128
+ }
119
129
  throw new Error(`unsupported issue-fix mode: ${mode}`);
120
130
  }
121
131
 
@@ -130,21 +140,9 @@ function loadState() {
130
140
 
131
141
  // ── Concurrency-1 lock (defense in depth alongside the run loop's own sequential processing: a
132
142
  // single issue can take up to TIMEOUT_MS, which can outlive the 10-minute poll cadence). ──
133
- function acquireLock() {
134
- fs.mkdirSync(path.dirname(LOCK_PATH), { recursive: true });
135
- if (fs.existsSync(LOCK_PATH)) {
136
- try {
137
- const prev = JSON.parse(fs.readFileSync(LOCK_PATH, 'utf8'));
138
- process.kill(prev.pid, 0); // throws if the pid is not alive -> stale lock, fall through to reclaim
139
- return { acquired: false, holder: prev };
140
- } catch { /* stale lock or unreadable — reclaim it below */ }
141
- }
142
- fs.writeFileSync(LOCK_PATH, JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString() }));
143
- return { acquired: true };
144
- }
145
- function releaseLock() {
146
- try { fs.unlinkSync(LOCK_PATH); } catch { /* already gone */ }
147
- }
143
+ // O_EXCL lock shared with autofix mode (scripts/issue-fix-lock.mjs); release is pid-checked.
144
+ const acquireLock = (mode = 'supervised') => acquireLockFile(LOCK_PATH, { mode });
145
+ const releaseLock = () => releaseLockFile(LOCK_PATH);
148
146
 
149
147
  function prepareWorktree(issue) {
150
148
  const stamp = Date.now();
@@ -321,6 +319,7 @@ export function attemptStartRecord(prev, now) {
321
319
 
322
320
  export async function run({ dryRun = false, simulate = [], now = Date.now(), repo = REPO, mode = 'unattended' } = {}) {
323
321
  const policy = executionPolicy(mode);
322
+ if (mode === 'autofix') throw new Error('autofix runs through scripts/issue-autofix.mjs, never this loop');
324
323
  if (simulate.length && !dryRun) {
325
324
  throw new Error('--simulate is only permitted with --dry-run — refusing to touch a real issue outside a dry run');
326
325
  }
@@ -501,9 +500,12 @@ async function main() {
501
500
  process.exit(1);
502
501
  }
503
502
 
503
+ // autofix: a live dry-run still takes the lock; only a --simulate dry-run (fixtures, no real repo) skips it.
504
+ // --self-test (ADR-107) changes nothing in the repository or on GitHub, so it never contends for the lock.
505
+ const needsLock = mode === 'autofix' ? !(dryRun && argv.includes('--simulate')) && !argv.includes('--self-test') : (!dryRun && mode === 'supervised');
504
506
  let lock = { acquired: true };
505
- if (!dryRun && mode === 'supervised') {
506
- lock = acquireLock();
507
+ if (needsLock) {
508
+ lock = acquireLock(mode);
507
509
  if (!lock.acquired) {
508
510
  console.log(`issue-fix: another run is already in progress (pid ${lock.holder?.pid}, started ${lock.holder?.startedAt}) — exiting (concurrency 1).`);
509
511
  // 75 = the reserved skip code: job-heartbeat.sh restores the live run's receipt (F3) instead
@@ -512,15 +514,26 @@ async function main() {
512
514
  }
513
515
  }
514
516
 
517
+ if (mode === 'autofix') {
518
+ try {
519
+ const { main: autofixMain } = await import('./issue-autofix.mjs');
520
+ process.exitCode = await autofixMain(argv);
521
+ } catch (err) {
522
+ console.error(`issue-fix: autofix FAILED — ${err.message}`);
523
+ process.exitCode = 1;
524
+ } finally { if (needsLock) releaseLock(); }
525
+ process.exit(process.exitCode);
526
+ }
527
+
515
528
  let output;
516
529
  try {
517
530
  output = await run({ dryRun, simulate, mode });
518
531
  } catch (err) {
519
532
  console.error(`issue-fix: FAILED — ${err.message}`);
520
- if (!dryRun && mode === 'supervised') releaseLock();
533
+ if (needsLock) releaseLock();
521
534
  process.exit(1);
522
535
  }
523
- if (!dryRun && mode === 'supervised') releaseLock();
536
+ if (needsLock) releaseLock();
524
537
 
525
538
  if (asJson) {
526
539
  console.log(JSON.stringify(output, null, 2));
@@ -167,7 +167,7 @@ export function audit(root = ROOT) {
167
167
  // · every module that appends to a file must be registered in `stateFiles` with its bound (or an
168
168
  // explicit `bound: null`), and the count of unbounded ones may not rise above `maxUnbounded`.
169
169
  export const STATE_CONFIG = 'config/state-files.json';
170
- const APPEND = /\b(?:appendFileSync|appendFile|createWriteStream)\b|\bO_APPEND\b|flags?\s*:\s*['"]a\+?['"]/;
170
+ const APPEND = /\b(?:appendFileSync|appendFile|createWriteStream)\s*\(|\bO_APPEND\b|flags?\s*:\s*['"]a\+?['"]/;
171
171
  const BOUND = /\bMAX_\w*(?:BYTES|LINES|RECORDS|ROWS|ENTRIES|AGE\w*)\b|\bmax(?:Bytes|Lines|Records|Rows|Entries)\b|\brotat\w+|\bcompact\w*|\btruncateSync\b|\bprune\w*/;
172
172
 
173
173
  function scopedFiles(root) {
@@ -64,6 +64,21 @@ export function classify(msg) {
64
64
  // "✅ X is healthy again". Same cause, same key, so either recovery closes either failure.
65
65
  if ((m = /SCHEDULED JOB FAILED: (\S+)/.exec(title)) || (m = /^\W*(?:FAILING|NEVER-RAN|STALE|MISSING|STALLED|STUCK): (\S+)/.exec(title))) return { kind: 'job', key: `job:${m[1]}`, label: m[1], actionable: true };
66
66
  if ((m = /Recovered: (\S+)/.exec(title)) || (m = /^\W*(\S+) is healthy again/.exec(title))) return { kind: 'job-recovered', key: `job:${m[1]}`, label: m[1], actionable: false, resolves: true };
67
+ // The daily issue-autofix job (ADR-107) pages "AUTOFIX SHIPPED vX" / "ABORTED #n at S4" / "NEEDS OWNER #n" /
68
+ // "SECURITY HOLD #n" / "HALTED vX at S10" / "PAUSED vX at S11" / "STOPPED at S5" / "STANDING DOWN: reason". A ship
69
+ // or an owner's own stop is news, everything else is work. The responder never acts on these (its task builder knows
70
+ // only job/ci/issue/pr), and a halt or a security hold stays open until a person clears it.
71
+ if ((m = /\bAUTOFIX (SHIPPED|ABORTED|NEEDS OWNER|SECURITY HOLD|HALTED|PAUSED|STOPPED|STANDING DOWN|SELF-TEST)\b/.exec(title))) {
72
+ const n = /#(\d+)/.exec(title)?.[1];
73
+ const outcome = m[1].toLowerCase().replace(/ /g, '-');
74
+ if (m[1] === 'HALTED') return { kind: 'autofix', key: 'autofix:halt', outcome, actionable: true };
75
+ if (m[1] === 'PAUSED') return { kind: 'autofix', key: 'autofix:paused', outcome, actionable: true };
76
+ if (m[1] === 'STANDING DOWN') return { kind: 'autofix', key: 'autofix:standdown', outcome, actionable: true };
77
+ if (m[1] === 'SECURITY HOLD') return { kind: 'autofix', key: `autofix:security:${n || 'night'}`, outcome, ...(n ? { number: Number(n) } : {}), actionable: true };
78
+ if (m[1] === 'SELF-TEST') return { kind: 'autofix', key: 'autofix:selftest', outcome, actionable: /FAILED/.test(title) };
79
+ if (m[1] === 'STOPPED') return { kind: 'autofix', key: 'autofix:stopped', outcome, actionable: false };
80
+ return { kind: 'autofix', key: `autofix:${n || 'unknown'}`, outcome, ...(n ? { number: Number(n) } : {}), actionable: m[1] !== 'SHIPPED' };
81
+ }
67
82
  if ((m = /CI \w+: (.+)$/.exec(title))) {
68
83
  const b = /branch ([\w./-]{1,100})/.exec(body);
69
84
  // Keyed by workflow AND branch: a green main must never close a still-red release branch (review round 4).
@@ -2092,7 +2092,12 @@ function gatherActivity(cwd) {
2092
2092
  // recent decisions from its append-only log. Nothing here can disagree with what actually routes.
2093
2093
  function gatherRouterEngine() {
2094
2094
  const profile = engineProfile();
2095
- const candidates = applyProfile(engineCatalog(), profile);
2095
+ // A missing or invalid per-user engine catalog is a degraded panel (catalogSource says 'unavailable'),
2096
+ // never a thrown read model that takes the whole Console API down with it (issue #86 boundary; red on
2097
+ // CI from 4.7.22, where no per-user catalog exists). No built-in model list is substituted.
2098
+ let engineCandidates = [];
2099
+ try { engineCandidates = engineCatalog(); } catch { /* reported through catalogSource: 'unavailable' */ }
2100
+ const candidates = applyProfile(engineCandidates, profile);
2096
2101
  const prices = effectivePrices(candidates, profile);
2097
2102
  const { rows, unusable } = loadLabelledRows();
2098
2103
  const installed = fs.existsSync(path.join(__dirname, '..', 'node_modules', '@metaharness', 'router', 'package.json'));