ruvnet-brain 4.7.22 → 4.7.23

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 (33) hide show
  1. package/README.md +1 -1
  2. package/package.json +2 -1
  3. package/plugin/.claude-plugin/plugin.json +1 -1
  4. package/plugin/.codex-plugin/plugin.json +1 -1
  5. package/scripts/generation-census.mjs +220 -0
  6. package/scripts/issue-autofix-agents.mjs +172 -0
  7. package/scripts/issue-autofix-comment.mjs +224 -0
  8. package/scripts/issue-autofix-diff.mjs +108 -0
  9. package/scripts/issue-autofix-exec.mjs +128 -0
  10. package/scripts/issue-autofix-fix.mjs +104 -0
  11. package/scripts/issue-autofix-gate.mjs +137 -0
  12. package/scripts/issue-autofix-night.mjs +164 -0
  13. package/scripts/issue-autofix-notes.mjs +346 -0
  14. package/scripts/issue-autofix-policy.mjs +349 -0
  15. package/scripts/issue-autofix-prompts.mjs +105 -0
  16. package/scripts/issue-autofix-release-ship.mjs +226 -0
  17. package/scripts/issue-autofix-release-util.mjs +141 -0
  18. package/scripts/issue-autofix-release.mjs +272 -0
  19. package/scripts/issue-autofix-report.mjs +121 -0
  20. package/scripts/issue-autofix-runner.mjs +75 -0
  21. package/scripts/issue-autofix-sandbox.mjs +108 -0
  22. package/scripts/issue-autofix-security.mjs +346 -0
  23. package/scripts/issue-autofix-selftest.mjs +101 -0
  24. package/scripts/issue-autofix-state.mjs +61 -0
  25. package/scripts/issue-autofix.mjs +382 -0
  26. package/scripts/issue-fix-lock.mjs +40 -0
  27. package/scripts/issue-fix.mjs +33 -20
  28. package/scripts/no-silent-substitution.mjs +1 -1
  29. package/scripts/ntfy-inbox.mjs +15 -0
  30. package/scripts/release-prepush-checks.mjs +251 -0
  31. package/scripts/release-prepush.mjs +168 -0
  32. package/scripts/release-qualification-contract.mjs +23 -0
  33. package/scripts/wired-check.mjs +11 -5
