@codedology/code-forge 0.15.2 → 0.15.3
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/CHANGELOG.md +56 -0
- package/README.md +5 -1
- package/docs/README.md +2 -0
- package/docs/reference/blocks.json +154 -1
- package/docs/reference/config.md +27 -14
- package/package.json +1 -1
- package/schema/code-forge.schema.json +57 -2
- package/schema/outcome.schema.json +45 -0
- package/schema/pipeline.schema.json +30 -0
- package/scripts/first-use.mjs +8 -0
- package/scripts/gen-config-doc.mjs +1 -1
- package/skill/references/code.md +15 -0
- package/skill/references/ledger.md +13 -1
- package/skill/scripts/forge +1 -1
- package/src/cli/block.mjs +146 -30
- package/src/cli/context.mjs +17 -7
- package/src/cli/gates.mjs +76 -10
- package/src/cli/help.mjs +4 -0
- package/src/cli/outcome.mjs +87 -0
- package/src/cli/pipeline.mjs +61 -0
- package/src/cli/proof.mjs +10 -7
- package/src/cli/report.mjs +53 -6
- package/src/cli/review-file.mjs +168 -38
- package/src/cli/run.mjs +3 -2
- package/src/cli/sweep.mjs +28 -0
- package/src/cli/validate.mjs +6 -0
- package/src/config/validate.mjs +83 -1
- package/src/context/pack.mjs +53 -3
- package/src/doctor/local.mjs +22 -4
- package/src/engines/builders/codex.mjs +27 -7
- package/src/engines/builders/grok.mjs +11 -5
- package/src/engines/codex-home.mjs +18 -15
- package/src/engines/sentinel.mjs +11 -0
- package/src/gates/detect.mjs +6 -3
- package/src/gates/run.mjs +295 -27
- package/src/ledger/export.mjs +1 -1
- package/src/ledger/report.mjs +143 -5
- package/src/outcome/outcome.mjs +108 -0
- package/src/pipeline/pipeline.mjs +112 -0
- package/src/proof/red-green.mjs +41 -13
- package/src/proof/runners.mjs +28 -4
- package/src/proof/worktree.mjs +25 -15
- package/src/review/agent-waiver.mjs +18 -4
- package/src/review/engine.mjs +25 -5
- package/src/review/gate-check.mjs +39 -1
- package/src/review/lenses/full.md +1 -0
- package/src/review/lenses/quick.md +1 -0
- package/src/review/packet.mjs +34 -6
- package/src/review/related.mjs +112 -0
- package/src/review/reopen.mjs +4 -3
- package/src/review/session-retry.mjs +59 -0
- package/src/session/spawn.mjs +54 -3
- package/src/state/block.mjs +101 -34
- package/src/state/config-snapshot.mjs +13 -3
- package/src/state/run.mjs +3 -2
- package/src/util/exec.mjs +48 -31
- package/src/util/reaper.mjs +238 -8
- package/src/worker/engine.mjs +7 -3
- package/src/worker/loop.mjs +67 -14
- package/src/worker/queue.mjs +106 -23
- package/src/worker/watch.mjs +2 -2
|
@@ -92,7 +92,7 @@ import { ownsFile } from '../state/registry.mjs';
|
|
|
92
92
|
import { sameStamp, treeStamp } from '../state/block.mjs';
|
|
93
93
|
import { verifyRow } from '../state/signer.mjs';
|
|
94
94
|
import { tierFor } from '../proof/tiers.mjs';
|
|
95
|
-
import { DELETED_HASH, contentHash, gitChildEnv } from '../worker/ticket.mjs';
|
|
95
|
+
import { DELETED_HASH, contentHash, gitChildEnv, readRegularFileNoFollow } from '../worker/ticket.mjs';
|
|
96
96
|
import { verifyResult } from '../worker/queue.mjs';
|
|
97
97
|
import { stopExitsLine } from './stop-exits.mjs';
|
|
98
98
|
import { checkFileForm, checkFindingId } from './verb-forms.mjs';
|
|
@@ -915,6 +915,44 @@ export async function blockFileSet({ repoRoot, base, owned, ownedKinds, matchOwn
|
|
|
915
915
|
return all.filter((f) => isOwned(f)).map((file) => ({ file, content_hash: currentHash(repoRoot, file) }));
|
|
916
916
|
}
|
|
917
917
|
|
|
918
|
+
/** Safety check for npm locks excluded from model review; other formats are not parsed.
|
|
919
|
+
* @param {string} repoRoot @param {string} file @param {Record<string, any> | null} cfg
|
|
920
|
+
* @returns {string | null} refusal detail, or null for a safe (or deleted) lock
|
|
921
|
+
*/
|
|
922
|
+
export function lockfileProblem(repoRoot, file, cfg) {
|
|
923
|
+
if (!['package-lock.json', 'npm-shrinkwrap.json'].includes(path.posix.basename(file))) return null;
|
|
924
|
+
let lock;
|
|
925
|
+
try {
|
|
926
|
+
const bytes = readRegularFileNoFollow(repoRoot, file);
|
|
927
|
+
if (bytes === null) return null;
|
|
928
|
+
lock = JSON.parse(bytes.toString('utf8'));
|
|
929
|
+
} catch { return 'cannot read valid lock JSON'; }
|
|
930
|
+
if (!lock || typeof lock !== 'object' || Array.isArray(lock)) return 'expected a lock object';
|
|
931
|
+
const hosts = cfg?.review?.lockfile_registries ?? ['registry.npmjs.org'];
|
|
932
|
+
/** @param {any} entries @param {string} prefix @param {boolean} recursive @returns {string | null} */
|
|
933
|
+
function checkEntries(entries, prefix, recursive) {
|
|
934
|
+
if (!entries || typeof entries !== 'object' || Array.isArray(entries)) return `${prefix} must be an object`;
|
|
935
|
+
for (const [name, pkg] of Object.entries(entries)) {
|
|
936
|
+
const entry = `${prefix}.${name}`;
|
|
937
|
+
if (!pkg || typeof pkg !== 'object' || Array.isArray(pkg)) return `${entry} must be an object`;
|
|
938
|
+
if (pkg.link !== true && pkg.resolved !== undefined) {
|
|
939
|
+
let url;
|
|
940
|
+
try { url = new URL(pkg.resolved); } catch { return `${entry}.resolved must be an HTTPS URL on an allowed host`; }
|
|
941
|
+
if (url.protocol !== 'https:' || !hosts.includes(url.host)) return `${entry}.resolved must be HTTPS on review.lockfile_registries`;
|
|
942
|
+
if (typeof pkg.integrity !== 'string' || !pkg.integrity.trim()) return `${entry} has no integrity`;
|
|
943
|
+
}
|
|
944
|
+
if (recursive && pkg.dependencies !== undefined) {
|
|
945
|
+
const problem = checkEntries(pkg.dependencies, `${entry}.dependencies`, true);
|
|
946
|
+
if (problem) return problem;
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
return null;
|
|
950
|
+
}
|
|
951
|
+
const problem = checkEntries(lock.packages ?? {}, 'packages', false);
|
|
952
|
+
if (problem) return problem;
|
|
953
|
+
return lock.dependencies === undefined ? null : checkEntries(lock.dependencies, 'dependencies', true);
|
|
954
|
+
}
|
|
955
|
+
|
|
918
956
|
/**
|
|
919
957
|
* Every changed tracked file since `base` (deletions included) ∪ every untracked, not ignored
|
|
920
958
|
* file, through `repoRelativePath`, sorted — the set {@link blockFileSet} filters to the block.
|
|
@@ -19,6 +19,7 @@ Code or tests beyond what the change needs (unused options, extra layers, specul
|
|
|
19
19
|
## Rules for every answer
|
|
20
20
|
|
|
21
21
|
- Judge only what is in the packet; if you need a file you cannot see, return it in `needs_file` instead of guessing.
|
|
22
|
+
- A finding that claims something is missing or wrong across the project must say in `evidence` which related file it could not see, e.g. "assumes no rule in src/styles/base.css".
|
|
22
23
|
- A test named in `## paired` exists; do not report it as unseen; use `needs_file` when its body matters.
|
|
23
24
|
- You have no tools and no project files. The packet is everything you get.
|
|
24
25
|
- `reviewed_hunks`: copy every `@@ … @@` hunk header listed under "hunks" in the packet, exactly and in the same order. A review whose `reviewed_hunks` does not match is discarded.
|
|
@@ -17,6 +17,7 @@ Code or tests beyond what the change needs (unused options, extra layers, specul
|
|
|
17
17
|
## Rules for every answer
|
|
18
18
|
|
|
19
19
|
- Judge only what is in the packet; if you need a file you cannot see, return it in `needs_file` instead of guessing.
|
|
20
|
+
- A finding that claims something is missing or wrong across the project must say in `evidence` which related file it could not see, e.g. "assumes no rule in src/styles/base.css".
|
|
20
21
|
- You have no tools and no project files. The packet is everything you get.
|
|
21
22
|
- `reviewed_hunks`: copy every `@@ … @@` hunk header listed under "hunks" in the packet, exactly and in the same order. A review whose `reviewed_hunks` does not match is discarded.
|
|
22
23
|
- Answer with ONE JSON object that matches the answer schema: `passed`, `summary` (≤ 300 chars), `reviewed_hunks`, `findings[]`, and `needs_file[]` (empty when you need nothing).
|
package/src/review/packet.mjs
CHANGED
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
* ## project rules the rules digest (≤ 60 lines)
|
|
11
11
|
* ## facts the facts-sheet excerpt
|
|
12
12
|
* ## context the current file (whole, or hunk windows — `context.mjs`)
|
|
13
|
-
* ##
|
|
13
|
+
* ## related files (not shown) optional related paths only, dropped first over budget
|
|
14
|
+
* ## paired optional paired-file names and summary (B97), dropped next over budget
|
|
14
15
|
* ## moved code only when code moved to/from another file of the block (`moved.mjs`, B56)
|
|
15
16
|
* ## diff `file:`, the `hunks:` list the reviewer must echo, then the diff
|
|
16
17
|
*
|
|
@@ -21,7 +22,7 @@
|
|
|
21
22
|
* Budget (§4.3): `review.budgets.full_in` (`quick_in` for the quick lens), in tokens estimated as
|
|
22
23
|
* bytes / 4. A diff that alone exceeds it ⇒ `split_required` (a Markdown file is then reviewed
|
|
23
24
|
* section by section — `sections.mjs`, B54). Otherwise over budget ⇒ the context shrinks to
|
|
24
|
-
* ± `min_context_lines` (`minimal`) after dropping paired, then the digest is trimmed from the end; the diff is
|
|
25
|
+
* ± `min_context_lines` (`minimal`) after dropping related files, then paired, then the digest is trimmed from the end; the diff is
|
|
25
26
|
* never cut. The `## moved code` section (B56) is part of that measure: it is counted in the diff-only
|
|
26
27
|
* size (`diffOnlyTokens`, `split_required`) and never trimmed.
|
|
27
28
|
*
|
|
@@ -38,6 +39,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
38
39
|
import { exec } from '../util/exec.mjs';
|
|
39
40
|
import { assertInsideRoot, assertRowPath, gitChildEnv, readRegularFileNoFollow, WorkerError } from '../worker/ticket.mjs';
|
|
40
41
|
import { buildContext, parseDiff, splitLines } from './context.mjs';
|
|
42
|
+
import { relatedFiles } from './related.mjs';
|
|
41
43
|
|
|
42
44
|
export const LENSES = Object.freeze(['quick', 'full', 'recheck', 'A', 'B', 'judge']);
|
|
43
45
|
|
|
@@ -137,6 +139,7 @@ export function assertPacketPath(repoRoot, file) {
|
|
|
137
139
|
* @property {import('./context.mjs').Hunk[]} hunks
|
|
138
140
|
* @property {number} plusCount
|
|
139
141
|
* @property {number} minusCount
|
|
142
|
+
* @property {string[]} [related] - related file names, never their content.
|
|
140
143
|
*/
|
|
141
144
|
|
|
142
145
|
/**
|
|
@@ -171,7 +174,19 @@ export async function readFileDiff({ repoRoot, file, base }) {
|
|
|
171
174
|
const res = await exec(argv, { cwd: repoRoot, env, timeoutMs: GIT_TIMEOUT_MS, ...(kind === 'new' ? { okExitCodes: [0, 1] } : {}) });
|
|
172
175
|
if (res.result !== 'ok') throw new PacketError('git', `git diff failed for ${rel} (exit ${res.code})`);
|
|
173
176
|
const content = onDisk ? readRepoText(repoRoot, rel) : null;
|
|
174
|
-
|
|
177
|
+
const related = res.stdout.trim().length === 0 ? [] : await relatedFiles({
|
|
178
|
+
repoRoot, file: rel,
|
|
179
|
+
listFiles: async () => {
|
|
180
|
+
const tracked = await exec(['git', 'ls-files', '-z'], { cwd: repoRoot, env, timeoutMs: GIT_TIMEOUT_MS });
|
|
181
|
+
return tracked.result === 'ok' ? tracked.stdout.split('\0').filter(Boolean) : [];
|
|
182
|
+
},
|
|
183
|
+
readFile: (full) => {
|
|
184
|
+
const candidate = path.relative(repoRoot, full).split(path.sep).join('/');
|
|
185
|
+
if (isSecretLike(candidate)) return null;
|
|
186
|
+
return readRepoText(repoRoot, assertPacketPath(repoRoot, candidate));
|
|
187
|
+
},
|
|
188
|
+
});
|
|
189
|
+
return { file: rel, kind, diffText: res.stdout, content, related, ...parseDiff(res.stdout) };
|
|
175
190
|
}
|
|
176
191
|
|
|
177
192
|
/**
|
|
@@ -206,7 +221,13 @@ export function assemblePacket({ diff, lens, rulesDigest = '', factsExcerpt = ''
|
|
|
206
221
|
let digest = splitLines(rulesDigest).slice(0, DIGEST_MAX_LINES);
|
|
207
222
|
let ctx = buildContext({ file: diff.file, content: diff.content, hunks: diff.hunks, cfg, mode: contextMode });
|
|
208
223
|
let pairedSection = boundedPaired(paired, budgetFor(cfg, 'full_in'));
|
|
209
|
-
|
|
224
|
+
const names = ['quick', 'full'].includes(lens) ? (diff.related ?? []) : [];
|
|
225
|
+
let relatedSection = names.length > 0 ? `## related files (not shown)\n${names.join('\n')}` : '';
|
|
226
|
+
let text = render(lensText, digest, factsExcerpt, ctx.text, diffSection, moved, pairedSection, relatedSection);
|
|
227
|
+
if (estimateTokens(text) > limit && relatedSection.length > 0) {
|
|
228
|
+
relatedSection = '';
|
|
229
|
+
text = render(lensText, digest, factsExcerpt, ctx.text, diffSection, moved, pairedSection);
|
|
230
|
+
}
|
|
210
231
|
if (estimateTokens(text) > limit && pairedSection.length > 0) {
|
|
211
232
|
pairedSection = '';
|
|
212
233
|
text = render(lensText, digest, factsExcerpt, ctx.text, diffSection, moved);
|
|
@@ -234,7 +255,12 @@ export function assembleLightPacket({ diffs, cfg, rulesDigest = '', factsExcerpt
|
|
|
234
255
|
const context = (mode) => diffs.map((diff) => `### ${diff.file}\n${buildContext({ file: diff.file, content: diff.content, hunks: diff.hunks, cfg, mode }).text}`).join('\n\n');
|
|
235
256
|
let contextMode = 'auto';
|
|
236
257
|
let ctx = context(contextMode);
|
|
237
|
-
|
|
258
|
+
const shown = new Set(diffs.map((diff) => diff.file));
|
|
259
|
+
const related = [...new Set(diffs.flatMap((diff) => diff.related ?? []))].filter((file) => !shown.has(file)).sort().slice(0, 12);
|
|
260
|
+
let text = render(lens, digest, factsExcerpt, ctx, diffText, '', '', related.length > 0 ? `## related files (not shown)\n${related.join('\n')}` : '');
|
|
261
|
+
if (estimateTokens(text) > budget && related.length > 0) {
|
|
262
|
+
text = render(lens, digest, factsExcerpt, ctx, diffText);
|
|
263
|
+
}
|
|
238
264
|
if (estimateTokens(text) > budget) {
|
|
239
265
|
contextMode = 'minimal';
|
|
240
266
|
ctx = context(contextMode);
|
|
@@ -293,8 +319,9 @@ function diffSectionOf(diff) {
|
|
|
293
319
|
* @param {string} lensText @param {string[]} digest @param {string} facts @param {string} context
|
|
294
320
|
* @param {string} diffSection @param {string} [moved] - the `## moved code` section (heading included), '' for none.
|
|
295
321
|
* @param {string} [paired] - the bounded `## paired` section (heading included).
|
|
322
|
+
* @param {string} [related] - the names-only related files section (heading included).
|
|
296
323
|
*/
|
|
297
|
-
function render(lensText, digest, facts, context, diffSection, moved = '', paired = '') {
|
|
324
|
+
function render(lensText, digest, facts, context, diffSection, moved = '', paired = '', related = '') {
|
|
298
325
|
return [
|
|
299
326
|
'# code-forge review packet',
|
|
300
327
|
'## lens',
|
|
@@ -305,6 +332,7 @@ function render(lensText, digest, facts, context, diffSection, moved = '', paire
|
|
|
305
332
|
facts.trim().length > 0 ? facts.trimEnd() : '(none)',
|
|
306
333
|
'## context',
|
|
307
334
|
context.length > 0 ? context : '(none)',
|
|
335
|
+
...(related.length > 0 ? [related] : []),
|
|
308
336
|
...(paired.length > 0 ? [paired] : []),
|
|
309
337
|
...(moved.length > 0 ? [moved] : []),
|
|
310
338
|
'## diff',
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/** Best-effort local import relationships; I/O is supplied by the packet reader. */
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
const EXTENSIONS = ['.js', '.jsx', '.mjs', '.cjs', '.ts', '.tsx', '.mts', '.cts', '.css', '.scss', '.html', '.htm', '.astro', '.vue'];
|
|
5
|
+
const STYLES = new Set(['.css', '.scss']);
|
|
6
|
+
|
|
7
|
+
/** @param {string} file */
|
|
8
|
+
function safePath(file) {
|
|
9
|
+
return typeof file === 'string' && !path.posix.isAbsolute(file) &&
|
|
10
|
+
!/[\\\x00-\x1f\x7f]/.test(file) &&
|
|
11
|
+
!file.split('/').some((part) => ['', '.', '..', '.git', '.code-forge'].includes(part));
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** @param {string} text @param {string} file @returns {string[]} */
|
|
15
|
+
function specifiers(text, file) {
|
|
16
|
+
const ext = path.posix.extname(file).toLowerCase();
|
|
17
|
+
const found = [];
|
|
18
|
+
// CSS and HTML use relative URLs without requiring JS's leading ./ or ../.
|
|
19
|
+
const url = (value) => {
|
|
20
|
+
if (!/^(?:[a-z][a-z\d+.-]*:|\/|[?#])/i.test(value)) found.push(/^\.\.?\//.test(value) ? value : `./${value}`);
|
|
21
|
+
};
|
|
22
|
+
if (!STYLES.has(ext)) {
|
|
23
|
+
// Keep strings as single tokens, discard comments/templates, and never interpret a string's
|
|
24
|
+
// contents as code. This also handles imports in Astro frontmatter and Vue script blocks.
|
|
25
|
+
const tokens = [...text.matchAll(/\/\/[^\r\n]*|\/\*[\s\S]*?\*\/|`(?:\\[\s\S]|[^`\\])*`|'(?:\\[\s\S]|[^'\\])*'|"(?:\\[\s\S]|[^"\\])*"|[\w$]+|[^\s]/g)]
|
|
26
|
+
.map((match) => match[0]).filter((token) => !token.startsWith('//') && !token.startsWith('/*') && !token.startsWith('`'));
|
|
27
|
+
const quoted = (token) => token?.startsWith('"') || token?.startsWith("'");
|
|
28
|
+
for (let i = 0; i < tokens.length; i += 1) {
|
|
29
|
+
const token = tokens[i];
|
|
30
|
+
if (token !== 'import' && token !== 'require') continue;
|
|
31
|
+
if (tokens[i - 1] === '.') continue;
|
|
32
|
+
if (tokens[i + 1] === '(' && quoted(tokens[i + 2]) && tokens[i + 3] === ')') found.push(tokens[i + 2].slice(1, -1));
|
|
33
|
+
else if (token === 'import') {
|
|
34
|
+
if (quoted(tokens[i + 1])) found.push(tokens[i + 1].slice(1, -1));
|
|
35
|
+
else {
|
|
36
|
+
for (let j = i + 1; j < tokens.length && ![';', 'import', 'require', '('].includes(tokens[j]); j += 1) {
|
|
37
|
+
if (tokens[j] === 'from' && quoted(tokens[j + 1])) { found.push(tokens[j + 1].slice(1, -1)); break; }
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
if (STYLES.has(ext) || ['.html', '.htm', '.astro', '.vue'].includes(ext)) {
|
|
44
|
+
const markup = text.replace(/\/\*[\s\S]*?\*\/|<!--[\s\S]*?-->/g, '');
|
|
45
|
+
for (const match of markup.matchAll(/@import\s+(?:url\(\s*(?:'([^']+)'|"([^"]+)"|([^\s)]+))\s*\)|'([^']+)'|"([^"]+)")/gi)) {
|
|
46
|
+
url(match.slice(1).find((value) => value !== undefined));
|
|
47
|
+
}
|
|
48
|
+
if (!STYLES.has(ext)) {
|
|
49
|
+
for (const tag of markup.matchAll(/<(link|script)\b[^>]*>/gi)) {
|
|
50
|
+
const attr = tag[1].toLowerCase() === 'link' ? 'href' : 'src';
|
|
51
|
+
const match = tag[0].match(new RegExp(`\\s${attr}\\s*=\\s*(?:"([^"]+)"|'([^']+)'|([^\\s>]+))`, 'i'));
|
|
52
|
+
if (match) url(match[1] ?? match[2] ?? match[3]);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return found;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Names only: outgoing imports, tracked importers (at most 2000 candidates), and a stylesheet's
|
|
61
|
+
* cascade siblings. A missing/unreadable file is not a relationship. No source text is returned.
|
|
62
|
+
* `listFiles` lists tracked repo-relative paths; `readFile` reads an absolute path or returns null.
|
|
63
|
+
* @param {{repoRoot: string, file: string,
|
|
64
|
+
* listFiles: (repoRoot: string) => string[] | Promise<string[]>,
|
|
65
|
+
* readFile: (file: string) => string | null | Promise<string | null>}} opts
|
|
66
|
+
* @returns {Promise<string[]>}
|
|
67
|
+
*/
|
|
68
|
+
export async function relatedFiles({ repoRoot, file, listFiles, readFile }) {
|
|
69
|
+
if (!safePath(file) || !EXTENSIONS.includes(path.posix.extname(file).toLowerCase())) return [];
|
|
70
|
+
const cache = new Map();
|
|
71
|
+
const read = async (rel) => {
|
|
72
|
+
if (!safePath(rel)) return null;
|
|
73
|
+
if (!cache.has(rel)) {
|
|
74
|
+
try { cache.set(rel, await readFile(path.resolve(repoRoot, rel))); }
|
|
75
|
+
catch { cache.set(rel, null); }
|
|
76
|
+
}
|
|
77
|
+
return cache.get(rel);
|
|
78
|
+
};
|
|
79
|
+
const imports = async (rel) => {
|
|
80
|
+
const text = await read(rel);
|
|
81
|
+
const resolved = new Set();
|
|
82
|
+
if (text === null) return resolved;
|
|
83
|
+
for (const specifier of specifiers(text, rel)) {
|
|
84
|
+
if (!/^\.\.?\//.test(specifier)) continue;
|
|
85
|
+
const target = path.posix.normalize(path.posix.join(path.posix.dirname(rel), specifier.split(/[?#]/)[0]));
|
|
86
|
+
if (!safePath(target)) continue;
|
|
87
|
+
const candidates = [target];
|
|
88
|
+
if (!path.posix.extname(target)) candidates.push(...EXTENSIONS.map((ext) => target + ext), ...EXTENSIONS.map((ext) => `${target}/index${ext}`));
|
|
89
|
+
for (const candidate of candidates) {
|
|
90
|
+
if (await read(candidate) !== null) { resolved.add(candidate); break; }
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return resolved;
|
|
94
|
+
};
|
|
95
|
+
const related = await imports(file);
|
|
96
|
+
let tracked = [];
|
|
97
|
+
try { tracked = [...new Set(await listFiles(repoRoot))].filter((rel) => safePath(rel) && EXTENSIONS.includes(path.posix.extname(rel).toLowerCase())); }
|
|
98
|
+
catch { /* Outgoing imports still help when listing is unavailable. */ }
|
|
99
|
+
if (tracked.length <= 2000) {
|
|
100
|
+
for (const importer of tracked) {
|
|
101
|
+
if (importer === file) continue;
|
|
102
|
+
const targets = await imports(importer);
|
|
103
|
+
if (!targets.has(file)) continue;
|
|
104
|
+
related.add(importer);
|
|
105
|
+
if (STYLES.has(path.posix.extname(file).toLowerCase())) {
|
|
106
|
+
for (const sibling of targets) if (STYLES.has(path.posix.extname(sibling).toLowerCase())) related.add(sibling);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
related.delete(file);
|
|
111
|
+
return [...related].sort().slice(0, 12);
|
|
112
|
+
}
|
package/src/review/reopen.mjs
CHANGED
|
@@ -192,9 +192,10 @@ export function reopenRowFor(rows, { runId, block, file, key, status, state, seq
|
|
|
192
192
|
* contentHash: (file: string) => string,
|
|
193
193
|
* readFile?: (file: string) => Buffer | null,
|
|
194
194
|
* clearDone: (file: string) => boolean,
|
|
195
|
+
* enqueue?: (file: string, row: Record<string, any>) => string | undefined,
|
|
195
196
|
* },
|
|
196
197
|
* }} opts
|
|
197
|
-
* @returns {Promise<{row: Record<string, any>, cleared: boolean, existing: boolean}>}
|
|
198
|
+
* @returns {Promise<{row: Record<string, any>, cleared: boolean, existing: boolean, ticket?: string}>}
|
|
198
199
|
* @throws {StateError} `usage` or a refusal code (see the module doc).
|
|
199
200
|
*/
|
|
200
201
|
export async function reopenFile({ runId, id, file, reason, record, rows, key, writeRow, writeSigned, io, agent }) {
|
|
@@ -255,7 +256,7 @@ export async function reopenFile({ runId, id, file, reason, record, rows, key, w
|
|
|
255
256
|
// so a failed row write leaves at worst a re-run that returns the same stop, never a row with a
|
|
256
257
|
// stale marker
|
|
257
258
|
const cleared = io.clearDone(file);
|
|
258
|
-
if (earlier !== null) return { row: earlier, cleared, existing: true };
|
|
259
|
+
if (earlier !== null) return { row: earlier, cleared, existing: true, ticket: io.enqueue?.(file, earlier) };
|
|
259
260
|
const row = { event: REOPENED_EVENT, block: id, file, from, stop_reason: stopReason, at_seq: loaded.seq, from_hash: fromHash, to_hash: current, reason: reason.trim(), by: agent ? 'agent' : 'human', ...(agent ? { verdict } : {}) };
|
|
260
261
|
/** @type {Record<string, any> | null} */
|
|
261
262
|
let written = null;
|
|
@@ -285,5 +286,5 @@ export async function reopenFile({ runId, id, file, reason, record, rows, key, w
|
|
|
285
286
|
if (threw) Object.defineProperty(refused, 'cause', { value: failure, enumerable: false, configurable: true, writable: true });
|
|
286
287
|
throw refused;
|
|
287
288
|
}
|
|
288
|
-
return { row: written, cleared, existing: false };
|
|
289
|
+
return { row: written, cleared, existing: false, ticket: io.enqueue?.(file, written) };
|
|
289
290
|
}
|
|
@@ -32,10 +32,69 @@
|
|
|
32
32
|
*/
|
|
33
33
|
|
|
34
34
|
import { logWarning } from '../util/error-log.mjs';
|
|
35
|
+
import { resolveLevel } from '../config/known-ids.mjs';
|
|
36
|
+
import { closedBookRefused, openBookCodexAllowed } from '../config/closed-book.mjs';
|
|
35
37
|
|
|
36
38
|
/** How many times a timed-out session is spawned again. */
|
|
37
39
|
export const TIMEOUT_RETRIES = 1;
|
|
38
40
|
|
|
41
|
+
/**
|
|
42
|
+
* A broken answer gets one retry on its model, then the next distinct, runnable model of the
|
|
43
|
+
* level's ladder. Pin each retry to one model so provider-error fallback cannot consume the
|
|
44
|
+
* ladder before the same-model retry. A valid negative answer stops just like a valid positive.
|
|
45
|
+
* The packet and the original config are unchanged throughout.
|
|
46
|
+
* @param {(opts: Record<string, any>) => Promise<{res: Record<string, any> | null, attempts: number}>} spawn
|
|
47
|
+
* @param {Record<string, any>} opts
|
|
48
|
+
* @param {(res: Record<string, any> | null) => boolean} isBroken
|
|
49
|
+
* @param {(res: Record<string, any> | null, cfg: Record<string, any>) => Promise<unknown>} [onBroken]
|
|
50
|
+
* @param {{res: Record<string, any> | null, attempts: number}} [initial] - reuse a completed
|
|
51
|
+
* attempt, including any provider fallback it already took, rather than spawning it again.
|
|
52
|
+
* @returns {Promise<{res: Record<string, any> | null, attempts: number, to?: BrokenTry['to'], usage: {tokens_in: number | null, tokens_out: number | null}}>}
|
|
53
|
+
*/
|
|
54
|
+
export async function spawnWithBrokenFallback(spawn, opts, isBroken, onBroken = async () => {}, initial) {
|
|
55
|
+
const resolved = resolveLevel(opts.cfg, opts.level);
|
|
56
|
+
const models = [resolved, ...resolved.fallback];
|
|
57
|
+
const startAt = initial ? Math.max(0, models.findIndex((entry) => entry.model === initial.res?.model
|
|
58
|
+
&& (entry.provider ?? resolved.provider) === initial.res?.provider)) : 0;
|
|
59
|
+
let pending = initial;
|
|
60
|
+
const seen = new Set();
|
|
61
|
+
let attempts = 0;
|
|
62
|
+
/** @type {Record<string, any> | null} */
|
|
63
|
+
let res = null;
|
|
64
|
+
/** @type {BrokenTry['to'] | undefined} */
|
|
65
|
+
let to;
|
|
66
|
+
/** @type {{tokens_in: number | null, tokens_out: number | null}} */
|
|
67
|
+
const usage = { tokens_in: null, tokens_out: null };
|
|
68
|
+
for (const [index, entry] of models.entries()) {
|
|
69
|
+
if (index < startAt) continue;
|
|
70
|
+
const provider = entry.provider ?? resolved.provider;
|
|
71
|
+
const key = `${provider}\0${entry.model}`;
|
|
72
|
+
if (seen.has(key) || closedBookRefused(provider, opts.role, openBookCodexAllowed(opts.cfg))) continue;
|
|
73
|
+
seen.add(key);
|
|
74
|
+
const cfg = { ...opts.cfg, levels: { ...opts.cfg.levels, [opts.level]: {
|
|
75
|
+
provider, model: entry.model, ...(entry.effort !== undefined ? { effort: entry.effort } : {}), fallback: [],
|
|
76
|
+
} } };
|
|
77
|
+
if (index > 0) to = { level: `levels.${opts.level}.fallback`, provider, model: entry.model };
|
|
78
|
+
for (let n = 0; n < 2; n++) {
|
|
79
|
+
try {
|
|
80
|
+
const out = pending ?? await spawn({ ...opts, cfg });
|
|
81
|
+
pending = undefined;
|
|
82
|
+
res = out.res;
|
|
83
|
+
attempts += out.attempts;
|
|
84
|
+
} catch {
|
|
85
|
+
res = null;
|
|
86
|
+
attempts++;
|
|
87
|
+
}
|
|
88
|
+
for (const field of /** @type {const} */ (['tokens_in', 'tokens_out'])) {
|
|
89
|
+
if (typeof res?.usage?.[field] === 'number') usage[field] = (usage[field] ?? 0) + res.usage[field];
|
|
90
|
+
}
|
|
91
|
+
if (!isBroken(res)) return { res, attempts, ...(to ? { to } : {}), usage };
|
|
92
|
+
await onBroken(res, cfg);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return { res, attempts, ...(to ? { to } : {}), usage };
|
|
96
|
+
}
|
|
97
|
+
|
|
39
98
|
/**
|
|
40
99
|
* @typedef {{res: Record<string, any> | null, attempts: number, to: {level: string, provider: string, model: string}}} BrokenTry -
|
|
41
100
|
* the outcome of a second-level try started by a timed-out attempt (`afterSchemaMiss`).
|
package/src/session/spawn.mjs
CHANGED
|
@@ -51,6 +51,7 @@ import { Ajv2020 } from 'ajv/dist/2020.js';
|
|
|
51
51
|
import { CODEX_CLOSED_BOOK_REFUSAL, closedBookRefused, isNoToolRole, openBookCodexAllowed } from '../config/closed-book.mjs';
|
|
52
52
|
import { resolveLevel } from '../config/known-ids.mjs';
|
|
53
53
|
import { compileSchema } from '../config/schema-compile.mjs';
|
|
54
|
+
import { blockTaskLines, ContextError, readBlockBrief } from '../context/pack.mjs';
|
|
54
55
|
import { buildArgv } from '../engines/builders/index.mjs';
|
|
55
56
|
import { removeCodexHome } from '../engines/codex-home.mjs';
|
|
56
57
|
import { cliNameForProvider } from '../engines/provider-cli.mjs';
|
|
@@ -61,6 +62,7 @@ import { budgetUsdOf, checkBudget, estimateSessionUsd, priceSession, reservedUsd
|
|
|
61
62
|
import { appendRow, readAllRows } from '../ledger/write.mjs';
|
|
62
63
|
import { categoryGate, categoryPlan, RECORD_UNKNOWN_MESSAGE } from '../autopilot/limits.mjs';
|
|
63
64
|
import { StateError } from '../state/paths.mjs';
|
|
65
|
+
import { readRun } from '../state/run.mjs';
|
|
64
66
|
import { exec } from '../util/exec.mjs';
|
|
65
67
|
import { isForbidden, mergeForbidden } from '../util/forbidden.mjs';
|
|
66
68
|
import { semaphore } from '../util/locks.mjs';
|
|
@@ -68,6 +70,7 @@ import { readStartTime, registerPid, UNKNOWN_START_TIME } from '../util/reaper.m
|
|
|
68
70
|
import { asText, stderrTail } from '../util/diagnostic-tail.mjs';
|
|
69
71
|
import { redact, writeSafe } from '../util/redact.mjs';
|
|
70
72
|
import { currentRunRoot, pidsDir, runRoot, tmpBase, untrustedReason } from '../util/tmp.mjs';
|
|
73
|
+
import { assertBlockId, mainRootOf } from '../worker/ticket.mjs';
|
|
71
74
|
|
|
72
75
|
export const ROLES = Object.freeze(['coder', 'reviewer', 'judge', 's2', 'author', 'facts', 'delegate']);
|
|
73
76
|
export const LEVELS = Object.freeze(['L0', 'L1', 'L2', 'L3']);
|
|
@@ -538,6 +541,8 @@ export async function spawnSession(opts, deps = {}) {
|
|
|
538
541
|
if (!LEVELS.includes(level)) throw new SessionError('usage', `level must be one of ${LEVELS.join(', ')}`);
|
|
539
542
|
if (!ROLES.includes(role)) throw new SessionError('usage', `role must be one of ${ROLES.join(', ')}`);
|
|
540
543
|
if (typeof promptPath !== 'string' || !path.isAbsolute(promptPath)) throw new SessionError('usage', 'promptPath must be an absolute path');
|
|
544
|
+
/** @type {string | null} */
|
|
545
|
+
let coderPrompt = null;
|
|
541
546
|
const stderr = deps.stderr ?? process.stderr;
|
|
542
547
|
const runRootDir = opts.runRoot ?? currentRunRoot();
|
|
543
548
|
if (role === 'facts' && opts.cwd !== undefined) assertFactsCwd(opts.cwd, runRootDir);
|
|
@@ -570,6 +575,9 @@ export async function spawnSession(opts, deps = {}) {
|
|
|
570
575
|
// in the `finally` below on every other exit path (a throw, a background start)
|
|
571
576
|
const releaseReservation = gate.release;
|
|
572
577
|
try {
|
|
578
|
+
// Budget refusals take precedence over task loading, including legacy runs without
|
|
579
|
+
// this block. A failed context load must still release the accepted reservation.
|
|
580
|
+
if (role === 'coder' && opts.block && coderPrompt === null) coderPrompt = await coderPromptFor(opts);
|
|
573
581
|
const outcome = await runAttempt(step, i + 1 === steps.length, releaseReservation);
|
|
574
582
|
if (outcome.result) return outcome.result;
|
|
575
583
|
waitMs = outcome.waitMs;
|
|
@@ -602,7 +610,7 @@ export async function spawnSession(opts, deps = {}) {
|
|
|
602
610
|
mkdirSync(sessionDir, { recursive: true, mode: 0o700 });
|
|
603
611
|
let keep = false;
|
|
604
612
|
try {
|
|
605
|
-
const built = buildStep(opts, step, sessionDir, openBook);
|
|
613
|
+
const built = buildStep(opts, step, sessionDir, openBook, coderPrompt);
|
|
606
614
|
const cli = built.cli;
|
|
607
615
|
built.argv[0] = deps.bins?.[cli] ?? built.argv[0];
|
|
608
616
|
if (role === 'coder') {
|
|
@@ -659,6 +667,42 @@ export async function spawnSession(opts, deps = {}) {
|
|
|
659
667
|
}
|
|
660
668
|
}
|
|
661
669
|
|
|
670
|
+
/**
|
|
671
|
+
* Add the recorded task and completion contract without overwriting the caller's brief.
|
|
672
|
+
* @param {SessionOpts} opts @returns {Promise<string>}
|
|
673
|
+
*/
|
|
674
|
+
async function coderPromptFor(opts) {
|
|
675
|
+
let blockId;
|
|
676
|
+
let original;
|
|
677
|
+
try {
|
|
678
|
+
blockId = assertBlockId(opts.block);
|
|
679
|
+
original = readFileSync(opts.promptPath, 'utf8');
|
|
680
|
+
} catch (err) {
|
|
681
|
+
throw new SessionError('usage', err instanceof Error ? err.message : 'the coder brief cannot be read');
|
|
682
|
+
}
|
|
683
|
+
let task = [];
|
|
684
|
+
if (opts.run) {
|
|
685
|
+
let record;
|
|
686
|
+
try {
|
|
687
|
+
record = await readRun(opts.run);
|
|
688
|
+
} catch (err) {
|
|
689
|
+
if (!(err instanceof StateError && err.code === 'no-run')) throw err;
|
|
690
|
+
}
|
|
691
|
+
if (record) {
|
|
692
|
+
const block = record.blocks?.[blockId];
|
|
693
|
+
if (!block) throw new SessionError('usage', `run ${opts.run} has no block ${blockId}`);
|
|
694
|
+
try {
|
|
695
|
+
const repoRoot = await mainRootOf(record.workspace);
|
|
696
|
+
task = blockTaskLines({ brief: readBlockBrief(repoRoot, block), acceptance: block.acceptance }, original);
|
|
697
|
+
} catch (err) {
|
|
698
|
+
if (err instanceof ContextError) throw new SessionError('usage', err.message);
|
|
699
|
+
throw err;
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
return `${original.trimEnd()}\n\n${task.length ? `${task.join('\n')}\n\n` : ''}Report the changed files and pass/fail counts. Print TESTS <command> exit=<n> on its own line before the sentinel. On success, print this exact sentinel as your last line:\n===BLOCK ${blockId} COMPLETE===\n`;
|
|
704
|
+
}
|
|
705
|
+
|
|
662
706
|
/**
|
|
663
707
|
* B33: a session row's dollars from its tokens and the static price table — `{usd, cost_source:
|
|
664
708
|
* 'estimated'}`, or `{usd: null, usd_unknown: true}` when the provider/level has no price.
|
|
@@ -812,8 +856,9 @@ async function checkGate(opts, deps, writeRow, stderr, budget, estimate) {
|
|
|
812
856
|
* @param {{provider: string, model: string, effort?: string, flagFallback?: {provider: string, model: string}}} step
|
|
813
857
|
* @param {string} sessionDir
|
|
814
858
|
* @param {boolean} openBook - B32: `review.allow_open_book_codex`, read once by `spawnSession`.
|
|
859
|
+
* @param {string | null} [coderPrompt]
|
|
815
860
|
*/
|
|
816
|
-
function buildStep(opts, step, sessionDir, openBook) {
|
|
861
|
+
function buildStep(opts, step, sessionDir, openBook, coderPrompt = null) {
|
|
817
862
|
const closedBook = opts.role !== 'coder';
|
|
818
863
|
// B9b.1: the facts delegate may run in the caller's read-only project snapshot; nobody else may
|
|
819
864
|
const snapshot = opts.role === 'facts' && opts.cwd !== undefined ? realpathSync(path.resolve(opts.cwd)) : null; // validated by assertFactsCwd
|
|
@@ -823,11 +868,17 @@ function buildStep(opts, step, sessionDir, openBook) {
|
|
|
823
868
|
if (cli === undefined) throw new SessionError('usage', `provider "${step.provider}" has no CLI mapping`);
|
|
824
869
|
/** @type {Record<string, any>} */
|
|
825
870
|
const params = { provider: step.provider, role: opts.role, model: step.model, promptPath: opts.promptPath, cwd };
|
|
871
|
+
if (coderPrompt !== null) {
|
|
872
|
+
params.promptPath = path.join(sessionDir, 'coder-brief.md');
|
|
873
|
+
writeFileSync(params.promptPath, coderPrompt, { mode: 0o600 });
|
|
874
|
+
}
|
|
826
875
|
if (step.effort) params.effort = step.effort;
|
|
827
876
|
if (step.flagFallback) params.fallback = [step.flagFallback];
|
|
828
877
|
if (cli === 'claude' && opts.maxBudgetUsd !== undefined) params.maxBudgetUsd = opts.maxBudgetUsd;
|
|
829
878
|
if (closedBook && opts.systemPromptText !== undefined && cli !== 'codex') params.systemPromptText = opts.systemPromptText;
|
|
830
879
|
if (cli === 'codex') params.outPath = path.join(sessionDir, 'out.json');
|
|
880
|
+
// The session owns this home: a named run must not use the spawning process's lazy root.
|
|
881
|
+
if (cli === 'codex' && !closedBook) params.runRoot = opts.runRoot ?? currentRunRoot();
|
|
831
882
|
// B32 opt-in: only a no-tools role needs it; coder and facts build exactly as without it
|
|
832
883
|
if (cli === 'codex' && openBook && isNoToolRole(opts.role)) params.allowOpenBook = true;
|
|
833
884
|
// B93: the Codex coder's sandbox profile; no `coder.sandbox` key builds exactly today's argv
|
|
@@ -903,7 +954,7 @@ async function runForeground(built, opts, deps) {
|
|
|
903
954
|
});
|
|
904
955
|
} finally {
|
|
905
956
|
// B4.1: the Codex coder's per-session CODEX_HOME (rules + auth copy) dies with the session.
|
|
906
|
-
if (built.env?.CODEX_HOME) removeCodexHome(built.env.CODEX_HOME);
|
|
957
|
+
if (built.env?.CODEX_HOME) removeCodexHome(built.env.CODEX_HOME, opts.runRoot);
|
|
907
958
|
}
|
|
908
959
|
const duration_ms = Date.now() - started;
|
|
909
960
|
/** @type {string | null} */
|