@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.
Files changed (64) hide show
  1. package/CHANGELOG.md +114 -0
  2. package/README.md +46 -7
  3. package/docs/reference/blocks.json +618 -1
  4. package/docs/reference/config.md +36 -0
  5. package/package.json +1 -1
  6. package/schema/code-forge.schema.json +89 -0
  7. package/skill/references/adapters/claude-code.md +1 -1
  8. package/skill/references/adapters/solo.md +2 -2
  9. package/skill/references/adapters/subprocess.md +12 -2
  10. package/skill/references/code.md +21 -7
  11. package/skill/references/ledger.md +2 -2
  12. package/skill/references/proof.md +77 -2
  13. package/skill/references/review.md +29 -4
  14. package/skill/references/security.md +5 -5
  15. package/skill/scripts/forge +1 -1
  16. package/skill/templates/coder-brief.md +4 -2
  17. package/src/autopilot/binnacle.mjs +9 -3
  18. package/src/cli/block.mjs +300 -33
  19. package/src/cli/jev.mjs +22 -3
  20. package/src/cli/keys.mjs +156 -21
  21. package/src/cli/proof.mjs +35 -7
  22. package/src/cli/review-file.mjs +264 -13
  23. package/src/cli/spawn.mjs +44 -3
  24. package/src/config/validate.mjs +320 -5
  25. package/src/doctor/index.mjs +7 -5
  26. package/src/doctor/local.mjs +116 -8
  27. package/src/doctor/probes.mjs +38 -0
  28. package/src/engines/builders/codex.mjs +44 -4
  29. package/src/engines/sandbox-dirs.mjs +257 -0
  30. package/src/keys/store.mjs +128 -21
  31. package/src/ledger/write.mjs +7 -1
  32. package/src/proof/junit.mjs +530 -0
  33. package/src/proof/red-green.mjs +57 -9
  34. package/src/proof/red-parse.mjs +2 -0
  35. package/src/proof/runners.mjs +472 -0
  36. package/src/proof/xcresult.mjs +285 -0
  37. package/src/review/answers.mjs +445 -0
  38. package/src/review/deletion.mjs +106 -0
  39. package/src/review/engine.mjs +117 -31
  40. package/src/review/fixloop.mjs +70 -28
  41. package/src/review/gate-check.mjs +378 -80
  42. package/src/review/lenses/A.md +1 -0
  43. package/src/review/lenses/B.md +1 -0
  44. package/src/review/lenses/full.md +1 -0
  45. package/src/review/lenses/quick.md +1 -0
  46. package/src/review/lenses/recheck.md +1 -0
  47. package/src/review/reopen.mjs +256 -0
  48. package/src/review/route.mjs +232 -0
  49. package/src/review/schema-fallback.mjs +77 -22
  50. package/src/review/session-retry.mjs +44 -9
  51. package/src/review/stop-exits.mjs +15 -0
  52. package/src/review/test-quality.mjs +201 -0
  53. package/src/review/verb-forms.mjs +39 -0
  54. package/src/session/spawn.mjs +39 -2
  55. package/src/state/block.mjs +13 -3
  56. package/src/state/config-snapshot.mjs +39 -0
  57. package/src/state/run.mjs +4 -0
  58. package/src/util/forbidden.mjs +11 -1
  59. package/src/util/tmp.mjs +16 -4
  60. package/src/worker/engine.mjs +75 -9
  61. package/src/worker/loop.mjs +82 -15
  62. package/src/worker/queue.mjs +265 -10
  63. package/src/worker/review-state.mjs +346 -51
  64. package/src/worker/ticket.mjs +9 -1
@@ -19,9 +19,12 @@
19
19
  * request with a non-repo-relative path is refused (`needs_file-refused`, never approval);
20
20
  * 6. a session that ends in `timeout` is spawned ONCE more on the same packet and level
21
21
  * (`session-retry.mjs`, B30); a second timeout is `unavailable: timeout`. The summary's