@@ -0,0 +1,137 @@
1
+ // scripts/issue-autofix-gate.mjs — the security gate of the autofix job (ADR-107): nothing a stranger's issue produced
2
+ // reaches npm unless every check below is clean. Three layers, cheapest first, any finding blocks:
3
+ // 1. INPUT — scanIssueText: rUv's AIDefence (@claude-flow/aidefence, already a dependency) over every word a model
4
+ // will read (title, body, every comment). Unavailable scanner = the issue is not attempted (fail closed).
5
+ // 2. ISSUE — per fix: the editable allowlist + diff policy (policy.mjs), the static malware/secret scan of the added
6
+ // lines (security.mjs), then a fresh Opus red-team that sees only the diff. Its claims are re-checked
7
+ // here against the static scan: a "CLEAN" that contradicts the scanner, or any finding, blocks.
8
+ // 3. NIGHT — runBatchGate on the release branch: manifest and lockfile may change only in "version"; the
9
+ // `npm pack --dry-run` file list may differ from the last published release only in reviewed paths and
10
+ // never by a binary; `npm audit` may show no NEW high/critical advisory; a second red-team over the whole
11
+ // batch diff. Any finding blocks the whole night's release (owner, 2026-10-30).
12
+ import fs from 'node:fs';
13
+ import path from 'node:path';
14
+ import { AutofixAbort } from './issue-autofix-policy.mjs';
15
+ import { scanAddedLines, scanSecrets, judgeManifestChange, judgeLockfileChange, judgePackList, judgeAudit, securityVerdict } from './issue-autofix-security.mjs';
16
+ import { parseUnifiedDiff } from './issue-autofix-release-util.mjs';
17
+
18
+ const MIN = 60_000;
19
+ const PRESENT_TO_RULES = {
20
+ networkEgress: ['network-egress', 'fetch-execute'], processExec: ['process-exec'], dynamicCode: ['dynamic-code'],
21
+ credentialAccess: ['credential-access', 'env-harvest'], obfuscation: ['obfuscation', 'hidden-unicode'],
22
+ fsOutsideRepo: ['fs-outside-repo'], installHook: ['install-hook', 'lifecycle-script'],
23
+ };
24
+
25
+ // ── 1. input ────────────────────────────────────────────────────────────────────────────────
26
+ let aidefence = null;
27
+ async function loadAidefence() {
28
+ if (!aidefence) aidefence = await import('@claude-flow/aidefence');
29
+ return aidefence;
30
+ }
31
+
32
+ /** { safe, threats } over everything a model will read. Throws AutofixAbort('injection-scan-failed') if it cannot scan. */
33
+ export async function scanIssueText(issue, { load = loadAidefence } = {}) {
34
+ let mod;
35
+ try { mod = await load(); } catch (e) { throw new AutofixAbort('S1', 'injection-scan-failed', `AIDefence unavailable: ${e.message}`); }
36
+ if (typeof mod?.checkThreats !== 'function') throw new AutofixAbort('S1', 'injection-scan-failed', 'AIDefence has no checkThreats');
37
+ const texts = [issue.title, issue.body, ...(issue.comments || []).map((c) => c.body)].map((t) => String(t ?? ''));
38
+ const threats = [];
39
+ for (const t of texts) {
40
+ const r = mod.checkThreats(t);
41
+ if (!r || typeof r.safe !== 'boolean') throw new AutofixAbort('S1', 'injection-scan-failed', 'AIDefence returned no verdict');
42
+ if (!r.safe) threats.push(...(r.threats || []).map((x) => `${x.type}:${x.severity}`));
43
+ }
44
+ return { safe: threats.length === 0, threats };
45
+ }
46
+
47
+ // ── 2. per-issue ────────────────────────────────────────────────────────────────────────────
48
+ /** Static scan of a working-tree diff ({files, text}). */
49
+ export function staticScan(diff) {
50
+ return securityVerdict(scanAddedLines(diff.files), scanSecrets(diff.text || ''));
51
+ }
52
+
53
+ /** Map each red-team finding onto an added line (confirmed) or not; any finding, any non-CLEAN verdict, or a present
54
+ * flag that the static scanner contradicts, blocks. */
55
+ export function judgeRedTeam(result, files, staticFindings = []) {
56
+ const reasons = [];
57
+ if (!result || typeof result !== 'object') return { ok: false, reasons: ['no-result'], findings: [] };
58
+ if (result.verdict !== 'CLEAN') reasons.push(`verdict:${result.verdict}`);
59
+ const byPath = new Map(files.map((f) => [f.path, f]));
60
+ const findings = (result.findings || []).map((f) => {
61
+ const file = byPath.get(String(f.file || '').replace(/^[ab]\//, ''));
62
+ const confirmed = !!file && file.added.some((l) => String(f.evidence || '').includes(l.trim().slice(0, 40)) && l.trim().length > 3);
63
+ return { ...f, confirmed };
64
+ });
65
+ if (findings.length) reasons.push(`findings:${findings.length}`);
66
+ for (const [k, rules] of Object.entries(PRESENT_TO_RULES)) {
67
+ const saidPresent = result.present?.[k] === true;
68
+ const scanned = staticFindings.some((s) => rules.includes(s.rule));
69
+ if (saidPresent) reasons.push(`present:${k}`);
70
+ else if (scanned) reasons.push(`contradicts-scan:${k}`); // the model said absent; the scanner found it
71
+ }
72
+ return { ok: reasons.length === 0, reasons, findings };
73
+ }
74
+
75
+ /** Run the red-team stage over `diffText` in an empty directory and judge it. Throws AutofixAbort on a block. */
76
+ export async function redTeam(rt, { stage, diffText, files, staticFindings, scope }) {
77
+ fs.mkdirSync(rt.tmpRoot, { recursive: true });
78
+ const cwd = fs.mkdtempSync(path.join(rt.tmpRoot, 'autofix-redteam-'));
79
+ const r = await rt.agent('redteam', stage, cwd, { diffText, scope });
80
+ const v = judgeRedTeam(r.result, files, staticFindings);
81
+ if (!v.ok) {
82
+ throw new AutofixAbort(stage, 'redteam-flagged', `${v.reasons.join(', ')}; ${v.findings.slice(0, 5)
83
+ .map((f) => `${f.file}:${f.line} ${f.category}${f.confirmed ? ' (confirmed)' : ''}`).join('; ')}`.slice(0, 900));
84
+ }
85
+ return v;
86
+ }
87
+
88
+ // ── 3. the night ────────────────────────────────────────────────────────────────────────────
89
+ const readJsonAt = async (rt, dir, rev, file) => {
90
+ const r = await rt.exec('git', ['-C', dir, 'show', `${rev}:${file}`]);
91
+ if (r.status !== 0) throw new AutofixAbort('S6', 'batch-security', `cannot read ${file} at ${rev}`);
92
+ try { return JSON.parse(r.stdout); } catch { throw new AutofixAbort('S6', 'batch-security', `${file} at ${rev} is not JSON`); }
93
+ };
94
+ const packFiles = (stdout) => {
95
+ const j = JSON.parse(stdout.slice(stdout.indexOf('[')));
96
+ return (j[0]?.files || []).map((f) => ({ path: f.path, size: f.size }));
97
+ };
98
+
99
+ /**
100
+ * The night's gate over the release worktree. `fixBase`..`fixHead` spans exactly the cherry-picked fixes (nothing
101
+ * mechanical), so the static scan and red-team see only model-written code; manifest, lockfile, pack list and audit
102
+ * look at the whole branch against origin/main and the last published release.
103
+ */
104
+ export async function runBatchGate(rt, { rwt, fixBase, fixHead, mainVersion, baseDir }) {
105
+ const lists = [];
106
+ const fixDiff = await rt.exec('git', ['-C', rwt, 'diff', '--no-color', '--no-ext-diff', '--no-renames', '-U0', fixBase, fixHead]);
107
+ const fixText = await rt.exec('git', ['-C', rwt, 'diff', '--no-color', '--no-ext-diff', '--no-renames', fixBase, fixHead]);
108
+ if (fixDiff.status !== 0 || fixText.status !== 0) throw new AutofixAbort('S6', 'batch-security', 'cannot diff the batch');
109
+ const files = parseUnifiedDiff(fixDiff.stdout);
110
+ const st = staticScan({ files, text: fixText.stdout });
111
+ lists.push(st.findings);
112
+ lists.push(judgeManifestChange(await readJsonAt(rt, rwt, 'origin/main', 'package.json'), await readJsonAt(rt, rwt, 'HEAD', 'package.json')));
113
+ lists.push(judgeLockfileChange(await readJsonAt(rt, rwt, 'origin/main', 'package-lock.json'), await readJsonAt(rt, rwt, 'HEAD', 'package-lock.json')));
114
+
115
+ const changed = (await rt.exec('git', ['-C', rwt, 'diff', '--name-only', 'origin/main', 'HEAD'])).stdout.split('\n').filter(Boolean);
116
+ const cand = await rt.exec('npm', ['pack', '--dry-run', '--json', '--ignore-scripts'], { cwd: rwt, timeoutMs: 10 * MIN });
117
+ const prev = await rt.exec('npm', ['pack', `ruvnet-brain@${mainVersion}`, '--dry-run', '--json', '--ignore-scripts'], { cwd: baseDir, timeoutMs: 10 * MIN });
118
+ try {
119
+ lists.push(judgePackList({ previous: packFiles(prev.stdout), candidate: packFiles(cand.stdout), changedPaths: changed }));
120
+ } catch (e) {
121
+ lists.push([{ path: '<npm pack>', line: null, rule: 'pack-unreadable', detail: `npm pack --dry-run unreadable (${cand.status}/${prev.status}): ${e.message}`, excerpt: '' }]);
122
+ }
123
+
124
+ // Only NEW high/critical advisories count: the same advisories on origin/main are not this batch's doing.
125
+ const audit = async (dir) => { const r = await rt.exec('npm', ['audit', '--json', '--omit=dev'], { cwd: dir, timeoutMs: 10 * MIN });
126
+ try { return JSON.parse(r.stdout); } catch { return null; } };
127
+ const mine = judgeAudit(await audit(rwt));
128
+ const base = new Set(judgeAudit(await audit(baseDir)).map((f) => `${f.rule}:${f.detail}`));
129
+ lists.push(mine.filter((f) => f.rule === 'audit-unreadable' || !base.has(`${f.rule}:${f.detail}`)));
130
+
131
+ const verdict = securityVerdict(...lists);
132
+ if (verdict.ok && files.length) {
133
+ try { await redTeam(rt, { stage: 'S6', diffText: fixText.stdout, files, staticFindings: st.findings, scope: 'the whole nightly batch of fixes' }); }
134
+ catch (e) { if (e instanceof AutofixAbort) verdict.findings.push({ path: '*', line: null, rule: 'redteam-flagged', detail: e.detail, excerpt: '' }); else throw e; }
135
+ }
136
+ return { ok: verdict.findings.length === 0, findings: verdict.findings, changed };
137
+ }
@@ -0,0 +1,164 @@
1
+ // scripts/issue-autofix-night.mjs — one night of the autofix job (ADR-107), after the run has been armed, locked and
2
+ // its window opened: fix EVERY eligible issue the time budget allows (each independently), put every fix that passed
3
+ // its own verification and security checks into ONE release, run the night's security gate over that release, ship it
4
+ // through the protected rail, and give each person one honest reply. One issue's failure never blocks another's fix.
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import { AutofixAbort, nextPatchVersion, etDay } from './issue-autofix-policy.mjs';
8
+ import { fixOne } from './issue-autofix-fix.mjs';
9
+ import { runBatchGate } from './issue-autofix-gate.mjs';
10
+ import { mustGit } from './issue-autofix-release-util.mjs';
11
+ import { patchIssue, readNs, patchNs, writeHaltFile } from './issue-autofix-state.mjs';
12
+
13
+ const HOUR = 3_600_000;
14
+ const STOP_RUN = new Set(['kill-switch-off', 'halted', 'deadline', 'sandbox-unavailable']);
15
+ // Not the issue's fault (infrastructure, quota, a model we did not ask for): retried next night, nothing said publicly.
16
+ // The job's own guards (primary-checkout, forbidden-command) tripping is the job's problem, never the reporter's.
17
+ const INFRA = new Set(['agent-failed', 'agent-timeout', 'model-downgrade', 'window-closed', 'transient', 'lock-held',
18
+ 'primary-checkout', 'forbidden-command', 'injection-scan-failed', 'plan-malformed']);
19
+ // The fix itself looked hostile: held for the owner (URGENT page, never retried until a person labels it autofix:go).
20
+ // The reporter is still answered honestly (owner, 2026-10-30) but in words that reveal nothing about what was detected,
21
+ // and the hold, not the breaker, keeps it out of later nights.
22
+ const HOSTILE = new Set(['security-finding', 'redteam-flagged', 'modules-tampered', 'injection-suspected']);
23
+ const BREAKER = new Set(['test-validity', 'verify-not-resolved', 'verify-evidence-failed', 'diff-policy', 'not-editable',
24
+ 'test-weakened', 'protected-path', 'plan-not-fixable', 'plan-high-risk', 'gate-failed']);
25
+
26
+ const iso = (ms) => new Date(ms).toISOString();
27
+ const rootCause = (plan) => (plan?.rootCause?.explanation ? `${plan.rootCause.explanation}${plan.rootCause.line > 0 ? ` (${plan.rootCause.file}:${plan.rootCause.line})` : ''}` : '');
28
+
29
+ /** Decide what one failed issue means: a public item (findings / not-fixed / reply), a private page, or nothing. */
30
+ export function classifyFailure(e, { plan, verifySummary } = {}) {
31
+ const code = e.code;
32
+ if (STOP_RUN.has(code)) return { stop: true };
33
+ if (INFRA.has(code)) return { item: null, page: { title: `AUTOFIX ABORTED at ${e.stage}`, priority: 'default' }, breaker: false };
34
+ if (HOSTILE.has(code)) {
35
+ return { hold: true, breaker: false, page: { title: 'AUTOFIX SECURITY HOLD', priority: 'urgent' },
36
+ item: { kind: 'not-fixed', fallback: { whatWasWrong: rootCause(plan) || 'the cause is not yet pinned down.', whatItNeeds: 'a maintainer to review a change by hand.' } },
37
+ facts: { verified: [], notVerified: ['a change that passes every check'] } };
38
+ }
39
+ const wrong = rootCause(plan);
40
+ // "Already fixed" is the planner's reading of main; it may be right (a person's release fixed it and will close it).
41
+ // Saying it publicly from a model's guess, or calling it a maintainer's decision, would be untrue: page privately.
42
+ if (e.stage === 'S3' && plan?.classification === 'already-fixed') return { item: null, page: { title: 'AUTOFIX NEEDS OWNER', priority: 'default' }, breaker: true };
43
+ if (e.stage === 'S3' || (e.stage === 'S5' && e.publicFindings)) {
44
+ if (e.publicFindings && wrong) return { item: { kind: 'findings', fallback: { whatWasWrong: wrong, whatItNeeds: plan?.whatItNeeds || 'a maintainer’s decision.' } }, breaker: BREAKER.has(code) };
45
+ return { item: { kind: 'reply', fallback: { whatWasWrong: wrong || 'I read through this and the code it points at, but could not pin the cause down well enough to change anything safely.', whatItNeeds: plan?.whatItNeeds || 'a maintainer to take a look.' } }, breaker: BREAKER.has(code) };
46
+ }
47
+ return { item: { kind: 'not-fixed', fallback: { whatWasWrong: wrong || String(verifySummary || 'the cause is not yet pinned down.'), whatItNeeds: 'a maintainer to finish or rethink the fix.' } },
48
+ facts: { verified: [], notVerified: ['a fix that passes every check'] }, breaker: BREAKER.has(code) };
49
+ }
50
+
51
+ /** Fix every candidate the budget allows. Returns { ready: [...], items: [...public replies...] }. */
52
+ export async function fixAll(rt, candidates, { governed }) {
53
+ const ready = []; const items = [];
54
+ for (const issue of candidates) {
55
+ const n = issue.number;
56
+ if (rt.deadlineAt - rt.now() < rt.releaseReserveMs + HOUR) { rt.note(`# budget: #${n} and later wait for the next night`); break; }
57
+ const rec = readNs(rt.paths.state)[n] || {};
58
+ rt.bump(n, { attempts: (rec.attempts || 0) + 1, failCount: rec.failCount || 0, lastAttemptAt: iso(rt.now()), lastStage: 'S2' });
59
+ rt.current = { issue, plan: null, wt: null };
60
+ try {
61
+ const r = await fixOne(rt, issue, { governed });
62
+ ready.push({ ...r, issue });
63
+ rt.bump(n, { lastStage: 'S5', lastOutcome: 'ready', lastCode: null });
64
+ } catch (e) {
65
+ const a = e instanceof AutofixAbort ? e : new AutofixAbort('S4', 'agent-failed', e?.stack || String(e));
66
+ const c = classifyFailure(a, { plan: rt.current.plan });
67
+ if (c.stop) throw a;
68
+ rt.receipt.dropped.push({ number: n, stage: a.stage, code: a.code, detail: String(a.detail || '').slice(0, 600) }); // private: receipt only
69
+ rt.bump(n, { lastStage: a.stage, lastCode: a.code, lastOutcome: c.item?.kind || (c.hold ? 'security-hold' : 'aborted'),
70
+ failCount: (readNs(rt.paths.state)[n]?.failCount || 0) + (c.breaker ? 1 : 0), ...(c.hold ? { securityHold: { at: iso(rt.now()), code: a.code } } : {}) });
71
+ if (c.page) rt.page(`${c.page.title} #${n}`, `${a.stage}:${a.code} — ${a.detail}`.slice(0, 900), c.page.priority);
72
+ if (c.item) items.push({ issue, ...c.item, facts: c.facts || null });
73
+ }
74
+ }
75
+ return { ready, items };
76
+ }
77
+
78
+ /** S6 for the whole batch: branch, census, ADR-0103 row, the night's security gate, local gates. */
79
+ async function prepareBatch(rt, ready, version) {
80
+ const rel = rt.rel;
81
+ const prep = await rel.prepareReleaseBranch(rt.ctx, { version, fixes: ready.map((r) => ({ issueNumber: r.number, fixSha: r.fixSha })) });
82
+ rt.receipt.release = { phase: 'preparing', version, applied: prep.applied, droppedOnApply: prep.dropped };
83
+ await rel.stampCensus(rt.ctx, { rwt: prep.rwt });
84
+ await rel.stamp0103Review(rt.ctx, { rwt: prep.rwt, version, issues: prep.applied, date: etDay(rt.now()) });
85
+ const baseDir = `${prep.rwt}-gatebase`;
86
+ if (!fs.existsSync(baseDir)) await mustGit(rt.ctx, 'S6', 'gate-failed', rt.paths.clone, ['worktree', 'add', '--detach', baseDir, 'origin/main']);
87
+ const gate = await (rt.batchGate || runBatchGate)(rt, { rwt: prep.rwt, fixBase: prep.fixBase, fixHead: prep.fixHead, mainVersion: rt.mainVersion, baseDir });
88
+ rt.receipt.securityGate = { ok: gate.ok, findings: gate.findings.slice(0, 40) };
89
+ if (!gate.ok) {
90
+ throw new AutofixAbort('S6', 'batch-security', gate.findings.slice(0, 10).map((f) => `${f.rule} ${f.path}:${f.line ?? '-'} ${f.detail}`).join('; ').slice(0, 900));
91
+ }
92
+ const g = await rel.runLocalGates(rt.ctx, { rwt: prep.rwt });
93
+ rt.receipt.gates = g.gates.map((x) => ({ name: x.name, exitCode: x.exitCode, durationMs: x.durationMs }));
94
+ if (!g.ok) { const bad = g.gates[g.gates.length - 1]; throw new AutofixAbort('S6', 'gate-failed', `${bad.name} exit ${bad.exitCode}: ${bad.outputTail}`); }
95
+ const sha = rt.exec('git', ['-C', prep.rwt, 'rev-parse', 'HEAD']).stdout.trim();
96
+ return { ...prep, sha };
97
+ }
98
+
99
+ /** Journal entry for an issue in the release: everything S13/S13b need to tell the reporter later. */
100
+ const carry = (r) => ({ fixSha: r.fixSha, testPath: r.testPath, platformCaveat: r.platformCaveat, facts: r.facts,
101
+ fallback: { whatWasWrong: rootCause(r.plan) || r.summary, whatChanged: String(r.plan?.summary || r.summary || '').slice(0, 400) } });
102
+
103
+ /** Build and start the night's release. Returns the journal, or null when there is nothing to release. */
104
+ export async function releaseBatch(rt, ready) {
105
+ if (!ready.length) return null;
106
+ const version = nextPatchVersion(rt.mainVersion);
107
+ let prep;
108
+ try { prep = await prepareBatch(rt, ready, version); }
109
+ catch (e) {
110
+ const a = e instanceof AutofixAbort ? e : new AutofixAbort('S6', 'gate-failed', e?.stack || String(e));
111
+ if (STOP_RUN.has(a.code)) throw a;
112
+ rt.receipt.abort = { stage: a.stage, code: a.code, detail: a.detail, halt: false };
113
+ rt.page(a.code === 'batch-security' ? `AUTOFIX SECURITY HOLD: night release v${version} blocked` : `AUTOFIX ABORTED v${version} at ${a.stage}`,
114
+ `${a.code}: ${a.detail}`.slice(0, 900), a.code === 'batch-security' ? 'urgent' : 'high');
115
+ for (const r of ready) rt.bump(r.number, { lastStage: 'S6', lastCode: a.code, lastOutcome: 'aborted' });
116
+ return { aborted: true };
117
+ }
118
+ const byNum = Object.fromEntries(ready.map((r) => [r.number, r]));
119
+ const journal = { version, branch: prep.branch, sha: prep.sha, rwt: prep.rwt, issues: prep.applied, phase: 'ready', startedAt: iso(rt.now()),
120
+ items: Object.fromEntries(prep.applied.map((n) => [n, carry(byNum[n])])) };
121
+ rt.saveJournal(journal);
122
+ return journal;
123
+ }
124
+
125
+ /** Ship (or resume) the journal and turn the outcome into reporter items, state and pages. */
126
+ export async function shipAndSettle(rt, journal) {
127
+ const res = await rt.rel.shipRelease(rt.ctx, { rwt: journal.rwt, journal });
128
+ rt.receipt.release = { ...res };
129
+ const items = [];
130
+ const publish = (kind, extraVerified) => {
131
+ for (const n of journal.issues) {
132
+ const c = journal.items[n] || {};
133
+ const facts = { verified: [...(c.facts?.verified || []), 'the full test suite and every release check', ...extraVerified], notVerified: c.facts?.notVerified || [] };
134
+ rt.bump(n, { lastOutcome: 'shipped', lastStage: 'S13', awaitingVerification: { version: journal.version, sha: journal.sha, publishedAt: iso(rt.now()), ...c, facts } });
135
+ items.push({ n, kind, facts, version: journal.version, sha: c.fixSha, testPath: c.testPath, platformCaveat: c.platformCaveat, fallback: c.fallback });
136
+ }
137
+ };
138
+ if (res.phase === 'shipped') {
139
+ publish('fixed', ['install-verified on Linux, macOS and Windows']);
140
+ patchNs(rt.paths.state, { _daily: { day: etDay(rt.now()), shipped: true, version: journal.version, issues: journal.issues } });
141
+ rt.page(`AUTOFIX SHIPPED v${journal.version}`, `Issues ${journal.issues.join(', ')}; install-verified on main ${journal.sha}.`, 'low');
142
+ return { items, status: 'shipped', exitCode: 0 };
143
+ }
144
+ if (res.phase === 'published-unverified') {
145
+ publish('shipping', []);
146
+ return { items, status: 'published-unverified', exitCode: 1, halt: res.abort };
147
+ }
148
+ if (res.phase === 'halted') return { items, status: 'halted', exitCode: 1, halt: res.abort };
149
+ if (res.phase === 'paused') {
150
+ rt.page(`AUTOFIX PAUSED v${journal.version} at ${res.abort.stage}`, `${res.abort.code}: ${res.abort.detail}. The next armed run resumes it${journal.moved ? '; main has ALREADY moved' : ''}.`.slice(0, 900), journal.moved ? 'urgent' : 'high');
151
+ return { items, status: 'paused', exitCode: journal.moved ? 1 : 0 };
152
+ }
153
+ rt.receipt.abort = res.abort;
154
+ rt.page(`AUTOFIX ABORTED v${journal.version} at ${res.abort.stage}`, `${res.abort.code}: ${res.abort.detail}. Nothing public changed; release/${journal.version} is kept and blocks the window until a person deletes it.`.slice(0, 900), 'high');
155
+ return { items, status: 'aborted', exitCode: 1 };
156
+ }
157
+
158
+ /** A red after main moved: the halt file, then the URGENT page. Only a person removes the file. */
159
+ export function haltNight(rt, journal, abort) {
160
+ writeHaltFile(rt.paths.haltFile, { stage: abort.stage, code: abort.code, version: journal.version, sha: journal.sha, detail: abort.detail });
161
+ rt.page(`AUTOFIX HALTED v${journal.version} at ${abort.stage}`, `${abort.code}: ${abort.detail}`.slice(0, 900), 'urgent');
162
+ }
163
+
164
+ export const releaseWorktreeExists = (j) => !!(j?.rwt && fs.existsSync(path.join(j.rwt, '.git')));