@codedology/code-forge 0.15.4 → 0.16.0
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 +26 -0
- package/README.md +1 -1
- package/native/bin/darwin-arm64/code-forge-native +0 -0
- package/native/bin/darwin-x64/code-forge-native +0 -0
- package/native/bin/linux-arm64/code-forge-native +0 -0
- package/native/bin/linux-x64/code-forge-native +0 -0
- package/package.json +4 -2
- package/schema/facts.schema.json +1 -1
- package/skill/SKILL.md +1 -1
- package/skill/references/adapters/solo.md +1 -1
- package/skill/references/decisions.md +1 -1
- package/skill/references/plan.md +14 -5
- package/skill/references/review.md +1 -1
- package/skill/scripts/forge +1 -1
- package/skill/templates/facts-sheet.md +9 -3
- package/src/cli/review-file.mjs +5 -0
- package/src/cli/spawn.mjs +26 -22
- package/src/cli/status.mjs +37 -5
- package/src/decide/escalation.mjs +5 -5
- package/src/gates/dependents.mjs +200 -0
- package/src/gates/expand.mjs +83 -26
- package/src/gates/run.mjs +76 -14
- package/src/ledger/tail.mjs +89 -0
- package/src/review/definitions.mjs +170 -0
- package/src/review/fixloop.mjs +4 -4
- package/src/review/gate-check.mjs +20 -4
- package/src/review/packet.mjs +58 -20
- package/src/session/facts.mjs +234 -64
- package/src/session/spawn.mjs +70 -19
- package/src/state/block-end.mjs +3 -1
- package/src/state/block.mjs +17 -5
- package/src/state/run.mjs +12 -5
- package/src/util/native.mjs +2 -0
- package/src/worker/engine.mjs +11 -26
- package/src/worker/loop.mjs +6 -4
- package/src/worker/queue.mjs +2 -1
- package/src/worker/review-state.mjs +15 -3
- package/src/worker/watch.mjs +92 -89
- package/src/worker/worker-warning.mjs +35 -0
package/src/session/facts.mjs
CHANGED
|
@@ -2,14 +2,13 @@
|
|
|
2
2
|
* The facts rule (plan §3.8, C16; block B9b): `forge facts` builds the facts sheet a Plan job
|
|
3
3
|
* must read before it designs anything.
|
|
4
4
|
*
|
|
5
|
-
* Step 1 (deterministic): {@link extractClaims} lists
|
|
6
|
-
*
|
|
7
|
-
* paths, versions, http(s) endpoints, `@scope/name` packages and environment variables.
|
|
5
|
+
* Step 1 (deterministic): {@link extractClaims} lists claim tokens from the brief alone — flags,
|
|
6
|
+
* commands, paths, versions, endpoints, packages, environment variables and opt-in code symbols.
|
|
8
7
|
* Step 2: ONE L0 `facts` session (closed-book; Claude gets `--tools "Bash"` with the forbidden list
|
|
9
8
|
* rendered, §5.2) whose packet is the claim list and the rule "run the cheapest read-only check,
|
|
10
9
|
* quote the command and the output line, tag it, never infer". B9b.1: when the caller names the
|
|
11
10
|
* project (`projectDir`), the session runs in a READ-ONLY SNAPSHOT of it ({@link buildSnapshot}:
|
|
12
|
-
* `git archive HEAD` plus
|
|
11
|
+
* `git archive HEAD` plus in-project sources and outside roots under `_sources/<label>`, symlinks and secret-looking files
|
|
13
12
|
* dropped, every file 0444 and directory 0555) so `./…` path claims can be checked; the snapshot
|
|
14
13
|
* lives under the run root — or, when that root itself sits inside a git work tree, under a
|
|
15
14
|
* private B0.1 root in `os.tmpdir()` (`factsTmpRoot`) — and is removed when the verb ends;
|
|
@@ -43,13 +42,15 @@ import { redact } from '../util/redact.mjs';
|
|
|
43
42
|
import { currentRunRoot } from '../util/tmp.mjs';
|
|
44
43
|
import { factsTmpRoot, gitWorkTreeAncestor, spawnSession } from './spawn.mjs';
|
|
45
44
|
|
|
46
|
-
/** @typedef {'flag'|'command'|'path'|'version'|'endpoint'|'package'|'env'} ClaimKind */
|
|
47
|
-
/** @typedef {{
|
|
45
|
+
/** @typedef {'flag'|'command'|'path'|'version'|'endpoint'|'package'|'env'|'symbol'} ClaimKind */
|
|
46
|
+
/** @typedef {{dir: string, label: string, prefix: string, file?: string}} SourceRoot */
|
|
47
|
+
/** @typedef {{token: string, kind: ClaimKind, source?: string, checkPath?: string, root?: SourceRoot, unavailable?: string}} Claim */
|
|
48
48
|
/**
|
|
49
49
|
* @typedef {object} Fact
|
|
50
50
|
* @property {string} fact_id @property {string} claim @property {ClaimKind} kind
|
|
51
51
|
* @property {string} command @property {string} output_excerpt
|
|
52
52
|
* @property {'VERIFIED'|'NOT-FOUND'|'UNVERIFIABLE'} tag @property {string | null} why
|
|
53
|
+
* @property {string} [source]
|
|
53
54
|
*/
|
|
54
55
|
|
|
55
56
|
export const FACTS_SCHEMA_PATH = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'schema', 'facts.schema.json');
|
|
@@ -123,6 +124,8 @@ const trimTail = (token) => token.replace(/[.,:;]+$/, '');
|
|
|
123
124
|
* @property {string} [briefText] - the brief alone (no sources): only IT can name the project CLI
|
|
124
125
|
* that a command claim gets prefixed with (default: the whole text).
|
|
125
126
|
* @property {string} [projectDir] - project root whose top-level folders admit extensionless paths.
|
|
127
|
+
* @property {boolean} [symbols] - whole code spans naming code identifiers or code file names.
|
|
128
|
+
* @property {boolean} [globPaths] - keep shell glob characters in path tokens (facts only).
|
|
126
129
|
*/
|
|
127
130
|
|
|
128
131
|
/**
|
|
@@ -220,6 +223,7 @@ export function extractClaims(text, opts = {}) {
|
|
|
220
223
|
const token = trimTail(kind === 'env' ? m[0].replace(/^\$/, '') : m[0]);
|
|
221
224
|
if (token.length === 0 || (kind === 'path' && /^(~|\.{1,2})?\/$/.test(token))) continue;
|
|
222
225
|
if (kind === 'path' && !/^(~|\.{1,2})?\//.test(token)
|
|
226
|
+
&& !(opts.globPaths && /[*?\[\]{}]/.test(token))
|
|
223
227
|
&& !/\.[a-zA-Z0-9][\w-]*$/.test(token) && !topLevelFolders.has(token.split('/')[0])) continue;
|
|
224
228
|
found.push({ index: /** @type {number} */ (m.index), token, kind });
|
|
225
229
|
mask(/** @type {number} */ (m.index), m[0].length);
|
|
@@ -227,8 +231,14 @@ export function extractClaims(text, opts = {}) {
|
|
|
227
231
|
};
|
|
228
232
|
scan(...PATTERNS[0]);
|
|
229
233
|
scan(...PATTERNS[1]);
|
|
230
|
-
scan(
|
|
234
|
+
scan('path', opts.globPaths ? /(?<![\w/.~-])(?:~|\.{1,2}|[\w.-]+)?\/[^\s`'"<>)|;]*/g : PATTERNS[2][1]);
|
|
231
235
|
for (const m of [...masked.matchAll(/`([^`\n]+)`/g)]) {
|
|
236
|
+
const symbol = m[1].trim();
|
|
237
|
+
if (opts.symbols && (IDENTIFIER_SYMBOL.test(symbol) || FILE_SYMBOL.test(symbol))) {
|
|
238
|
+
found.push({ index: /** @type {number} */ (m.index) + 1, token: symbol, kind: 'symbol' });
|
|
239
|
+
mask(/** @type {number} */ (m.index) + 1, m[1].length);
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
232
242
|
const cmd = COMMAND_IN_SPAN.exec(m[1]);
|
|
233
243
|
if (!cmd) continue;
|
|
234
244
|
const start = /** @type {number} */ (m.index) + 1;
|
|
@@ -249,35 +259,8 @@ export function extractClaims(text, opts = {}) {
|
|
|
249
259
|
return claims;
|
|
250
260
|
}
|
|
251
261
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
* @param {ReadonlyArray<string>} sources
|
|
255
|
-
* @returns {string[]}
|
|
256
|
-
*/
|
|
257
|
-
function readSourcesText(sources) {
|
|
258
|
-
/** @type {string[]} */
|
|
259
|
-
const parts = [];
|
|
260
|
-
for (const source of sources) {
|
|
261
|
-
const st = statSync(source);
|
|
262
|
-
if (st.isDirectory()) {
|
|
263
|
-
for (const name of readdirSync(source).filter((n) => /\.(md|txt)$/.test(n)).sort()) {
|
|
264
|
-
parts.push(readFileSync(path.join(source, name), 'utf8'));
|
|
265
|
-
}
|
|
266
|
-
} else {
|
|
267
|
-
parts.push(readFileSync(source, 'utf8'));
|
|
268
|
-
}
|
|
269
|
-
}
|
|
270
|
-
return parts;
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
/**
|
|
274
|
-
* The brief's text plus every source it names ({@link readSourcesText}).
|
|
275
|
-
* @param {string} briefPath @param {ReadonlyArray<string>} [sources]
|
|
276
|
-
* @returns {string}
|
|
277
|
-
*/
|
|
278
|
-
export function readClaimText(briefPath, sources = []) {
|
|
279
|
-
return [readFileSync(briefPath, 'utf8'), ...readSourcesText(sources)].join('\n');
|
|
280
|
-
}
|
|
262
|
+
const IDENTIFIER_SYMBOL = /^(?:(?:[A-Z][a-z0-9]+|[A-Z]+(?=[A-Z][a-z]|$)){2,}|[a-z][a-z0-9]*(?:_[a-z0-9]+)+)$/;
|
|
263
|
+
const FILE_SYMBOL = /^[\w.-]+\.(?:php|mjs|cjs|js|ts|tsx|jsx|go|py|rb|swift|kt|java|rs|cs|vue|svelte|sql)$/;
|
|
281
264
|
|
|
282
265
|
/**
|
|
283
266
|
* The brief, read ONCE: its bytes (sha256 and claim text) and its mtime from the same open file.
|
|
@@ -309,12 +292,12 @@ const GLOB_CHARS = new Set(['*', '?', '[', ']', '{', '}']);
|
|
|
309
292
|
* reported instead of parsed, and so is an unquoted `* ? [ ] { }` (a glob expands AFTER the
|
|
310
293
|
* secret-path check and could name what the check never saw): the allow-list never needs one
|
|
311
294
|
* (fail closed).
|
|
312
|
-
* @param {string} command
|
|
295
|
+
* @param {string} command @param {boolean} [allowGlob] - only the exact glob ls proof uses this.
|
|
313
296
|
* @returns {{segments: string[][], separators: string[], pipedOut?: boolean[], refused: string | null}}
|
|
314
297
|
* `separators`: every separator met, in order (`|`, `||`, `&&`, `;`, `&`, newline, CR);
|
|
315
298
|
* `pipedOut[i]`: segment i feeds a single `|` (its output is not the command's output).
|
|
316
299
|
*/
|
|
317
|
-
function splitCommand(command) {
|
|
300
|
+
function splitCommand(command, allowGlob = false) {
|
|
318
301
|
if (/[`]/.test(command)) return { segments: [], separators: [], refused: 'backticks are not allowed' };
|
|
319
302
|
if (/\$/.test(command)) return { segments: [], separators: [], refused: '$ expansion is not allowed' };
|
|
320
303
|
if (/[<>]/.test(command)) return { segments: [], separators: [], refused: 'redirection is not allowed' };
|
|
@@ -358,7 +341,7 @@ function splitCommand(command) {
|
|
|
358
341
|
} else if (c === ' ' || c === '\t') {
|
|
359
342
|
flush();
|
|
360
343
|
} else {
|
|
361
|
-
if (GLOB_CHARS.has(c)) return { segments: [], separators: [], refused: 'unquoted glob characters (* ? [ ] { }) are not allowed' };
|
|
344
|
+
if (!allowGlob && GLOB_CHARS.has(c)) return { segments: [], separators: [], refused: 'unquoted glob characters (* ? [ ] { }) are not allowed' };
|
|
362
345
|
word += c;
|
|
363
346
|
inWord = true;
|
|
364
347
|
}
|
|
@@ -583,7 +566,8 @@ function isSubPath(where) {
|
|
|
583
566
|
function sourceGrep(rest) {
|
|
584
567
|
if (rest.length !== 4 || !GREP_RECURSIVE.has(rest[0]) || rest[1] !== '--') return null;
|
|
585
568
|
const [, , pattern, where] = rest;
|
|
586
|
-
return SOURCE_GREP_FLAG.test(pattern)
|
|
569
|
+
return (SOURCE_GREP_FLAG.test(pattern) || /^(?:(?:class|interface|trait|enum|struct|type|function|func|def) )?[A-Za-z_][A-Za-z0-9_]*$/.test(pattern))
|
|
570
|
+
&& isSubPath(where) ? { pattern, path: where } : null;
|
|
587
571
|
}
|
|
588
572
|
|
|
589
573
|
/**
|
|
@@ -625,16 +609,18 @@ function isSourceFile(file, briefRel) {
|
|
|
625
609
|
* path, is a source file ({@link isSourceFile}) and whose text holds `flag` as a whole token
|
|
626
610
|
* ({@link holdsFlag})? A hit line without the `file:line:` prefix never counts.
|
|
627
611
|
* @param {string} excerpt @param {string} flag @param {string} under - the grep's path.
|
|
628
|
-
* @param {string | undefined} briefRel
|
|
612
|
+
* @param {string | undefined} briefRel @param {boolean} [symbol] @param {string} [prefix]
|
|
629
613
|
* @returns {boolean}
|
|
630
614
|
*/
|
|
631
|
-
function sourceHit(excerpt, flag, under, briefRel) {
|
|
615
|
+
function sourceHit(excerpt, flag, under, briefRel, symbol = false, prefix = '') {
|
|
632
616
|
const scope = relPosix(under);
|
|
633
617
|
return excerpt.split(/\r?\n/).some((line) => {
|
|
634
618
|
const hit = /^([^:\n]+):(\d+):(.*)$/.exec(line.trim());
|
|
635
619
|
if (hit === null) return false;
|
|
636
620
|
const file = relPosix(hit[1]);
|
|
637
|
-
|
|
621
|
+
const sourceFile = prefix ? file.slice(prefix.length + 1) : file;
|
|
622
|
+
return (file === scope || file.startsWith(`${scope}/`)) && file !== briefRel && isSourceFile(sourceFile, prefix ? undefined : briefRel)
|
|
623
|
+
&& (symbol ? new RegExp(`\\b${flag}\\b`).test(hit[3]) : holdsFlag(hit[3], flag));
|
|
638
624
|
});
|
|
639
625
|
}
|
|
640
626
|
|
|
@@ -643,14 +629,15 @@ function sourceHit(excerpt, flag, under, briefRel) {
|
|
|
643
629
|
* hidden (`.x`) or one of {@link NON_SOURCE_FOLDERS}, and — with `topLevel` — one single name (a
|
|
644
630
|
* NOT-FOUND proves absence only over a whole top-level folder). A file (`package.json`,
|
|
645
631
|
* `src/a.mjs`) never counts; with no known cwd nothing can be checked, so nothing counts.
|
|
646
|
-
* @param {string} where @param {string | undefined} cwd @param {boolean} topLevel
|
|
632
|
+
* @param {string} where @param {string | undefined} cwd @param {boolean} topLevel @param {string} [prefix]
|
|
647
633
|
* @returns {boolean}
|
|
648
634
|
*/
|
|
649
|
-
function isSourceFolder(where, cwd, topLevel) {
|
|
635
|
+
function isSourceFolder(where, cwd, topLevel, prefix = '') {
|
|
650
636
|
const rel = relPosix(where);
|
|
651
|
-
|
|
637
|
+
if (prefix && !rel.startsWith(`${prefix}/`)) return false;
|
|
638
|
+
const segs = (prefix ? rel.slice(prefix.length + 1) : rel).split('/');
|
|
652
639
|
if (cwd === undefined || rel.length === 0 || rel === '.' || (topLevel && segs.length !== 1)) return false;
|
|
653
|
-
if (segs.some((seg) => seg.startsWith('.') || NON_SOURCE_FOLDERS.has(seg))) return false;
|
|
640
|
+
if (segs.some((seg) => seg === '_sources' || seg.startsWith('.') || NON_SOURCE_FOLDERS.has(seg))) return false;
|
|
654
641
|
try {
|
|
655
642
|
return statSync(path.join(cwd, rel)).isDirectory();
|
|
656
643
|
} catch {
|
|
@@ -738,6 +725,45 @@ const SOURCE_GREP_CLAIM_MESSAGE = 'a source grep counts only for a flag claim, w
|
|
|
738
725
|
const SOURCE_GREP_HIT_MESSAGE = 'VERIFIED source grep without a file:line: hit in a source file naming the claimed flag (docs, plans, tests, fixtures, text files and the brief do not count); not counted';
|
|
739
726
|
const SOURCE_GREP_SCOPE_MESSAGE = 'NOT-FOUND source grep must search one whole top-level source folder (not docs, plans, test, tests, fixtures or node_modules); not counted';
|
|
740
727
|
const FLAG_PROOF_MESSAGE = 'a flag claim is proved only by grep -rn -- <flag> <source folder>; not counted';
|
|
728
|
+
const SYMBOL_PROOF_MESSAGE = 'a symbol claim needs grep -rn -- <quoted token or keyword token> <top-level source folder> with a file:line: source hit, or find <root> -name <file name>; not counted';
|
|
729
|
+
|
|
730
|
+
/** @param {Fact} row @param {Claim} claim @param {{briefRel?: string, cwd?: string}} ctx */
|
|
731
|
+
function symbolRule(row, claim, ctx) {
|
|
732
|
+
if (readOnlyViolation(row.command)) return SYMBOL_PROOF_MESSAGE;
|
|
733
|
+
const prefix = claim.root?.prefix ?? '';
|
|
734
|
+
if (FILE_SYMBOL.test(claim.token)) {
|
|
735
|
+
const { segments } = splitCommand(row.command);
|
|
736
|
+
const argv = segments[0] ?? [];
|
|
737
|
+
if (argv.length !== 4 || argv[0] !== 'find' || argv[1] !== (prefix || '.') || argv[2] !== '-name' || argv[3] !== claim.token) return SYMBOL_PROOF_MESSAGE;
|
|
738
|
+
if (row.tag === 'VERIFIED' && !row.output_excerpt.split(/\r?\n/).some((line) => {
|
|
739
|
+
const file = relPosix(line.trim());
|
|
740
|
+
return line.trim().endsWith(`/${claim.token}`) && (prefix ? file.startsWith(`${prefix}/`) : !file.startsWith('_sources/')) && isSubPath(file);
|
|
741
|
+
})) return SYMBOL_PROOF_MESSAGE;
|
|
742
|
+
return null;
|
|
743
|
+
}
|
|
744
|
+
const grep = sourceGrepOf(row.command);
|
|
745
|
+
if (!grep || !/^grep -rn -- (['"])[^'"]+\1(?:\s|$)/.test(row.command)
|
|
746
|
+
|| !(grep.pattern === claim.token || /^(class|interface|trait|enum|struct|type|function|func|def) /.test(grep.pattern) && grep.pattern.split(' ')[1] === claim.token)
|
|
747
|
+
|| !isSourceFolder(grep.path, ctx.cwd, true, prefix)) return SYMBOL_PROOF_MESSAGE;
|
|
748
|
+
if (row.tag === 'VERIFIED' && !sourceHit(row.output_excerpt, claim.token, grep.path, ctx.briefRel, true, prefix)) return SYMBOL_PROOF_MESSAGE;
|
|
749
|
+
return null;
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
/** @param {Fact} row @param {Claim} claim */
|
|
753
|
+
function globRule(row, claim) {
|
|
754
|
+
const token = claim.checkPath ?? claim.token;
|
|
755
|
+
const { segments, separators, refused } = splitCommand(row.command, true);
|
|
756
|
+
const argv = segments[0] ?? [];
|
|
757
|
+
const ls = argv.length === 3 && argv[0] === 'ls' && argv[1] === '-d' && argv[2] === token;
|
|
758
|
+
const find = !/[*?\[\]{}]/.test(path.posix.dirname(token))
|
|
759
|
+
&& argv.length === 4 && argv[0] === 'find' && argv[1] === path.posix.dirname(token)
|
|
760
|
+
&& argv[2] === '-name' && argv[3] === path.posix.basename(token) && readOnlyViolation(row.command) === null
|
|
761
|
+
&& /-name (['"])[^'"]+\1$/.test(row.command);
|
|
762
|
+
if (refused || separators.length || segments.length !== 1 || (!ls && !find) || segmentViolation(argv)) return 'a glob needs find <literal dir> -name <quoted pattern> or ls -d <exact glob>; not counted';
|
|
763
|
+
if (row.tag === 'NOT-FOUND') return /No such file/i.test(row.output_excerpt) ? null : 'a NOT-FOUND glob needs No such file output; not counted';
|
|
764
|
+
return row.output_excerpt.split(/\r?\n/).some((line) => path.matchesGlob(relPosix(line.trim()), relPosix(token)))
|
|
765
|
+
? null : 'VERIFIED glob without a matching output path; not counted';
|
|
766
|
+
}
|
|
741
767
|
|
|
742
768
|
/**
|
|
743
769
|
* Does a grep hit line hold `flag` as a whole token — not preceded by a word character or `-`, not
|
|
@@ -759,6 +785,11 @@ function holdsFlag(excerpt, flag) {
|
|
|
759
785
|
function ruleBroken(row, claim, ctx) {
|
|
760
786
|
if (row.tag === 'UNVERIFIABLE') return null;
|
|
761
787
|
if (row.command.trim().length === 0) return `${row.tag} without a check command; not counted`;
|
|
788
|
+
if (claim.kind === 'path' && (claim.token.startsWith('~') || /(^|\/)\.\.(\/|$)/.test(claim.token))) {
|
|
789
|
+
return `check command not allowed: ${pathViolation(claim.token)}; not counted`;
|
|
790
|
+
}
|
|
791
|
+
if (claim.kind === 'symbol') return symbolRule(row, claim, ctx);
|
|
792
|
+
if (claim.kind === 'path' && /[*?\[\]{}]/.test(claim.token)) return globRule(row, claim);
|
|
762
793
|
if (claim.kind === 'env') {
|
|
763
794
|
const env = ENV_CHECK.exec(row.command);
|
|
764
795
|
if (!env || env[1] !== claim.token) return `an env claim may only be checked with printenv ${claim.token} >/dev/null; not counted`;
|
|
@@ -773,9 +804,9 @@ function ruleBroken(row, claim, ctx) {
|
|
|
773
804
|
// prove itself); a NOT-FOUND one needs one whole top-level source folder
|
|
774
805
|
const grep = sourceGrepOf(row.command);
|
|
775
806
|
if (grep !== null && (claim.kind !== 'flag' || grep.pattern !== claim.token)) return SOURCE_GREP_CLAIM_MESSAGE;
|
|
776
|
-
if (claim.kind === 'flag' && (grep === null || !isSourceFolder(grep.path, ctx.cwd, false))) return FLAG_PROOF_MESSAGE;
|
|
777
|
-
if (grep !== null && row.tag === 'VERIFIED' && !sourceHit(row.output_excerpt, grep.pattern, grep.path, ctx.briefRel)) return SOURCE_GREP_HIT_MESSAGE;
|
|
778
|
-
if (grep !== null && row.tag === 'NOT-FOUND' && !isSourceFolder(grep.path, ctx.cwd, true)) return SOURCE_GREP_SCOPE_MESSAGE;
|
|
807
|
+
if (claim.kind === 'flag' && (grep === null || !isSourceFolder(grep.path, ctx.cwd, false, claim.root?.prefix))) return FLAG_PROOF_MESSAGE;
|
|
808
|
+
if (grep !== null && row.tag === 'VERIFIED' && !sourceHit(row.output_excerpt, grep.pattern, grep.path, ctx.briefRel, false, claim.root?.prefix)) return SOURCE_GREP_HIT_MESSAGE;
|
|
809
|
+
if (grep !== null && row.tag === 'NOT-FOUND' && !isSourceFolder(grep.path, ctx.cwd, true, claim.root?.prefix)) return SOURCE_GREP_SCOPE_MESSAGE;
|
|
779
810
|
}
|
|
780
811
|
if (row.tag === 'VERIFIED' && row.output_excerpt.trim().length === 0) return 'VERIFIED with an empty output excerpt; not counted';
|
|
781
812
|
return null;
|
|
@@ -837,6 +868,7 @@ export function validateFacts(answer, claims, opts = {}) {
|
|
|
837
868
|
});
|
|
838
869
|
claims.forEach((claim, i) => {
|
|
839
870
|
if (chosen[i]) return;
|
|
871
|
+
if (claims.filter((c) => c.token === claim.token).length > 1) return;
|
|
840
872
|
const row = rows.find((r) => !taken.has(r) && r.kind === claim.kind && echoedClaim(r.claim, claim.kind) === claim.token);
|
|
841
873
|
if (row) taken.add(row);
|
|
842
874
|
chosen[i] = row;
|
|
@@ -844,11 +876,12 @@ export function validateFacts(answer, claims, opts = {}) {
|
|
|
844
876
|
claims.forEach((claim, i) => {
|
|
845
877
|
const id = `F${i + 1}`;
|
|
846
878
|
const row = chosen[i];
|
|
847
|
-
|
|
848
|
-
|
|
879
|
+
const source = claim.source === undefined ? {} : { source: redact(claim.source) };
|
|
880
|
+
if (claim.unavailable || !row) {
|
|
881
|
+
facts.push({ fact_id: id, claim: redact(claim.token), kind: claim.kind, ...source, command: '', output_excerpt: '', tag: 'UNVERIFIABLE', why: redact(claim.unavailable ?? 'no answer from the delegate') });
|
|
849
882
|
return;
|
|
850
883
|
}
|
|
851
|
-
const base = { fact_id: id, claim: redact(claim.token), kind: claim.kind, command: redact(row.command), output_excerpt: claim.kind === 'env' ? '' : redact(row.output_excerpt) };
|
|
884
|
+
const base = { fact_id: id, claim: redact(claim.token), kind: claim.kind, ...source, command: redact(row.command), output_excerpt: claim.kind === 'env' ? '' : redact(row.output_excerpt) };
|
|
852
885
|
const fields = nullFields.get(row);
|
|
853
886
|
const broken = fields ? `delegate returned null ${fields.join(', ')}` : ruleBroken(row, claim, opts);
|
|
854
887
|
if (broken) {
|
|
@@ -881,6 +914,7 @@ const code = (text) => (text.length === 0 ? '—' : text.includes('`') ? `\`\` $
|
|
|
881
914
|
*/
|
|
882
915
|
export function renderSheet({ briefRef, briefSha, briefMtime, builtAt, facts }) {
|
|
883
916
|
const count = (/** @type {string} */ tag) => facts.filter((f) => f.tag === tag).length;
|
|
917
|
+
const sources = facts.some((f) => f.source !== undefined);
|
|
884
918
|
const lines = [
|
|
885
919
|
'# Facts sheet',
|
|
886
920
|
'',
|
|
@@ -890,9 +924,9 @@ export function renderSheet({ briefRef, briefSha, briefMtime, builtAt, facts })
|
|
|
890
924
|
`- built_at: \`${builtAt}\``,
|
|
891
925
|
`- claims: ${facts.length} (VERIFIED ${count('VERIFIED')} · NOT-FOUND ${count('NOT-FOUND')} · UNVERIFIABLE ${count('UNVERIFIABLE')})`,
|
|
892
926
|
'',
|
|
893
|
-
|
|
894
|
-
'
|
|
895
|
-
...facts.map((f) => `| ${f.fact_id} | ${f.tag} | ${f.kind}
|
|
927
|
+
`| fact_id | tag | kind |${sources ? ' source |' : ''} claim | command | output | why |`,
|
|
928
|
+
`|---|---|---|---|---|---|---|${sources ? '---|' : ''}`,
|
|
929
|
+
...facts.map((f) => `| ${f.fact_id} | ${f.tag} | ${f.kind} |${sources ? ` ${cell(f.source ?? '—')} |` : ''} ${cell(code(f.claim))} | ${cell(code(f.command))} | ${cell(f.output_excerpt || '—')} | ${cell(f.why ?? '—')} |`),
|
|
896
930
|
'',
|
|
897
931
|
'<!-- facts-json',
|
|
898
932
|
`[\n${facts.map((f) => JSON.stringify(f)).join(',\n')}\n]`.replace(/</g, '\\u003c').replace(/>/g, '\\u003e'),
|
|
@@ -961,13 +995,24 @@ const RULE = [
|
|
|
961
995
|
'-not ! -a -o -and -or, plus paths), stat, file, uname, sw_vers, jq, git',
|
|
962
996
|
'log|show|status|rev-parse|ls-files|cat-file, npm view|ls, test -e|-f|-d|-n, `curl -sI <one URL>`.',
|
|
963
997
|
'A check that breaks these rules is not counted.',
|
|
964
|
-
'
|
|
998
|
+
'A source root is the project (`.`) or `_sources/<label>`. A top-level source folder is directly',
|
|
999
|
+
'under that root: `src` or `_sources/<label>/app`. `_sources` itself is never a source folder.',
|
|
1000
|
+
'An identifier symbol needs `grep -rn -- <quoted token or keyword token> <top-level source folder>`.',
|
|
1001
|
+
'Quote the pattern. Keywords: class interface trait enum struct type function func def.',
|
|
1002
|
+
'VERIFIED needs a file:line: hit in a source file holding the whole token; NOT-FOUND needs a whole top-level source folder.',
|
|
1003
|
+
'A file-name symbol needs `find <root> -name <token>`; VERIFIED needs an output path ending in /<token>.',
|
|
1004
|
+
'For a glob path use the per-claim check command. A find directory must be literal and its pattern quoted.',
|
|
1005
|
+
'If a directory segment has glob characters, only `ls -d <exact glob token>` is accepted. For any glob claim,',
|
|
1006
|
+
'`ls -d <exact glob token>` is also accepted, quoted or unquoted. VERIFIED needs a matching output path;',
|
|
1007
|
+
'NOT-FOUND needs No such file output. Use the check path given for a source, never its live absolute path.',
|
|
1008
|
+
'No `$`, no backticks, no redirection, no unquoted glob except the exact ls -d form above, no `~`, no absolute path outside /usr, /opt/homebrew or',
|
|
965
1009
|
'/bin, no secret files.',
|
|
966
1010
|
'An env claim is checked ONLY with `printenv NAME >/dev/null` (quote its exit status; the value is never shown).',
|
|
967
1011
|
'',
|
|
968
1012
|
'Reply with ONE JSON object: {"facts": [{"fact_id", "claim", "kind", "command", "output_excerpt", "tag", "why"}]} —',
|
|
969
1013
|
'one row per claim: `fact_id` (F<n>) and `kind` copied from the list, `claim` = the token alone (the text after',
|
|
970
1014
|
'`<kind>: `), `why` null unless UNVERIFIABLE.',
|
|
1015
|
+
'The claim is the token alone, without source/check annotations or the bracketed glob check hint.',
|
|
971
1016
|
'',
|
|
972
1017
|
'## Claims',
|
|
973
1018
|
'',
|
|
@@ -978,7 +1023,23 @@ const RULE = [
|
|
|
978
1023
|
* @returns {string} the delegate's packet.
|
|
979
1024
|
*/
|
|
980
1025
|
export function buildFactsPacket(claims) {
|
|
981
|
-
return `${RULE}${claims.
|
|
1026
|
+
return `${RULE}${claims.flatMap((c, i) => {
|
|
1027
|
+
if (c.unavailable) return [];
|
|
1028
|
+
const token = c.checkPath ?? c.token;
|
|
1029
|
+
let hint = '';
|
|
1030
|
+
if (c.kind === 'path' && /[*?\[\]{}]/.test(c.token)) {
|
|
1031
|
+
const dir = path.posix.dirname(token);
|
|
1032
|
+
hint = /[*?\[\]{}]/.test(dir)
|
|
1033
|
+
? ` [check with ls -d ${shellQuote(token)}]`
|
|
1034
|
+
: ` [check with find ${shellQuote(dir)} -name ${shellQuote(path.posix.basename(token), true)}]`;
|
|
1035
|
+
}
|
|
1036
|
+
return [`- F${i + 1} ${c.kind}: ${c.token}${c.source === undefined ? (c.checkPath ? ` (check at ${c.checkPath})` : '') : ` (source ${c.source}, check at ${c.checkPath})`}${hint}`];
|
|
1037
|
+
}).join('\n')}\n`;
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
/** @param {string} value @param {boolean} [always] */
|
|
1041
|
+
function shellQuote(value, always = false) {
|
|
1042
|
+
return !always && /^[\w./-]+$/.test(value) ? value : "'" + value.replace(/'/g, "'\\''") + "'";
|
|
982
1043
|
}
|
|
983
1044
|
|
|
984
1045
|
/**
|
|
@@ -992,6 +1053,7 @@ export function buildFactsPacket(claims) {
|
|
|
992
1053
|
* @property {string} [projectDir] - the project (the verb's cwd): the delegate runs in a read-only
|
|
993
1054
|
* snapshot of its git HEAD plus the in-project `sources`; absent ⇒ an empty cwd.
|
|
994
1055
|
* @property {() => Date} [now]
|
|
1056
|
+
* @property {typeof exec} [gitRun] - real git runner seam for the batched availability check.
|
|
995
1057
|
*/
|
|
996
1058
|
|
|
997
1059
|
// ---------------------------------------------------------------------------------------------
|
|
@@ -1136,13 +1198,91 @@ function copyNoFollow(src, dst, snapDir) {
|
|
|
1136
1198
|
}
|
|
1137
1199
|
}
|
|
1138
1200
|
|
|
1201
|
+
/** @param {string} root @param {string} file */
|
|
1202
|
+
function belowRoot(root, file) {
|
|
1203
|
+
const rel = path.relative(root, file);
|
|
1204
|
+
return rel === '' || (!rel.startsWith(`..${path.sep}`) && rel !== '..' && !path.isAbsolute(rel));
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
/** @param {string | undefined} projectDir @param {ReadonlyArray<string>} sources @returns {SourceRoot[]} */
|
|
1208
|
+
function sourceRoots(projectDir, sources) {
|
|
1209
|
+
/** @type {SourceRoot[]} */
|
|
1210
|
+
const roots = [];
|
|
1211
|
+
const project = projectDir === undefined ? undefined : realpathSync(projectDir);
|
|
1212
|
+
/** @param {string} dir @param {string} [file] */
|
|
1213
|
+
const add = (dir, file) => {
|
|
1214
|
+
const existing = roots.find((r) => r.dir === dir);
|
|
1215
|
+
if (existing && (!existing.file || existing.file === file)) return;
|
|
1216
|
+
const base = path.basename(dir);
|
|
1217
|
+
let label = base;
|
|
1218
|
+
for (let n = 2; roots.some((r) => r.label === label); n += 1) label = `${base}-${n}`;
|
|
1219
|
+
roots.push({ dir, label, prefix: dir === project ? '' : `_sources/${label}`, ...(file ? { file } : {}) });
|
|
1220
|
+
};
|
|
1221
|
+
if (project !== undefined) add(project);
|
|
1222
|
+
for (const source of sources) {
|
|
1223
|
+
const real = realpathSync(source);
|
|
1224
|
+
if (project !== undefined && belowRoot(project, real)) continue;
|
|
1225
|
+
if (statSync(real).isDirectory()) add(real);
|
|
1226
|
+
else add(path.dirname(real), real);
|
|
1227
|
+
}
|
|
1228
|
+
return roots;
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
/** @param {ReadonlyArray<Claim>} claims @param {SourceRoot[]} roots @returns {Claim[]} */
|
|
1232
|
+
function mapClaims(claims, roots) {
|
|
1233
|
+
// An explicitly named outside file is mounted, but only directories multiply relative claims.
|
|
1234
|
+
const directories = roots.filter((r) => !r.file);
|
|
1235
|
+
return claims.flatMap((claim) => {
|
|
1236
|
+
if (claim.kind === 'path' && path.isAbsolute(claim.token)) {
|
|
1237
|
+
if (claim.token === '/dev' || claim.token.startsWith('/dev/')) return [];
|
|
1238
|
+
// A nested source is the most specific root for an absolute path.
|
|
1239
|
+
const root = [...roots].sort((a, b) => b.dir.length - a.dir.length).find((r) => belowRoot(r.dir, claim.token) && (!r.file || r.file === claim.token));
|
|
1240
|
+
if (root) return [{ ...claim, ...(roots.length > 1 || root.prefix !== '' ? { source: root.label } : {}), root, checkPath: path.posix.join(root.prefix, path.relative(root.dir, claim.token).split(path.sep).join('/')) || '.' }];
|
|
1241
|
+
if (TOOL_ROOTS.some((r) => belowRoot(r, claim.token))) return [claim];
|
|
1242
|
+
try {
|
|
1243
|
+
if (!statSync('/' + claim.token.split('/')[1]).isDirectory()) return [];
|
|
1244
|
+
} catch { return []; }
|
|
1245
|
+
return [{ ...claim, unavailable: 'outside every --sources root and the snapshot' }];
|
|
1246
|
+
}
|
|
1247
|
+
if (directories.length < 2 || !['path', 'symbol', 'flag'].includes(claim.kind)) return [claim];
|
|
1248
|
+
return directories.map((root) => ({ ...claim, source: root.label, root,
|
|
1249
|
+
checkPath: claim.kind === 'path' ? (root.prefix ? `${root.prefix}/${claim.token}` : claim.token) : root.prefix || '.',
|
|
1250
|
+
}));
|
|
1251
|
+
});
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
/**
|
|
1255
|
+
* One git process per root, never per claim. Only lstat checks working-tree presence; Node never
|
|
1256
|
+
* searches source text. A path omitted by the snapshot cannot become a delegate NOT-FOUND.
|
|
1257
|
+
* @param {Claim[]} claims @param {SourceRoot[]} roots @param {string} cwd @param {typeof exec} run
|
|
1258
|
+
*/
|
|
1259
|
+
async function markUnavailable(claims, roots, cwd, run) {
|
|
1260
|
+
const directories = roots.filter((r) => !r.file);
|
|
1261
|
+
for (const root of roots) {
|
|
1262
|
+
const paths = claims.filter((c) => c.kind === 'path' && !c.unavailable && (c.root === root || !c.root && directories.length === 1 && root === directories[0] && !path.isAbsolute(c.token))
|
|
1263
|
+
&& pathViolation(c.checkPath ?? c.token) === null);
|
|
1264
|
+
const relatives = paths.map((c) => path.isAbsolute(c.token) ? path.relative(root.dir, c.token) || '.' : c.token);
|
|
1265
|
+
const checked = await run(['git', 'check-ignore', '-z', '--stdin'], {
|
|
1266
|
+
cwd: root.dir, env: gitEnv(), timeoutMs: GIT_TIMEOUT_MS, input: relatives.map((p) => `${p}\0`).join(''), okExitCodes: [0, 1],
|
|
1267
|
+
});
|
|
1268
|
+
if (checked.result !== 'ok' && !(checked.code === 128 && /not a git repository/i.test(checked.stderr))) throw new FactsError('snapshot', 'git check-ignore could not check the source paths');
|
|
1269
|
+
const ignored = new Set(checked.stdout.split('\0').filter(Boolean));
|
|
1270
|
+
paths.forEach((claim, i) => {
|
|
1271
|
+
if (ignored.has(relatives[i])) claim.unavailable = `git-ignored in ${root.label}: the snapshot leaves ignored paths out`;
|
|
1272
|
+
else if (lstatOrNull(path.join(root.dir, relatives[i])) && !lstatOrNull(path.join(cwd, claim.checkPath ?? claim.token))) {
|
|
1273
|
+
claim.unavailable = `not at HEAD in ${root.label}: outside the snapshot`;
|
|
1274
|
+
}
|
|
1275
|
+
});
|
|
1276
|
+
}
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1139
1279
|
/**
|
|
1140
1280
|
* Build the facts delegate's read-only view of the project at `snapDir`: the tracked tree at HEAD
|
|
1141
1281
|
* (`git archive` + `tar -x`, binary-safe, the real index and tree never touched; every symlink and
|
|
1142
1282
|
* special entry dropped right after extraction) plus every `source` inside the project at its
|
|
1143
|
-
* current content ({@link copyNoFollow}; untracked sources included
|
|
1144
|
-
*
|
|
1145
|
-
*
|
|
1283
|
+
* current content ({@link copyNoFollow}; untracked sources included). Outside source roots live
|
|
1284
|
+
* under `_sources/<label>`: their HEAD subtree, or a no-follow copy for non-git sources and files.
|
|
1285
|
+
* Not a git repo, or no commit yet (`git rev-parse --verify HEAD` fails) ⇒ the sources
|
|
1146
1286
|
* alone; HEAD resolves but the archive or the extraction fails ⇒ `FactsError('snapshot')`. The
|
|
1147
1287
|
* tar file never outlives the call.
|
|
1148
1288
|
* @param {string} projectDir @param {ReadonlyArray<string>} sources - absolute.
|
|
@@ -1183,6 +1323,34 @@ export async function buildSnapshot(projectDir, sources, snapDir, workDir, run =
|
|
|
1183
1323
|
if (inProject === '' || inProject.startsWith('..') || path.isAbsolute(inProject)) continue;
|
|
1184
1324
|
copyNoFollow(real, path.join(sessionCwd, inProject), snapDir);
|
|
1185
1325
|
}
|
|
1326
|
+
for (const source of sourceRoots(projectDir, sources).filter((r) => r.prefix)) {
|
|
1327
|
+
const dest = path.join(sessionCwd, source.prefix);
|
|
1328
|
+
assertNoLinkSegments(snapDir, dest);
|
|
1329
|
+
mkdirSync(dest, { recursive: true, mode: 0o700 });
|
|
1330
|
+
if (source.file) {
|
|
1331
|
+
copyNoFollow(source.file, path.join(dest, path.basename(source.file)), snapDir);
|
|
1332
|
+
continue;
|
|
1333
|
+
}
|
|
1334
|
+
const sourceTop = await run(['git', 'rev-parse', '--show-toplevel'], { cwd: source.dir, env: gitEnv(), timeoutMs: GIT_TIMEOUT_MS });
|
|
1335
|
+
if (sourceTop.result !== 'ok') {
|
|
1336
|
+
copyNoFollow(source.dir, dest, snapDir);
|
|
1337
|
+
continue;
|
|
1338
|
+
}
|
|
1339
|
+
const repo = realpathSync(sourceTop.stdout.trim());
|
|
1340
|
+
const head = await run(['git', 'rev-parse', '--verify', '-q', 'HEAD'], { cwd: repo, env: gitEnv(), timeoutMs: GIT_TIMEOUT_MS });
|
|
1341
|
+
if (head.result !== 'ok') continue;
|
|
1342
|
+
const subfolder = path.relative(repo, source.dir).split(path.sep).join('/');
|
|
1343
|
+
const tarPath = path.join(workDir, 'source.tar');
|
|
1344
|
+
try {
|
|
1345
|
+
const archived = await run(['git', 'archive', '--format=tar', '-o', tarPath, subfolder ? `HEAD:${subfolder}` : 'HEAD'], { cwd: repo, env: gitEnv(), timeoutMs: GIT_TIMEOUT_MS });
|
|
1346
|
+
if (archived.result !== 'ok') throw new FactsError('snapshot', `source ${source.label} could not be archived`);
|
|
1347
|
+
const untar = await run(['tar', '-xf', tarPath, '-C', dest], { timeoutMs: GIT_TIMEOUT_MS });
|
|
1348
|
+
if (untar.result !== 'ok') throw new FactsError('snapshot', `source ${source.label} could not be extracted`);
|
|
1349
|
+
dropLinks(dest);
|
|
1350
|
+
} finally {
|
|
1351
|
+
rmSync(tarPath, { force: true });
|
|
1352
|
+
}
|
|
1353
|
+
}
|
|
1186
1354
|
sealTree(snapDir);
|
|
1187
1355
|
return sessionCwd;
|
|
1188
1356
|
}
|
|
@@ -1202,8 +1370,9 @@ export async function buildFacts(opts, deps = {}) {
|
|
|
1202
1370
|
if (opts.projectDir !== undefined) {
|
|
1203
1371
|
try { projectBins = readProjectBins(opts.projectDir); } catch { projectBins = []; }
|
|
1204
1372
|
}
|
|
1205
|
-
const extractOpts =
|
|
1206
|
-
const
|
|
1373
|
+
const extractOpts = { symbols: true, globPaths: true, projectBins, briefText, projectDir: opts.projectDir };
|
|
1374
|
+
const roots = sourceRoots(opts.projectDir, opts.sources ?? []);
|
|
1375
|
+
const claims = mapClaims(extractClaims(briefText, extractOpts), roots);
|
|
1207
1376
|
const lines = briefText.split(/\r?\n/).length - Number(briefText.endsWith('\n'));
|
|
1208
1377
|
if (lines > 40 && claims.length < 5) {
|
|
1209
1378
|
(deps.stderr ?? process.stderr).write(`forge facts: the brief has ${lines} lines but only ${claims.length} claims; the sheet checks almost nothing\n`);
|
|
@@ -1218,8 +1387,9 @@ export async function buildFacts(opts, deps = {}) {
|
|
|
1218
1387
|
const packetPath = path.join(dir, 'packet.md');
|
|
1219
1388
|
const snapDir = path.join(dir, 'snapshot');
|
|
1220
1389
|
try {
|
|
1221
|
-
writeFileSync(packetPath, buildFactsPacket(claims), { mode: 0o600 });
|
|
1222
1390
|
const cwd = opts.projectDir === undefined ? undefined : await buildSnapshot(opts.projectDir, opts.sources ?? [], snapDir, dir);
|
|
1391
|
+
if (cwd !== undefined) await markUnavailable(claims, roots, cwd, opts.gitRun ?? exec);
|
|
1392
|
+
writeFileSync(packetPath, buildFactsPacket(claims), { mode: 0o600 });
|
|
1223
1393
|
const result = await spawnSession(
|
|
1224
1394
|
{
|
|
1225
1395
|
cfg: opts.cfg,
|
package/src/session/spawn.mjs
CHANGED
|
@@ -45,7 +45,8 @@
|
|
|
45
45
|
|
|
46
46
|
import { spawn } from 'node:child_process';
|
|
47
47
|
import { createHash, randomBytes } from 'node:crypto';
|
|
48
|
-
import { closeSync, existsSync, lstatSync, mkdirSync, openSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
48
|
+
import { closeSync, existsSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
49
|
+
import os from 'node:os';
|
|
49
50
|
import path from 'node:path';
|
|
50
51
|
import { Ajv2020 } from 'ajv/dist/2020.js';
|
|
51
52
|
import { CODEX_CLOSED_BOOK_REFUSAL, closedBookRefused, isNoToolRole, openBookCodexAllowed } from '../config/closed-book.mjs';
|
|
@@ -531,22 +532,17 @@ function assertFactsCwd(cwd, runRootDir) {
|
|
|
531
532
|
if (workTree !== null) throw refuse(`inside a git work tree (${workTree})`);
|
|
532
533
|
}
|
|
533
534
|
|
|
534
|
-
/**
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
* @returns {Promise<SessionResult>}
|
|
538
|
-
*/
|
|
539
|
-
export async function spawnSession(opts, deps = {}) {
|
|
540
|
-
const { cfg, level, role, promptPath } = opts;
|
|
535
|
+
/** @param {SessionOpts} opts */
|
|
536
|
+
function validateSessionOpts(opts) {
|
|
537
|
+
const { level, role, promptPath } = opts;
|
|
541
538
|
if (!LEVELS.includes(level)) throw new SessionError('usage', `level must be one of ${LEVELS.join(', ')}`);
|
|
542
539
|
if (!ROLES.includes(role)) throw new SessionError('usage', `role must be one of ${ROLES.join(', ')}`);
|
|
543
540
|
if (typeof promptPath !== 'string' || !path.isAbsolute(promptPath)) throw new SessionError('usage', 'promptPath must be an absolute path');
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
const writeRow = deps.writeRow ?? (opts.slug ? (/** @type {Record<string, any>} */ row) => appendRow(row, { slug: /** @type {string} */ (opts.slug) }) : null);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/** @param {SessionOpts} opts @param {{write: (s: string) => unknown}} stderr */
|
|
544
|
+
function sessionSteps(opts, stderr) {
|
|
545
|
+
const { cfg, level, role } = opts;
|
|
550
546
|
const ladder = ladderFor(resolveLevel(cfg, level), role);
|
|
551
547
|
// B32: Codex has no no-tools mode, so it runs no reviewer/judge/S2/author session unless the
|
|
552
548
|
// config opts in (`review.allow_open_book_codex: true`). Without it, step 0 on openai refuses the
|
|
@@ -556,6 +552,53 @@ export async function spawnSession(opts, deps = {}) {
|
|
|
556
552
|
const steps = ladder.filter((step) => !closedBookRefused(step.provider, role, openBook));
|
|
557
553
|
if (steps.length < ladder.length) writeSafe(stderr, `fallback skipped: ${ladder.length - steps.length} openai step(s) — ${CODEX_CLOSED_BOOK_REFUSAL}\n`);
|
|
558
554
|
if (opts.background) steps.length = 1;
|
|
555
|
+
return { steps, openBook };
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* Build the first runnable step without entering the budget gate or provider semaphore.
|
|
560
|
+
* All session artifacts are temporary; argv paths use the stable <session> placeholder.
|
|
561
|
+
* @param {SessionOpts} opts @param {SessionDeps} [deps]
|
|
562
|
+
* @returns {Promise<{argv: string[], cwd: string, provider: string, model: string, effort: string | null, fallback_step: number}>}
|
|
563
|
+
*/
|
|
564
|
+
export async function previewArgv(opts, deps = {}) {
|
|
565
|
+
validateSessionOpts(opts);
|
|
566
|
+
if (opts.role === 'facts' && opts.cwd !== undefined) assertFactsCwd(opts.cwd, opts.runRoot ?? currentRunRoot());
|
|
567
|
+
const { steps, openBook } = sessionSteps(opts, deps.stderr ?? process.stderr);
|
|
568
|
+
const step = steps[0];
|
|
569
|
+
const sessionDir = mkdtempSync(path.join(os.tmpdir(), 'code-forge-preview-'));
|
|
570
|
+
try {
|
|
571
|
+
const coderPrompt = opts.role === 'coder' && opts.block ? await coderPromptFor(opts) : null;
|
|
572
|
+
const built = buildStep({ ...opts, runRoot: sessionDir }, step, sessionDir, openBook, coderPrompt);
|
|
573
|
+
checkBuiltArgv(built, opts, deps);
|
|
574
|
+
return {
|
|
575
|
+
argv: built.argv.map((arg) => arg.replaceAll(sessionDir, '<session>')),
|
|
576
|
+
cwd: built.cwd.replaceAll(sessionDir, '<session>'),
|
|
577
|
+
provider: step.provider,
|
|
578
|
+
model: step.model,
|
|
579
|
+
effort: step.effort ?? null,
|
|
580
|
+
fallback_step: step.fallback_step,
|
|
581
|
+
};
|
|
582
|
+
} finally {
|
|
583
|
+
rmSync(sessionDir, { recursive: true, force: true });
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* @param {SessionOpts} opts
|
|
589
|
+
* @param {SessionDeps} [deps]
|
|
590
|
+
* @returns {Promise<SessionResult>}
|
|
591
|
+
*/
|
|
592
|
+
export async function spawnSession(opts, deps = {}) {
|
|
593
|
+
const { cfg, level, role } = opts;
|
|
594
|
+
validateSessionOpts(opts);
|
|
595
|
+
/** @type {string | null} */
|
|
596
|
+
let coderPrompt = null;
|
|
597
|
+
const stderr = deps.stderr ?? process.stderr;
|
|
598
|
+
const runRootDir = opts.runRoot ?? currentRunRoot();
|
|
599
|
+
if (role === 'facts' && opts.cwd !== undefined) assertFactsCwd(opts.cwd, runRootDir);
|
|
600
|
+
const writeRow = deps.writeRow ?? (opts.slug ? (/** @type {Record<string, any>} */ row) => appendRow(row, { slug: /** @type {string} */ (opts.slug) }) : null);
|
|
601
|
+
const { steps, openBook } = sessionSteps(opts, stderr);
|
|
559
602
|
|
|
560
603
|
const attempts = [];
|
|
561
604
|
const sleep = deps.sleep ?? realSleep;
|
|
@@ -612,11 +655,7 @@ export async function spawnSession(opts, deps = {}) {
|
|
|
612
655
|
try {
|
|
613
656
|
const built = buildStep(opts, step, sessionDir, openBook, coderPrompt);
|
|
614
657
|
const cli = built.cli;
|
|
615
|
-
built
|
|
616
|
-
if (role === 'coder') {
|
|
617
|
-
const hit = isForbidden(built.argv, opts.forbidden ?? mergeForbidden());
|
|
618
|
-
if (hit) throw new SessionError('forbidden', `coder argv refused before spawn: forbidden entry ${hit.id}`);
|
|
619
|
-
}
|
|
658
|
+
checkBuiltArgv(built, opts, deps);
|
|
620
659
|
writeSafe(stderr, `level=${level} provider=${step.provider} model=${step.model} effort=${step.effort ?? 'none'} fallback_step=${step.fallback_step}\n`);
|
|
621
660
|
const base = { role, level, provider: step.provider, model: step.model, effort: step.effort ?? null, fallback_step: step.fallback_step, run: opts.run ?? null, block: opts.block ?? null, ...(role === 's2' ? { source: step.fallback_step > 0 ? 's2-fallback' : 's2' } : {}), ...sandboxRowField(opts, cli), ...(opts.rowExtra ?? {}) };
|
|
622
661
|
|
|
@@ -910,6 +949,18 @@ function buildStep(opts, step, sessionDir, openBook, coderPrompt = null) {
|
|
|
910
949
|
return { ...built, argv: [...built.argv], compiled, strict };
|
|
911
950
|
}
|
|
912
951
|
|
|
952
|
+
/**
|
|
953
|
+
* Apply the binary override before the coder's forbidden-argv check, in both launch modes.
|
|
954
|
+
* @param {{cli: string, argv: string[]}} built @param {SessionOpts} opts @param {SessionDeps} deps
|
|
955
|
+
*/
|
|
956
|
+
function checkBuiltArgv(built, opts, deps) {
|
|
957
|
+
built.argv[0] = deps.bins?.[built.cli] ?? built.argv[0];
|
|
958
|
+
if (opts.role === 'coder') {
|
|
959
|
+
const hit = isForbidden(built.argv, opts.forbidden ?? mergeForbidden());
|
|
960
|
+
if (hit) throw new SessionError('forbidden', `coder argv refused before spawn: forbidden entry ${hit.id}`);
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
|
|
913
964
|
/**
|
|
914
965
|
* B93: `{sandbox: <profile>}` for a Codex coder session row when `coder.sandbox` is set; `{}`
|
|
915
966
|
* otherwise, so a row without the key is byte-equal to the row before B93.
|