22
- * `attempts` says how many ran;
22
+ * `attempts` says how many ran. B91: a timed-out attempt is a broken result like a schema
23
+ * miss: its signed `review.session_timeout` note carries the packet's `packet_hash`, and when
24
+ * it is the packet's second broken result (schema or timeout, in any mix) the second level
25
+ * (6b) is tried in the same session instead of the same-model retry (`trigger: timeout`);
23
26
  * 6b. a session whose answer fails the schema logs it (`review.schema_invalid`, the raw answer
24
- * redacted); at the packet's second such miss it is tried ONCE on `review.second_levels.<level>`
27
+ * redacted); at the packet's second broken result it is tried ONCE on `review.second_levels.<level>`
25
28
  * when the owner configured one (`schema-fallback.mjs`, B55; never in consensus mode).
26
29
  * The summary then carries `schema_fallback: {level, provider, model}` (`level`:
27
30
  * `review.second_levels.<level>`), the second level's own provider/model, and `tokens_in` /
@@ -40,12 +43,20 @@
40
43
  * (`moved.mjs`, B56). The peers' diffs are read once per review (`input.peerDiffs`, or a
41
44
  * reader built from `input.peers`). It is extra text: every hunk still goes to the reviewer,
42
45
  * and it counts toward the budget in the same measure.
