@codedology/code-forge 0.10.0 → 0.12.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 (44) hide show
  1. package/CHANGELOG.md +74 -0
  2. package/README.md +34 -3
  3. package/docs/reference/blocks.json +360 -1
  4. package/docs/reference/config.md +6 -5
  5. package/package.json +2 -2
  6. package/schema/code-forge.schema.json +3 -2
  7. package/skill/SKILL.md +3 -1
  8. package/skill/references/code.md +17 -8
  9. package/skill/references/proof.md +8 -2
  10. package/skill/references/review.md +32 -5
  11. package/skill/references/rules-core.md +1 -1
  12. package/skill/scripts/forge +1 -1
  13. package/skill/templates/coder-brief.md +17 -7
  14. package/src/cli/block.mjs +112 -7
  15. package/src/cli/gates.mjs +15 -6
  16. package/src/cli/review-file.mjs +118 -14
  17. package/src/config/validate.mjs +12 -5
  18. package/src/context/pack.mjs +7 -4
  19. package/src/gates/expand.mjs +102 -9
  20. package/src/gates/run.mjs +6 -4
  21. package/src/install/wizard/flags.mjs +1 -1
  22. package/src/proof/lock.mjs +23 -10
  23. package/src/proof/rule.mjs +9 -0
  24. package/src/proof/tiers.mjs +2 -14
  25. package/src/review/engine.mjs +59 -12
  26. package/src/review/fixloop.mjs +41 -16
  27. package/src/review/gate-check.mjs +2 -2
  28. package/src/review/lenses/A.md +5 -0
  29. package/src/review/lenses/B.md +5 -0
  30. package/src/review/lenses/full.md +5 -0
  31. package/src/review/lenses/judge.md +4 -0
  32. package/src/review/lenses/quick.md +4 -0
  33. package/src/review/lenses/recheck.md +5 -0
  34. package/src/review/packet.mjs +48 -14
  35. package/src/review/paired.mjs +81 -0
  36. package/src/review/reopen.mjs +18 -6
  37. package/src/review/reviewed-lines.mjs +94 -0
  38. package/src/state/block-end.mjs +10 -6
  39. package/src/util/exec.mjs +2 -1
  40. package/src/util/gate-token.mjs +3 -0
  41. package/src/worker/engine.mjs +43 -15
  42. package/src/worker/loop.mjs +35 -2
  43. package/src/worker/queue.mjs +60 -2
  44. package/src/worker/watch.mjs +113 -0
