ruvnet-brain 4.7.21 → 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 (51) hide show
  1. package/README.md +2 -2
  2. package/bin/install.mjs +126 -9
  3. package/console/activity.js +122 -38
  4. package/package.json +2 -1
  5. package/plugin/.claude-plugin/plugin.json +1 -1
  6. package/plugin/.codex-plugin/plugin.json +1 -1
  7. package/plugin/scripts/agentdb-recall-process.mjs +115 -5
  8. package/plugin/scripts/agentdb-recall-reader.mjs +44 -2
  9. package/plugin/scripts/agentdb-recall.mjs +34 -12
  10. package/plugin/scripts/completion-claim-evidence.mjs +185 -11
  11. package/plugin/scripts/continuation-gate.mjs +52 -7
  12. package/plugin/scripts/lesson-gate.mjs +7 -39
  13. package/plugin/scripts/lesson-scope.mjs +51 -0
  14. package/plugin/scripts/lesson-store.mjs +1 -1
  15. package/plugin/scripts/ruflo-bin.mjs +14 -6
  16. package/plugin/scripts/ruflo-memory-hooks.mjs +157 -19
  17. package/plugin/scripts/session-snapshot-hook.mjs +1 -1
  18. package/plugin/scripts/session-start-core.mjs +2 -1
  19. package/plugin/scripts/session-start-health.mjs +3 -2
  20. package/scripts/codex-boot-surface-proof.mjs +208 -0
  21. package/scripts/generation-census.mjs +220 -0
  22. package/scripts/issue-autofix-agents.mjs +172 -0
  23. package/scripts/issue-autofix-comment.mjs +224 -0
  24. package/scripts/issue-autofix-diff.mjs +108 -0
  25. package/scripts/issue-autofix-exec.mjs +128 -0
  26. package/scripts/issue-autofix-fix.mjs +104 -0
  27. package/scripts/issue-autofix-gate.mjs +137 -0
  28. package/scripts/issue-autofix-night.mjs +164 -0
  29. package/scripts/issue-autofix-notes.mjs +346 -0
  30. package/scripts/issue-autofix-policy.mjs +349 -0
  31. package/scripts/issue-autofix-prompts.mjs +105 -0
  32. package/scripts/issue-autofix-release-ship.mjs +226 -0
  33. package/scripts/issue-autofix-release-util.mjs +141 -0
  34. package/scripts/issue-autofix-release.mjs +272 -0
  35. package/scripts/issue-autofix-report.mjs +121 -0
  36. package/scripts/issue-autofix-runner.mjs +75 -0
  37. package/scripts/issue-autofix-sandbox.mjs +108 -0
  38. package/scripts/issue-autofix-security.mjs +346 -0
  39. package/scripts/issue-autofix-selftest.mjs +101 -0
  40. package/scripts/issue-autofix-state.mjs +61 -0
  41. package/scripts/issue-autofix.mjs +382 -0
  42. package/scripts/issue-fix-lock.mjs +40 -0
  43. package/scripts/issue-fix.mjs +33 -20
  44. package/scripts/no-silent-substitution.mjs +1 -1
  45. package/scripts/ntfy-inbox.mjs +15 -0
  46. package/scripts/onboarding-console.mjs +76 -15
  47. package/scripts/recall-latency-probe.mjs +48 -0
  48. package/scripts/release-prepush-checks.mjs +251 -0
  49. package/scripts/release-prepush.mjs +168 -0
  50. package/scripts/release-qualification-contract.mjs +41 -0
  51. package/scripts/wired-check.mjs +13 -5