46
+ * 9. a pure deletion (the file is gone from the reviewed tree) gets a `## deleted file` note in
47
+ * every packet — reviewers and judge — naming up to 20 tree paths that still name its basename
48
+ * (`deletion.mjs`, B74). It is a hint, never an exemption: the deletion still needs approval.
49
+ * 10. a test file that reads a source file and asserts on its text gets a `## test-quality` note in
50
+ * every packet — reviewers and judge — naming up to 10 line numbers, never the file's text
51
+ * (`test-quality.mjs`, B92). It is a hint, never a finding: the lens tells the reviewer to
52
+ * raise it as a `warning` unless the read file is a fixture. It is extra text like the
53
+ * moved code (after it), counted in the same measure; no flag ⇒ no section, no row.
43
54
  * Approval (until B12b's triage lands) = the final answer (the judge's in dual/consensus mode, else
44
55
  * the single reviewer's) passed the guard, says `passed: true`, and carries no `critical` or
45
56
  * `warning` finding.
46
57
  */
47
58
 
48
- import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
59
+ import { lstatSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
49
60
  import { randomBytes } from 'node:crypto';
50
61
  import path from 'node:path';
51
62
  import { resolveLevel } from '../config/known-ids.mjs';
@@ -55,10 +66,12 @@ import { isMigrationPath, isPolicyPath } from '../decide/lane-facts.mjs';
55
66
  import { tierFor } from '../proof/tiers.mjs';
56
67
  import { assembleJudgePacket, assemblePacket, attachFiles, budgetFor, diffOnlyTokens, readFileDiff } from './packet.mjs';
57
68
  import { movedText, movesFor, peerDiffReader } from './moved.mjs';
69
+ import { deletionNote, renderDeletionNote } from './deletion.mjs';
70
+ import { testQualityNote } from './test-quality.mjs';
58
71
  import { isMarkdownFile, packSections, sectionFindings, sectionGroups } from './sections.mjs';
59
72
  import { FINDING_SCHEMA, minTokensOut, validateReview } from './validate-review.mjs';
60
73
  import { spawnWithTimeoutRetry } from './session-retry.mjs';
61
- import { afterSchemaMiss, logSecondLevelMiss } from './schema-fallback.mjs';
74
+ import { afterSchemaMiss, logSecondLevelMiss, packetHash } from './schema-fallback.mjs';
62
75
  import { assertRowPath } from '../worker/ticket.mjs';
63
76
 
64
77
  /**
@@ -182,6 +195,8 @@ export function rulesRisk({ file, plusCount, cfg }) {
182
195
  * @property {(row: Record<string, any>) => Promise<unknown>} [writeRow] - ledger rows
183
196
  * (`review.plan`, `review.unavailable`, `review.schema_invalid`, `review.schema_fallback`); a
184
197
  * failed write never fails the review (a failed `review.schema_fallback` write means no try).
198
+ * @property {(opts: {repoRoot: string, file: string}) => Promise<string>} [deletionNote] - B74: the
199
+ * deletion note reader (default `deletionNote`, `deletion.mjs`); a throw gives `references not checked`.
185
200
  * @property {import('./schema-fallback.mjs').SchemaHistory} [schemaHistory] - B55: the packet's
186
201
  * signed schema-miss history (the worker's); absent ⇒ no second-level try.
187
202
  */
@@ -233,8 +248,25 @@ export async function reviewFile(input, deps) {
233
248
  } catch {
234
249
  moves = null; // no hint; the review goes on
235
250
  }
251
+ // B74: a pure deletion gets the `## deleted file` note in every packet, read once; it is extra
252
+ // text like the moved code, counted in the same measure. "Deleted" is explicit: the diff is not
253
+ // empty (checked above), the reader found no content (`readFileDiff` gives null ONLY for a path
254
+ // not on disk: a binary file is decoded text, an unreadable one throws) AND the path is absent
255
+ // from the reviewed tree now. The references are read in that same tree (`repoRoot`).
256
+ let deleted = '';
257
+ if (diff.content === null && isAbsent(repoRoot, diff.file)) {
258
+ const note = deps.deletionNote ?? deletionNote;
259
+ try {
260
+ deleted = await note({ repoRoot, file: diff.file });
261
+ } catch {
262
+ deleted = renderDeletionNote(diff.file, { status: 'not_checked', paths: [], more: 0 }); // the review goes on
263
+ }
264
+ }
265
+ // B92: a test that reads a source file and asserts on its text gets the `## test-quality` hint
266
+ // (line numbers only), read once from the current content; '' for none
267
+ const quality = testQualityNote(diff.file, diff.content);
236
268
  /** @param {ReadonlyArray<number> | null} keys - a Markdown section's group keys; null ⇒ the whole file. */
237
- const movedOf = (keys) => movedText(moves, diff.content, keys);
269
+ const movedOf = (keys) => [deleted, movedText(moves, diff.content, keys), quality].filter((t) => typeof t === 'string' && t.length > 0).join('\n');
238
270
  const min = minTokensOut(cfg);
239
271
  // B55: never in consensus mode — its providers (two reviewers, the judge) are the check itself
240
272
  const schemaFallback = plan.mode !== 'consensus';
@@ -363,8 +395,9 @@ export async function reviewFile(input, deps) {
363
395
  }
364
396
 
365
397
  /**
366
- * One session: packet file (0600, removed after), spawn, stub guard — and, on a `schema` miss, the
367
- * B55 second-level try (`schema-fallback.mjs`) on the same packet file before it is removed.
398
+ * One session: packet file (0600, removed after), spawn, stub guard — and, on a `schema` miss or
399
+ * (B91) a timed-out attempt, the B55 second-level try (`schema-fallback.mjs`) on the same packet
400
+ * file before it is removed.
368
401
  * @param {SessionSpec} spec @param {string} text
369
402
  * @param {{hunkHeaders: string[], tokensIn: number, contextMode: string | null}} packet
370
403
  * @param {{deps: ReviewDeps, workDir: string, min: number, cfg: Record<string, any>, schemaFallback?: boolean}} env -
@@ -381,10 +414,39 @@ async function runOne(spec, text, packet, { deps, workDir, min, cfg, schemaFallb
381
414
  let verdict = { ok: false, reason: 'exit', detail: 'no session result' };
382
415
  /** @type {{level: string, provider: string, model: string} | null} */
383
416
  let fellBackTo = null;
384
- /** @param {Record<string, any> | undefined} sessionCfg @param {number} done - attempts before this call. */
385
- const attempt = (sessionCfg, done) =>
417
+ const session = { lens: spec.lens, role: spec.role, level: spec.level };
418
+ // no ledger writer ⇒ nothing logged, nothing tried; every row goes through the same writer
419
+ // (each helper handles its own failed write)
420
+ const write = deps.writeRow ? (/** @type {Record<string, any>} */ row) => /** @type {NonNullable<ReviewDeps['writeRow']>} */ (deps.writeRow)(row) : undefined;
421
+ const hash = packetHash(text);
422
+ /** @type {{level: string, provider: string, model: string} | null} the second level once its try started */
423
+ let tried = null;
424
+ /** @param {Record<string, any> | null} missed @param {'schema' | 'timeout'} trigger */
425
+ const tryNext = (missed, trigger) =>
426
+ afterSchemaMiss({
427
+ res: missed,
428
+ session,
429
+ packetText: text,
430
+ cfg: spec.cfg ?? cfg,
431
+ allowed: schemaFallback && !spec.cfg,
432
+ history: deps.schemaHistory,
433
+ writeRow: write,
434
+ // the second level's attempts follow the ones already run in this session
435
+ spawnAt: (c, to) => {
436
+ tried = to; // the try started: whatever happens next, the session fell back
437
+ return attempt(c, attempts, true);
438
+ },
439
+ trigger,
440
+ });
441
+ /**
442
+ * @param {Record<string, any> | undefined} sessionCfg @param {number} done - attempts before this call.
443
+ * @param {boolean} [secondLevel] - B91: the second-level try (its timeouts never count, never try again).
444
+ * @returns {ReturnType<typeof spawnWithTimeoutRetry>}
445
+ */
446
+ const attempt = (sessionCfg, done, secondLevel = false) =>
386
447
  // a `timeout` is spawned once more on the same packet and level (B30); a spawn that throws
387
- // (bad config, refused argv) is an `exit` failure, never approval
448
+ // (bad config, refused argv) is an `exit` failure, never approval. B91: each timed-out attempt
449
+ // is a broken result of this packet; the second one tries the second level instead
388
450
  spawnWithTimeoutRetry(
389
451
  deps.spawn,
390
452
  {
@@ -399,6 +461,7 @@ async function runOne(spec, text, packet, { deps, workDir, min, cfg, schemaFallb
399
461
  (n) => {
400
462
  attempts = done + n;
401
463
  },
464
+ { packetHash: hash, secondLevel, ...(secondLevel ? {} : { onBroken: (/** @type {Record<string, any>} */ r) => tryNext(r, 'timeout') }) },
402
465
  );
403
466
  /** @type {number | null} the session's tokens: the first spawn's, plus a second-level try's */
404
467
  let tokensIn = null;
@@ -406,34 +469,42 @@ async function runOne(spec, text, packet, { deps, workDir, min, cfg, schemaFallb
406
469
  let tokensOut = null;
407
470
  // the packet file is removed on every path, and only after the second-level try (which reads it)
408
471
  try {
472
+ /** @type {Awaited<ReturnType<typeof spawnWithTimeoutRetry>> | null} */
473
+ let first = null;
409
474
  try {
410
- ({ res, attempts } = await attempt(spec.cfg, 0));
475
+ first = await attempt(spec.cfg, 0);
476
+ ({ res, attempts } = first);
411
477
  } catch {
412
- // defensive only: the helper never throws. If it ever did, it is still an `exit` failure,
413
- // never approval, with the attempts it had reported so far (at least 1).
478
+ // defensive only: the helpers never throw. If they ever did, it is still an `exit` failure,
479
+ // never approval, with the attempts reported so far (at least 1; a second-level try's spawns
480
+ // included) and, once that try started, its second level (never a same-model spawn after it).
414
481
  res = null;
415
482
  attempts = Math.max(attempts, 1);
483
+ fellBackTo = tried;
416
484
  }
417
- tokensIn = res?.usage?.tokens_in ?? null;
418
- tokensOut = res?.usage?.tokens_out ?? null;
419
- verdict = validateReview(res, { hunkHeaders: packet.hunkHeaders, minTokensOut: min });
420
- if (!verdict.ok && verdict.reason === 'schema') {
485
+ if (first?.to) {
486
+ // B91: a timed-out attempt was the packet's second broken result; the second level ran and
487
+ // `attempts` already counts its spawns. The verdict is set first: whatever throws after it
488
+ // leaves that verdict (a failed one stays a non-approval), never an exception
489
+ fellBackTo = first.to;
490
+ verdict = { ok: false, reason: 'exit', detail: 'second-level result not checked' };
491
+ try {
492
+ verdict = validateReview(res, { hunkHeaders: packet.hunkHeaders, minTokensOut: min });
493
+ tokensIn = addTokens(first.broken?.usage?.tokens_in ?? null, res?.usage?.tokens_in);
494
+ tokensOut = addTokens(first.broken?.usage?.tokens_out ?? null, res?.usage?.tokens_out);
495
+ if (!verdict.ok && verdict.reason === 'schema') await logSecondLevelMiss({ res, session, packetText: text, writeRow: write });
496
+ } catch {
497
+ // defensive only, as on the schema path below
498
+ }
499
+ } else {
500
+ tokensIn = res?.usage?.tokens_in ?? null;
501
+ tokensOut = res?.usage?.tokens_out ?? null;
502
+ verdict = validateReview(res, { hunkHeaders: packet.hunkHeaders, minTokensOut: min });
503
+ }
504
+ if (!fellBackTo && !verdict.ok && verdict.reason === 'schema') {
421
505
  try {
422
- const session = { lens: spec.lens, role: spec.role, level: spec.level };
423
506
  const done = attempts;
424
- // no ledger writer ⇒ nothing logged, nothing tried; every row goes through the same writer
425
- // (each helper handles its own failed write)
426
- const write = deps.writeRow ? (/** @type {Record<string, any>} */ row) => /** @type {NonNullable<ReviewDeps['writeRow']>} */ (deps.writeRow)(row) : undefined;
427
- const second = await afterSchemaMiss({
428
- res,
429
- session,
430
- packetText: text,
431
- cfg: spec.cfg ?? cfg,
432
- allowed: schemaFallback && !spec.cfg,
433
- history: deps.schemaHistory,
434
- writeRow: write,
435
- spawnAt: (c) => attempt(c, done),
436
- });
507
+ const second = await tryNext(res, 'schema');
437
508
  if (second) {
438
509
  const secondVerdict = validateReview(second.res, { hunkHeaders: packet.hunkHeaders, minTokensOut: min });
439
510
  res = second.res;
@@ -447,6 +518,7 @@ async function runOne(spec, text, packet, { deps, workDir, min, cfg, schemaFallb
447
518
  } catch {
448
519
  // defensive only: the helpers never throw. If they ever did, the first session's result
449
520
  // and its `schema` verdict stand (never turned into `exit`, never approval)
521
+ fellBackTo = fellBackTo ?? tried;
450
522
  }
451
523
  }
452
524
  } finally {
@@ -466,6 +538,20 @@ async function runOne(spec, text, packet, { deps, workDir, min, cfg, schemaFallb
466
538
  return { ...meta, status: 'ok', reason: null, review: verdict.review };
467
539
  }
468
540
 
541
+ /**
542
+ * B74: whether `file` is absent from the tree at `repoRoot` (lstat says ENOENT or ENOTDIR).
543
+ * @param {string} repoRoot @param {string} file @returns {boolean}
544
+ */
545
+ function isAbsent(repoRoot, file) {
546
+ try {
547
+ lstatSync(path.join(repoRoot, file));
548
+ return false;
549
+ } catch (err) {
550
+ const code = /** @type {NodeJS.ErrnoException} */ (err).code;
551
+ return code === 'ENOENT' || code === 'ENOTDIR';
552
+ }
553
+ }
554
+
469
555
  /**
470
556
  * Two token counts added; a missing one (not a number) adds nothing, both missing ⇒ null.
471
557
  * @param {unknown} a @param {unknown} b @returns {number | null}
@@ -24,11 +24,15 @@
24
24
  * a coding level, R1) takes it, so for an L2 block the rung IS the +1 at round 2. After the
25
25
  * patch the block continues at L2 (§3.6, Fable ruling A): `l3_rung_used = true`,
26
26
  * `rounds_at_level = 0`, and the `patch_check` goes through `decideNext` like any recheck —
27
- * clean ⇒ complete; open set not smaller than before the patch ⇒ `stopped: l3_patch_exhausted`
28
- * (`trigger: review_stall`); round ≥ cap ⇒ `stopped: review_cap`; else `fix` at L2 with
29
- * `l3_patch: true` in the brief. A later stall ⇒ `l3_patch_exhausted`; round 4 still open ⇒
30
- * `review_cap` (the rung is spent). Either way the ledger `review.cap` row lists the open
31
- * ids — the human decides (fix by hand, `block waive`, or re-decompose).
27
+ * clean ⇒ complete; the patch closed NO finding that was open before it (ids compared, not
28
+ * counts) ⇒ `stopped: l3_patch_exhausted` (`trigger: review_stall`); round ≥ cap ⇒
29
+ * `stopped: review_cap`; else `fix` at L2 with `l3_patch: true` in the brief. B73 (issue #12):
30
+ * a patch that closed ≥ 1 old id below the cap is progress, not a stall, even when new findings
31
+ * in its fix hunks keep the open set from shrinking. A later stall at L2 ⇒ `l3_patch_exhausted`
32
+ * (the rung is spent, never a second one); round 4 still open ⇒ `review_cap`. Either way the
33
+ * ledger `review.cap` row lists the open ids — the human decides (`STOP_EXITS`,
34
+ * `stop-exits.mjs`): `block reopen` after a fix, `block waive` per open finding,
35
+ * `review-file --answers` for a fix in another owned file, or re-decompose.
32
36
  * 6. Ledger per round: `review.round {file, round, level, kind, open_before, closed,
33
37
  * new_in_hunks, late, open_after, tokens_in, tokens_out}`; a converged file gets
34
38
  * `review.approved {file, content_hash, round}` (the worker signs every row it writes).
@@ -93,7 +97,7 @@ import { isMarkdownFile, packSections, sectionFindings, sectionGroups, sectionOf
93
97
  import { cameFromJudge, resolvedBand, triageFindings } from './triage.mjs';
94
98
  import { FINDING_SCHEMA, minTokensOut, validateReview } from './validate-review.mjs';
95
99
  import { spawnWithTimeoutRetry } from './session-retry.mjs';
96
- import { afterSchemaMiss, logSecondLevelMiss } from './schema-fallback.mjs';
100
+ import { afterSchemaMiss, logSecondLevelMiss, packetHash } from './schema-fallback.mjs';
97
101
 
98
102
  /** @typedef {import('./triage.mjs').Finding} Finding */
99
103
  /** @typedef {import('./context.mjs').Hunk} Hunk */
@@ -427,8 +431,9 @@ export function insideHunks(f, hunks) {
427
431
  }
428
432
 
429
433
  /**
430
- * One recheck session through the stub guard — and, on a `schema` miss, the B55 second-level try
431
- * on the same packet (`schema-fallback.mjs`; rechecks and the patch_check alike).
434
+ * One recheck session through the stub guard — and, on a `schema` miss or (B91) a timed-out
435
+ * attempt that is the packet's second broken result, the B55 second-level try on the same packet
436
+ * (`schema-fallback.mjs`; rechecks and the patch_check alike).
432
437
  * @param {{text: string, hunkHeaders: string[], tokensIn: number, contextMode: string}} packet
433
438
  * @param {LoopDeps} deps
434
439
  */
@@ -438,14 +443,28 @@ async function recheckSession(packet, deps) {
438
443
  writeFileSync(promptPath, packet.text, { mode: 0o600 });
439
444
  const min = minTokensOut(deps.cfg);
440
445
  const session = { lens: 'recheck', role: 'reviewer', level: 'L2' };
441
- /** @param {Record<string, any> | undefined} cfg */
446
+ // no ledger writer ⇒ nothing logged, nothing tried; every row goes through the same writer
447
+ // (each helper handles its own failed write)
448
+ const write = deps.writeRow ? (/** @type {Record<string, any>} */ row) => /** @type {NonNullable<LoopDeps['writeRow']>} */ (deps.writeRow)(row) : undefined;
449
+ const hash = packetHash(packet.text);
450
+ /** @param {Record<string, any> | null} missed @param {'schema' | 'timeout'} trigger */
451
+ const tryNext = (missed, trigger) =>
452
+ afterSchemaMiss({ res: missed, session, packetText: packet.text, cfg: deps.cfg ?? {}, allowed: true /* rechecks are never consensus; the budget gate is inside the spawn */, history: deps.schemaHistory, writeRow: write, spawnAt: attempt, trigger });
453
+ /**
454
+ * @param {Record<string, any> | undefined} cfg - set only for the second-level try (B91: its
455
+ * timeouts never count and never try again).
456
+ * @returns {Promise<{res: Record<string, any> | null, attempts: number, to?: Record<string, any>, broken?: Record<string, any>}>}
457
+ */
442
458
  const attempt = async (cfg) =>
443
459
  deps.spawn
444
- ? // a `timeout` is spawned once more on the same packet and level (B30)
460
+ ? // a `timeout` is spawned once more on the same packet and level (B30); B91: the packet's
461
+ // second broken result tries the second level instead
445
462
  spawnWithTimeoutRetry(
446
463
  deps.spawn,
447
464
  { level: 'L2', role: 'reviewer', promptPath, schema: FINDING_SCHEMA, rowExtra: { lens: 'recheck', context_mode: packet.contextMode, ctx_tokens_in: packet.tokensIn }, ...(cfg ? { cfg } : {}) },
448
465
  (row) => note(deps, row),
466
+ undefined,
467
+ cfg ? { packetHash: hash, secondLevel: true } : { packetHash: hash, onBroken: (r) => tryNext(r, 'timeout') },
449
468
  )
450
469
  : { res: null, attempts: 0 };
451
470
  /** @type {Record<string, any> | null} */
@@ -458,20 +477,37 @@ async function recheckSession(packet, deps) {
458
477
  let tokensOut = null;
459
478
  // the packet file is removed on every path, and only after the second-level try (which reads it)
460
479
  try {
480
+ /** @type {{res: Record<string, any> | null, attempts: number, to?: Record<string, any>, broken?: Record<string, any>} | null} */
481
+ let first = null;
461
482
  try {
462
- res = (await attempt(undefined)).res;
483
+ first = await attempt(undefined);
484
+ res = first.res;
463
485
  } catch {
464
486
  res = null; // the helper never throws; if it ever did, it is still an `exit` failure
465
487
  }
466
- tokensIn = res?.usage?.tokens_in ?? null;
467
- tokensOut = res?.usage?.tokens_out ?? null;
468
488
  verdict = validateReview(res, { hunkHeaders: packet.hunkHeaders, minTokensOut: min });
469
- if (!verdict.ok && verdict.reason === 'schema') {
489
+ if (first?.to) {
490
+ // B91: a timed-out attempt was the packet's second broken result; the second level ran. The
491
+ // verdict is already set: whatever throws after it leaves that verdict, never an exception
492
+ // the second level's miss is logged first, in its own try, so no sum error can skip it
470
493
  try {
471
- // no ledger writer ⇒ nothing logged, nothing tried; every row goes through the same writer
472
- // (each helper handles its own failed write)
473
- const write = deps.writeRow ? (/** @type {Record<string, any>} */ row) => /** @type {NonNullable<LoopDeps['writeRow']>} */ (deps.writeRow)(row) : undefined;
474
- const second = await afterSchemaMiss({ res, session, packetText: packet.text, cfg: deps.cfg ?? {}, allowed: true /* rechecks are never consensus; the budget gate is inside the spawn */, history: deps.schemaHistory, writeRow: write, spawnAt: attempt });
494
+ if (!verdict.ok && verdict.reason === 'schema') await logSecondLevelMiss({ res, session, packetText: packet.text, writeRow: write });
495
+ } catch {
496
+ // defensive only: the helper never throws; the verdict above stands (never approval)
497
+ }
498
+ try {
499
+ tokensIn = sum(first.broken?.usage?.tokens_in ?? null, res?.usage?.tokens_in);
500
+ tokensOut = sum(first.broken?.usage?.tokens_out ?? null, res?.usage?.tokens_out);
501
+ } catch {
502
+ // defensive only, as on the schema path below
503
+ }
504
+ } else {
505
+ tokensIn = res?.usage?.tokens_in ?? null;
506
+ tokensOut = res?.usage?.tokens_out ?? null;
507
+ }
508
+ if (!first?.to && !verdict.ok && verdict.reason === 'schema') {
509
+ try {
510
+ const second = await tryNext(res, 'schema');
475
511
  if (second) {
476
512
  // compute everything first, then assign together, so a throw never leaves a mixed state
477
513
  const secondVerdict = validateReview(second.res, { hunkHeaders: packet.hunkHeaders, minTokensOut: min });
@@ -497,17 +533,20 @@ async function recheckSession(packet, deps) {
497
533
  /**
498
534
  * After a counted round with open findings: cap first (rule 6), else stall / ladder (2b, 2).
499
535
  * @param {FileState} state @param {number} openBefore @param {ReturnType<typeof settings>} s
500
- * @param {'full' | 'recheck' | 'patch_check'} kind - a `patch_check` compares against the open
501
- * set before the patch (the rung's own shrink test), whatever the round number.
536
+ * @param {'full' | 'recheck' | 'patch_check'} kind - a `patch_check` is judged by `closedIds`
537
+ * (B73), whatever the round number.
538
+ * @param {number} closedIds - how many ids open before the round are not open after it.
502
539
  * @returns {NextStep}
503
540
  */
504
- function decideNext(state, openBefore, s, kind) {
541
+ function decideNext(state, openBefore, s, kind, closedIds) {
505
542
  if (state.open.length === 0) return { action: 'complete' };
506
543
  if (kind === 'patch_check') {
507
- // the block's one rung is spent: a patch that did not shrink the set stops FIRST — even when
508
- // the rung was taken at the cap — then the cap; else the block continues at L2 below
509
- if (reviewRoundStalled(openBefore, state.open.length)) return { action: 'stop', reason: 'l3_patch_exhausted', trigger: 'review_stall' };
544
+ // the block's one rung is spent: a patch that closed no old finding stops FIRST — even when
545
+ // the rung was taken at the cap — then the cap; else (B73: ≥ 1 old id closed is progress,
546
+ // whatever new findings its fix hunks brought) the block continues at L2: a fix, no stall
547
+ if (closedIds === 0) return { action: 'stop', reason: 'l3_patch_exhausted', trigger: 'review_stall' };
510
548
  if (state.round >= s.maxRounds) return { action: 'stop', reason: 'review_cap', trigger: 'review_cap' };
549
+ return { action: 'fix', level: state.level, trigger: null };
511
550
  }
512
551
  if (state.round >= s.maxRounds) {
513
552
  const esc = escalationAfterAttempt({ currentLevel: state.level, roundsAtFile: state.round, maxRoundsPerFile: s.maxRounds, openFixNowFinding: true, l3RungAlreadyUsed: state.l3_rung_used });
@@ -515,7 +554,7 @@ function decideNext(state, openBefore, s, kind) {
515
554
  }
516
555
  const esc = escalationAfterAttempt({
517
556
  currentLevel: state.level,
518
- reviewStall: (kind === 'patch_check' || state.round >= 2) && reviewRoundStalled(openBefore, state.open.length),
557
+ reviewStall: state.round >= 2 && reviewRoundStalled(openBefore, state.open.length),
519
558
  openFixNowFinding: true,
520
559
  reviewRoundsAtLevel: state.rounds_at_level,
521
560
  reviewRoundsPerLevel: s.perLevel,
@@ -740,9 +779,12 @@ export async function runRound(state, deps, opts = {}) {
740
779
  draft.status = 'complete';
741
780
  draft.next = { action: 'complete' };
742
781
  } else {
743
- // after a patch_check the block continues at L2 (§3.6): the rung is spent, so a stall now
744
- // stops (`l3_patch_exhausted`) and the cap stops (`review_cap`); a shrunk set is fixed at L2
745
- draft.next = decideNext(draft, openBefore, s, kind);
782
+ // after a patch_check the block continues at L2 (§3.6): the rung is spent, so a patch that
783
+ // closed no old id stops (`l3_patch_exhausted`) and the cap stops (`review_cap`); a patch that
784
+ // closed one is fixed at L2 (B73). Closed = an id open before the round, not open after it.
785
+ const after = new Set(draft.open.map((f) => f.id));
786
+ const closedIds = new Set(state.open.map((f) => f.id).filter((id) => !after.has(id))).size;
787
+ draft.next = decideNext(draft, openBefore, s, kind, closedIds);
746
788
  if (draft.next.action === 'stop') draft.status = 'stopped';
747
789
  }
748
790
  const rows = [...lateRows, roundRow];