@codedology/code-forge 0.8.0 → 0.10.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 +114 -0
- package/README.md +46 -7
- package/docs/reference/blocks.json +618 -1
- package/docs/reference/config.md +36 -0
- package/package.json +1 -1
- package/schema/code-forge.schema.json +89 -0
- 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 +21 -7
- package/skill/references/ledger.md +2 -2
- package/skill/references/proof.md +77 -2
- package/skill/references/review.md +29 -4
- package/skill/references/security.md +5 -5
- package/skill/scripts/forge +1 -1
- package/skill/templates/coder-brief.md +4 -2
- package/src/autopilot/binnacle.mjs +9 -3
- package/src/cli/block.mjs +300 -33
- 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 +264 -13
- package/src/cli/spawn.mjs +44 -3
- package/src/config/validate.mjs +320 -5
- package/src/doctor/index.mjs +7 -5
- package/src/doctor/local.mjs +116 -8
- 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/ledger/write.mjs +7 -1
- 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/answers.mjs +445 -0
- package/src/review/deletion.mjs +106 -0
- package/src/review/engine.mjs +117 -31
- package/src/review/fixloop.mjs +70 -28
- package/src/review/gate-check.mjs +378 -80
- package/src/review/lenses/A.md +1 -0
- package/src/review/lenses/B.md +1 -0
- package/src/review/lenses/full.md +1 -0
- package/src/review/lenses/quick.md +1 -0
- package/src/review/lenses/recheck.md +1 -0
- package/src/review/reopen.mjs +256 -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/stop-exits.mjs +15 -0
- 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.mjs +13 -3
- package/src/state/config-snapshot.mjs +39 -0
- package/src/state/run.mjs +4 -0
- package/src/util/forbidden.mjs +11 -1
- package/src/util/tmp.mjs +16 -4
- package/src/worker/engine.mjs +75 -9
- package/src/worker/loop.mjs +82 -15
- package/src/worker/queue.mjs +265 -10
- package/src/worker/review-state.mjs +346 -51
- package/src/worker/ticket.mjs +9 -1
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The second-level retry after
|
|
2
|
+
* The second-level retry after two broken results on ONE packet (block B55, issue #2): `schema`
|
|
3
|
+
* misses and, since B91, timed-out attempts, in any mix.
|
|
3
4
|
*
|
|
4
5
|
* Field data: one L2 reviewer model answered `unavailable: schema` four times in a row on the same
|
|
5
6
|
* packet (round 1, its retry, a patch_check recheck, a fresh review), while the model configured in
|
|
@@ -13,8 +14,7 @@
|
|
|
13
14
|
* `history`, which only counts rows whose MAC verifies) plus the miss in hand. In practice: the
|
|
14
15
|
* round's first session is miss 1; the worker's existing retry of that round (`next: retry`, the
|
|
15
16
|
* next ticket re-runs the SAME packet) is miss 2, and that ticket then tries the second level once.
|
|
16
|
-
*
|
|
17
|
-
* result; a `needs_file` round or a Markdown section is another packet text, so another count, and
|
|
17
|
+
* Each timed-out attempt counts too (B91, below); a `needs_file` round or a Markdown section is another packet text, so another count, and
|
|
18
18
|
* so is a recheck whose pending set changed between tries (S1 `resolved` closed another finding,
|
|
19
19
|
* so the open-findings list differs): a different packet starts its own count, by design.
|
|
20
20
|
*
|
|
@@ -43,6 +43,19 @@
|
|
|
43
43
|
* schema-side params, Ajv's message), for non-JSON the kind of parse error and its byte offset.
|
|
44
44
|
* Text a model copies from the packet into a JSON string can never reach the ledger this way.
|
|
45
45
|
*
|
|
46
|
+
* B91: a timed-out attempt is a broken result too. Every timed-out attempt of the level's own
|
|
47
|
+
* ladder leaves one signed `review.session_timeout` note with the packet's `packet_hash`
|
|
48
|
+
* (`session-retry.mjs`), so the count above is `review.schema_invalid` rows plus those notes
|
|
49
|
+
* (`second_level` not true) plus the miss in hand, schema and timeout in any mix. After each
|
|
50
|
+
* timed-out attempt `session-retry.mjs` asks {@link afterSchemaMiss} with `trigger: 'timeout'`:
|
|
51
|
+
* its note is already in the ledger (it IS the miss in hand, so nothing more is written or added),
|
|
52
|
+
* and at 2 the second level is tried in that same ticket instead of the same-model retry. The mark
|
|
53
|
+
* says which kind of broken result was the second one: `trigger: schema | timeout`; an old mark
|
|
54
|
+
* without `trigger` reads as `schema`. A note without `packet_hash` (written before B91), an
|
|
55
|
+
* unsigned note and a `second_level` note are read but never count. With no second level nothing
|
|
56
|
+
* is tried and no mark is written: the timeout is retried once on the same model as before, its
|
|
57
|
+
* note now carrying `packet_hash` and signed.
|
|
58
|
+
*
|
|
46
59
|
* Concurrency: the worker never runs two tickets for one (block, file) at once (its file lock,
|
|
47
60
|
* `worker/loop.mjs`), and round 1, its retry, every recheck and the patch_check of a file are all
|
|
48
61
|
* tickets for that file, so one packet's history is read and written by one ticket at a time. The
|
|
@@ -61,7 +74,7 @@ import { logWarning } from '../util/error-log.mjs';
|
|
|
61
74
|
import { keyedLock } from '../util/locks.mjs';
|
|
62
75
|
import { schemaErrorsOf } from './validate-review.mjs';
|
|
63
76
|
|
|
64
|
-
/**
|
|
77
|
+
/** Broken results (B91: schema misses and timeouts) of the level's own ladder on one packet before the second level is tried. */
|
|
65
78
|
export const SCHEMA_FAILURES_BEFORE_FALLBACK = 2;
|
|
66
79
|
|
|
67
80
|
/** At most this many schema errors are kept in a diagnosis (`errors_total` has the full count). */
|
|
@@ -78,8 +91,9 @@ const KEPT_PARAMS = Object.freeze(['missingProperty', 'additionalProperty', 'typ
|
|
|
78
91
|
/**
|
|
79
92
|
* @typedef {(packetHash: string, who: {level: string, role: string}) => Promise<{failures: number, fellBack: boolean}>} SchemaHistory -
|
|
80
93
|
* the signed history of ONE packet (`packetHash`) at one level and role in this run for this
|
|
81
|
-
* file at this content hash: `failures` = `review.schema_invalid`
|
|
82
|
-
*
|
|
94
|
+
* file at this content hash: `failures` = `review.schema_invalid` and (B91)
|
|
95
|
+
* `review.session_timeout` rows of the level's own ladder (`second_level` not true),
|
|
96
|
+
* `fellBack` = a `review.schema_fallback` row exists. Built by the
|
|
83
97
|
* worker; absent ⇒ no second-level try.
|
|
84
98
|
*/
|
|
85
99
|
|
|
@@ -218,8 +232,24 @@ export function secondLevelFor(cfg, level, from, role) {
|
|
|
218
232
|
}
|
|
219
233
|
|
|
220
234
|
/**
|
|
221
|
-
*
|
|
222
|
-
*
|
|
235
|
+
* Whether a {@link secondLevelFor} answer says no second level is configured (a `same-model` or
|
|
236
|
+
* `closed-book` refusal is a configured level that cannot run: its mark is still written).
|
|
237
|
+
* @param {ReturnType<typeof secondLevelFor>} second @returns {boolean}
|
|
238
|
+
*/
|
|
239
|
+
function noSecondLevel(second) {
|
|
240
|
+
return second.ok === false && second.reason === 'no-second-level';
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** @param {Record<string, any>} row @returns {'schema' | 'timeout'} what a mark says the second broken result was; an old mark without `trigger` is `schema`. */
|
|
244
|
+
export function fallbackTrigger(row) {
|
|
245
|
+
return row?.trigger === 'timeout' ? 'timeout' : 'schema';
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Log a schema miss and, when it is the packet's second broken result (see the module doc), try the
|
|
250
|
+
* second level once. B91: `trigger: 'timeout'` is a timed-out attempt whose signed note is already
|
|
251
|
+
* written (nothing more is logged, and the history already counts it). Never throws (see the
|
|
252
|
+
* module doc).
|
|
223
253
|
* @param {object} p
|
|
224
254
|
* @param {Record<string, any> | null} p.res - the missed session's result.
|
|
225
255
|
* @param {{lens: string, role: string, level: string}} p.session
|
|
@@ -229,21 +259,41 @@ export function secondLevelFor(cfg, level, from, role) {
|
|
|
229
259
|
* @param {SchemaHistory | undefined} p.history
|
|
230
260
|
* @param {((row: Record<string, any>) => Promise<unknown>) | undefined} p.writeRow - throws on a
|
|
231
261
|
* failed write; absent ⇒ nothing is logged or tried.
|
|
232
|
-
* @param {(cfg: Record<string, any
|
|
233
|
-
* - the same session on the same packet with another config (timeout retry included)
|
|
262
|
+
* @param {(cfg: Record<string, any>, to: {level: string, provider: string, model: string}) => Promise<{res: Record<string, any> | null, attempts: number}>} p.spawnAt
|
|
263
|
+
* - the same session on the same packet with another config (timeout retry included); `to`
|
|
264
|
+
* says which second level it is (called only after the mark row was written).
|
|
265
|
+
* @param {'schema' | 'timeout'} [p.trigger] - B91: the kind of the miss in hand (default `schema`).
|
|
234
266
|
* @returns {Promise<{res: Record<string, any> | null, attempts: number, to: {level: string, provider: string, model: string}} | null>}
|
|
235
267
|
* the second level's result, or null when none was spawned.
|
|
236
268
|
*/
|
|
237
|
-
export async function afterSchemaMiss({ res, session, packetText, cfg, allowed, history, writeRow, spawnAt }) {
|
|
269
|
+
export async function afterSchemaMiss({ res, session, packetText, cfg, allowed, history, writeRow, spawnAt, trigger = 'schema' }) {
|
|
238
270
|
if (!writeRow) return null;
|
|
271
|
+
const timedOut = trigger === 'timeout';
|
|
272
|
+
// a timeout's note is written by the retry helper: where no try can be made there is nothing to
|
|
273
|
+
// do — no history read, no row (B91: with no second level a timeout runs exactly as before B30)
|
|
274
|
+
if (timedOut && (!allowed || !history)) return null;
|
|
239
275
|
const write = writeRow;
|
|
240
276
|
const hash = packetHash(packetText);
|
|
241
277
|
const from = { provider: typeof res?.provider === 'string' ? res.provider : null, model: typeof res?.model === 'string' ? res.model : null };
|
|
278
|
+
if (timedOut && (from.provider === null || from.model === null)) {
|
|
279
|
+
// B91: a timed-out result may lack its model; it is the level's resolved one (what was spawned)
|
|
280
|
+
try {
|
|
281
|
+
const resolved = resolveLevel(cfg, /** @type {any} */ (session.level));
|
|
282
|
+
from.provider = from.provider ?? resolved.provider;
|
|
283
|
+
from.model = from.model ?? resolved.model;
|
|
284
|
+
} catch {
|
|
285
|
+
// an unresolvable level: the mark keeps null (the spawn itself could not have resolved it)
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
// no second level for a timeout: nothing read, nothing written. Asked with the SAME arguments as
|
|
289
|
+
// the decision below (cfg, level, from, role), so this guard can never disagree with it
|
|
290
|
+
if (timedOut && noSecondLevel(secondLevelFor(cfg, session.level, from, session.role))) return null;
|
|
242
291
|
/** @type {{cfg: Record<string, any>, to: {level: string, provider: string, model: string}} | null} */
|
|
243
292
|
let go = null;
|
|
244
293
|
try {
|
|
245
294
|
go = await keyedLock(`schema-fallback\0${hash}\0${session.level}\0${session.role}`, async () => {
|
|
246
|
-
// the history is read BEFORE
|
|
295
|
+
// the history is read BEFORE a schema miss is logged: failures = signed earlier misses + this
|
|
296
|
+
// one. A timeout's note is already in it (B91): it is the miss in hand, never added twice.
|
|
247
297
|
let seen = null;
|
|
248
298
|
if (allowed && history) {
|
|
249
299
|
try {
|
|
@@ -253,18 +303,21 @@ export async function afterSchemaMiss({ res, session, packetText, cfg, allowed,
|
|
|
253
303
|
await logWarning({ warning: 'review_schema_history', message: 'the schema-miss history could not be read; no second-level try' }).catch(() => null);
|
|
254
304
|
}
|
|
255
305
|
}
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
306
|
+
if (!timedOut) {
|
|
307
|
+
try {
|
|
308
|
+
await write({ event: 'review.schema_invalid', lens: session.lens, role: session.role, level: session.level, ...from, fallback_step: res?.fallback_step ?? 0, packet_hash: hash, second_level: false, ...diagnoseAnswer(res) });
|
|
309
|
+
} catch {
|
|
310
|
+
// the log is best-effort; the count then misses this one (fewer tries, never more)
|
|
311
|
+
}
|
|
260
312
|
}
|
|
261
|
-
|
|
313
|
+
const broken = seen ? seen.failures + (timedOut ? 0 : 1) : 0;
|
|
314
|
+
if (!seen || seen.fellBack || broken < SCHEMA_FAILURES_BEFORE_FALLBACK) return null;
|
|
262
315
|
const second = secondLevelFor(cfg, session.level, from, session.role);
|
|
263
316
|
const to = `review.second_levels.${session.level}`;
|
|
264
317
|
/** @type {string | null} */
|
|
265
318
|
let refused = null;
|
|
266
319
|
if (second.ok === false) {
|
|
267
|
-
if (second
|
|
320
|
+
if (noSecondLevel(second)) return null;
|
|
268
321
|
refused = second.reason; // `same-model` or `closed-book`: nothing of the level may run
|
|
269
322
|
}
|
|
270
323
|
await write({
|
|
@@ -278,7 +331,8 @@ export async function afterSchemaMiss({ res, session, packetText, cfg, allowed,
|
|
|
278
331
|
to,
|
|
279
332
|
to_provider: second.ok ? second.provider : null,
|
|
280
333
|
to_model: second.ok ? second.model : null,
|
|
281
|
-
attempts:
|
|
334
|
+
attempts: broken,
|
|
335
|
+
trigger,
|
|
282
336
|
...(refused ? { refused } : {}),
|
|
283
337
|
}); // the once-per-packet mark: a failed write throws ⇒ no try
|
|
284
338
|
return !second.ok ? null : { cfg: second.cfg, to: { level: to, provider: second.provider, model: second.model } };
|
|
@@ -287,9 +341,9 @@ export async function afterSchemaMiss({ res, session, packetText, cfg, allowed,
|
|
|
287
341
|
return null;
|
|
288
342
|
}
|
|
289
343
|
if (!go) return null;
|
|
290
|
-
await logWarning({ warning: 'review_schema_fallback', message: 'a review
|
|
344
|
+
await logWarning({ warning: 'review_schema_fallback', message: 'a review broke twice on one packet (schema or timeout); the second level was tried once' }).catch(() => null);
|
|
291
345
|
try {
|
|
292
|
-
const out = await spawnAt(go.cfg);
|
|
346
|
+
const out = await spawnAt(go.cfg, go.to);
|
|
293
347
|
return { res: out.res, attempts: out.attempts, to: go.to };
|
|
294
348
|
} catch {
|
|
295
349
|
// a throwing spawn is a session with no result: `exit` through the stub guard, never approval
|
|
@@ -326,6 +380,7 @@ export async function logSecondLevelMiss({ res, session, packetText, writeRow })
|
|
|
326
380
|
* about, rows of `runId` for `file` at `contentHash` with that `packet_hash`, `level` and `role`
|
|
327
381
|
* whose MAC verifies (`verify`; a row that does not verify, or whose check throws, counts for
|
|
328
382
|
* nothing). A miss on another packet of the same content, or at another level, never counts.
|
|
383
|
+
* B91: a signed `review.session_timeout` note with that `packet_hash` counts like a schema miss.
|
|
329
384
|
* @param {{readRows: () => Promise<Array<Record<string, any>>>, verify: (row: Record<string, any>) => boolean, runId: string, file: string, contentHash: string}} opts
|
|
330
385
|
* @returns {SchemaHistory}
|
|
331
386
|
*/
|
|
@@ -335,7 +390,7 @@ export function ledgerSchemaHistory({ readRows, verify, runId, file, contentHash
|
|
|
335
390
|
let failures = 0;
|
|
336
391
|
let fellBack = false;
|
|
337
392
|
for (const r of rows) {
|
|
338
|
-
if (r.event !== 'review.schema_invalid' && r.event !== 'review.schema_fallback') continue;
|
|
393
|
+
if (r.event !== 'review.schema_invalid' && r.event !== 'review.session_timeout' && r.event !== 'review.schema_fallback') continue;
|
|
339
394
|
let ok = false;
|
|
340
395
|
try {
|
|
341
396
|
ok = verify(r);
|
|
@@ -9,11 +9,26 @@
|
|
|
9
9
|
*
|
|
10
10
|
* Both attempts are recorded: `spawnSession` writes one ledger `session` row per spawn as always,
|
|
11
11
|
* and every timed-out attempt adds a `review.session_timeout` note `{lens, role, level, provider,
|
|
12
|
-
* model, attempt, retried, duration_ms, stderr_tail, tail_note?, stdout_bytes, stdout_events
|
|
13
|
-
* the last 4 KB of the session's stderr, packet-free and redacted
|
|
14
|
-
* `tail_note` when withheld), and stdout only as its byte count and last
|
|
15
|
-
* (`stdoutEvents`) — never stdout text.
|
|
16
|
-
*
|
|
12
|
+
* model, attempt, retried, duration_ms, stderr_tail, tail_note?, stdout_bytes, stdout_events,
|
|
13
|
+
* packet_hash?, second_level?}`: the last 4 KB of the session's stderr, packet-free and redacted
|
|
14
|
+
* (`stderrTail`; null with a `tail_note` when withheld), and stdout only as its byte count and last
|
|
15
|
+
* event types (`stdoutEvents`) — never stdout text. A throwing spawn is returned as `res: null` and
|
|
16
|
+
* a failed note is ignored; the helper throws only when `onBroken` throws (B91: rethrown, never
|
|
17
|
+
* followed by the same-model retry — the caller turns it into a non-approval). `onAttempt(n)` is told each attempt as it
|
|
18
|
+
* starts.
|
|
19
|
+
*
|
|
20
|
+
* B91: a timed-out attempt is a BROKEN result, like a schema miss. The caller passes the packet's
|
|
21
|
+
* hash (`packetHash`, sha256 of the packet text): every note then carries `packet_hash`, so the
|
|
22
|
+
* worker's signed history (`ledgerSchemaHistory`) counts it for that packet, level and role. In the
|
|
23
|
+
* worker every note goes through its ledger writer, which signs it with the run key. A session of
|
|
24
|
+
* the second-level try itself passes `secondLevel: true`: its notes say `second_level: true` and
|
|
25
|
+
* never count. `onBroken(res)` (never used on a second-level try) is asked after each timed-out
|
|
26
|
+
* attempt's note is written: null ⇒ the B30 flow goes on unchanged (retry once, then give up); a
|
|
27
|
+
* result ⇒ the packet's second broken result started the B55 second-level try
|
|
28
|
+
* (`schema-fallback.mjs`, `trigger: timeout`) and its outcome is the session's:
|
|
29
|
+
* `{res, attempts: n + its attempts, to, broken}` (`broken`: the timed-out result it replaced, for
|
|
30
|
+
* the token sum). The note's `retried` keeps its B30 meaning (the same-model retry was due); the
|
|
31
|
+
* `review.schema_fallback` row written after it says the second level ran instead.
|
|
17
32
|
*/
|
|
18
33
|
|
|
19
34
|
import { logWarning } from '../util/error-log.mjs';
|
|
@@ -21,15 +36,25 @@ import { logWarning } from '../util/error-log.mjs';
|
|
|
21
36
|
/** How many times a timed-out session is spawned again. */
|
|
22
37
|
export const TIMEOUT_RETRIES = 1;
|
|
23
38
|
|
|
39
|
+
/**
|
|
40
|
+
* @typedef {{res: Record<string, any> | null, attempts: number, to: {level: string, provider: string, model: string}}} BrokenTry -
|
|
41
|
+
* the outcome of a second-level try started by a timed-out attempt (`afterSchemaMiss`).
|
|
42
|
+
* @typedef {{packetHash?: string, secondLevel?: boolean, onBroken?: (res: Record<string, any>) => Promise<BrokenTry | null>}} BrokenOpts -
|
|
43
|
+
* B91: the packet hash every note carries, whether this is the second-level try, and the
|
|
44
|
+
* broken-result hook (see the module doc).
|
|
45
|
+
*/
|
|
46
|
+
|
|
24
47
|
/**
|
|
25
48
|
* @param {(opts: Record<string, any>) => Promise<Record<string, any>>} spawn
|
|
26
49
|
* @param {Record<string, any>} opts - the spawn options; passed unchanged to every attempt.
|
|
27
50
|
* @param {(row: Record<string, any>) => Promise<unknown>} note - best-effort ledger note.
|
|
28
51
|
* @param {(attempt: number) => void} [onAttempt] - told the attempt number as each one starts.
|
|
29
|
-
* @
|
|
30
|
-
*
|
|
52
|
+
* @param {BrokenOpts} [broken] - B91 (absent ⇒ the B30 behaviour, notes without `packet_hash`).
|
|
53
|
+
* @returns {Promise<{res: Record<string, any> | null, attempts: number, to?: BrokenTry['to'], broken?: Record<string, any>}>}
|
|
54
|
+
* the last attempt's result (`null` when the spawn threw) and how many attempts ran; with `to`
|
|
55
|
+
* (and `broken`) when a timed-out attempt started the second-level try.
|
|
31
56
|
*/
|
|
32
|
-
export async function spawnWithTimeoutRetry(spawn, opts, note, onAttempt = () => {}) {
|
|
57
|
+
export async function spawnWithTimeoutRetry(spawn, opts, note, onAttempt = () => {}, broken = {}) {
|
|
33
58
|
let attempts = 0;
|
|
34
59
|
for (;;) {
|
|
35
60
|
attempts += 1;
|
|
@@ -58,9 +83,19 @@ export async function spawnWithTimeoutRetry(spawn, opts, note, onAttempt = () =>
|
|
|
58
83
|
...(typeof res.tail_note === 'string' ? { tail_note: res.tail_note } : {}),
|
|
59
84
|
stdout_bytes: typeof res.stdout_bytes === 'number' ? res.stdout_bytes : null,
|
|
60
85
|
stdout_events: Array.isArray(res.stdout_events) ? res.stdout_events : [],
|
|
86
|
+
...(typeof broken.packetHash === 'string' ? { packet_hash: broken.packetHash } : {}),
|
|
87
|
+
...(broken.secondLevel === true ? { second_level: true } : {}),
|
|
61
88
|
});
|
|
62
89
|
} catch {
|
|
63
|
-
// a failed note never stops the retry or the result
|
|
90
|
+
// a failed note never stops the retry or the result (the count then misses it: fewer tries)
|
|
91
|
+
}
|
|
92
|
+
if (broken.onBroken && broken.secondLevel !== true) {
|
|
93
|
+
// Contract: `onBroken` is called AFTER this attempt's note was written (or its write failed),
|
|
94
|
+
// so the hook's count already holds the attempt in hand and must not add it a second time.
|
|
95
|
+
// A hook that throws is rethrown, never followed by the B30 same-model retry: a second-level
|
|
96
|
+
// try may already have started, and the caller turns the throw into a non-approval.
|
|
97
|
+
const taken = await broken.onBroken(res);
|
|
98
|
+
if (taken) return { res: taken.res, attempts: attempts + taken.attempts, to: taken.to, broken: res };
|
|
64
99
|
}
|
|
65
100
|
if (!retried) return { res, attempts };
|
|
66
101
|
// B37: the retry is a recoverable problem: one warning line in the local error log
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* B73 (issue #12): the ways out of a file the fix loop stopped (`review_cap`,
|
|
3
|
+
* `l3_patch_exhausted`, `split_required`), in this order — `block reopen` after a fix,
|
|
4
|
+
* `block waive` for each open finding, `review-file --answers` for a fix made in another owned
|
|
5
|
+
* file, or re-decompose the block. `review-file`'s `stopped` answer carries them as `exits`; the
|
|
6
|
+
* gate appends {@link stopExitsLine} to its `review_cap` and `unreviewed` detail for a stopped
|
|
7
|
+
* file. A `tampered` review state is not a stop (it is refused; the owner's way out is
|
|
8
|
+
* `block close --no-require-reviews`) and never gets them.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** @type {ReadonlyArray<string>} */
|
|
12
|
+
export const STOP_EXITS = Object.freeze(['block reopen', 'block waive', 'review-file --answers', 're-decompose']);
|
|
13
|
+
|
|
14
|
+
/** The one short line the gate appends to a stopped file's detail. */
|
|
15
|
+
export const stopExitsLine = `exits: ${STOP_EXITS.join(', ')}`;
|
|
@@ -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.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
|