@@ -0,0 +1,224 @@
1
+ // Issue autofix REPORTER COMMENTS (ADR-107 §6). What a person who filed an issue reads from the automation.
2
+ //
3
+ // Four promises, each enforced in code rather than hoped for:
4
+ // 1. MARKER FIRST. Every body begins with BOT_MARKER. issue-watch's judgeIssue ignores an owner comment only when it
5
+ // starts with the marker; a body without it would count as the maintainer's own reply and silence the SLA page.
6
+ // 2. HONEST. `fixed` is the only kind that says "Fixed in"; `shipping` says it is still being verified; `findings`
7
+ // claims no fix. A hidden marker at the END makes every outcome idempotent even if the state file is wiped.
8
+ // 3. PERSONAL. The thanks sentence must name something concrete from THEIR issue and must not echo a note we already
9
+ // sent that person; otherwise it is dropped (the caller pages the owner to write one) rather than posted generic.
10
+ // 4. SAFE. The note fields are model-written. They are scrubbed of closing keywords, other @mentions, robot markers and
11
+ // HTML comments before they can reach a public thread.
12
+ import { BOT_MARKER, OWNER_LOGIN } from './issue-watch.mjs';
13
+ import { AutofixAbort, findClosingKeywords, CLOSING_KEYWORD_SOURCE } from './issue-autofix-policy.mjs';
14
+
15
+ export { BOT_MARKER, OWNER_LOGIN };
16
+
17
+ const OUTCOMES = new Set(['fixed', 'shipping', 'findings', 'not-fixed', 'reply']);
18
+ const MARKER_RE = /<!--\s*ruvnet-autofix v1\s+([^>]*?)\s*-->/g;
19
+ // Our own comment (and the autofix:needs-owner label that follows it) bumps issue.updatedAt a moment after the marker's
20
+ // `at`. Only an update later than this grace counts as the reporter coming back.
21
+ export const FINDINGS_GRACE_MS = 10 * 60_000;
22
+ export const SIMILARITY_LIMIT = 0.5;
23
+
24
+ const fail = (detail) => new AutofixAbort('S13', 'comment-failed', detail);
25
+
26
+ // ---------------------------------------------------------------------------------------------
27
+ // Hidden marker
28
+ // ---------------------------------------------------------------------------------------------
29
+ export function hiddenMarker({ issueNumber, outcome, version, sha, at } = {}) {
30
+ if (!Number.isInteger(issueNumber) || issueNumber < 1) throw fail('hiddenMarker: bad issue number');
31
+ if (!OUTCOMES.has(outcome)) throw fail(`hiddenMarker: bad outcome ${outcome}`);
32
+ const parts = [`issue=${issueNumber}`, `outcome=${outcome}`];
33
+ if (version != null) { if (!/^\d+\.\d+\.\d+$/.test(version)) throw fail('hiddenMarker: bad version'); parts.push(`version=${version}`); }
34
+ if (sha != null) { if (!/^[0-9a-f]{7,40}$/.test(sha)) throw fail('hiddenMarker: bad sha'); parts.push(`sha=${sha}`); }
35
+ if (at != null) { if (!/^[\w:.+-]+$/.test(at)) throw fail('hiddenMarker: bad timestamp'); parts.push(`at=${at}`); }
36
+ return `<!-- ruvnet-autofix v1 ${parts.join(' ')} -->`;
37
+ }
38
+
39
+ const isBotComment = (c) => String(c?.author?.login || '').toLowerCase() === OWNER_LOGIN
40
+ && String(c?.body || '').trimStart().startsWith(BOT_MARKER);
41
+
42
+ function markerEntries(comments) {
43
+ const out = [];
44
+ for (const c of Array.isArray(comments) ? comments : []) {
45
+ if (!isBotComment(c)) continue; // a stranger quoting a marker proves nothing
46
+ for (const m of String(c.body).matchAll(MARKER_RE)) {
47
+ const kv = Object.fromEntries([...m[1].matchAll(/(\w+)=(\S+)/g)].map((x) => [x[1], x[2]]));
48
+ const issue = Number(kv.issue);
49
+ if (!Number.isInteger(issue) || !kv.outcome) continue;
50
+ out.push({ marker: { issue, outcome: kv.outcome, version: kv.version ?? null, sha: kv.sha ?? null, at: kv.at ?? null }, createdAt: c.createdAt });
51
+ }
52
+ }
53
+ return out;
54
+ }
55
+
56
+ export const parseHiddenMarkers = (comments) => markerEntries(comments).map((e) => e.marker);
57
+
58
+ export function alreadyPosted(comments, { outcome, version, issueUpdatedAt } = {}) {
59
+ const entries = markerEntries(comments);
60
+ if (outcome === 'reply') return entries.some(({ marker: m }) => m.outcome === 'reply'); // once per issue, ever
61
+ if (outcome === 'findings' || outcome === 'not-fixed') {
62
+ const times = entries.filter((e) => e.marker.outcome === outcome)
63
+ .map((e) => Math.max(Date.parse(e.marker.at) || 0, Date.parse(e.createdAt) || 0));
64
+ if (times.length === 0) return false;
65
+ const updated = Date.parse(issueUpdatedAt);
66
+ if (!Number.isFinite(updated)) return true; // cannot show the reporter came back: do not repeat ourselves
67
+ return !(updated > Math.max(...times) + FINDINGS_GRACE_MS);
68
+ }
69
+ return entries.some(({ marker: m }) => m.version === (version ?? null)
70
+ && (m.outcome === outcome || (outcome === 'shipping' && m.outcome === 'fixed'))); // "shipping" after "fixed" would be a regression
71
+ }
72
+
73
+ // ---------------------------------------------------------------------------------------------
74
+ // Thanks: specific and never repeated
75
+ // ---------------------------------------------------------------------------------------------
76
+ const trigrams = (s) => {
77
+ const t = String(s ?? '').toLowerCase().replace(/\s+/g, ' ').trim();
78
+ const set = new Set();
79
+ for (let i = 0; i + 3 <= t.length; i++) set.add(t.slice(i, i + 3));
80
+ return set;
81
+ };
82
+
83
+ export function trigramSimilarity(a, b) {
84
+ const x = trigrams(a); const y = trigrams(b);
85
+ if (x.size === 0 && y.size === 0) return 0;
86
+ let both = 0;
87
+ for (const g of x) if (y.has(g)) both++;
88
+ return both / (x.size + y.size - both);
89
+ }
90
+
91
+ const STOP = new Set(['the', 'a', 'an', 'this', 'that', 'issue', 'bug', 'report', 'problem', 'thanks', 'thank', 'you', 'for', 'your', 'it', 'is', 'of', 'to', 'and', 'in', 'on', 'with', 'error', 'fix', 'was', 'were', 'when', 'what']);
92
+ const codeLike = (t) => /[./\\_:()[\]{}=<>\d]/.test(t) || /[a-z][A-Z]/.test(t) || /^--?[a-z]/i.test(t)
93
+ || (/^[A-Z][A-Z0-9_]{3,}$/.test(t) && !STOP.has(t.toLowerCase())); // error codes such as ENOENT
94
+
95
+ /** Concrete things a sentence points at: code-shaped tokens (path, function, flag, version) or a quoted phrase that is not
96
+ * just filler. Each must appear VERBATIM in the reporter's own text. */
97
+ function specificMentions(thanks, issueText) {
98
+ const found = [];
99
+ for (const raw of String(thanks).split(/\s+/)) {
100
+ const tok = raw.replace(/^[("'`*_]+/, '').replace(/[)"'`*_.,;:!?]+$/, '');
101
+ if (tok.replace(/[^A-Za-z0-9]/g, '').length >= 4 && codeLike(tok) && issueText.includes(tok)) found.push(tok);
102
+ }
103
+ for (const m of String(thanks).matchAll(/[`"“]([^`"”]{4,120})[`"”]/g)) {
104
+ const span = m[1].trim();
105
+ const words = span.toLowerCase().split(/\W+/).filter(Boolean);
106
+ if (span.length >= 4 && words.some((w) => !STOP.has(w)) && issueText.includes(span)) found.push(span);
107
+ }
108
+ return found;
109
+ }
110
+
111
+ export function checkThanks({ thanks, issueText, priorNotes = [] } = {}) {
112
+ const text = String(thanks ?? '').trim();
113
+ if (!text) return { ok: false, reasons: ['empty'] };
114
+ const reasons = [];
115
+ if (specificMentions(text, String(issueText ?? '')).length === 0) reasons.push('not-specific');
116
+ if ((priorNotes || []).some((p) => trigramSimilarity(text, p) >= SIMILARITY_LIMIT)) reasons.push('too-similar');
117
+ return { ok: reasons.length === 0, reasons };
118
+ }
119
+
120
+ // ---------------------------------------------------------------------------------------------
121
+ // Rendering
122
+ // ---------------------------------------------------------------------------------------------
123
+ const NAMES = { windows: 'Windows', linux: 'Linux', macos: 'macOS', codex: 'Codex', grok: 'Grok' };
124
+ const ran = new Set(['macos', 'linux']); // the platforms our evidence actually ran on
125
+
126
+ /** Scrub model-written text for a public thread (see header, promise 4). */
127
+ function scrub(text, kind, max) {
128
+ let t = String(text ?? '')
129
+ .replace(/<!--[\s\S]*?-->/g, ' ').replace(/<!--|-->/g, ' ')
130
+ .split(BOT_MARKER).join('')
131
+ .replace(/[\u0000-\u001f\u007f]+/g, ' ')
132
+ .replace(/(^|[^\w.])@(?=[A-Za-z0-9_-])/g, '$1')
133
+ .replace(new RegExp(CLOSING_KEYWORD_SOURCE, 'gi'), (m) => `see ${m.replace(/^\w+\s*:?\s*/, '')}`)
134
+ .replace(/\s+/g, ' ').trim();
135
+ if (kind !== 'fixed') t = t.replace(/\bfixed in\b/gi, 'changed in');
136
+ return t.length > max ? `${t.slice(0, max - 1).trimEnd()}…` : t;
137
+ }
138
+
139
+ const joinNames = (xs) => (xs.length <= 1 ? xs.join('') : `${xs.slice(0, -1).join(', ')} and ${xs[xs.length - 1]}`);
140
+
141
+ /**
142
+ * thanks: the person's note (only on the first of their issues tonight) or null; thanksOn: the issue that carries it.
143
+ * entry: the note's perIssue entry for THIS issue; facts: {verified, notVerified} measured by machines, never by a model.
144
+ */
145
+ export function renderComment({ kind, issueNumber, login, thanks = null, thanksOn = null, entry = {}, facts = null, version, sha, commitUrl, testPath, platformCaveat = [], at } = {}) {
146
+ if (!OUTCOMES.has(kind)) throw fail(`renderComment: unknown kind ${kind}`);
147
+ if (!/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/.test(String(login || ''))) throw fail('renderComment: unsafe reporter login');
148
+ const t = thanks ? scrub(thanks, kind, 700) : '';
149
+ const hi = t ? `Hi @${login} — ${t}` : Number.isInteger(thanksOn) && thanksOn !== issueNumber ? `Hi @${login} — my note to you is on issue ${thanksOn}.` : `Hi @${login}.`;
150
+ const wrong = scrub(entry.whatWasWrong, kind, 600);
151
+ if (!wrong) throw fail('renderComment: entry.whatWasWrong is required');
152
+ const list = (xs) => (xs || []).map((x) => scrub(x, kind, 200)).filter(Boolean).join('; ');
153
+ const factsLine = facts && (facts.verified?.length || facts.notVerified?.length)
154
+ ? [`**Checked:** ${list(facts.verified) || 'nothing yet'}.`, facts.notVerified?.length ? ` **Not checked:** ${list(facts.notVerified)}.` : ''].join('') : null;
155
+ const needs = scrub(entry.whatItNeeds, kind, 600) || 'a maintainer’s decision.';
156
+ const foot = (o) => hiddenMarker({ issueNumber, outcome: o, at: at ?? new Date().toISOString() });
157
+
158
+ if (kind === 'findings') {
159
+ return [`${BOT_MARKER} findings — RuvNet Brain`, '', hi, '',
160
+ `I dug into this rather than guess at a patch. Here's what I found: ${wrong}`,
161
+ `What it needs: ${needs} I've flagged it for the maintainer, so the next reply here will be from a person or a real fix.`, '', foot('findings')].join('\n');
162
+ }
163
+ if (kind === 'not-fixed') {
164
+ return [`${BOT_MARKER} status — RuvNet Brain`, '', hi, '',
165
+ `I worked on a fix for this, but it did not pass verification, so nothing was shipped. What I found: ${wrong}`,
166
+ ...(factsLine ? [factsLine] : []),
167
+ `What it needs: ${needs} A maintainer will take it from here.`, '', foot('not-fixed')].join('\n');
168
+ }
169
+ if (kind === 'reply') {
170
+ return [`${BOT_MARKER} note — RuvNet Brain`, '', hi, '',
171
+ `${wrong}`, `This needs the maintainer's call, so nothing has been changed: ${needs} I've flagged it, so the next reply here will be from a person.`, '', foot('reply')].join('\n');
172
+ }
173
+
174
+ const changed = scrub(entry.whatChanged, kind, 400);
175
+ if (!changed) throw fail('renderComment: entry.whatChanged is required');
176
+ if (!/^\d+\.\d+\.\d+$/.test(String(version))) throw fail('renderComment: version is required');
177
+ if (!/^[0-9a-f]{7,40}$/.test(String(sha))) throw fail('renderComment: sha is required');
178
+ const short = sha.slice(0, 7);
179
+ const ref = /^https:\/\/github\.com\/[\w.-]+\/[\w.-]+\/commit\/[0-9a-f]{7,40}$/.test(String(commitUrl)) ? `[${short}](${commitUrl})` : `\`${short}\``;
180
+ const test = String(testPath || '').replace(/[`\s]/g, '');
181
+ const where = kind === 'fixed'
182
+ ? `Fixed in **v${version}**, published to npm and GitHub and install-verified on Linux, macOS and Windows. \`npx ruvnet-brain@latest --update\` picks it up (plugin installs update on their own).`
183
+ : `Built and merged; shipping in **v${version}**, which is still finishing verification. I'll confirm here once it is verified on all three platforms.`;
184
+ const missing = [...new Set((platformCaveat || []).map((p) => String(p).toLowerCase()))].filter((p) => !ran.has(p) && NAMES[p]);
185
+ const lines = [`${BOT_MARKER} fix report — RuvNet Brain`, '', hi, '',
186
+ `**What was wrong:** ${wrong}`,
187
+ `**What changed:** ${ref} — ${changed}${test ? ` It comes with a new test (\`${test}\`) that fails on the old code and passes now.` : ''}`,
188
+ `**Where it is:** ${where}`];
189
+ if (factsLine) lines.push(factsLine);
190
+ if (missing.length) lines.push(`Our tests ran on macOS and Linux; if you can confirm on ${joinNames(missing.map((p) => NAMES[p]))}, that closes the loop.`);
191
+ lines.push('If it still looks wrong after updating, reply here or open a new issue — a maintainer sees both.', '',
192
+ hiddenMarker({ issueNumber, outcome: kind, version, sha }));
193
+ return lines.join('\n');
194
+ }
195
+
196
+ // ---------------------------------------------------------------------------------------------
197
+ // Posting (the only function here that writes to GitHub)
198
+ // ---------------------------------------------------------------------------------------------
199
+ export function postOutcome(ctx, { issueNumber, body, close = false, addLabels = [] } = {}) {
200
+ if (!Number.isInteger(issueNumber) || issueNumber < 1) throw fail('postOutcome: bad issue number');
201
+ if (typeof body !== 'string' || !body.startsWith(BOT_MARKER)) throw fail('body must begin with the bot marker');
202
+ if (body.startsWith(`${BOT_MARKER} acknowledgment`)) throw fail('body must not impersonate the acknowledgment');
203
+ if (findClosingKeywords(body).length) throw fail('body contains a closing keyword');
204
+ const own = markerEntries([{ author: { login: OWNER_LOGIN }, body }]).map((e) => e.marker);
205
+ if (own.length === 0) throw fail('body lacks the hidden dedupe marker');
206
+ if (own.some((m) => m.issue !== issueNumber)) throw fail('hidden marker names a different issue');
207
+ if (close && own[own.length - 1].outcome !== 'fixed') throw fail('only a fixed comment may close an issue');
208
+ if (!(addLabels || []).every((l) => /^autofix:[a-z-]+$/.test(l))) throw fail('label outside the autofix: namespace');
209
+ if (!/^[\w.-]+\/[\w.-]+$/.test(String(ctx?.repo))) throw fail('bad repo');
210
+
211
+ const gh = ctx.ghBin || process.env.GH_BIN || 'gh';
212
+ const base = (...a) => [...a, String(issueNumber), '--repo', ctx.repo];
213
+ const result = { commented: false, closed: false, labeled: false, error: null };
214
+ const step = (args) => {
215
+ try { const r = ctx.exec(gh, args); return r.status === 0 ? null : String(r.stderr || r.stdout || `exit ${r.status}`).trim().slice(0, 300); }
216
+ catch (e) { if (e?.name === 'AutofixAbort') throw e; return String(e?.message || e).slice(0, 300); }
217
+ };
218
+ let err = step([...base('issue', 'comment'), '--body', body]);
219
+ if (err) return { ...result, error: `comment: ${err}` };
220
+ result.commented = true;
221
+ if (addLabels.length) { err = step([...base('issue', 'edit'), '--add-label', addLabels.join(',')]); if (err) result.error = `label: ${err}`; else result.labeled = true; }
222
+ if (close) { err = step([...base('issue', 'close'), '--reason', 'completed']); if (err) result.error = [result.error, `close: ${err}`].filter(Boolean).join('; '); else result.closed = true; }
223
+ return result;
224
+ }
@@ -0,0 +1,108 @@
1
+ // scripts/issue-autofix-diff.mjs — what the build stage changed, and whether its new test is a real test.
2
+ // The orchestrator never takes the builder's word for either: it reads the diff from git itself (S4) and measures
3
+ // the new test against origin/main WITHOUT the fix and against the worktree WITH it (spec §4 "Test validity").
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import { AutofixAbort, judgeDiff, judgeTestValidity } from './issue-autofix-policy.mjs';
7
+ import { parseUnifiedDiff } from './issue-autofix-release-util.mjs';
8
+
9
+ const GIT_DIFF = ['diff', '--no-color', '--no-ext-diff', '--no-renames'];
10
+ const SAFE_TEST_PATH = /^[\w./-]+$/;
11
+
12
+ /** The working-tree diff against the commit the worktree was cut from (untracked files included via intent-to-add). */
13
+ export function collectDiff(exec, wt, gitBin = 'git') {
14
+ const g = (...a) => exec(gitBin, ['-C', wt, ...a]);
15
+ const head = g('rev-parse', 'HEAD');
16
+ if (head.status !== 0) throw new AutofixAbort('S4', 'diff-policy', 'cannot read the worktree HEAD');
17
+ const add = g('add', '-A', '-N');
18
+ if (add.status !== 0) throw new AutofixAbort('S4', 'diff-policy', `git add -N failed: ${add.stderr.slice(-300)}`);
19
+ const u0 = g(...GIT_DIFF, '-U0', 'HEAD');
20
+ const full = g(...GIT_DIFF, 'HEAD');
21
+ if (u0.status !== 0 || full.status !== 0) throw new AutofixAbort('S4', 'diff-policy', 'git diff failed');
22
+ const files = parseUnifiedDiff(u0.stdout);
23
+ return { base: head.stdout.trim(), files, text: full.stdout,
24
+ changedLines: files.reduce((n, f) => n + f.added.length + f.removed.length, 0) };
25
+ }
26
+
27
+ /** judgeDiff, mapped to the abort code the receipt and page use. */
28
+ export function assertDiffAllowed(diff, governed0103) {
29
+ if (diff.files.length === 0) throw new AutofixAbort('S4', 'diff-policy', 'the build stage changed nothing');
30
+ const verdict = judgeDiff({ files: diff.files, governed0103 });
31
+ if (!verdict.ok) {
32
+ const v = verdict.violations[0];
33
+ const code = { 'test-weakened': 'test-weakened', 'protected-path': 'protected-path', 'not-editable': 'not-editable' }[v.rule] || 'diff-policy';
34
+ throw new AutofixAbort('S4', code, verdict.violations.map((x) => `${x.rule} ${x.path}: ${x.detail}`).join('; ').slice(0, 800));
35
+ }
36
+ return verdict;
37
+ }
38
+
39
+ function parseVitestJson(stdout) {
40
+ const i = stdout.indexOf('{'); const j = stdout.lastIndexOf('}');
41
+ if (i < 0 || j <= i) return null;
42
+ try { return JSON.parse(stdout.slice(i, j + 1)); } catch { return null; }
43
+ }
44
+
45
+ /**
46
+ * `vitest run <files>` in `cwd`, ALWAYS through the sandbox runner (issue-autofix-sandbox.mjs): the files were written
47
+ * by a model from a stranger's issue. vitest is started from the worktree's own node_modules entry, never through npx,
48
+ * so nothing is resolved from the network. Returns the exit code, the failing tests ("<file> > <title>") and the output.
49
+ */
50
+ export function runVitest(runSandboxed, cwd, files) {
51
+ if (typeof runSandboxed !== 'function') throw new AutofixAbort('S4', 'sandbox-unavailable', 'no sandbox runner');
52
+ if (!files.length || !files.every((f) => SAFE_TEST_PATH.test(f) && !f.startsWith('-') && !f.split('/').includes('..'))) {
53
+ throw new AutofixAbort('S4', 'test-validity', `unsafe test path in ${JSON.stringify(files)}`);
54
+ }
55
+ const r = runSandboxed(process.execPath, ['node_modules/vitest/vitest.mjs', 'run', '--reporter=json', ...files], { cwd, timeoutMs: 20 * 60_000 });
56
+ const out = `${r.stdout}${r.stderr}`;
57
+ const json = parseVitestJson(r.stdout);
58
+ const failedTests = [];
59
+ for (const tr of json?.testResults || []) {
60
+ const file = files.find((f) => String(tr.name).endsWith(`/${f}`) || tr.name === f) || tr.name;
61
+ const failed = (tr.assertionResults || []).filter((a) => a.status === 'failed');
62
+ for (const a of failed) failedTests.push(`${file} > ${a.fullName || a.title}`);
63
+ // A new test that imports what the fix adds cannot even load against the old code: the FILE failed. That is the
64
+ // new test failing on unfixed code, attributed to that file (judgeTestValidity still requires it to be a new test).
65
+ if (tr.status === 'failed' && failed.length === 0) failedTests.push(`${file} > (the test file failed to load)`);
66
+ }
67
+ return { exitCode: r.timedOut ? 124 : r.status, failedTests, output: out.slice(-6000) };
68
+ }
69
+
70
+ /**
71
+ * The worktree gets its OWN node_modules directory whose entries link to the shared install (measured 2026-10-30:
72
+ * vitest compiles vitest.config.mjs into node_modules/.vite-temp, and with node_modules itself a link into the shared
73
+ * install the sandbox refused that write, so no real test could start). Packages still resolve to the shared, read-only
74
+ * install; vitest's scratch files land inside the worktree, where the sandbox allows writes.
75
+ */
76
+ const linkModules = (from, to) => {
77
+ const dest = path.join(to, 'node_modules');
78
+ if (!from || !fs.existsSync(from) || fs.existsSync(dest)) return;
79
+ const src = fs.realpathSync(from);
80
+ fs.mkdirSync(dest);
81
+ for (const e of fs.readdirSync(src)) {
82
+ if (e === '.vite' || e === '.vite-temp' || e === '.cache') continue; // tool scratch stays per worktree
83
+ fs.symlinkSync(path.join(src, e), path.join(dest, e));
84
+ }
85
+ };
86
+ export { linkModules };
87
+
88
+ /**
89
+ * The new tests must FAIL on unfixed code and PASS on the fix. `scratch` is a detached worktree at the base commit
90
+ * onto which only the test files are copied.
91
+ */
92
+ export function measureTestValidity(exec, { wt, scratch, diff, newTests, nodeModules, gitBin = 'git', runSandboxed }) {
93
+ const present = newTests.map((t) => t.replace(/^(\.\/)+/, '')).filter((t) => diff.files.some((f) => f.path === t));
94
+ if (present.length === 0) return { verdict: judgeTestValidity({ newTests: [] }), before: null, after: null, newTests: [] };
95
+ const head = exec(gitBin, ['-C', wt, 'rev-parse', 'HEAD']);
96
+ const mk = exec(gitBin, ['-C', wt, 'worktree', 'add', '--detach', scratch, head.stdout.trim()]);
97
+ if (mk.status !== 0) throw new AutofixAbort('S4', 'test-validity', `cannot create the scratch worktree: ${mk.stderr.slice(-300)}`);
98
+ // The scratch checkout is left in place (it holds only copied test files): nothing is force-removed, and a
99
+ // directory the job did not finish with is evidence, not litter.
100
+ for (const f of diff.files.filter((x) => x.status !== 'D' && /(^|\/)tests?\/|\.(test|spec)\.[cm]?[jt]sx?$/i.test(x.path))) {
101
+ fs.mkdirSync(path.dirname(path.join(scratch, f.path)), { recursive: true });
102
+ fs.copyFileSync(path.join(wt, f.path), path.join(scratch, f.path));
103
+ }
104
+ linkModules(nodeModules, scratch);
105
+ const before = runVitest(runSandboxed, scratch, present);
106
+ const after = runVitest(runSandboxed, wt, present);
107
+ return { verdict: judgeTestValidity({ before, after, newTests: present }), before, after, newTests: present };
108
+ }
@@ -0,0 +1,128 @@
1
+ // scripts/issue-autofix-exec.mjs — the ONE place the autofix job (ADR-107) touches child_process, plus the
2
+ // primary-checkout guard. Every git/gh/npm/node call in the orchestrator, the release path and the reporter goes
3
+ // through the exec this module builds, so two rails live here and nowhere else to be forgotten:
4
+ // * assertCommandAllowed (no force, no merge, no delete) runs on EVERY argv before it is spawned;
5
+ // * a command whose cwd (or `git -C` dir) is inside the developer's primary checkout is refused.
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import { spawn, spawnSync } from 'node:child_process';
9
+ import { AutofixAbort, assertCommandAllowed } from './issue-autofix-policy.mjs';
10
+
11
+ /** realpath of the nearest existing ancestor + the not-yet-existing tail (macOS /var -> /private/var safe). */
12
+ function realish(p) {
13
+ let cur = path.resolve(String(p));
14
+ const rest = [];
15
+ while (!fs.existsSync(cur)) {
16
+ const up = path.dirname(cur);
17
+ if (up === cur) break;
18
+ rest.unshift(path.basename(cur));
19
+ cur = up;
20
+ }
21
+ let base = cur;
22
+ try { base = fs.realpathSync(cur); } catch { /* keep the unresolved path */ }
23
+ return path.join(base, ...rest);
24
+ }
25
+
26
+ /** Throws AutofixAbort('S2','primary-checkout') when `p` is the primary checkout or anywhere inside it. */
27
+ export function resolveWorkPath(p, { primary }) {
28
+ const real = realish(p);
29
+ const prim = realish(primary);
30
+ if (real === prim || real.startsWith(prim + path.sep)) {
31
+ throw new AutofixAbort('S2', 'primary-checkout', `${real} is inside the primary checkout ${prim}`);
32
+ }
33
+ return real;
34
+ }
35
+
36
+ // git global options that take a value, so the sub-command is the first token after them.
37
+ function gitSub(args) {
38
+ const a = args.map(String);
39
+ for (let i = 0; i < a.length; i += 1) {
40
+ if (a[i] === '-C' || a[i] === '-c') { i += 1; continue; }
41
+ if (a[i].startsWith('-')) continue;
42
+ return { sub: a[i], rest: a.slice(i + 1) };
43
+ }
44
+ return { sub: '', rest: [] };
45
+ }
46
+
47
+ /** True for a command that WRITES (to a remote, the clone, or a worktree): a --dry-run records and skips these. */
48
+ export function isWriteCommand(cmd, args, { ghBin } = {}) {
49
+ const a = (args || []).map(String);
50
+ const bin = path.basename(String(cmd));
51
+ if (bin === 'git') {
52
+ const { sub, rest } = gitSub(a);
53
+ if (['push', 'commit', 'cherry-pick', 'clone', 'add'].includes(sub)) return true;
54
+ return sub === 'worktree' && ['add', 'remove'].includes(rest[0]);
55
+ }
56
+ if (String(cmd) === ghBin || bin === 'gh') {
57
+ const [g, s] = a;
58
+ if (g === 'issue') return ['comment', 'close', 'edit'].includes(s);
59
+ if (g === 'pr') return ['create', 'close'].includes(s);
60
+ if (g === 'workflow') return s === 'run';
61
+ if (g === 'variable') return s === 'set';
62
+ if (g === 'api') return a.some((t) => /dispatches/.test(t));
63
+ return false;
64
+ }
65
+ if (bin === 'npm') return a[0] === 'ci' || (a[0] === 'run' && a[1] === 'version:set');
66
+ return false;
67
+ }
68
+
69
+ /** True for a command that reaches GitHub with a write (push, comment, label, close, PR, workflow, dispatch, variable). */
70
+ export function isRemoteWrite(cmd, args, { ghBin } = {}) {
71
+ const a = (args || []).map(String);
72
+ const bin = path.basename(String(cmd));
73
+ if (bin === 'git') return gitSub(a).sub === 'push';
74
+ return (String(cmd) === ghBin || bin === 'gh') && isWriteCommand(cmd, a, { ghBin });
75
+ }
76
+
77
+ /**
78
+ * exec(cmd, args, {cwd, env, timeoutMs, input}) -> {status, stdout, stderr, signal, timedOut}. Never throws on a
79
+ * non-zero exit; throws AutofixAbort for a forbidden argv or a primary-checkout cwd. dryRun skips every write;
80
+ * rehearse skips only writes that reach GitHub (local worktrees and commits in the job's own clone still happen).
81
+ */
82
+ export function makeExec({ dryRun = false, rehearse = false, log = () => {}, primary = null, ghBin = 'gh', env = process.env } = {}) {
83
+ let stage = 'S0';
84
+ const exec = (cmd, args = [], opts = {}) => {
85
+ const argv = args.map(String);
86
+ assertCommandAllowed(cmd, argv, stage);
87
+ if (primary) {
88
+ if (opts.cwd) resolveWorkPath(opts.cwd, { primary });
89
+ const c = argv.indexOf('-C');
90
+ if (path.basename(String(cmd)) === 'git' && c !== -1 && argv[c + 1]) resolveWorkPath(argv[c + 1], { primary });
91
+ }
92
+ const line = [cmd, ...argv.map((t) => (t.length > 160 ? `${t.slice(0, 160)}…` : t))].join(' ');
93
+ if ((dryRun && isWriteCommand(cmd, argv, { ghBin })) || (rehearse && isRemoteWrite(cmd, argv, { ghBin }))) {
94
+ log(`[dry-run, not executed] ${line}`);
95
+ return { status: 0, stdout: '', stderr: '', signal: null, timedOut: false, skipped: true };
96
+ }
97
+ log(line);
98
+ const r = spawnSync(cmd, argv, { cwd: opts.cwd, env: opts.env || env, encoding: 'utf8', timeout: opts.timeoutMs,
99
+ input: opts.input, maxBuffer: 256 * 1024 * 1024, killSignal: 'SIGTERM' });
100
+ const timedOut = r.error?.code === 'ETIMEDOUT';
101
+ return { status: r.status, stdout: r.stdout || '', stderr: r.stderr || (r.error && !timedOut ? String(r.error.message) : ''),
102
+ signal: r.signal || null, timedOut };
103
+ };
104
+ exec.setStage = (s) => { stage = s; };
105
+ return exec;
106
+ }
107
+
108
+ /**
109
+ * notify.sh exit code: 0 delivered, 1 failed, 2 paging off. The job runs from its own runner worktree, which has no
110
+ * .env, so the topic comes from $NTFY_TOPIC or ~/.cache/ruvnet-brain/ntfy-topic — the file job-heartbeat.sh reads.
111
+ * NTFY_TOPIC set but empty stays an explicit opt-out.
112
+ */
113
+ export function defaultNotify(root, env = process.env) {
114
+ return (title, body, priority = 'default') => {
115
+ let topic = env.NTFY_TOPIC;
116
+ if (topic === undefined) {
117
+ try { topic = fs.readFileSync(path.join(env.HOME || '', '.cache', 'ruvnet-brain', 'ntfy-topic'), 'utf8').trim(); } catch { topic = undefined; }
118
+ }
119
+ const r = spawnSync('sh', [path.join(root, 'scripts', 'notify.sh'), title, body, priority, 'autofix'],
120
+ { encoding: 'utf8', timeout: 30_000, env: topic === undefined ? env : { ...env, NTFY_TOPIC: topic } });
121
+ return Number.isInteger(r.status) ? r.status : 1;
122
+ };
123
+ }
124
+
125
+ /** Stops the Mac sleeping for as long as this process lives (best effort). */
126
+ export function keepAwake(pid = process.pid) {
127
+ try { spawn('caffeinate', ['-i', '-w', String(pid)], { stdio: 'ignore', detached: true }).unref(); } catch { /* best effort */ }
128
+ }
@@ -0,0 +1,104 @@
1
+ // scripts/issue-autofix-fix.mjs — one issue, start to verified fix commit (ADR-107 S2..S5). Each issue gets its own
2
+ // worktree, its own plan, build, red-team and verifier, and its own sandboxed evidence; one issue's failure never
3
+ // touches another's. Returns { status: 'ready', fixSha, ... } or throws AutofixAbort; the orchestrator decides what the
4
+ // reporter is told. Order matters: nothing a model wrote is EXECUTED until the static scan has passed, and then only
5
+ // inside the sandbox (issue-autofix-sandbox.mjs).
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import { AutofixAbort, judgePlan, judgeVerdict, EVIDENCE_COMMAND } from './issue-autofix-policy.mjs';
9
+ import { collectDiff, assertDiffAllowed, measureTestValidity, runVitest, linkModules } from './issue-autofix-diff.mjs';
10
+ import { staticScan, redTeam } from './issue-autofix-gate.mjs';
11
+ import { mustGit } from './issue-autofix-release-util.mjs';
12
+ import { commitFix } from './issue-autofix-release.mjs';
13
+
14
+ const PLATFORMS = { windows: 'Windows', linux: 'Linux', macos: 'macOS', codex: 'Codex', grok: 'Grok' };
15
+
16
+ /** Any file under node_modules newer than `stamp` means a model wrote through the link: never trust that tree again. */
17
+ function assertModulesUntouched(rt, nodeModules, stamp) {
18
+ if (!nodeModules) return;
19
+ const r = rt.exec('find', [fs.realpathSync(nodeModules), '-newer', stamp, '-not', '-path', '*/.vite/*', '-print', '-quit']);
20
+ if (r.status !== 0 || r.stdout.trim()) throw new AutofixAbort('S4', 'modules-tampered', `node_modules changed during the build: ${r.stdout.trim().slice(0, 200)}`);
21
+ }
22
+
23
+ const evidenceFiles = (cmd) => {
24
+ if (typeof cmd !== 'string' || !EVIDENCE_COMMAND.test(cmd)) return null;
25
+ const files = cmd.split(/\s+/).slice(3);
26
+ return files.length && files.every((t) => !t.startsWith('-') && !t.split('/').includes('..')) ? files : null;
27
+ };
28
+
29
+ export async function fixOne(rt, issue, { governed }) {
30
+ const n = issue.number;
31
+ const out = { number: n, status: 'dropped', plan: null, verify: null, testValidity: null, diff: null, wt: null, fixSha: null };
32
+ rt.current.wt = null;
33
+
34
+ // S2 — own worktree at origin/main; node_modules is a link to the clone's install made at origin/main.
35
+ rt.begin('S2');
36
+ const stamp = rt.now();
37
+ const wt = path.join(rt.paths.worktrees, `issue-${n}-${stamp}`);
38
+ rt.resolveWorkPath(wt);
39
+ fs.mkdirSync(rt.paths.worktrees, { recursive: true });
40
+ await mustGit(rt.ctx, 'S2', 'agent-failed', rt.paths.clone, ['worktree', 'add', '-b', `autofix/issue-${n}-${stamp}`, wt, rt.base || 'origin/main']);
41
+ out.wt = wt; rt.current.wt = wt;
42
+ linkModules(rt.nodeModules, wt);
43
+ rt.end(true);
44
+
45
+ // S3 — plan (read-only tools), judged against the allowlist and the protected set.
46
+ const plan = (await rt.agent('plan', 'S3', wt, { issue: rt.agentIssue(issue), repo: rt.repo })).result;
47
+ out.plan = plan; rt.current.plan = plan;
48
+ const jp = judgePlan(plan, { governed0103: governed });
49
+ if (!jp.proceed) throw new AutofixAbort('S3', jp.code, String(plan?.summary || ''), { publicFindings: jp.publicFindings });
50
+
51
+ // S4 — build (file tools only), then: diff policy → static scan → modules check → sandboxed test validity.
52
+ let repair = null; let diff; let tv;
53
+ for (let round = 0; round < 2; round += 1) {
54
+ const stampFile = path.join(rt.runDir, `build-${n}-${round}.stamp`);
55
+ fs.writeFileSync(stampFile, '');
56
+ await rt.agent('build', 'S4', wt, { issue: rt.agentIssue(issue), plan, repair });
57
+ const head = rt.exec('git', ['-C', wt, 'rev-parse', 'HEAD']).stdout.trim();
58
+ diff = collectDiff(rt.exec, wt);
59
+ if (head !== diff.base) throw new AutofixAbort('S4', 'diff-policy', 'HEAD moved during the build');
60
+ out.diff = { files: diff.files.length, changedLines: diff.changedLines, violations: [] };
61
+ try { assertDiffAllowed(diff, governed); } catch (e) { out.diff.violations = [e.detail]; throw e; }
62
+ const scan = staticScan(diff);
63
+ if (!scan.ok) {
64
+ out.diff.violations = scan.findings.map((f) => `${f.rule} ${f.path}:${f.line ?? '-'}`);
65
+ throw new AutofixAbort('S4', 'security-finding', scan.findings.slice(0, 8).map((f) => `${f.rule} ${f.path}:${f.line ?? '-'} ${f.excerpt}`).join('; ').slice(0, 900));
66
+ }
67
+ assertModulesUntouched(rt, rt.nodeModules, stampFile);
68
+ tv = measureTestValidity(rt.exec, { wt, scratch: `${wt}-scratch-${round}`, diff, newTests: plan.testPlan.newTests,
69
+ nodeModules: rt.nodeModules, runSandboxed: rt.runSandboxed });
70
+ out.testValidity = { ok: tv.verdict.ok, reason: tv.verdict.reason, round };
71
+ if (tv.verdict.ok) break;
72
+ if (round === 1 || tv.verdict.reason === 'no-new-tests') throw new AutofixAbort('S4', 'test-validity', tv.verdict.reason);
73
+ repair = `test-validity: ${tv.verdict.reason}\n--- unfixed code ---\n${tv.before?.output || ''}\n--- fixed code ---\n${tv.after?.output || ''}`;
74
+ }
75
+
76
+ // S4b — an independent Opus red-team that sees only this diff.
77
+ await redTeam(rt, { stage: 'S4', diffText: diff.text, files: diff.files, staticFindings: [], scope: `one fix for issue ${n}` });
78
+
79
+ // S5 — independent verifier (read-only), then the orchestrator re-runs its evidence in the sandbox.
80
+ const verify = (await rt.agent('verify', 'S5', wt, { issue: rt.agentIssue(issue), diffText: diff.text,
81
+ testValidity: { beforeOutput: tv.before.output, afterOutput: tv.after.output, newTests: tv.newTests } })).result;
82
+ const reruns = [];
83
+ for (const ev of verify.evidence || []) {
84
+ if (ev.command === undefined) continue;
85
+ const files = evidenceFiles(ev.command);
86
+ if (!files) { reruns.push({ command: ev.command, exitCode: -1 }); continue; }
87
+ const r = runVitest(rt.runSandboxed, wt, files);
88
+ reruns.push({ command: ev.command, exitCode: r.exitCode });
89
+ }
90
+ out.verify = { verdict: verify.verdict, evidence: (verify.evidence || []).length, reruns, summary: verify.summary };
91
+ const jv = judgeVerdict(verify, reruns);
92
+ if (!jv.ship) {
93
+ throw new AutofixAbort('S5', jv.code, `${verify.verdict}: ${verify.summary}`,
94
+ { publicFindings: verify.verdict === 'NEEDS_OWNER' && plan.rootCause?.line > 0 });
95
+ }
96
+
97
+ // Commit in the issue worktree (message names only the issue number: no stranger text enters git history).
98
+ const { fixSha } = await commitFix(rt.ctx, { wt, issueNumber: n });
99
+ const missing = [...new Set(plan.platformSpecific || [])].filter((p) => !['macos', 'linux'].includes(p)).map((p) => PLATFORMS[p]).filter(Boolean);
100
+ return { ...out, status: 'ready', fixSha, testPath: tv.newTests[0], platformCaveat: plan.platformSpecific || [],
101
+ facts: { verified: ['a new test that fails on the old code and passes with the fix (run in an isolated sandbox on macOS)',
102
+ 'an independent review of the change, whose cited checks were re-run'],
103
+ notVerified: [`your exact setup${missing.length ? ` on ${missing.join(' and ')}` : ''}`] }, summary: verify.summary };
104
+ }