package/src/cli/block.mjs CHANGED
@@ -70,6 +70,21 @@
70
70
  * FAILED: no review-file ticket id for <files>` (skipped under `--no-require-reviews`, the human's
71
71
  * way out). It runs after the review rows and reuses their file set (listed once).
72
72
  *
73
+ * B96 (E1): the tool builds the close report. After the review gate passed, the close prints
74
+ * `reviewed <path> ticket <id>` for every file the gate accepted, sorted by path, before the
75
+ * `reviewed_by_waiver` lines and the closed line; the id is the queue's ticket for that file at the
76
+ * hash the gate accepted (`resolveTicket`: the tree of a worktree block is part of it). `--json`
77
+ * and the done file get `reviewed: [{file, ticket, via}]` (`via`: approved, waiver or answered —
78
+ * the gate's own verdict, `review/reviewed-lines.mjs`), only when there is a line. So `--report`
79
+ * needs no ticket line: a missing hand line refuses only for a file the tool has no line for, and a
80
+ * hand line that names another ticket prints `WARN report ticket mismatch <path>` on stderr while
81
+ * the close goes on (D1). A gate-accepted file with no ticket prints `WARN no ticket for <path>`
82
+ * (`WARN bad ticket for <path>` when the ticket is not 24 lowercase hex) and has no line; lines that
83
+ * cannot be built at all give no line and `WARN reviewed lines unavailable: <message>` — then
84
+ * `--report` needs a hand line for every file the gate accepted, as before B96. All four warnings
85
+ * are held and print only when the block closes. A waived or external-review close is unchanged:
86
+ * no line, no key.
87
+ *
73
88
  * B58 (issue #11): `review.external.required: true` in the run's config snapshot (the owner set it
74
89
  * before `run start`; `run reload` refuses to change it) is the owner's non-interactive way out
75
90
  * when an outside reviewer reviews the block: the per-file review checks and the `--report` ticket
@@ -200,12 +215,13 @@ import { validateConfig } from '../config/validate.mjs';
200
215
  import { migrateConfig } from '../config/migrate.mjs';
201
216
  import { exec } from '../util/exec.mjs';
202
217
  import { contentHash, gitChildEnv } from '../worker/ticket.mjs';
203
- import { clearDone } from '../worker/queue.mjs';
218
+ import { clearDone, resolveTicket } from '../worker/queue.mjs';
204
219
  import { loadState } from '../worker/review-state.mjs';
205
220
  import { openBlockEntry, reopenFile } from '../review/reopen.mjs';
206
221
  import { checkFileForm, checkFindingId } from '../review/verb-forms.mjs';
207
222
  import { runRootPath } from '../util/tmp.mjs';
208
223
  import { missingReviewTickets, parseSentinel } from '../engines/sentinel.mjs';
224
+ import { reportMismatches, reviewedLines } from '../review/reviewed-lines.mjs';
209
225
  import { blockEndHook, runBlockEndHook, writeDoneFile } from '../state/block-end.mjs';
210
226
  import { blockOpenFindings } from '../review/open-findings.mjs';
211
227
  import { hookForbidden, scanTranscript } from '../util/forbidden.mjs';
@@ -281,11 +297,14 @@ const FLAG_VALUES = ['run', 'level', 'acceptance', 'brief', 'attempt', 'base', '
281
297
  * isTTY?: boolean, stdinIsTTY?: boolean, stdoutIsTTY?: boolean,
282
298
  * ui?: {confirm: (o: {message: string, initialValue?: boolean}) => Promise<unknown>, isCancel: (v: unknown) => boolean},
283
299
  * removeBlockTree?: typeof removeBlockTree,
300
+ * resolveTicket?: typeof resolveTicket, reviewedLines?: typeof reviewedLines,
284
301
  * }} [deps] - `now` (B48): the clock of the autopilot expiry checks. `stdinIsTTY` / `stdoutIsTTY`
285
302
  * (default `process.stdin.isTTY` / `process.stdout.isTTY`; `isTTY` sets both) and `ui` (default
286
303
  * `@clack/prompts`): the human's confirmation (B52 `--no-require-reviews`, B80 `block waive` and
287
304
  * `block reopen`), which needs both to be TTYs.
288
305
  * `removeBlockTree` (B64b, tests only): the `--remove-worktree` step, default the real one.
306
+ * `resolveTicket` / `reviewedLines` (B96, tests only): the queue's ticket rule and the reviewed
307
+ * lines builder of the close, default the real ones.
289
308
  * @returns {Promise<number>}
290
309
  */
291
310
  export async function runBlock(args, deps = {}) {
@@ -344,7 +363,8 @@ export async function runBlock(args, deps = {}) {
344
363
  // then the owner's `hooks.on_block_end` from the run-start snapshot. Without the hook key the
345
364
  // done file is the only addition: no row, and every output stays exactly as before.
346
365
  const hook = blockEndHook(record);
347
- const ended = async (/** @type {'closed' | 'stopped'} */ status, /** @type {string} */ event, /** @type {string | null} */ reason) => {
366
+ // B96: `reviewed` (the close's tool lines) joins the done file only when there is one
367
+ const ended = async (/** @type {'closed' | 'stopped'} */ status, /** @type {string} */ event, /** @type {string | null} */ reason, /** @type {import('../review/reviewed-lines.mjs').ReviewedLine[]} */ reviewed = []) => {
348
368
  // ruling (e): `ended` never throws — any failure here is the done-file warning and a null
349
369
  // `doneFile`, so the caller's try/finally still reaches the hook
350
370
  /** @type {string | null} */
@@ -358,6 +378,7 @@ export async function runBlock(args, deps = {}) {
358
378
  reason: typeof reason === 'string' ? redact(reason) : null,
359
379
  openFindings: (rows) => blockOpenFindings(record, rows, id),
360
380
  err,
381
+ ...(reviewed.length > 0 ? { reviewed } : {}),
361
382
  });
362
383
  } catch (thrown) {
363
384
  const code = /** @type {any} */ (thrown)?.code;
@@ -568,6 +589,9 @@ export async function runBlock(args, deps = {}) {
568
589
  // B70: a file whose review left only waived findings passes as `reviewed_by_waiver`; its signed
569
590
  // `gate.review_waived_all` row joins the close rows (written just before `block.close`, only
570
591
  // when the block closes — a refused close writes none)
592
+ // B96: whether the review gate passed — set from the gate's own result (below), so the files
593
+ // it checked are the ones it accepted and the tool can give their reviewed lines
594
+ let reviewsPassed = false;
571
595
  const onWaivedAll = (/** @type {import('../review/gate-check.mjs').WaivedAll[]} */ files) => {
572
596
  // idempotent: the waived rows are rebuilt from the final list, never appended per call
573
597
  // (closeBlock reads closeRows only after every extra check passed)
@@ -575,7 +599,7 @@ export async function runBlock(args, deps = {}) {
575
599
  const others = closeRows.filter((r) => r.event !== 'gate.review_waived_all');
576
600
  closeRows.splice(0, closeRows.length, ...others, ...files.map((f) => ({ event: 'gate.review_waived_all', block: id, file: f.file, content_hash: f.content_hash, findings: f.findings, by: f.by, ...(f.answered?.length ? { answered: f.answered } : {}) })));
577
601
  };
578
- const reviews = reviewGateCheck(async () => {
602
+ const gate = reviewGateCheck(async () => {
579
603
  const transcript = await findTranscript(record, id, typeof flags.transcript === 'string' ? flags.transcript : null);
580
604
  if (transcript === null) {
581
605
  await writeSigned(runId, writeRow, { event: 'gate.transcript_missing', block: id });
@@ -625,7 +649,63 @@ export async function runBlock(args, deps = {}) {
625
649
  highPaths,
626
650
  };
627
651
  }, { onWaivedAll });
628
- // B36: the coder's report names a review-file ticket for every changed file, or it is FAILED
652
+ // B96: `reviewsPassed` comes from the gate's own result, not from the onWaivedAll call. Each
653
+ // extra check runs at most once per close (`closeBlock`'s one loop, src/state/block.mjs:562,
654
+ // and `closeBlock` is called once below), so the gate's one pass is its final one.
655
+ const reviews = async () => {
656
+ const result = await gate();
657
+ reviewsPassed = result.ok;
658
+ return result;
659
+ };
660
+ // B96: the stderr warnings of the reviewed lines (`WARN no ticket for <path>`, `WARN bad
661
+ // ticket for <path>`, `WARN reviewed lines unavailable: <message>`, `WARN report ticket
662
+ // mismatch <path>`): kept here and printed only when the block closes, before the reviewed
663
+ // lines — a refused close prints none
664
+ /** @type {string[]} */
665
+ const lineWarnings = [];
666
+ // B96: the tool's reviewed lines — one per file the review gate accepted, with the queue's
667
+ // ticket for that file at the hash the gate accepted (`resolveTicket`, the queue's own rule:
668
+ // the same path check, tree resolution and ticket id); a file whose content moved since the
669
+ // gate hashed it, or whose ticket cannot be given, has no line (fail closed, `WARN no ticket
670
+ // for <path>`), and so has a file whose ticket is not 24 lowercase hex (`WARN bad ticket for
671
+ // <path>`). None when the reviews were waived (the human flag or `review.external`) or the
672
+ // gate has not passed — and that empty answer is never memoised: the lines are built once,
673
+ // from the gate's pass (its file set and hashes, its waivedAll). It never throws: an error
674
+ // gives no lines and one held `WARN reviewed lines unavailable: <message>`, so the done file
675
+ // and the on_block_end hook of a closed block always follow.
676
+ /** @type {Promise<import('../review/reviewed-lines.mjs').ReviewedLine[]> | undefined} */
677
+ let linesOnce;
678
+ const toolLines = async () => {
679
+ if (!reviewsPassed || reviewsWaived || external !== null) return [];
680
+ linesOnce ??= (async () => {
681
+ try {
682
+ const { mainTop, top, inTree, fileSet } = await tree();
683
+ const resolve = deps.resolveTicket ?? resolveTicket;
684
+ const ticketOf = (/** @type {{file: string, content_hash: string}} */ f) => {
685
+ try {
686
+ const t = resolve({ repoRoot: mainTop, run: runId, block: id, file: f.file, tree: inTree ? top : null });
687
+ return t.content_hash === f.content_hash ? t.ticket : null;
688
+ } catch {
689
+ return null;
690
+ }
691
+ };
692
+ return await (deps.reviewedLines ?? reviewedLines)({
693
+ files: fileSet,
694
+ waivedAll,
695
+ ticketOf,
696
+ onNoTicket: (file) => lineWarnings.push(`WARN no ticket for ${file}\n`),
697
+ onBadTicket: (file) => lineWarnings.push(`WARN bad ticket for ${file}\n`),
698
+ });
699
+ } catch (thrown) {
700
+ lineWarnings.push(`WARN reviewed lines unavailable: ${thrown instanceof Error ? thrown.message : String(thrown)}\n`);
701
+ return [];
702
+ }
703
+ })();
704
+ return linesOnce;
705
+ };
706
+ // B36 → B96: the coder's report. The tool gives the reviewed lines, so a missing hand line is
707
+ // a refusal only for a file the tool has no line for; a hand line that names another ticket
708
+ // is a warning and the close goes on (D1: the tool lines are the record)
629
709
  const report = async () => {
630
710
  if (typeof flags.report !== 'string' || reviewsWaived || external !== null) return { ok: true };
631
711
  let text;
@@ -635,8 +715,12 @@ export async function runBlock(args, deps = {}) {
635
715
  return { ok: false, reason: 'coder report FAILED: --report cannot be read' };
636
716
  }
637
717
  const { fileSet } = await tree();
638
- const missing = missingReviewTickets(text, fileSet.map((f) => f.file));
639
- return missing.length === 0 ? { ok: true } : { ok: false, reason: `coder report FAILED: no review-file ticket id for ${missing.join(', ')}` };
718
+ const lines = await toolLines();
719
+ const given = new Set(lines.map((l) => l.file));
720
+ const missing = missingReviewTickets(text, fileSet.map((f) => f.file)).filter((f) => !given.has(f));
721
+ if (missing.length > 0) return { ok: false, reason: `coder report FAILED: no review-file ticket id for ${missing.join(', ')}` };
722
+ for (const file of reportMismatches(text, lines)) lineWarnings.push(`WARN report ticket mismatch ${file}\n`);
723
+ return { ok: true };
640
724
  };
