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.
- package/README.md +1 -1
- package/package.json +2 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/scripts/generation-census.mjs +220 -0
- package/scripts/issue-autofix-agents.mjs +172 -0
- package/scripts/issue-autofix-comment.mjs +224 -0
- package/scripts/issue-autofix-diff.mjs +108 -0
- package/scripts/issue-autofix-exec.mjs +128 -0
- package/scripts/issue-autofix-fix.mjs +104 -0
- package/scripts/issue-autofix-gate.mjs +137 -0
- package/scripts/issue-autofix-night.mjs +164 -0
- package/scripts/issue-autofix-notes.mjs +346 -0
- package/scripts/issue-autofix-policy.mjs +349 -0
- package/scripts/issue-autofix-prompts.mjs +105 -0
- package/scripts/issue-autofix-release-ship.mjs +226 -0
- package/scripts/issue-autofix-release-util.mjs +141 -0
- package/scripts/issue-autofix-release.mjs +272 -0
- package/scripts/issue-autofix-report.mjs +121 -0
- package/scripts/issue-autofix-runner.mjs +75 -0
- package/scripts/issue-autofix-sandbox.mjs +108 -0
- package/scripts/issue-autofix-security.mjs +346 -0
- package/scripts/issue-autofix-selftest.mjs +101 -0
- package/scripts/issue-autofix-state.mjs +61 -0
- package/scripts/issue-autofix.mjs +382 -0
- package/scripts/issue-fix-lock.mjs +40 -0
- package/scripts/issue-fix.mjs +33 -20
- package/scripts/no-silent-substitution.mjs +1 -1
- package/scripts/ntfy-inbox.mjs +15 -0
- package/scripts/release-prepush-checks.mjs +251 -0
- package/scripts/release-prepush.mjs +168 -0
- package/scripts/release-qualification-contract.mjs +23 -0
- package/scripts/wired-check.mjs +11 -5
package/README.md
CHANGED
|
@@ -9,7 +9,7 @@ Created: 2026-06-29 22:36:38 EDT
|
|
|
9
9
|
|
|
10
10
|
### Discover the stack. Read the source. Build with confidence.
|
|
11
11
|
|
|
12
|
-
[](plugin/.claude-plugin/plugin.json)
|
|
13
13
|
|
|
14
14
|
**A source-grounded companion for Claude Code and Codex that helps you find and use Reuven Cohen's (rUv's) RuvNet ecosystem.**
|
|
15
15
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ruvnet-brain",
|
|
3
|
-
"version": "4.7.
|
|
3
|
+
"version": "4.7.23",
|
|
4
4
|
"description": "One-command installer for RuvNet Brain — a portable, source-grounded brain over rUv's RuvNet building blocks, delivered as a Claude Code plugin so Claude uses the stack instead of fighting it.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -77,6 +77,7 @@
|
|
|
77
77
|
"deferral:replay": "node scripts/deferral-replay.mjs",
|
|
78
78
|
"hooks:check": "node scripts/hook-retirement-check.mjs",
|
|
79
79
|
"release:qualify": "node scripts/release-qualification.mjs",
|
|
80
|
+
"release:prepush": "node scripts/release-prepush.mjs",
|
|
80
81
|
"rehearse:corpus": "node scripts/rehearse-corpus-pipeline.mjs",
|
|
81
82
|
"single-source:check": "node scripts/single-source-check.mjs",
|
|
82
83
|
"host:codex:execution-proof": "node scripts/codex-host-execution-proof.mjs"
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ruvnet-brain",
|
|
3
3
|
"description": "RuvNet brain transplant for Claude Code — grounds every RuvNet decision in real source across 77 rUv repositories, prefers Ruflo / RuVector-RVF / AgentDB over training-prior defaults (pgvector, Pinecone, hand-rolled cosine), and can pull in any RuvNet repo on demand. Ships a UserPromptSubmit retrieve-and-inject grounding hook and a PreToolUse write gate that refuses ungrounded rUv-product code until search_ruvnet has been consulted (ADR-0012 / ADR-067).",
|
|
4
|
-
"version": "4.7.
|
|
4
|
+
"version": "4.7.23",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Stuart Kerr"
|
|
7
7
|
},
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// DISTINCT-FROM: kb/zip-extract.mjs — that extracts a whole local archive from a file descriptor; this reads only the idmap sidecars of a REMOTE release asset over HTTP Range and never fetches the ~750 MB body.
|
|
3
|
+
//
|
|
4
|
+
// scripts/generation-census.mjs — the public census (public stores, chunks) of a published corpus
|
|
5
|
+
// generation, read straight from its release asset without downloading it.
|
|
6
|
+
//
|
|
7
|
+
// Release QE requires the committed claim surfaces (README, explainer/*) to quote the exact census of
|
|
8
|
+
// the NEWEST corpus generation, and the corpus republishes nightly. Assembling a candidate to learn
|
|
9
|
+
// two numbers costs ~750 MB and a minute; the numbers live in ~200 small `*.big.rvf.idmap.json`
|
|
10
|
+
// members of the zip. So: Range-read the end-of-central-directory, the central directory, and each
|
|
11
|
+
// idmap member; inflate, CRC-check, count `idToLabel` keys; exclude the private fence exactly as
|
|
12
|
+
// scripts/claims-verify.mjs computePublicChunkTotal does. Anything unexpected (zip64, another
|
|
13
|
+
// compression method, a CRC mismatch, a short read) fails loudly — a wrong census is worse than none.
|
|
14
|
+
//
|
|
15
|
+
// node scripts/generation-census.mjs --repo owner/name --tag corpus-sha256-<digest>
|
|
16
|
+
|
|
17
|
+
import crypto from 'node:crypto';
|
|
18
|
+
import fs from 'node:fs';
|
|
19
|
+
import os from 'node:os';
|
|
20
|
+
import path from 'node:path';
|
|
21
|
+
import zlib from 'node:zlib';
|
|
22
|
+
import { fileURLToPath } from 'node:url';
|
|
23
|
+
import { isDirectInvocation } from '../plugin/scripts/entrypoint.mjs';
|
|
24
|
+
|
|
25
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
26
|
+
export const ARCHIVE_ASSET = 'ruvnet-brain.zip';
|
|
27
|
+
export const IDMAP_SUFFIX = '.big.rvf.idmap.json';
|
|
28
|
+
const TAIL_BYTES = 66_000; // EOCD (22) + the largest possible comment (65,535) + the zip64 locator (20)
|
|
29
|
+
const SIG = { eocd: 0x06054b50, zip64Locator: 0x07064b50, central: 0x02014b50, local: 0x04034b50 };
|
|
30
|
+
const U16 = 0xffff;
|
|
31
|
+
const U32 = 0xffffffff;
|
|
32
|
+
const CONCURRENCY = 8;
|
|
33
|
+
|
|
34
|
+
const zip64 = (what) => new Error(`zip64 ${what}: this reader refuses zip64 archives rather than guess at their layout`);
|
|
35
|
+
|
|
36
|
+
/** End-of-central-directory from the archive's last bytes. */
|
|
37
|
+
export function parseEocd(tail) {
|
|
38
|
+
for (let i = tail.length - 22; i >= 0; i -= 1) {
|
|
39
|
+
if (tail.readUInt32LE(i) !== SIG.eocd) continue;
|
|
40
|
+
if (i >= 20 && tail.readUInt32LE(i - 20) === SIG.zip64Locator) throw zip64('end-of-central-directory locator');
|
|
41
|
+
const disk = tail.readUInt16LE(i + 4), cdDisk = tail.readUInt16LE(i + 6);
|
|
42
|
+
const onDisk = tail.readUInt16LE(i + 8), total = tail.readUInt16LE(i + 10);
|
|
43
|
+
const cdSize = tail.readUInt32LE(i + 12), cdOffset = tail.readUInt32LE(i + 16);
|
|
44
|
+
if (total === U16 || onDisk === U16 || cdSize === U32 || cdOffset === U32) throw zip64('marker in the end-of-central-directory');
|
|
45
|
+
if (disk !== 0 || cdDisk !== 0 || onDisk !== total) throw new Error('multi-disk zip archives are not supported');
|
|
46
|
+
return { total, cdSize, cdOffset };
|
|
47
|
+
}
|
|
48
|
+
throw new Error('no end-of-central-directory record in the archive tail');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Every central-directory entry: name, method, flags, crc, sizes, local-header offset. */
|
|
52
|
+
export function parseCentralDirectory(cd, expected) {
|
|
53
|
+
const entries = [];
|
|
54
|
+
let p = 0;
|
|
55
|
+
while (p < cd.length) {
|
|
56
|
+
if (p + 46 > cd.length || cd.readUInt32LE(p) !== SIG.central) throw new Error(`malformed central directory at byte ${p}`);
|
|
57
|
+
const nameLength = cd.readUInt16LE(p + 28), extraLength = cd.readUInt16LE(p + 30), commentLength = cd.readUInt16LE(p + 32);
|
|
58
|
+
const entry = {
|
|
59
|
+
name: cd.subarray(p + 46, p + 46 + nameLength).toString('utf8'),
|
|
60
|
+
flags: cd.readUInt16LE(p + 8), method: cd.readUInt16LE(p + 10), crc: cd.readUInt32LE(p + 16),
|
|
61
|
+
compressedSize: cd.readUInt32LE(p + 20), size: cd.readUInt32LE(p + 24), offset: cd.readUInt32LE(p + 42),
|
|
62
|
+
extraLength,
|
|
63
|
+
};
|
|
64
|
+
if (entry.compressedSize === U32 || entry.size === U32 || entry.offset === U32) throw zip64(`marker on entry ${entry.name}`);
|
|
65
|
+
const extra = cd.subarray(p + 46 + nameLength, p + 46 + nameLength + extraLength);
|
|
66
|
+
for (let q = 0; q + 4 <= extra.length; q += 4 + extra.readUInt16LE(q + 2)) {
|
|
67
|
+
if (extra.readUInt16LE(q) === 0x0001) throw zip64(`extra field on entry ${entry.name}`);
|
|
68
|
+
}
|
|
69
|
+
entries.push(entry);
|
|
70
|
+
p += 46 + nameLength + extraLength + commentLength;
|
|
71
|
+
}
|
|
72
|
+
if (Number.isInteger(expected) && entries.length !== expected) {
|
|
73
|
+
throw new Error(`central directory lists ${entries.length} entries; the end record says ${expected}`);
|
|
74
|
+
}
|
|
75
|
+
return entries;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The public idmap members: no resource forks, no private store, no store counted twice. */
|
|
79
|
+
export function selectIdmapEntries(entries, privateStores = []) {
|
|
80
|
+
const fence = new Set(privateStores);
|
|
81
|
+
const seen = new Map();
|
|
82
|
+
for (const entry of entries) {
|
|
83
|
+
if (!entry.name.endsWith(IDMAP_SUFFIX) || entry.name.split('/').includes('__MACOSX')) continue;
|
|
84
|
+
const base = path.posix.basename(entry.name);
|
|
85
|
+
if (base.startsWith('._')) continue;
|
|
86
|
+
const store = base.slice(0, -IDMAP_SUFFIX.length);
|
|
87
|
+
if (fence.has(store)) continue;
|
|
88
|
+
if (seen.has(store)) throw new Error(`store ${store} has two idmap members (${seen.get(store).name}, ${entry.name})`);
|
|
89
|
+
seen.set(store, { ...entry, store });
|
|
90
|
+
}
|
|
91
|
+
return [...seen.values()];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** One member's bytes from a buffer that starts at its local header; null when the buffer is too short. */
|
|
95
|
+
export function decodeEntry(entry, local) {
|
|
96
|
+
if (local.length < 30 || local.readUInt32LE(0) !== SIG.local) throw new Error(`no local header for ${entry.name}`);
|
|
97
|
+
const start = 30 + local.readUInt16LE(26) + local.readUInt16LE(28);
|
|
98
|
+
if (local.length < start + entry.compressedSize) return { need: start + entry.compressedSize };
|
|
99
|
+
const raw = local.subarray(start, start + entry.compressedSize);
|
|
100
|
+
let data;
|
|
101
|
+
if (entry.method === 0) data = raw;
|
|
102
|
+
else if (entry.method === 8) data = zlib.inflateRawSync(raw);
|
|
103
|
+
else throw new Error(`${entry.name} uses compression method ${entry.method}; only stored (0) and deflate (8) are read`);
|
|
104
|
+
if (typeof zlib.crc32 !== 'function') throw new Error('zlib.crc32 is unavailable (Node >= 20.15 / 22.2 required) — cannot verify members');
|
|
105
|
+
if (data.length !== entry.size) throw new Error(`${entry.name} inflated to ${data.length} bytes; the directory says ${entry.size}`);
|
|
106
|
+
const crc = zlib.crc32(data) >>> 0;
|
|
107
|
+
if (crc !== entry.crc >>> 0) throw new Error(`${entry.name} failed its CRC-32 check (expected ${entry.crc.toString(16)}, got ${crc.toString(16)})`);
|
|
108
|
+
return { data };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function countIdmap(buffer, name) {
|
|
112
|
+
const { idToLabel } = JSON.parse(buffer.toString('utf8'));
|
|
113
|
+
if (!idToLabel || typeof idToLabel !== 'object' || Array.isArray(idToLabel)) throw new Error(`${name} has no idToLabel object`);
|
|
114
|
+
return Object.keys(idToLabel).length;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function readPrivateStores(file = path.join(ROOT, 'kb', 'PRIVATE-STORES.json')) {
|
|
118
|
+
const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
119
|
+
if (!Array.isArray(parsed?.privateStores)) throw new Error(`${file} lacks a privateStores array`);
|
|
120
|
+
return [...parsed.privateStores].sort();
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** The release asset row (id, size, url) from the GitHub API. */
|
|
124
|
+
async function resolveAsset({ repo, tag, fetch, token }) {
|
|
125
|
+
const headers = { Accept: 'application/vnd.github+json', ...(token ? { Authorization: `Bearer ${token}` } : {}) };
|
|
126
|
+
const response = await fetch(`https://api.github.com/repos/${repo}/releases/tags/${encodeURIComponent(tag)}`, { headers });
|
|
127
|
+
if (!response.ok) throw new Error(`release ${tag}: GitHub API answered HTTP ${response.status}`);
|
|
128
|
+
const assets = ((await response.json())?.assets || []).filter((asset) => asset?.name === ARCHIVE_ASSET);
|
|
129
|
+
if (assets.length !== 1) throw new Error(`release ${tag} has ${assets.length} ${ARCHIVE_ASSET} assets; expected exactly one`);
|
|
130
|
+
return { id: assets[0].id, size: assets[0].size, url: assets[0].browser_download_url };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Range reads against the asset; the redirect is resolved once and re-resolved if the signed URL lapses. */
|
|
134
|
+
function rangeReader({ url, size, fetch }) {
|
|
135
|
+
let target = url;
|
|
136
|
+
let fetched = 0;
|
|
137
|
+
const read = async (start, end, retried = false) => {
|
|
138
|
+
const response = await fetch(target, { headers: { Range: `bytes=${start}-${end}` }, redirect: 'follow' });
|
|
139
|
+
if (response.status !== 206) {
|
|
140
|
+
if (!retried && target !== url) { target = url; return read(start, end, true); }
|
|
141
|
+
throw new Error(`range ${start}-${end} of ${url}: expected HTTP 206, got ${response.status}`);
|
|
142
|
+
}
|
|
143
|
+
const total = Number(/\/(\d+)$/.exec(response.headers.get('content-range') || '')?.[1]);
|
|
144
|
+
if (total !== size) throw new Error(`asset is ${total} bytes; the release says ${size}`);
|
|
145
|
+
if (response.url) target = response.url;
|
|
146
|
+
const buffer = Buffer.from(await response.arrayBuffer());
|
|
147
|
+
if (buffer.length !== end - start + 1) throw new Error(`short range read ${start}-${end}: ${buffer.length} bytes`);
|
|
148
|
+
fetched += buffer.length;
|
|
149
|
+
return buffer;
|
|
150
|
+
};
|
|
151
|
+
return { read, fetched: () => fetched };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function pool(items, limit, work) {
|
|
155
|
+
const out = new Array(items.length);
|
|
156
|
+
let next = 0;
|
|
157
|
+
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, async () => {
|
|
158
|
+
while (next < items.length) { const i = next++; out[i] = await work(items[i]); }
|
|
159
|
+
}));
|
|
160
|
+
return out;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export async function rangeCensus({ repo, tag, bytes = null, privateStoresFile, fetch = globalThis.fetch,
|
|
164
|
+
cacheDir = os.tmpdir(), token = process.env.GH_TOKEN || process.env.GITHUB_TOKEN || null, asset = null,
|
|
165
|
+
concurrency = CONCURRENCY } = {}) {
|
|
166
|
+
if (!/^[^/\s]+\/[^/\s]+$/.test(String(repo || ''))) throw new Error('repo must be owner/name');
|
|
167
|
+
if (!/^[A-Za-z0-9._-]+$/.test(String(tag || ''))) throw new Error(`invalid release tag: ${tag}`);
|
|
168
|
+
const privateStores = readPrivateStores(privateStoresFile);
|
|
169
|
+
const resolved = asset || await resolveAsset({ repo, tag, fetch, token });
|
|
170
|
+
if (!Number.isSafeInteger(resolved.size) || resolved.size < 22) throw new Error(`release ${tag} asset has no usable size`);
|
|
171
|
+
if (bytes !== null && bytes !== resolved.size) throw new Error(`release ${tag} asset is ${resolved.size} bytes; expected ${bytes}`);
|
|
172
|
+
|
|
173
|
+
const key = crypto.createHash('sha256').update(JSON.stringify({ repo, tag, assetId: resolved.id, size: resolved.size, privateStores }))
|
|
174
|
+
.digest('hex').slice(0, 32);
|
|
175
|
+
const cacheFile = path.join(cacheDir, 'ruvnet-brain-generation-census', `${key}.json`);
|
|
176
|
+
try {
|
|
177
|
+
const cached = JSON.parse(fs.readFileSync(cacheFile, 'utf8'));
|
|
178
|
+
if (cached?.tag === tag && Number.isSafeInteger(cached.chunks)) return { ...cached, cached: true };
|
|
179
|
+
} catch { /* no usable cache entry: measure */ }
|
|
180
|
+
|
|
181
|
+
const started = Date.now();
|
|
182
|
+
const { read, fetched } = rangeReader({ url: resolved.url || `https://github.com/${repo}/releases/download/${tag}/${ARCHIVE_ASSET}`,
|
|
183
|
+
size: resolved.size, fetch });
|
|
184
|
+
const tailStart = Math.max(0, resolved.size - TAIL_BYTES);
|
|
185
|
+
const eocd = parseEocd(await read(tailStart, resolved.size - 1));
|
|
186
|
+
if (eocd.cdOffset + eocd.cdSize > resolved.size) throw new Error('central directory lies outside the archive');
|
|
187
|
+
const entries = parseCentralDirectory(await read(eocd.cdOffset, eocd.cdOffset + eocd.cdSize - 1), eocd.total);
|
|
188
|
+
const selected = selectIdmapEntries(entries, privateStores);
|
|
189
|
+
if (!selected.length) throw new Error(`release ${tag} contains no public *${IDMAP_SUFFIX} members`);
|
|
190
|
+
const counts = await pool(selected, concurrency, async (entry) => {
|
|
191
|
+
const guess = 30 + Buffer.byteLength(entry.name) + entry.extraLength + entry.compressedSize + 256;
|
|
192
|
+
let local = await read(entry.offset, Math.min(resolved.size, entry.offset + guess) - 1);
|
|
193
|
+
let decoded = decodeEntry(entry, local);
|
|
194
|
+
if (decoded.need) {
|
|
195
|
+
local = await read(entry.offset, entry.offset + decoded.need - 1);
|
|
196
|
+
decoded = decodeEntry(entry, local);
|
|
197
|
+
if (decoded.need) throw new Error(`${entry.name}: local header disagrees with the central directory`);
|
|
198
|
+
}
|
|
199
|
+
return countIdmap(decoded.data, entry.name);
|
|
200
|
+
});
|
|
201
|
+
const result = { tag, publicStores: selected.length, chunks: counts.reduce((a, b) => a + b, 0), builtStores: selected.length,
|
|
202
|
+
assetId: resolved.id ?? null, archiveBytes: resolved.size, fetchedBytes: fetched(), entries: entries.length,
|
|
203
|
+
seconds: Math.round((Date.now() - started) / 100) / 10 };
|
|
204
|
+
try {
|
|
205
|
+
fs.mkdirSync(path.dirname(cacheFile), { recursive: true });
|
|
206
|
+
const temp = `${cacheFile}.${process.pid}.tmp`;
|
|
207
|
+
fs.writeFileSync(temp, `${JSON.stringify(result, null, 2)}\n`);
|
|
208
|
+
fs.renameSync(temp, cacheFile);
|
|
209
|
+
} catch { /* a cache is an optimisation; the measured result stands */ }
|
|
210
|
+
return { ...result, cached: false };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const arg = (argv, name) => { const i = argv.indexOf(name); return i >= 0 ? argv[i + 1] : undefined; };
|
|
214
|
+
|
|
215
|
+
if (isDirectInvocation(import.meta.url)) {
|
|
216
|
+
const argv = process.argv.slice(2);
|
|
217
|
+
rangeCensus({ repo: arg(argv, '--repo') || process.env.GITHUB_REPOSITORY, tag: arg(argv, '--tag') })
|
|
218
|
+
.then((result) => process.stdout.write(`${JSON.stringify(result, null, 2)}\n`))
|
|
219
|
+
.catch((error) => { console.error(`[generation-census] ${error.message}`); process.exitCode = 1; });
|
|
220
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
// scripts/issue-autofix-agents.mjs — how the daily autofix job (ADR-107) spawns `claude -p`.
|
|
2
|
+
//
|
|
3
|
+
// Four LLM stages (plan / build / verify / note) with no git, gh or network access: the orchestrator does every
|
|
4
|
+
// push and API call itself. This module only decides WHAT is spawned (exact argv, tool allowlist, model alias,
|
|
5
|
+
// stripped environment, wall clock) and PROVES what answered (the model named in the result, not the one we asked
|
|
6
|
+
// for). It never asks the CLI to bypass permissions: least-privilege --allowedTools instead.
|
|
7
|
+
//
|
|
8
|
+
// Verified live against claude 2.1.312 (2026-10-29): --max-turns (undocumented in --help but accepted),
|
|
9
|
+
// --json-schema with --output-format json returns the object in `structured_output` and the models that served the
|
|
10
|
+
// call as the keys of `modelUsage`.
|
|
11
|
+
import fs from 'node:fs';
|
|
12
|
+
import os from 'node:os';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import { spawn } from 'node:child_process';
|
|
15
|
+
import { AutofixAbort } from './issue-autofix-policy.mjs';
|
|
16
|
+
|
|
17
|
+
// NO stage can run a command: Bash is absent from every tool list, and --restricted removes every code-running tool
|
|
18
|
+
// that --tools does not name. Code a model wrote runs only in the orchestrator, under the macOS sandbox
|
|
19
|
+
// (issue-autofix-sandbox.mjs), after the static security scan. The note and red-team stages get no tools at all and
|
|
20
|
+
// run in an empty temporary directory: they see only what the prompt hands them.
|
|
21
|
+
export const STAGE_CONFIG = Object.freeze({
|
|
22
|
+
plan: { model: 'opus', maxTurns: 40, timeoutMs: 20 * 60_000, tools: ['Read', 'Glob', 'Grep'], schema: 'config/issue-autofix/plan.schema.json', family: /opus/ },
|
|
23
|
+
build: { model: 'sonnet', maxTurns: 80, timeoutMs: 45 * 60_000, tools: ['Read', 'Edit', 'Write', 'Glob', 'Grep'], schema: null, family: /sonnet|opus/ },
|
|
24
|
+
verify: { model: 'opus', maxTurns: 60, timeoutMs: 30 * 60_000, tools: ['Read', 'Glob', 'Grep'], schema: 'config/issue-autofix/verify.schema.json', family: /opus/ },
|
|
25
|
+
redteam: { model: 'opus', maxTurns: 4, timeoutMs: 15 * 60_000, tools: [], schema: 'config/issue-autofix/redteam.schema.json', family: /opus/ },
|
|
26
|
+
note: { model: 'opus', maxTurns: 4, timeoutMs: 5 * 60_000, tools: [], schema: 'config/issue-autofix/note.schema.json', family: /opus/ },
|
|
27
|
+
});
|
|
28
|
+
export const GRACE_MS = 20_000;
|
|
29
|
+
const MAX_STDOUT = 32 * 1024 * 1024;
|
|
30
|
+
const NO_MCP = 'config/issue-autofix/no-mcp.json';
|
|
31
|
+
|
|
32
|
+
// BILLING SAFETY (issue-fix.mjs): an ambient API key either fails the run (stale key) or bills it. The git/gh
|
|
33
|
+
// credentials go too, so an LLM stage that somehow got a shell still cannot push or comment.
|
|
34
|
+
export const STRIPPED_ENV = [/^ANTHROPIC_/, /^CLAUDE_API_KEY$/, /^CLAUDE_CODE_USE_/, /^GH_TOKEN$/, /^GITHUB_TOKEN$/, /^GH_ENTERPRISE_TOKEN$/, /^SSH_AUTH_SOCK$/];
|
|
35
|
+
|
|
36
|
+
// SUBSCRIPTION LOGIN UNDER LAUNCHD (measured 2026-10-30). Under launchd the login keychain is readable, and the
|
|
37
|
+
// "Claude Code-credentials" item there was an EXPIRED OAuth session that could not refresh: `claude auth status`
|
|
38
|
+
// said loggedIn false. Claude Code's file login (~/.claude/.credentials.json), which the owner's own sessions keep
|
|
39
|
+
// fresh, is the one that works; claude uses it when the keychain cannot be reached. So on macOS every stage runs
|
|
40
|
+
// with keychain lookups denied: measured under a test LaunchAgent, loggedIn true (max) and served by opus. It also
|
|
41
|
+
// means no stage can read any other keychain item. No API key is ever used (BILLING SAFETY above).
|
|
42
|
+
export const KEYCHAIN_DENY_PROFILE = '(version 1)(allow default)(deny mach-lookup (global-name "com.apple.SecurityServer") '
|
|
43
|
+
+ '(global-name "com.apple.securityd.xpc") (global-name "com.apple.security.agent") (global-name "com.apple.secd"))';
|
|
44
|
+
const SANDBOX_EXEC = '/usr/bin/sandbox-exec';
|
|
45
|
+
/** [command, args] that start claude without keychain access on macOS (the real spawn only; an injected spawn is not wrapped). */
|
|
46
|
+
export function claudeCommand(claudeBin, args, { platform = process.platform, exists = fs.existsSync } = {}) {
|
|
47
|
+
return platform === 'darwin' && exists(SANDBOX_EXEC) ? [SANDBOX_EXEC, ['-p', KEYCHAIN_DENY_PROFILE, claudeBin, ...args]] : [claudeBin, args];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function sanitizedEnv(baseEnv, { ghConfigDir }) {
|
|
51
|
+
const env = {};
|
|
52
|
+
for (const [k, v] of Object.entries(baseEnv || {})) {
|
|
53
|
+
if (STRIPPED_ENV.some((re) => re.test(k)) || /^GIT_CONFIG_/.test(k)) continue;
|
|
54
|
+
env[k] = v;
|
|
55
|
+
}
|
|
56
|
+
return { ...env, GH_CONFIG_DIR: ghConfigDir, GIT_TERMINAL_PROMPT: '0', GIT_ASKPASS: '/usr/bin/false',
|
|
57
|
+
GIT_CONFIG_COUNT: '1', GIT_CONFIG_KEY_0: 'credential.helper', GIT_CONFIG_VALUE_0: '' };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const stageOf = (stage) => { const c = STAGE_CONFIG[stage]; if (!c) throw new Error(`unknown stage: ${stage}`); return c; };
|
|
61
|
+
|
|
62
|
+
export function buildClaudeArgs(stage, prompt, { root }) {
|
|
63
|
+
const c = stageOf(stage);
|
|
64
|
+
const schema = c.schema ? ['--json-schema', JSON.stringify(JSON.parse(fs.readFileSync(path.join(root, c.schema), 'utf8')))] : [];
|
|
65
|
+
// --restricted: no code-running tool, user/project/local settings ignored (so a hook or permission the build stage
|
|
66
|
+
// writes into the worktree never loads), file tools confined to the cwd, writes to settings/git files refused.
|
|
67
|
+
// --safe-mode: no CLAUDE.md, skills, plugins, hooks or MCP from anywhere. Both measured live on claude 2.1.314
|
|
68
|
+
// (2026-10-30): planted .claude/settings.json hook and CLAUDE.md inert; reads/writes outside the cwd refused.
|
|
69
|
+
// --permission-prompts none: anything that would ask is denied. Subscription OAuth still works (no API key used).
|
|
70
|
+
const tools = c.tools.length ? c.tools.join(',') : '';
|
|
71
|
+
const allow = c.tools.length ? ['--allowedTools', ...c.tools] : [];
|
|
72
|
+
return ['-p', prompt, '--model', c.model, '--max-turns', String(c.maxTurns), '--output-format', 'json', ...schema,
|
|
73
|
+
'--restricted', '--safe-mode', '--tools', tools, ...allow, '--disallowedTools', 'Bash', 'WebFetch', 'WebSearch', 'Task',
|
|
74
|
+
'--permission-prompts', 'none', '--strict-mcp-config', '--mcp-config', path.join(root, NO_MCP),
|
|
75
|
+
'--no-session-persistence', '--max-budget-usd', '5'];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export { buildPrompt } from './issue-autofix-prompts.mjs';
|
|
79
|
+
import { buildPrompt } from './issue-autofix-prompts.mjs';
|
|
80
|
+
|
|
81
|
+
// ── minimal JSON-schema check (type/enum/const/required/additionalProperties/items/min/max). The claude CLI
|
|
82
|
+
// validates too; this keeps a hallucinated shape from ever reaching the policy code. ──
|
|
83
|
+
function check(schema, v, at, errs) {
|
|
84
|
+
if ('const' in schema && v !== schema.const) errs.push(`${at}: must be ${JSON.stringify(schema.const)}`);
|
|
85
|
+
if (schema.enum && !schema.enum.includes(v)) errs.push(`${at}: not one of ${schema.enum.join('|')}`);
|
|
86
|
+
const t = schema.type;
|
|
87
|
+
const actual = Array.isArray(v) ? 'array' : v === null ? 'null' : Number.isInteger(v) ? 'integer' : typeof v;
|
|
88
|
+
if (t && !(t === actual || (t === 'number' && actual === 'integer'))) { errs.push(`${at}: expected ${t}, got ${actual}`); return; }
|
|
89
|
+
if (t === 'string') {
|
|
90
|
+
if (schema.maxLength != null && v.length > schema.maxLength) errs.push(`${at}: longer than ${schema.maxLength}`);
|
|
91
|
+
if (schema.pattern && !new RegExp(schema.pattern).test(v)) errs.push(`${at}: does not match ${schema.pattern}`);
|
|
92
|
+
}
|
|
93
|
+
if (t === 'integer' && schema.minimum != null && v < schema.minimum) errs.push(`${at}: below ${schema.minimum}`);
|
|
94
|
+
if (t === 'array') {
|
|
95
|
+
if (schema.minItems != null && v.length < schema.minItems) errs.push(`${at}: fewer than ${schema.minItems} items`);
|
|
96
|
+
if (schema.maxItems != null && v.length > schema.maxItems) errs.push(`${at}: more than ${schema.maxItems} items`);
|
|
97
|
+
if (schema.items) v.forEach((x, i) => check(schema.items, x, `${at}[${i}]`, errs));
|
|
98
|
+
}
|
|
99
|
+
if (t === 'object') {
|
|
100
|
+
for (const k of schema.required || []) if (!(k in v)) errs.push(`${at}.${k}: missing`);
|
|
101
|
+
for (const [k, x] of Object.entries(v)) {
|
|
102
|
+
if (schema.properties?.[k]) check(schema.properties[k], x, `${at}.${k}`, errs);
|
|
103
|
+
else if (schema.additionalProperties === false) errs.push(`${at}.${k}: not allowed`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// The model that served the call: the key of `modelUsage` that cost the most (helper calls to a smaller model do
|
|
109
|
+
// not count), else the `model` field.
|
|
110
|
+
function resolveModel(out) {
|
|
111
|
+
const usage = out?.modelUsage && typeof out.modelUsage === 'object' ? Object.entries(out.modelUsage) : [];
|
|
112
|
+
if (usage.length) return usage.sort((a, b) => (Number(b[1]?.costUSD) || 0) - (Number(a[1]?.costUSD) || 0))[0][0];
|
|
113
|
+
return typeof out?.model === 'string' && out.model ? out.model : null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function assertModelFamily(stage, resolvedModel) {
|
|
117
|
+
const c = stageOf(stage);
|
|
118
|
+
if (!resolvedModel || !c.family.test(resolvedModel)) {
|
|
119
|
+
throw new AutofixAbort(stage, 'model-downgrade', `asked for ${c.model}; ${resolvedModel ? `served by ${resolvedModel}` : 'the result names no model'}`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export async function runAgent({ stage, cwd, input, claudeBin, logPath, root, ghConfigDir, timeoutMs, graceMs, spawnImpl = spawn }) {
|
|
124
|
+
const c = stageOf(stage);
|
|
125
|
+
const limit = timeoutMs ?? STAGE_CONFIG[stage].timeoutMs;
|
|
126
|
+
const grace = graceMs ?? GRACE_MS;
|
|
127
|
+
const t0 = Date.now();
|
|
128
|
+
const ownDir = ghConfigDir ? null : fs.mkdtempSync(path.join(os.tmpdir(), 'autofix-gh-'));
|
|
129
|
+
const finish = (r) => ({ ok: false, stage, exitCode: null, signal: null, timedOut: false, resolvedModel: null, result: null, error: null, logPath, ...r, durationMs: Date.now() - t0 });
|
|
130
|
+
try {
|
|
131
|
+
// Behind sandbox-exec a missing binary would only show as an exit code: say what is wrong plainly.
|
|
132
|
+
if (path.isAbsolute(claudeBin) && !fs.existsSync(claudeBin)) return finish({ error: `spawn failed: ENOENT ${claudeBin}` });
|
|
133
|
+
const args = buildClaudeArgs(stage, buildPrompt(stage, input), { root });
|
|
134
|
+
fs.mkdirSync(path.dirname(logPath), { recursive: true });
|
|
135
|
+
const logFd = fs.openSync(logPath, 'a');
|
|
136
|
+
fs.writeSync(logFd, `===== autofix ${stage} — started ${new Date().toISOString()} (model ${c.model}, max-turns ${c.maxTurns}, timeout ${limit}ms) =====\n`);
|
|
137
|
+
const env = sanitizedEnv(process.env, { ghConfigDir: ghConfigDir || ownDir });
|
|
138
|
+
const res = await new Promise((resolve) => {
|
|
139
|
+
let stdout = ''; let timedOut = false; let killTimer; let graceTimer; let child;
|
|
140
|
+
const [cmd, argv] = spawnImpl === spawn ? claudeCommand(claudeBin, args) : [claudeBin, args];
|
|
141
|
+
try { child = spawnImpl(cmd, argv, { cwd, env, stdio: ['ignore', 'pipe', 'pipe'] }); } catch (e) { resolve({ spawnError: e }); return; }
|
|
142
|
+
child.on('error', (e) => { clearTimeout(killTimer); clearTimeout(graceTimer); resolve({ spawnError: e }); });
|
|
143
|
+
child.stdout.on('data', (d) => { if (stdout.length < MAX_STDOUT) stdout += d; fs.writeSync(logFd, d); });
|
|
144
|
+
child.stderr.on('data', (d) => fs.writeSync(logFd, d));
|
|
145
|
+
killTimer = setTimeout(() => {
|
|
146
|
+
timedOut = true;
|
|
147
|
+
fs.writeSync(logFd, `\n===== WALL-CLOCK TIMEOUT (${limit}ms) — SIGTERM =====\n`);
|
|
148
|
+
child.kill('SIGTERM');
|
|
149
|
+
graceTimer = setTimeout(() => { try { child.kill('SIGKILL'); } catch { /* already dead */ } }, grace);
|
|
150
|
+
}, limit);
|
|
151
|
+
child.on('close', (code, signal) => { clearTimeout(killTimer); clearTimeout(graceTimer); resolve({ code, signal, timedOut, stdout }); });
|
|
152
|
+
});
|
|
153
|
+
try { fs.writeSync(logFd, `\n===== autofix ${stage} — ended ${new Date().toISOString()} =====\n`); fs.closeSync(logFd); } catch { /* log only */ }
|
|
154
|
+
if (res.spawnError) return finish({ error: `spawn failed: ${res.spawnError.message}` });
|
|
155
|
+
const base = { exitCode: res.code, signal: res.signal, timedOut: res.timedOut };
|
|
156
|
+
if (res.timedOut) return finish({ ...base, error: `${stage} timed out after ${limit}ms` });
|
|
157
|
+
let out;
|
|
158
|
+
try { out = JSON.parse(res.stdout); } catch { out = null; }
|
|
159
|
+
const resolvedModel = resolveModel(out);
|
|
160
|
+
if (res.code !== 0) return finish({ ...base, resolvedModel, error: `claude exited with exit ${res.code}${res.signal ? ` (${res.signal})` : ''}` });
|
|
161
|
+
if (!out || typeof out !== 'object') return finish({ ...base, error: 'claude output was not JSON' });
|
|
162
|
+
if (out.is_error || (out.subtype && out.subtype !== 'success')) return finish({ ...base, resolvedModel, error: `claude reported ${out.subtype || 'error'}` });
|
|
163
|
+
assertModelFamily(stage, resolvedModel); // fail closed: never accept work from a model we did not ask for
|
|
164
|
+
if (!c.schema) return finish({ ...base, ok: true, resolvedModel });
|
|
165
|
+
let result = out.structured_output;
|
|
166
|
+
if (result === undefined && typeof out.result === 'string') { try { result = JSON.parse(out.result); } catch { /* handled below */ } }
|
|
167
|
+
if (!result || typeof result !== 'object') return finish({ ...base, resolvedModel, error: 'no structured output in the result' });
|
|
168
|
+
const errs = []; check(JSON.parse(fs.readFileSync(path.join(root, c.schema), 'utf8')), result, '$', errs);
|
|
169
|
+
if (errs.length) return finish({ ...base, resolvedModel, error: `structured output failed the schema: ${errs.slice(0, 5).join('; ')}` });
|
|
170
|
+
return finish({ ...base, ok: true, resolvedModel, result });
|
|
171
|
+
} finally { if (ownDir) fs.rmSync(ownDir, { recursive: true, force: true }); }
|
|
172
|
+
}
|