@codedology/code-forge 0.9.0 → 0.11.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 +93 -0
- package/README.md +49 -9
- package/docs/reference/blocks.json +587 -1
- package/docs/reference/config.md +36 -0
- package/package.json +2 -2
- package/schema/code-forge.schema.json +89 -0
- package/skill/SKILL.md +1 -1
- package/skill/references/adapters/claude-code.md +1 -1
- package/skill/references/adapters/solo.md +2 -2
- package/skill/references/adapters/subprocess.md +12 -2
- package/skill/references/code.md +14 -7
- package/skill/references/ledger.md +2 -2
- package/skill/references/proof.md +77 -2
- package/skill/references/review.md +39 -7
- package/skill/references/security.md +4 -4
- package/skill/scripts/forge +1 -1
- package/skill/templates/coder-brief.md +6 -6
- package/src/autopilot/binnacle.mjs +9 -3
- package/src/cli/block.mjs +262 -31
- package/src/cli/jev.mjs +22 -3
- package/src/cli/keys.mjs +156 -21
- package/src/cli/proof.mjs +35 -7
- package/src/cli/review-file.mjs +17 -11
- package/src/cli/spawn.mjs +44 -3
- package/src/config/validate.mjs +320 -5
- package/src/doctor/index.mjs +2 -1
- package/src/doctor/local.mjs +27 -6
- package/src/doctor/probes.mjs +38 -0
- package/src/engines/builders/codex.mjs +44 -4
- package/src/engines/sandbox-dirs.mjs +257 -0
- package/src/keys/store.mjs +128 -21
- package/src/proof/junit.mjs +530 -0
- package/src/proof/red-green.mjs +57 -9
- package/src/proof/red-parse.mjs +2 -0
- package/src/proof/runners.mjs +472 -0
- package/src/proof/xcresult.mjs +285 -0
- package/src/review/engine.mjs +141 -42
- package/src/review/fixloop.mjs +75 -23
- package/src/review/gate-check.mjs +3 -2
- package/src/review/lenses/A.md +2 -0
- package/src/review/lenses/B.md +2 -0
- package/src/review/lenses/full.md +2 -0
- package/src/review/lenses/quick.md +1 -0
- package/src/review/lenses/recheck.md +2 -0
- package/src/review/packet.mjs +48 -14
- package/src/review/paired.mjs +80 -0
- package/src/review/reopen.mjs +4 -6
- package/src/review/reviewed-lines.mjs +94 -0
- package/src/review/route.mjs +232 -0
- package/src/review/schema-fallback.mjs +77 -22
- package/src/review/session-retry.mjs +44 -9
- package/src/review/test-quality.mjs +201 -0
- package/src/review/verb-forms.mjs +39 -0
- package/src/session/spawn.mjs +39 -2
- package/src/state/block-end.mjs +10 -6
- package/src/state/block.mjs +13 -3
- package/src/state/config-snapshot.mjs +39 -0
- package/src/state/run.mjs +4 -0
- package/src/util/forbidden.mjs +2 -2
- package/src/worker/engine.mjs +43 -15
- package/src/worker/loop.mjs +98 -15
- package/src/worker/ticket.mjs +9 -1
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The test-quality note (block B92, issue #10 item 3): a test file that reads a source file and
|
|
3
|
+
* asserts on its text proves text, not behaviour. Its review packets get a `## test-quality`
|
|
4
|
+
* section before the diff that names the lines (never their text), so the reviewer looks at them.
|
|
5
|
+
* The lenses tell the reviewer to raise such a test as a `warning` unless the read file is a
|
|
6
|
+
* fixture. The note is a hint, never a tool-made finding: no row, no gate change.
|
|
7
|
+
*
|
|
8
|
+
* The detector is pure (path and content in, line numbers out; no I/O):
|
|
9
|
+
* - it runs only on a test file: `isTestPath` (`test/ tests/ __tests__/ spec/` dirs, `*.test.*`,
|
|
10
|
+
* `*.spec.*`, `*Test.php`, `*_test.go`, `test_*.py`) plus a `Tests/` dir and a name ending in
|
|
11
|
+
* `Tests.<ext>` or `Test.<ext>` (`FooTests.swift`, `FooTest.kt`). A docs file (`.md`, `.txt`)
|
|
12
|
+
* and a file under a fixtures dir are never checked;
|
|
13
|
+
* - a READ line holds a read call ({@link READ_CALLS}) and, in the call's span, either a quoted
|
|
14
|
+
* path literal that ends in a source extension ({@link SOURCE_EXTENSIONS}), or a name bound on an
|
|
15
|
+
* earlier line to such a literal (`const SRC = 'src/a.mjs'` … `readFileSync(SRC)`). A name is
|
|
16
|
+
* bound only when its value IS the literal: the whole right-hand side, or the literal passed
|
|
17
|
+
* straight to `join`/`path.join`/`resolve`/`path.resolve`; the binding may carry modifiers and a
|
|
18
|
+
* type (`private val`, `const val`, `private static let`, `private static final String`,
|
|
19
|
+
* `private const string`, `export const`). The span is the call's line, plus up to
|
|
20
|
+
* {@link SPAN_LINES} following lines while the read call's own `(` is still open (a formatter
|
|
21
|
+
* split); the call's line is the one reported. A span with any string literal that has a
|
|
22
|
+
* fixtures dir segment (`fixtures`, `fixture`, `__fixtures__`) — `'test/fixtures/a.mjs'` or
|
|
23
|
+
* `join(dir, 'fixtures', 'a.mjs')` — or that names a name bound to such a literal
|
|
24
|
+
* (`const FIXTURES = join(dir, 'fixtures')`) is a fixture read, never a source read;
|
|
25
|
+
* - a TEXT-ASSERT line holds a contains or match call ({@link ASSERT_CALLS});
|
|
26
|
+
* - the file is flagged only when it has at least one of each. The flagged lines are every read
|
|
27
|
+
* line and every text-assert line, 1-based, sorted; the section lists at most
|
|
28
|
+
* {@link MAX_LINES} of them and counts the rest.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { isTestPath } from '../gates/expand.mjs';
|
|
32
|
+
|
|
33
|
+
/** The section names at most this many line numbers. */
|
|
34
|
+
export const MAX_LINES = 10;
|
|
35
|
+
|
|
36
|
+
/** The file extensions of source code a test should not read for its text. */
|
|
37
|
+
export const SOURCE_EXTENSIONS = Object.freeze(['swift', 'ts', 'tsx', 'js', 'mjs', 'cjs', 'py', 'go', 'rs', 'kt', 'java', 'rb', 'php', 'cs']);
|
|
38
|
+
|
|
39
|
+
/** Common read calls, matched as plain text. */
|
|
40
|
+
export const READ_CALLS = Object.freeze(['readFileSync', 'readFile', 'open(', 'read_text', 'String(contentsOf', 'contentsOfFile', 'File.read', 'os.ReadFile', 'fs::read_to_string', 'readText(', 'readString(', 'ReadAllText(']);
|
|
41
|
+
|
|
42
|
+
/** A read call's path may sit on at most this many lines after the call (a formatter split). */
|
|
43
|
+
export const SPAN_LINES = 3;
|
|
44
|
+
|
|
45
|
+
/** Contains or match calls on text, matched as plain text (`XCTAssertTrue(x.contains(…))` holds `contains(`). */
|
|
46
|
+
export const ASSERT_CALLS = Object.freeze(['includes(', 'contains(', 'toContain(', 'toMatch(', 'assert.match(', 'assertIn(', 'strings.Contains(']);
|
|
47
|
+
|
|
48
|
+
const DOCS = /\.(md|txt)$/i;
|
|
49
|
+
const FIXTURE_DIRS = new Set(['fixtures', 'fixture', '__fixtures__']);
|
|
50
|
+
/** A quoted string literal (single, double or back quote) with no quote or newline inside. */
|
|
51
|
+
const LITERAL = /(['"`])([^'"`\n]*)\1/g;
|
|
52
|
+
const SOURCE_END = new RegExp(`\\.(${SOURCE_EXTENSIONS.join('|')})$`);
|
|
53
|
+
/**
|
|
54
|
+
* A binding at the line start: modifiers (`export`, `private`, `static`, `final`, `const`, …), then
|
|
55
|
+
* `const|let|var|val X` (JS, Swift, Kotlin) or an optional type and `X` (Java `String X`, Python
|
|
56
|
+
* `X`, Go `X :=`), an optional `: Type`, and `=` (not `==`). Group 1 or 2 is the name.
|
|
57
|
+
*/
|
|
58
|
+
const BINDING = /^\s*(?:(?:export|public|private|protected|internal|fileprivate|open|static|final|readonly|lazy|const)\s+)*(?:(?:const|let|var|val)\s+([A-Za-z_$][\w$]*)|(?:[A-Za-z_][\w.<>[\],]*\s+)?([A-Za-z_$][\w$]*))\s*(?::[^=]*)?=(?!=)/;
|
|
59
|
+
|
|
60
|
+
/** @param {string} p @returns {string[]} the `/`-separated segments of a path. */
|
|
61
|
+
const segments = (p) => p.split('/');
|
|
62
|
+
|
|
63
|
+
/** @param {string} p @returns {boolean} whether `p` has a fixtures dir segment. */
|
|
64
|
+
const underFixtures = (p) => segments(p).some((s) => FIXTURE_DIRS.has(s));
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Whether the detector checks `file`: a test file that is not a docs file and not a fixture.
|
|
68
|
+
* @param {unknown} file - a repo-relative POSIX path.
|
|
69
|
+
* @returns {boolean}
|
|
70
|
+
*/
|
|
71
|
+
export function isCheckedTestFile(file) {
|
|
72
|
+
if (typeof file !== 'string' || file.length === 0) return false;
|
|
73
|
+
if (DOCS.test(file) || underFixtures(file)) return false;
|
|
74
|
+
if (isTestPath(file)) return true;
|
|
75
|
+
const parts = segments(file);
|
|
76
|
+
const name = parts[parts.length - 1];
|
|
77
|
+
return parts.slice(0, -1).includes('Tests') || /(Tests|Test)\.[^./]+$/.test(name);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** @param {string} line @returns {boolean} whether the line holds a quoted source-path literal (not a fixture). */
|
|
81
|
+
function hasSourceLiteral(line) {
|
|
82
|
+
for (const m of line.matchAll(LITERAL)) {
|
|
83
|
+
const value = m[2];
|
|
84
|
+
if (SOURCE_END.test(value) && !underFixtures(value)) return true;
|
|
85
|
+
}
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** @param {string} line @returns {boolean} whether any string literal on the line has a fixtures dir segment. */
|
|
90
|
+
const hasFixtureLiteral = (line) => [...line.matchAll(LITERAL)].some((m) => underFixtures(m[2]));
|
|
91
|
+
|
|
92
|
+
/** @param {string} line @returns {number} `(` minus `)` outside string literals. */
|
|
93
|
+
const parenDelta = (line) => {
|
|
94
|
+
const bare = line.replace(LITERAL, '');
|
|
95
|
+
return (bare.match(/\(/g) ?? []).length - (bare.match(/\)/g) ?? []).length;
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The lines of the read call on `lines[i]`: that line, plus up to {@link SPAN_LINES} more while
|
|
100
|
+
* the read call's own `(` stays open (the depth counts from that `(`, not the whole line).
|
|
101
|
+
* @param {string[]} lines @param {number} i @returns {string[]}
|
|
102
|
+
*/
|
|
103
|
+
function callSpan(lines, i) {
|
|
104
|
+
const line = lines[i];
|
|
105
|
+
const span = [line];
|
|
106
|
+
const at = Math.min(...READ_CALLS.map((c) => line.indexOf(c)).filter((n) => n >= 0));
|
|
107
|
+
const open = line.indexOf('(', at);
|
|
108
|
+
let depth = open === -1 ? 0 : parenDelta(line.slice(open));
|
|
109
|
+
for (let j = i + 1; depth > 0 && j < lines.length && j <= i + SPAN_LINES; j += 1) {
|
|
110
|
+
span.push(lines[j]);
|
|
111
|
+
depth += parenDelta(lines[j]);
|
|
112
|
+
}
|
|
113
|
+
return span;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** A call that passes a path straight on: `join(`, `path.join(`, `resolve(`, `path.resolve(`. */
|
|
117
|
+
const PATH_CALL = /^(?:path\.)?(?:join|resolve)\((.*)\)$/;
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Whether a binding's right-hand side IS a source-path literal: the literal alone, or the literal
|
|
121
|
+
* passed straight to a path call. `spawnSync('node', ['bin/cli.mjs'])` is not.
|
|
122
|
+
* @param {string} rhs @returns {boolean}
|
|
123
|
+
*/
|
|
124
|
+
function valueIsSourceLiteral(rhs) {
|
|
125
|
+
const value = rhs.trim().replace(/;$/, '').trim();
|
|
126
|
+
const whole = /^(['"`])([^'"`\n]*)\1$/.exec(value);
|
|
127
|
+
if (whole) return hasSourceLiteral(value);
|
|
128
|
+
const call = PATH_CALL.exec(value);
|
|
129
|
+
return call !== null && hasSourceLiteral(call[1]);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** @param {string} line @param {ReadonlyArray<string>} calls @returns {boolean} */
|
|
133
|
+
const holdsAny = (line, calls) => calls.some((c) => line.includes(c));
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The flagged line numbers of a test file that reads a source file and asserts on its text.
|
|
137
|
+
* @param {unknown} file - repo-relative path. @param {unknown} content - the file's current text.
|
|
138
|
+
* @returns {number[]} 1-based, sorted, no duplicates; [] when the file is not flagged.
|
|
139
|
+
*/
|
|
140
|
+
export function testQualityLines(file, content) {
|
|
141
|
+
if (!isCheckedTestFile(file) || typeof content !== 'string') return [];
|
|
142
|
+
const lines = content.split(/\r?\n/);
|
|
143
|
+
/** @type {Set<string>} names bound to a source-path literal so far */
|
|
144
|
+
const bound = new Set();
|
|
145
|
+
/** @type {Set<string>} names bound to a literal with a fixtures segment so far */
|
|
146
|
+
const fixtureBound = new Set();
|
|
147
|
+
/** @type {number[]} */
|
|
148
|
+
const reads = [];
|
|
149
|
+
/** @type {number[]} */
|
|
150
|
+
const asserts = [];
|
|
151
|
+
/** @param {Set<string>} names @returns {(line: string) => boolean} whether a line names one of `names` */
|
|
152
|
+
const names = (names) => (line) => [...names].some((name) => new RegExp(`(^|[^\\w$])${name.replace(/\$/g, '\\$')}([^\\w$]|$)`).test(line));
|
|
153
|
+
const namesBound = names(bound);
|
|
154
|
+
const namesFixture = names(fixtureBound);
|
|
155
|
+
for (const [i, line] of lines.entries()) {
|
|
156
|
+
if (holdsAny(line, READ_CALLS)) {
|
|
157
|
+
const span = callSpan(lines, i);
|
|
158
|
+
const fixture = span.some((l) => hasFixtureLiteral(l) || namesFixture(l));
|
|
159
|
+
if (!fixture && span.some((l) => hasSourceLiteral(l) || namesBound(l))) reads.push(i + 1);
|
|
160
|
+
}
|
|
161
|
+
if (holdsAny(line, ASSERT_CALLS)) asserts.push(i + 1);
|
|
162
|
+
const m = BINDING.exec(line);
|
|
163
|
+
const name = m?.[1] ?? m?.[2];
|
|
164
|
+
if (name) {
|
|
165
|
+
if (hasFixtureLiteral(line)) fixtureBound.add(name);
|
|
166
|
+
else if (valueIsSourceLiteral(line.slice(m[0].length))) bound.add(name);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
if (reads.length === 0 || asserts.length === 0) return [];
|
|
170
|
+
return [...new Set([...reads, ...asserts])].sort((a, b) => a - b);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* The `## test-quality` section for `file`, or '' when no line is flagged. It names line numbers
|
|
175
|
+
* only — never the file's text.
|
|
176
|
+
* @param {string} file @param {ReadonlyArray<number>} lines - from {@link testQualityLines}.
|
|
177
|
+
* @returns {string}
|
|
178
|
+
*/
|
|
179
|
+
export function renderTestQuality(file, lines) {
|
|
180
|
+
if (lines.length === 0) return '';
|
|
181
|
+
const shown = lines.slice(0, MAX_LINES);
|
|
182
|
+
const more = lines.length - shown.length;
|
|
183
|
+
return [
|
|
184
|
+
'## test-quality',
|
|
185
|
+
`file: ${file}`,
|
|
186
|
+
`lines: ${shown.join(', ')}${more > 0 ? ` (${more} more not listed)` : ''}`,
|
|
187
|
+
'This test file reads a source file and asserts on its text (a hint from a text match, not a finding): check these lines and apply the lens rule on tests that read source text.',
|
|
188
|
+
].join('\n');
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* The section for one file at its current content ('' for none, a deleted file, or any error).
|
|
193
|
+
* @param {string} file @param {string | null} content @returns {string}
|
|
194
|
+
*/
|
|
195
|
+
export function testQualityNote(file, content) {
|
|
196
|
+
try {
|
|
197
|
+
return content === null ? '' : renderTestQuality(file, testQualityLines(file, content));
|
|
198
|
+
} catch {
|
|
199
|
+
return ''; // a hint only: no note, the review goes on
|
|
200
|
+
}
|
|
201
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* B80: the argument forms of the human verbs `block waive` and `block reopen`, in ONE place, so the
|
|
3
|
+
* check that runs before the terminal question (`src/cli/block.mjs`) and the verbs themselves
|
|
4
|
+
* (`waiveFinding`, `reopenFile`) can never drift apart. Every refusal is `usage` (exit 2).
|
|
5
|
+
*
|
|
6
|
+
* A control character (any char below 0x20, or 0x7f) is refused before anything else: such a
|
|
7
|
+
* value would reach the terminal question and the stderr messages that quote it.
|
|
8
|
+
*/
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import { StateError } from '../state/paths.mjs';
|
|
11
|
+
|
|
12
|
+
/** The finding id form a waive takes. */
|
|
13
|
+
const FINDING_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$/;
|
|
14
|
+
// eslint-disable-next-line no-control-regex
|
|
15
|
+
const CONTROL = /[\x00-\x1f\x7f]/;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @param {unknown} finding
|
|
19
|
+
* @throws {StateError} `usage` for a value with a control character or not of the finding id form.
|
|
20
|
+
*/
|
|
21
|
+
export function checkFindingId(finding) {
|
|
22
|
+
if (typeof finding === 'string' && CONTROL.test(finding)) throw new StateError('usage', 'a finding id must not contain control characters');
|
|
23
|
+
if (typeof finding !== 'string' || !FINDING_ID.test(finding)) throw new StateError('usage', 'a finding id is required (letters, digits, . _ : -)');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The `--file` form: no control character, then a repo-root-relative path (not absolute, no `..`
|
|
28
|
+
* segment, no leading `./`). `normalized` also refuses a path that `path.posix.normalize` changes
|
|
29
|
+
* — the rule `block reopen` has always had; `block waive` keeps its own (B80 does not change it).
|
|
30
|
+
* @param {unknown} file @param {{normalized: boolean}} rule
|
|
31
|
+
* @throws {StateError} `usage`.
|
|
32
|
+
*/
|
|
33
|
+
export function checkFileForm(file, { normalized }) {
|
|
34
|
+
if (typeof file !== 'string' || file === '') throw new StateError('usage', '--file is required');
|
|
35
|
+
if (CONTROL.test(file)) throw new StateError('usage', '--file must not contain control characters');
|
|
36
|
+
if (path.isAbsolute(file) || file.split('/').includes('..') || file.startsWith('./') || (normalized && path.posix.normalize(file) !== file)) {
|
|
37
|
+
throw new StateError('usage', '--file must be repo-root-relative');
|
|
38
|
+
}
|
|
39
|
+
}
|
package/src/session/spawn.mjs
CHANGED
|
@@ -54,6 +54,7 @@ import { compileSchema } from '../config/schema-compile.mjs';
|
|
|
54
54
|
import { buildArgv } from '../engines/builders/index.mjs';
|
|
55
55
|
import { removeCodexHome } from '../engines/codex-home.mjs';
|
|
56
56
|
import { cliNameForProvider } from '../engines/provider-cli.mjs';
|
|
57
|
+
import { DEFAULT_SANDBOX_PROFILE, SANDBOX_DIR_REFUSED, SandboxDirError } from '../engines/sandbox-dirs.mjs';
|
|
57
58
|
import { parseSentinel } from '../engines/sentinel.mjs';
|
|
58
59
|
import { parseUsage } from '../engines/usage-parse.mjs';
|
|
59
60
|
import { budgetUsdOf, checkBudget, estimateSessionUsd, priceSession, reservedUsd, reserveBudget, SPEND_UNREADABLE_MESSAGE, withBudgetLock } from '../ledger/spend.mjs';
|
|
@@ -609,7 +610,7 @@ export async function spawnSession(opts, deps = {}) {
|
|
|
609
610
|
if (hit) throw new SessionError('forbidden', `coder argv refused before spawn: forbidden entry ${hit.id}`);
|
|
610
611
|
}
|
|
611
612
|
writeSafe(stderr, `level=${level} provider=${step.provider} model=${step.model} effort=${step.effort ?? 'none'} fallback_step=${step.fallback_step}\n`);
|
|
612
|
-
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' } : {}), ...(opts.rowExtra ?? {}) };
|
|
613
|
+
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 ?? {}) };
|
|
613
614
|
|
|
614
615
|
if (opts.background) {
|
|
615
616
|
keep = true;
|
|
@@ -829,6 +830,12 @@ function buildStep(opts, step, sessionDir, openBook) {
|
|
|
829
830
|
if (cli === 'codex') params.outPath = path.join(sessionDir, 'out.json');
|
|
830
831
|
// B32 opt-in: only a no-tools role needs it; coder and facts build exactly as without it
|
|
831
832
|
if (cli === 'codex' && openBook && isNoToolRole(opts.role)) params.allowOpenBook = true;
|
|
833
|
+
// B93: the Codex coder's sandbox profile; no `coder.sandbox` key builds exactly today's argv
|
|
834
|
+
const sandbox = coderSandboxFor(opts, cli);
|
|
835
|
+
if (sandbox !== null) {
|
|
836
|
+
params.sandbox = sandbox;
|
|
837
|
+
params.runRoot = opts.runRoot ?? currentRunRoot();
|
|
838
|
+
}
|
|
832
839
|
/** @type {Record<string, any> | null} */
|
|
833
840
|
let compiled = null;
|
|
834
841
|
const strict = variantFor(step.provider) === 'strict';
|
|
@@ -841,10 +848,40 @@ function buildStep(opts, step, sessionDir, openBook) {
|
|
|
841
848
|
params.schema = compiled;
|
|
842
849
|
}
|
|
843
850
|
}
|
|
844
|
-
|
|
851
|
+
let built;
|
|
852
|
+
try {
|
|
853
|
+
built = buildArgv(/** @type {any} */ (params));
|
|
854
|
+
} catch (err) {
|
|
855
|
+
// B93: a refused `add_dirs` entry; the message names the key path, never the value
|
|
856
|
+
if (err instanceof SandboxDirError) throw new SessionError(SANDBOX_DIR_REFUSED, err.message);
|
|
857
|
+
throw err;
|
|
858
|
+
}
|
|
845
859
|
return { ...built, argv: [...built.argv], compiled, strict };
|
|
846
860
|
}
|
|
847
861
|
|
|
862
|
+
/**
|
|
863
|
+
* B93: `{sandbox: <profile>}` for a Codex coder session row when `coder.sandbox` is set; `{}`
|
|
864
|
+
* otherwise, so a row without the key is byte-equal to the row before B93.
|
|
865
|
+
* @param {SessionOpts} opts @param {string} cli @returns {{sandbox?: string}}
|
|
866
|
+
*/
|
|
867
|
+
function sandboxRowField(opts, cli) {
|
|
868
|
+
const sandbox = coderSandboxFor(opts, cli);
|
|
869
|
+
if (sandbox === null) return {};
|
|
870
|
+
return { sandbox: typeof sandbox.profile === 'string' ? sandbox.profile : DEFAULT_SANDBOX_PROFILE };
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
/**
|
|
874
|
+
* B93: the `coder.sandbox` value a Codex coder session builds with, or null (no key, another
|
|
875
|
+
* role, or another CLI — the Claude and Grok coders do not read it).
|
|
876
|
+
* @param {SessionOpts} opts @param {string} cli
|
|
877
|
+
* @returns {Record<string, any> | null}
|
|
878
|
+
*/
|
|
879
|
+
function coderSandboxFor(opts, cli) {
|
|
880
|
+
if (cli !== 'codex' || opts.role !== 'coder') return null;
|
|
881
|
+
const sandbox = /** @type {Record<string, any> | undefined} */ (opts.cfg)?.coder?.sandbox;
|
|
882
|
+
return sandbox === undefined || sandbox === null ? null : sandbox;
|
|
883
|
+
}
|
|
884
|
+
|
|
848
885
|
/**
|
|
849
886
|
* @param {{cli: string, argv: string[], cwd: string, stdinFile?: string, outPath?: string, env?: Record<string, string>, compiled: Record<string, any> | null, strict: boolean}} built
|
|
850
887
|
* @param {SessionOpts} opts
|
package/src/state/block-end.mjs
CHANGED
|
@@ -54,11 +54,13 @@ function safeId(id) {
|
|
|
54
54
|
* @param {{
|
|
55
55
|
* block: string, runId: string, status: 'closed' | 'stopped', event: string, reason?: string | null,
|
|
56
56
|
* commit: string | null, spend: {usd: number, unknown: number} | null, findings: number | null, at: Date,
|
|
57
|
-
* }
|
|
57
|
+
* reviewed?: ReadonlyArray<{file: string, ticket: string, via: string}>,
|
|
58
|
+
* }} facts `reviewed` (B96): the close's reviewed lines, sorted by file; the key is added last, and only when given.
|
|
58
59
|
* @returns {{block: string, run_id: string, status: string, event: string, reason: string | null, commit: string | null,
|
|
59
|
-
* spent_usd: number | null, unknown_usd_sessions: number | null, findings: number | null, at: string
|
|
60
|
+
* spent_usd: number | null, unknown_usd_sessions: number | null, findings: number | null, at: string,
|
|
61
|
+
* reviewed?: Array<{file: string, ticket: string, via: string}>}}
|
|
60
62
|
*/
|
|
61
|
-
export function doneFileContent({ block, runId, status, event, reason = null, commit, spend, findings, at }) {
|
|
63
|
+
export function doneFileContent({ block, runId, status, event, reason = null, commit, spend, findings, at, reviewed }) {
|
|
62
64
|
return {
|
|
63
65
|
block,
|
|
64
66
|
run_id: runId,
|
|
@@ -71,6 +73,7 @@ export function doneFileContent({ block, runId, status, event, reason = null, co
|
|
|
71
73
|
unknown_usd_sessions: spend && Number.isInteger(spend.unknown) ? spend.unknown : null,
|
|
72
74
|
findings: Number.isInteger(findings) ? findings : null,
|
|
73
75
|
at: at.toISOString(),
|
|
76
|
+
...(Array.isArray(reviewed) ? { reviewed: reviewed.map((r) => ({ file: r.file, ticket: r.ticket, via: r.via })).sort((x, y) => (x.file < y.file ? -1 : x.file > y.file ? 1 : 0)) } : {}),
|
|
74
77
|
};
|
|
75
78
|
}
|
|
76
79
|
|
|
@@ -97,12 +100,13 @@ async function headCommit(cwd) {
|
|
|
97
100
|
* @param {{
|
|
98
101
|
* record: Record<string, any>, id: string, status: 'closed' | 'stopped', event: string, reason?: string | null,
|
|
99
102
|
* openFindings: (rows: Record<string, any>[]) => Promise<number>, err: (s: string) => unknown, now?: Date,
|
|
103
|
+
* reviewed?: ReadonlyArray<{file: string, ticket: string, via: string}>,
|
|
100
104
|
* }} opts - `record` is the run record read before the end (its `workspace` and the block's
|
|
101
105
|
* `worktree` do not change at an end); `openFindings` counts the block's open findings exactly
|
|
102
|
-
* as `code-forge status` does.
|
|
106
|
+
* as `code-forge status` does. `reviewed` (B96): the close's reviewed lines, kept as the last key.
|
|
103
107
|
* @returns {Promise<string | null>} the done file's absolute path, or null when it was not written.
|
|
104
108
|
*/
|
|
105
|
-
export async function writeDoneFile({ record, id, status, event, reason = null, openFindings, err, now = new Date() }) {
|
|
109
|
+
export async function writeDoneFile({ record, id, status, event, reason = null, openFindings, err, now = new Date(), reviewed }) {
|
|
106
110
|
const runId = String(record?.run_id);
|
|
107
111
|
try {
|
|
108
112
|
const file = doneFilePath(runId, id);
|
|
@@ -127,7 +131,7 @@ export async function writeDoneFile({ record, id, status, event, reason = null,
|
|
|
127
131
|
}
|
|
128
132
|
const tree = record.blocks?.[id]?.worktree?.path;
|
|
129
133
|
const commit = await headCommit(typeof tree === 'string' && tree.length > 0 ? tree : record.workspace);
|
|
130
|
-
const content = doneFileContent({ block: id, runId, status, event, reason, commit, spend, findings, at: now });
|
|
134
|
+
const content = doneFileContent({ block: id, runId, status, event, reason, commit, spend, findings, at: now, ...(reviewed === undefined ? {} : { reviewed }) });
|
|
131
135
|
const dir = path.dirname(file);
|
|
132
136
|
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
133
137
|
const tmp = path.join(dir, `.${path.basename(file)}.${process.pid}.${Date.now()}.tmp`);
|
package/src/state/block.mjs
CHANGED
|
@@ -18,7 +18,7 @@ import path from 'node:path';
|
|
|
18
18
|
import { BLOCK_KINDS, blockKind, dispatchLevel } from '../decide/escalation.mjs';
|
|
19
19
|
import { exec } from '../util/exec.mjs';
|
|
20
20
|
import { DEFAULT_CONFIG_FILENAME } from '../config/load.mjs';
|
|
21
|
-
import { blockEndHook, externalReviewer } from './config-snapshot.mjs';
|
|
21
|
+
import { blockEndHook, configInForce, externalReviewer } from './config-snapshot.mjs';
|
|
22
22
|
import { ownedKinds, pathKind } from './owned-kinds.mjs';
|
|
23
23
|
import { isAncestorArgv, revParseArgv } from '../util/git.mjs';
|
|
24
24
|
import { StateError } from './paths.mjs';
|
|
@@ -263,12 +263,22 @@ function activeBlocks(record) {
|
|
|
263
263
|
* subdirectory, relative to the repository top level (the close gate matches both, `ownsAt`).
|
|
264
264
|
* Empty when the key is not set. B64b: `hooks.on_block_end` in the snapshot guards it the same way
|
|
265
265
|
* — the file names a command the orchestrator runs at every block end — and `why` names the key
|
|
266
|
-
* that guards it (`review.external.required` first, so its message is unchanged).
|
|
266
|
+
* that guards it (`review.external.required` first, so its message is unchanged). B87:
|
|
267
|
+
* `proof.runners` in the snapshot guards it too — the file names the commands that prove a block;
|
|
268
|
+
* any stored value guards it (fail closed: `validate` refuses an empty or malformed list anyway).
|
|
267
269
|
* @param {Record<string, any>} record @returns {Promise<{paths: string[], why: string}>}
|
|
268
270
|
* @throws {StateError} `config-snapshot`, `git-failed`
|
|
269
271
|
*/
|
|
270
272
|
async function guardedConfigPaths(record) {
|
|
271
|
-
const
|
|
273
|
+
const runners = configInForce(record)?.proof?.runners;
|
|
274
|
+
const why =
|
|
275
|
+
externalReviewer(record) !== null
|
|
276
|
+
? 'review.external.required is true'
|
|
277
|
+
: blockEndHook(record) !== null
|
|
278
|
+
? 'hooks.on_block_end is set'
|
|
279
|
+
: runners !== undefined && runners !== null
|
|
280
|
+
? 'proof.runners is set'
|
|
281
|
+
: null;
|
|
272
282
|
if (why === null) return { paths: [], why: '' };
|
|
273
283
|
const top = await repoTop(record.workspace);
|
|
274
284
|
// the repo-top spelling `<prefix>/.code-forge.yml` may also match a different file of the same
|
|
@@ -44,6 +44,11 @@ export const IMMUTABLE_KEYS = Object.freeze({
|
|
|
44
44
|
// B64b: the block-end hook runs a command on the owner's machine at every block end, so only
|
|
45
45
|
// the owner sets it, before the run starts
|
|
46
46
|
'hooks.on_block_end': 'it runs a command on the owner\'s machine at every block end; fixed by the owner before the run starts',
|
|
47
|
+
// B87: the runner argv proves the coder's own work, so only the owner sets it, before the run starts
|
|
48
|
+
'proof.runners': 'the commands that prove a block are fixed by the owner before the run starts',
|
|
49
|
+
// B93: the coder sandbox profile widens what the coder may write, so only the owner sets it,
|
|
50
|
+
// before the run starts (a coder can never own `.code-forge.yml`)
|
|
51
|
+
'coder.sandbox': 'the coder sandbox widens what the coder may write; fixed by the owner before the run starts',
|
|
47
52
|
});
|
|
48
53
|
|
|
49
54
|
/** @param {unknown} v @returns {boolean} what `JSON.stringify` drops from an object. */
|
|
@@ -235,3 +240,37 @@ export function blockEndHook(record) {
|
|
|
235
240
|
if (!Number.isInteger(timeoutS) || timeoutS < HOOK_TIMEOUT_MIN_S || timeoutS > HOOK_TIMEOUT_MAX_S) return null;
|
|
236
241
|
return { argv: [...argv], timeoutS };
|
|
237
242
|
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* B87: `proof.runners` of the run-start config snapshot (never the live config), as a deep copy
|
|
246
|
+
* (entries and argv arrays), or null when the key is absent or the record is from before
|
|
247
|
+
* snapshots. `proof/runners.mjs` checks each entry again before it runs one (fail closed).
|
|
248
|
+
* @param {Record<string, any>} record
|
|
249
|
+
* @returns {unknown[] | null}
|
|
250
|
+
* @throws {StateError} `config-snapshot` when the snapshot in force does not match its hash;
|
|
251
|
+
* `runner-invalid` when the stored value is not a list, or is an empty one.
|
|
252
|
+
*/
|
|
253
|
+
export function proofRunners(record) {
|
|
254
|
+
const runners = configInForce(record)?.proof?.runners;
|
|
255
|
+
if (runners === undefined || runners === null) return null;
|
|
256
|
+
if (!Array.isArray(runners)) throw new StateError('runner-invalid', `run ${record.run_id}: proof.runners in the run snapshot is not a list`);
|
|
257
|
+
// `validate` refuses an empty list (minItems 1); a record that holds one anyway is refused too
|
|
258
|
+
if (runners.length === 0) throw new StateError('runner-invalid', `run ${record.run_id}: proof.runners in the run snapshot is an empty list`);
|
|
259
|
+
// a deep copy: a caller that changes an entry or an argv array never changes the snapshot
|
|
260
|
+
return structuredClone(runners);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* B93: `coder.sandbox` of the run-start config snapshot (never the live config). A record from
|
|
265
|
+
* before snapshots has no run-start value: `{snapshot: false}`. Otherwise `{snapshot: true,
|
|
266
|
+
* sandbox}`, where `sandbox` is a deep copy of the value, or null when the snapshot has no key.
|
|
267
|
+
* @param {Record<string, any>} record
|
|
268
|
+
* @returns {{snapshot: false} | {snapshot: true, sandbox: Record<string, any> | null}}
|
|
269
|
+
* @throws {StateError} `config-snapshot` when the snapshot in force does not match its hash.
|
|
270
|
+
*/
|
|
271
|
+
export function coderSandbox(record) {
|
|
272
|
+
const config = configInForce(record);
|
|
273
|
+
if (config === null) return { snapshot: false };
|
|
274
|
+
const sandbox = config.coder?.sandbox;
|
|
275
|
+
return { snapshot: true, sandbox: sandbox === undefined || sandbox === null ? null : structuredClone(sandbox) };
|
|
276
|
+
}
|
package/src/state/run.mjs
CHANGED
|
@@ -269,6 +269,10 @@ export async function reloadRun({ runId, config, readPending = () => [], effecti
|
|
|
269
269
|
if (config.context?.require_coder_tests === true) frozen.push('context.require_coder_tests');
|
|
270
270
|
// B64b: and never had a block-end hook
|
|
271
271
|
if (config.hooks?.on_block_end !== undefined) frozen.push('hooks.on_block_end');
|
|
272
|
+
// B87: and never had a proof runner
|
|
273
|
+
if (config.proof?.runners !== undefined) frozen.push('proof.runners');
|
|
274
|
+
// B93: and never had a coder sandbox profile
|
|
275
|
+
if (config.coder?.sandbox !== undefined) frozen.push('coder.sandbox');
|
|
272
276
|
}
|
|
273
277
|
if (frozen.length > 0) {
|
|
274
278
|
const why = frozen.map((key) => `${key} cannot change mid-run (${IMMUTABLE_KEYS[/** @type {keyof typeof IMMUTABLE_KEYS} */ (key)]})`);
|
package/src/util/forbidden.mjs
CHANGED
|
@@ -292,7 +292,7 @@ export const CODER_ONLY_FORBIDDEN = Object.freeze(
|
|
|
292
292
|
['code-forge', 'block', 'waive'],
|
|
293
293
|
['forge', 'block', 'waive'],
|
|
294
294
|
],
|
|
295
|
-
description: 'Waive a review finding (forge block waive) — human-only,
|
|
295
|
+
description: 'Waive a review finding (forge block waive) — human-only, typed by the human in their own terminal (§4.9, B80)',
|
|
296
296
|
},
|
|
297
297
|
{
|
|
298
298
|
id: 'code-forge-block-reopen-from-coder',
|
|
@@ -301,7 +301,7 @@ export const CODER_ONLY_FORBIDDEN = Object.freeze(
|
|
|
301
301
|
['code-forge', 'block', 'reopen'],
|
|
302
302
|
['forge', 'block', 'reopen'],
|
|
303
303
|
],
|
|
304
|
-
description: 'Reopen the review of a stopped or lost file (forge block reopen) — human-only,
|
|
304
|
+
description: 'Reopen the review of a stopped or lost file (forge block reopen) — human-only, typed by the human in their own terminal (B72, B80)',
|
|
305
305
|
},
|
|
306
306
|
{
|
|
307
307
|
id: 'code-forge-autopilot-from-coder',
|