641
725
  /** @type {Array<() => Promise<{ok: boolean, reason?: string}>>} */
642
726
  const extraChecks = [reviews, report];
@@ -666,14 +750,19 @@ export async function runBlock(args, deps = {}) {
666
750
  const result = await closeBlock({ runId, id, rows, writeRow, livePid: intFlag(flags['worker-pid'], 'worker-pid'), probe, extraChecks, closeRows });
667
751
  // B64b: the block ended (closed, or stopped by a worker pin event or ledger.tamper) — its rows
668
752
  // are written and the run lock released; the done file is written before the tree is removed
669
- const end = result.status === 'closed' || result.status === 'stopped' ? await ended(result.status, result.event ?? (result.status === 'stopped' ? 'block.stop' : 'block.close'), result.reason ?? null) : null;
753
+ // B96: the tool's reviewed lines of a block that closed (also kept in its done file)
754
+ const reviewedJson = result.ok ? await toolLines() : [];
755
+ const end = result.status === 'closed' || result.status === 'stopped' ? await ended(result.status, result.event ?? (result.status === 'stopped' ? 'block.stop' : 'block.close'), result.reason ?? null, reviewedJson) : null;
670
756
  // B64b ruling (e): the hook runs exactly once for this end, after everything else the verb
671
757
  // does — also when a later step (the output, `--remove-worktree`) throws; it never changes
672
758
  // the exit code or stdout (`runHook` never throws)
673
759
  try {
760
+ // B96: the reviewed lines (sorted by path) come first, then the reviewed_by_waiver lines
674
761
  // B70: each file that passed as reviewed_by_waiver is named before the closed line
675
762
  // B71: an answered finding is named as answered; a file with only answered findings has no `by`
676
763
  if (result.ok) {
764
+ for (const w of lineWarnings) err(w);
765
+ for (const l of reviewedJson) say(`reviewed ${l.file} ticket ${l.ticket}\n`);
677
766
  for (const f of waivedAll) {
678
767
  const parts = f.findings.length > 0 ? [`${f.findings.join(', ')}; by ${f.by}`] : [];
679
768
  if (f.answered?.length) parts.push(`answered ${f.answered.join(', ')}`);
@@ -692,6 +781,8 @@ export async function runBlock(args, deps = {}) {
692
781
  ...(end?.json ?? {}),
693
782
  // B70: only when a file passed as reviewed_by_waiver (otherwise the output is unchanged)
694
783
  ...(result.ok && waivedAll.length > 0 ? { reviewed_by_waiver: waivedAll.map((f) => ({ file: f.file, content_hash: f.content_hash, status: f.status, findings: f.findings, by: f.by, ...(f.answered?.length ? { answered: f.answered } : {}) })) } : {}),
784
+ // B96: only when the tool gave reviewed lines (a waived or external close is unchanged)
785
+ ...(reviewedJson.length > 0 ? { reviewed: reviewedJson } : {}),
695
786
  };
696
787
  if (!result.ok || !removeTree) {
697
788
  if (json && result.ok) emit(closedJson);
@@ -756,6 +847,20 @@ async function coderTests(reportFlag, id) {
756
847
  }
757
848
  const sentinel = parseSentinel(text, id);
758
849
  if (sentinel?.status !== 'complete') return { ok: false, reason: `coder report FAILED: no ===BLOCK ${id} COMPLETE=== sentinel` };
850
+ // Keep diagnostics in the same segment as parseSentinel: any block's sentinel ends it.
851
+ let malformed = null;
852
+ let lastMalformed = null;
853
+ for (const rawLine of text.split('\n')) {
854
+ const line = rawLine.trim();
855
+ const end = parseSentinel(line);
856
+ if (end) {
857
+ if (end.blockId === id) lastMalformed = malformed;
858
+ malformed = null;
859
+ } else if (line.startsWith('TESTS ') && !parseSentinel(`${line}\n===BLOCK ${id} COMPLETE===`, id)?.tests) {
860
+ malformed = line.slice(0, 120);
861
+ }
862
+ }
863
+ if (lastMalformed !== null) return { ok: false, reason: `coder report FAILED: TESTS line malformed: ${lastMalformed}` };
759
864
  if (!sentinel.tests) return { ok: false, reason: 'coder report FAILED: no TESTS line' };
760
865
  if (sentinel.tests.exit !== 0) return { ok: false, reason: `coder report FAILED: TESTS exit=${sentinel.tests.exit}` };
761
866
  return { ok: true, command: sentinel.tests.command, exit: 0 };
package/src/cli/gates.mjs CHANGED
@@ -39,7 +39,7 @@ import { writeSafe, redactJSON } from '../util/redact.mjs';
39
39
  import { appendRow } from '../ledger/write.mjs';
40
40
  import { loadProjectConfig } from '../config/load.mjs';
41
41
  import { detectGates } from '../gates/detect.mjs';
42
- import { printableGates, thresholdOf } from '../gates/expand.mjs';
42
+ import { printableGates, scopeFields, thresholdOf } from '../gates/expand.mjs';
43
43
  import { runGates } from '../gates/run.mjs';
44
44
  import { runGatesGuarded } from '../proof/lock.mjs';
45
45
  import { blockTree } from '../proof/worktree.mjs';
@@ -189,6 +189,7 @@ function realOrResolved(p) {
189
189
  */
190
190
  function scopeOf({ scope, test_count: testCount, reason }) {
191
191
  if (scope === undefined) return {};
192
+ if (scope === 'changed') return { scope, test_count: testCount, ...(reason ? { reason } : {}) };
192
193
  return scope === 'related' ? { scope, test_count: testCount } : { scope, reason };
193
194
  }
194
195
 
@@ -251,6 +252,11 @@ export async function runGatesVerb(args, deps = {}) {
251
252
  err('gates run: --run <id> and --block <id> go together, each with a value\n');
252
253
  return 2;
253
254
  }
255
+ const changed = args.includes('--changed');
256
+ if (changed && runFlag.value === undefined) {
257
+ err('gates run: --changed needs --run <id> and --block <id>\n');
258
+ return 2;
259
+ }
254
260
 
255
261
  // B62b: a block with a worktree runs its gates in that tree, configured by the main workspace
256
262
  const placed = runFlag.value !== undefined && blockFlag.value !== undefined ? await blockWorktree(runFlag.value, blockFlag.value) : null;
@@ -273,29 +279,32 @@ export async function runGatesVerb(args, deps = {}) {
273
279
  let ran;
274
280
  if (runFlag.value !== undefined && blockFlag.value !== undefined) {
275
281
  const threshold = thresholdOf(configured?.full_suite_threshold_files);
276
- const guarded = await runGatesGuarded({ runId: runFlag.value, blockId: blockFlag.value, gates, cwd, threshold, ...(timeoutMs ? { timeoutMs } : {}) });
282
+ const guarded = await runGatesGuarded({ runId: runFlag.value, blockId: blockFlag.value, gates, cwd, threshold, changed, slug, ...(timeoutMs ? { timeoutMs } : {}) });
277
283
  if (guarded.result === 'proof.busy') {
278
284
  out({ result: 'proof.busy', holder: /** @type {{holder: string}} */ (guarded).holder });
279
285
  return 1;
280
286
  }
281
- ran = /** @type {{value: {results: any[], allOk: boolean}}} */ (guarded).value;
287
+ ran = /** @type {{value: {results: any[], allOk: boolean, ledger_slug?: string, files?: Record<string, string>, changed_scope?: import('../gates/expand.mjs').ChangedScope}}} */ (guarded).value;
282
288
  } else {
283
289
  ran = await runGates(gates, { cwd, ...(timeoutMs ? { timeoutMs } : {}) });
284
290
  }
285
291
  const { results, allOk } = ran;
286
292
 
287
- if (slug) {
293
+ const ledgerSlug = slug ?? ran.ledger_slug;
294
+ if (ledgerSlug) {
288
295
  // B60: with --run/--block the rows name the run and the block, so the run's binnacle reads them
289
296
  const owner = runFlag.value !== undefined ? { run: runFlag.value, block: blockFlag.value, ...inTree } : {};
290
297
  for (const result of results) {
291
298
  if (!result.skipped && !result.ok) {
292
- await appendRow({ ...owner, event: 'gate.red', gate: result.gate, code: result.code, ...scopeOf(result) }, { slug });
299
+ await appendRow({ ...owner, event: 'gate.red', gate: result.gate, code: result.code, ...scopeOf(result) }, { slug: ledgerSlug });
293
300
  }
294
301
  }
295
302
  // B60: one scope per run (every `{test_files}` gate shares it), named on gate.done too;
296
303
  // without --run/--block that is `scope: full, reason: no_tests`
297
304
  const scoped = results.find((r) => r.scope !== undefined);
298
- await appendRow({ ...owner, event: 'gate.done', stack: gates.stack, all_ok: allOk, ...(scoped ? scopeOf(scoped) : {}) }, { slug });
305
+ await appendRow({ ...owner, event: 'gate.done', stack: gates.stack, all_ok: allOk,
306
+ ...(ran.changed_scope ? scopeFields(ran.changed_scope) : scoped ? scopeOf(scoped) : {}),
307
+ ...(ran.files ? { files: ran.files } : {}) }, { slug: ledgerSlug });
299
308
  }
300
309
  out({ stack: gates.stack, ...inTree, results, allOk });
301
310
  return allOk ? 0 : 1;
@@ -5,13 +5,15 @@
5
5
  * review-file --wait <ticket> [--max <90s>] poll; prints {ticket, status: done, result} or
6
6
  * {ticket, status: pending, reason: wait_timeout}
7
7
  * when --max elapses (the review is still running)
8
+ * review-file --wait-block <id> --run <id> join owned changed files' current tickets;
9
+ * prints {block, files, done}; never reviews here
8
10
  * review-file <answering file> --block <id> --answers <ids> --of <source> [--run <id>]
9
11
  * B71: enqueue an answers ticket; prints {ticket,
10
12
  * status, kind: answers, file: source, by_file, answers}
11
13
  * review-file <path> --block <id> --fresh B75: re-run a cached result that never counted
12
14
  *
13
- * The same content enqueued again after an `unavailable` result is a fresh attempt (`retry: true`),
14
- * not the old result.
15
+ * The same content enqueued again after an `unavailable` or round-1 `split_required` result is a
16
+ * fresh attempt (`retry: true`), not the old result.
15
17
  * A done result that is not approved and carries open findings adds `fix_list` (what the coder
16
18
  * fixes before re-running `review-file <path>`, which is then a round-2 recheck of the fix hunks,
17
19
  * §4.11); a result the loop stopped adds `stop` with the reason (`review_cap`, …) and (B73)
@@ -28,8 +30,9 @@
28
30
  * down on two polls in a row, and a worker whose pid is alive with a fresh heartbeat is never down
29
31
  * (`liveWorker`, B30). Output is one JSON line on stdout.
30
32
  * B75: `--fresh` drops the cached done marker of the file's current content and queues the ticket
31
- * again ONLY when that result never counted as a review: `stale`, `unavailable`, or `refused` with
32
- * `other-run`, `context-unreadable` or a `worktree-*` reason (an allow-list, fail closed). Any
33
+ * again ONLY when that result never counted as a review: `stale`, `unavailable`, round-1
34
+ * `split_required`, or `refused` with `other-run`, `context-unreadable` or a `worktree-*` reason
35
+ * (an allow-list, fail closed). Any
33
36
  * other cached result — a round ran (findings, approved, stopped), `review-state-lost`, an answers
34
37
  * result, an unknown status or reason, an unreadable marker — is refused `fresh-needs-reopen`
35
38
  * (exit 1, `exits: [block reopen, block waive]`, the marker kept byte-for-byte); a
@@ -66,20 +69,22 @@
66
69
 
67
70
  import { readFileSync, realpathSync, rmSync } from 'node:fs';
68
71
  import path from 'node:path';
69
- import { answersTicketId, assertAnswers, dropDoneIf, enqueue, enqueueAnswers, isDone, liveWorker, MAX_ANSWERS, queueDir, resolveTicket } from '../worker/queue.mjs';
72
+ import { answersTicketId, assertAnswers, blockTickets, dropDoneIf, enqueue, enqueueAnswers, isDone, liveWorker, MAX_ANSWERS, queueDir, resolveTicket } from '../worker/queue.mjs';
70
73
  import { assertBlockId, assertInsideRoot, assertTicketId, contentHash, mainRootOf, normalizeRequestPath, repoRootOf, WorkerError } from '../worker/ticket.mjs';
71
- import { namedOnHash } from '../review/gate-check.mjs';
74
+ import { blockFileSet, namedOnHash } from '../review/gate-check.mjs';
72
75
  import { STOP_EXITS } from '../review/stop-exits.mjs';
73
76
  import { ownsFile } from '../state/registry.mjs';
74
77
  import { readAllRows } from '../ledger/write.mjs';
75
78
  import { parseFlags } from '../state/cli-args.mjs';
76
79
  import { StateError } from '../state/paths.mjs';
80
+ import { assertRunId } from '../state/paths.mjs';
77
81
  import { readRun } from '../state/run.mjs';
78
82
  import { blockTree } from '../proof/worktree.mjs';
79
83
  import { writeSafe } from '../util/redact.mjs';
80
84
 
81
85
  const USAGE =
82
86
  'usage: code-forge review-file <path> --block <id> [--run <id>] [--fresh] | review-file --wait <ticket> [--max <seconds>s]\n' +
87
+ ' code-forge review-file --wait-block <id> --run <id> [--max <seconds>s]\n' +
83
88
  ' code-forge review-file <answering file> --block <id> --answers <id,id,…> --of <source> [--run <id>]\n';
84
89
 
85
90
  export const DEFAULT_WAIT_MS = 90_000;
@@ -129,8 +134,9 @@ const FRESH_REFUSED = new Set(['other-run', 'context-unreadable']);
129
134
 
130
135
  /**
131
136
  * B75: may `--fresh` drop this cached result? An allow-list, fail closed: `rerun` only for a
132
- * result that never counted as a review — `stale`, `unavailable`, or `refused` with a reason that
133
- * is not a verdict (`other-run`, `context-unreadable`, `worktree-*`) — whose marker names the same
137
+ * result that never counted as a review — `stale`, `unavailable`, round-1 `split_required`, or
138
+ * `refused` with a reason that is not a verdict (`other-run`, `context-unreadable`, `worktree-*`)
139
+ * — whose marker names the same
134
140
  * status. `tampered` for `refused review-state-tampered`; `counted` for everything else (a round
135
141
  * ran, `review-state-lost`, an answers result, an unknown status or reason, an unreadable marker or
136
142
  * result).
@@ -145,7 +151,7 @@ export function freshVerdict(text, result) {
145
151
  return 'counted';
146
152
  }
147
153
  if (result === null || marker === null || typeof marker !== 'object' || marker.status !== result.status) return 'counted';
148
- if (result.status === 'stale' || result.status === 'unavailable') return 'rerun';
154
+ if (result.status === 'stale' || result.status === 'unavailable' || result.status === 'split_required') return 'rerun';
149
155
  if (result.status !== 'refused' || typeof result.reason !== 'string') return 'counted';
150
156
  if (result.reason === 'review-state-tampered') return 'tampered';
151
157
  return FRESH_REFUSED.has(result.reason) || result.reason.startsWith('worktree-') ? 'rerun' : 'counted';
@@ -166,10 +172,11 @@ export const FRESH_EXITS = Object.freeze(['block reopen', 'block waive']);
166
172
  * crash between the drop and the enqueue leaves only a re-runnable pending ticket. Anything else is refused and the marker kept byte-for-byte:
167
173
  * `fresh-needs-reopen` (names `block reopen` and `block waive`) or `fresh-tampered` (names only
168
174
  * the owner's `block close --no-require-reviews`; reopen refuses a tampered state).
169
- * @param {{repoRoot: string, run: string, block: string, file: string, where: {tree?: string}, say: (obj: Record<string, any>) => unknown, dropDone?: typeof dropDoneIf}} opts
175
+ * B110: a plain cached round-1 `split_required` uses the same claim, with `retry: true`.
176
+ * @param {{repoRoot: string, run: string, block: string, file: string, where: {tree?: string}, say: (obj: Record<string, any>) => unknown, dropDone?: typeof dropDoneIf, retry?: boolean}} opts
170
177
  * @returns {number} the exit code.
171
178
  */
172
- function freshTicket({ repoRoot, run, block, file, where, say, dropDone = dropDoneIf }) {
179
+ function freshTicket({ repoRoot, run, block, file, where, say, dropDone = dropDoneIf, retry = false }) {
173
180
  const { ticket } = resolveTicket({ repoRoot, run, block, file, tree: where.tree ?? null });
174
181
  const markerPath = path.join(queueDir(repoRoot), `${ticket}.done`);
175
182
  /** @type {'rerun' | 'tampered' | 'counted'} */
@@ -210,7 +217,7 @@ function freshTicket({ repoRoot, run, block, file, where, say, dropDone = dropDo
210
217
  // dropped: queue it again now (a crash right here leaves a pending ticket with no marker, which
211
218
  // the next review-file queues anyway); gone: another caller took the marker, a normal enqueue
212
219
  const queued = enqueue({ repoRoot, run, block, file, ...where });
213
- say(outcome === 'dropped' ? { ...queued, fresh: true } : queued);
220
+ say(outcome === 'dropped' ? { ...queued, ...(retry ? { retry: true } : { fresh: true }) } : queued);
214
221
  return 0;
215
222
  }
216
223
  if (verdict === 'tampered') {
@@ -223,7 +230,7 @@ function freshTicket({ repoRoot, run, block, file, where, say, dropDone = dropDo
223
230
  ticket,
224
231
  file,
225
232
  exits: [...FRESH_EXITS],
226
- message: `fresh-needs-reopen: the cached result of ${file} counts as a review (or cannot be read, or kept changing under the claim); --fresh re-runs only stale, unavailable or refused other-run/worktree-*/context-unreadable results; the human's ways out are block reopen and block waive; the cached result was kept`,
233
+ message: `fresh-needs-reopen: the cached result of ${file} counts as a review (or cannot be read, or kept changing under the claim); --fresh re-runs only stale, unavailable, round-1 split_required or refused other-run/worktree-*/context-unreadable results; the human's ways out are block reopen and block waive; the cached result was kept`,
227
234
  });
228
235
  return 1;
229
236
  }
@@ -352,6 +359,93 @@ export function fixList(result) {
352
359
  return out;
353
360
  }
354
361
 
362
+ /**
363
+ * B100: read block scope from the workspace mirror, never the private run record. This is a
364
+ * progress summary, not an approval: block close still verifies the authoritative signed rows.
365
+ * @param {string} repoRoot @param {string} cwd @param {string} run @param {string} block
366
+ * @returns {Promise<{entry: Record<string, any>, tree: string}>}
367
+ */
368
+ async function waitBlockScope(repoRoot, cwd, run, block) {
369
+ const top = await repoRootOf(cwd);
370
+ const candidates = new Set([repoRoot]);
371
+ for (let dir = realpathSync(cwd); ; dir = path.dirname(dir)) {
372
+ candidates.add(dir);
373
+ if (dir === top || dir === path.dirname(dir)) break;
374
+ }
375
+ for (const dir of candidates) {
376
+ let record;
377
+ try {
378
+ record = JSON.parse(readFileSync(path.join(dir, '.code-forge', 'runs', `${run}.json`), 'utf8'));
379
+ } catch (err) {
380
+ if (err.code === 'ENOENT') continue;
381
+ throw new WorkerError('block-scope-unreadable', 'the block scope cannot be read');
382
+ }
383
+ const entry = record?.blocks?.[block];
384
+ if (record?.run_id !== run || !entry || !Array.isArray(entry.owned_files) || entry.owned_files.length === 0) {
385
+ throw new WorkerError('block-scope-unreadable', 'the block scope cannot be read');
386
+ }
387
+ const tree = (await blockTree(record.workspace, entry))?.top ?? repoRoot;
388
+ return { entry, tree };
389
+ }
390
+ throw new WorkerError('block-scope-unreadable', 'the block scope cannot be read');
391
+ }
392
+
393
+ /** A block wait reads only the marker, never review results or the signing key.
394
+ * @param {string} repoRoot @param {string} ticket @returns {boolean}
395
+ */
396
+ function blockTicketDone(repoRoot, ticket) {
397
+ try {
398
+ const marker = JSON.parse(readFileSync(path.join(queueDir(repoRoot), `${ticket}.done`), 'utf8'));
399
+ if (marker?.ticket === ticket && typeof marker.status === 'string' && marker.status.length > 0) return true;
400
+ } catch (err) {
401
+ if (err.code === 'ENOENT') return false;
402
+ }
403
+ throw new WorkerError('result-unreadable', 'a ticket completion marker cannot be read');
404
+ }
405
+
406
+ /**
407
+ * Join current-content tickets only. Re-scan on every poll, including before returning done,
408
+ * so a file edited while waiting gets a new ticket. The worker alone finishes old tickets.
409
+ * @param {{repoRoot: string, cwd: string, block: string, run: string, maxMs: number, pollMs: number, say: (obj: Record<string, any>) => unknown}} opts
410
+ */
411
+ async function waitBlock({ repoRoot, cwd, block, run, maxMs, pollMs, say }) {
412
+ const { entry, tree } = await waitBlockScope(repoRoot, cwd, run, block);
413
+ const where = realpathSync(tree) === realpathSync(repoRoot) ? {} : { tree: realpathSync(tree) };
414
+ const deadline = Date.now() + maxMs;
415
+ let downPolls = 0;
416
+ for (;;) {
417
+ const worker = liveWorker(repoRoot);
418
+ const live = worker?.run === run;
419
+ downPolls = live ? 0 : downPolls + 1;
420
+ const tickets = blockTickets({ repoRoot, run, block, ...where });
421
+ const current = await blockFileSet({ repoRoot: tree, base: entry.base_sha, owned: entry.owned_files, ownedKinds: entry.owned_kinds });
422
+ const files = current.map(({ file }) => {
423
+ const opts = { repoRoot, run, block, file, ...where };
424
+ const { ticket } = resolveTicket(opts);
425
+ const previous = tickets.filter((t) => t.file === file);
426
+ let present = previous.find((t) => t.ticket === ticket);
427
+ if (!present && previous.length > 0 && live) {
428
+ enqueue(opts);
429
+ present = { ...previous[previous.length - 1], ticket };
430
+ }
431
+ return { file, ticket: present ? ticket : null, status: !present ? 'not_queued' : blockTicketDone(repoRoot, ticket) ? 'done' : 'pending' };
432
+ });
433
+ if (downPolls >= 2) {
434
+ say({ block, files, done: false, status: 'worker_down' });
435
+ return 3;
436
+ }
437
+ if (live && files.every((f) => f.status === 'done')) {
438
+ say({ block, files, done: true });
439
+ return 0;
440
+ }
441
+ if (live && (files.some((f) => f.status === 'not_queued') || Date.now() >= deadline)) {
442
+ say({ block, files, done: false, reason: files.some((f) => f.status === 'not_queued') ? 'not_queued' : 'wait_timeout' });
443
+ return 0;
444
+ }
445
+ await new Promise((resolve) => setTimeout(resolve, pollMs));
446
+ }
447
+ }
448
+
355
449
  /**
356
450
  * @param {string[]} args
357
451
  * @param {{stdout?: {write: (s: string) => unknown}, stderr?: {write: (s: string) => unknown}, cwd?: string, pollMs?: number, dropDone?: typeof dropDoneIf}} [deps] - `dropDone`: B75's claim, for tests.
@@ -363,10 +457,17 @@ export async function runReviewFile(args, deps = {}) {
363
457
  const say = (/** @type {Record<string, any>} */ obj) => writeSafe(stdout, `${JSON.stringify(obj)}\n`);
364
458
  const cwd = deps.cwd ?? process.cwd();
365
459
  try {
366
- const { flags, positionals } = parseFlags(args, { values: ['block', 'run', 'wait', 'max', 'answers', 'of'], booleans: ['fresh'] });
460
+ const { flags, positionals } = parseFlags(args, { values: ['block', 'run', 'wait', 'wait-block', 'max', 'answers', 'of'], booleans: ['fresh'] });
367
461
  // the queue and the worker: the main tree, also from inside a block's worktree
368
462
  const repoRoot = await mainRootOf(cwd);
369
463
 
464
+ if (flags['wait-block'] !== undefined) {
465
+ if (positionals.length > 0 || flags.block !== undefined || flags.wait !== undefined || flags.answers !== undefined || flags.of !== undefined || flags.fresh === true || typeof flags.run !== 'string') {
466
+ throw new WorkerError('usage', '--wait-block takes a block, --run and --max only');
467
+ }
468
+ return await waitBlock({ repoRoot, cwd, block: assertBlockId(flags['wait-block']), run: assertRunId(flags.run), maxMs: flags.max === undefined ? DEFAULT_WAIT_MS : parseMax(flags.max), pollMs: deps.pollMs ?? POLL_MS, say });
469
+ }
470
+
370
471
  if (typeof flags.wait === 'string') {
371
472
  if (positionals.length > 0 || flags.block !== undefined || flags.answers !== undefined || flags.of !== undefined || flags.fresh === true) throw new WorkerError('usage', '--wait takes only a ticket and --max');
372
473
  const ticket = assertTicketId(flags.wait);
@@ -410,6 +511,9 @@ export async function runReviewFile(args, deps = {}) {
410
511
  const where = realpathSync(tree) === realpathSync(repoRoot) ? {} : { tree };
411
512
  if (flags.fresh === true) return freshTicket({ repoRoot, run: worker.run, block, file, where, say, ...(deps.dropDone ? { dropDone: deps.dropDone } : {}) });
412
513
  const queued = enqueue({ repoRoot, run: worker.run, block, file, ...where });
514
+ if (queued.status === 'done' && doneResult(repoRoot, queued.ticket)?.status === 'split_required') {
515
+ return freshTicket({ repoRoot, run: worker.run, block, file, where, say, retry: true, ...(deps.dropDone ? { dropDone: deps.dropDone } : {}) });
516
+ }
413
517
  if (queued.status === 'done' && doneResult(repoRoot, queued.ticket)?.status === 'unavailable') {
414
518
  // `unavailable` is never approval AND never final: drop the done marker so the worker runs
415
519
  // the ticket again (the fix loop re-runs its pending round); the signed result is replaced.
@@ -66,7 +66,7 @@
66
66
  import { Ajv2020 } from 'ajv/dist/2020.js';
67
67
  import schema from '../../schema/code-forge.schema.json' with { type: 'json' };
68
68
  import { redact } from '../util/redact.mjs';
69
- import { TEST_FILES_TOKEN } from '../util/gate-token.mjs';
69
+ import { TEST_FILES_TOKEN, CHANGED_FILES_TOKEN } from '../util/gate-token.mjs';
70
70
  import { checkEffort, displayEffort } from '../engines/efforts.mjs';
71
71
  import { CLOSED_BOOK_LEVELS, CODEX_CLOSED_BOOK_REFUSAL, isNoClosedBookProvider, OPEN_BOOK_CODEX_WARNING, openBookCodexAllowed } from './closed-book.mjs';
72
72
  import { isKnownId, resolveLevel } from './known-ids.mjs';
@@ -484,10 +484,10 @@ function gateMessages(key, value) {
484
484
  // full-suite run would be an empty argv
485
485
  /** @param {string[]} argv @returns {string[]} */
486
486
  const tokenMessages = (argv) =>
487
- argv.length === 1 && argv[0] === TEST_FILES_TOKEN
488
- ? [`gates.${key}: '${TEST_FILES_TOKEN}' cannot be the only item (the full suite would run an empty command)`]
489
- : argv[0] === TEST_FILES_TOKEN
490
- ? [`gates.${key}: '${TEST_FILES_TOKEN}' cannot be the command (argv[0])`]
487
+ argv.length === 1 && [TEST_FILES_TOKEN, CHANGED_FILES_TOKEN].includes(argv[0])
488
+ ? [`gates.${key}: '${argv[0]}' cannot be the only item (the full suite would run an empty command)`]
489
+ : [TEST_FILES_TOKEN, CHANGED_FILES_TOKEN].includes(argv[0])
490
+ ? [`gates.${key}: '${argv[0]}' cannot be the command (argv[0])`]
491
491
  : [];
492
492
  if (typeof value !== 'object' || Array.isArray(value)) {
493
493
  if (!isArgvArray(value)) return [`gates.${key} must be a non-empty argv array of strings, an {argv, env} object, or null`];
@@ -1162,3 +1162,10 @@ export function validateConfig(cfg, opts = {}) {
1162
1162
  }
1163
1163
 
1164
1164
  export { schema as configSchema };
1165
+
1166
+ /** The effective file loop for a validated config, including opt-in commit watch.
1167
+ * @param {Record<string, any>} cfg @returns {string}
1168
+ */
1169
+ export function reviewFileLoop(cfg) {
1170
+ return cfg?.review?.file_loop ?? 'wait';
1171
+ }
@@ -12,11 +12,11 @@
12
12
  *
13
13
  * `assemblePack` turns it into ONE pack for a block, in this order: the header (run, block, level,
14
14
  * owned files, test files when the block has them, worktree path and branch when it has one), the
15
- * commands (never run; each argv shown POSIX shell-quoted, so a pasted line expands nothing), each
16
- * rules file, then the lessons file — every file section headed by its source path (an empty
17
- * file: the heading alone). Over `max_lines` the lessons are cut first, then the
15
+ * commands (never run; each argv shown POSIX shell-quoted, so a pasted line expands nothing), the
16
+ * built-in proof rule, each rules file, then the lessons file — every file section headed by its
17
+ * source path (an empty file: the heading alone). Over `max_lines` the lessons are cut first, then the
18
18
  * rules from the last file backwards, each cut section ending in `[cut: N lines dropped]`; the
19
- * header and the commands are never cut.
19
+ * header, commands and proof rule are never cut; all count toward the budget.
20
20
  *
21
21
  * `reviewDigest` is the ONE builder of the review packets' rules digest (`## project rules`, ≤ 60
22
22
  * lines, `packet.mjs`): the rules files' text, then a `### lessons` subheading and the lessons.
@@ -41,6 +41,7 @@ import path from 'node:path';
41
41
  import { estimateTokens } from '../review/packet.mjs';
42
42
  import { splitLines } from '../review/context.mjs';
43
43
  import { redact } from '../util/redact.mjs';
44
+ import { PROOF_RULE } from '../proof/rule.mjs';
44
45
 
45
46
  /** The pack's line cap when `context.max_lines` is not set. */
46
47
  export const DEFAULT_MAX_LINES = 300;
@@ -272,6 +273,8 @@ export function assemblePack({ settings, sources, block }) {
272
273
  // a command line can name a secret (a token in an argv): it is redacted as rules and lessons are
273
274
  for (const k of keys) header.push(`${k}: ${redact(argvText(settings.commands[k]))}`);
274
275
 
276
+ header.push(...splitLines(PROOF_RULE));
277
+
275
278
  /** @type {Array<{heading: string, body: string[], dropped: number}>} */
276
279
  const sections = sources.rules.map((r) => ({ heading: `## rules: ${r.path}`, body: [...r.lines], dropped: 0 }));
277
280
  if (sources.lessons) sections.push({ heading: `## lessons: ${sources.lessons.path}`, body: [...sources.lessons.lines], dropped: 0 });