@codedology/code-forge 0.6.2 → 0.7.1

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 CHANGED
@@ -6,6 +6,40 @@ waits on a second real consumer (plan §10.4, Q17).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.7.1] — 2026-10-07
10
+
11
+ ### Fixed
12
+
13
+ - A structured clean pass (passed: true, no findings, every hunk acknowledged, a non-blank summary) is no longer refused
14
+ as too_short on a larger diff: it needs 40 output tokens whatever the diff size (clean passes measured 108-121 tokens;
15
+ issue #6). An answer with any finding, or a blank summary, keeps review.min_tokens_out.
16
+
17
+ ## [0.7.0] — 2026-10-07
18
+
19
+ ### Fixed
20
+
21
+ - block close checked no file at all when the block's owned_files were directories, or when the coder's work was
22
+ committed before block open or done in another worktree, and then closed with findings open. The gate's file set is
23
+ now the owned diff plus every owned file with review rows for the block, an owned entry with no glob owns the paths
24
+ below it, an empty diff with review rows prints a WARN naming the likely cause, and a block with no such file refuses
25
+ no_changes.
26
+ - block close no longer passes a file whose approval a later review of the same content overrode: it refuses
27
+ not_approved <file> <finding> per open finding until the file is fixed and approved again or the finding is waived (a
28
+ critical only by the human's block waive; the severity the review recorded decides). The ledger review.result row now
29
+ carries approved and every open finding with its worst severity (a finding without a valid id as X<n>); an
30
+ "approved" result that lists a critical is recorded as not approved. An old ledger's review.result row is judged by
31
+ the ticket's signed result file, and refuses when that file is missing or fails its MAC. Triage never turns a
32
+ critical finding into a nit, a late-finding ruling clears only a warning or nit ruled nit, and review_cap stands
33
+ unless an approval of the current content comes after it.
34
+
35
+ ### Security
36
+
37
+ - block close refuses unowned_change <file> for a file outside the block's owned files that changed after block open
38
+ (block open now records the tree) and that no other block of the run accounts for; it used to drop such a file from
39
+ the gate silently. Only .code-forge/ is exempt, and a file an open sibling block owns gets a WARN line naming it.
40
+ --no-require-reviews now needs the owner to confirm at a terminal: it has no --yes, exits 2 without
41
+ a terminal and is refused while an autopilot grant is active.
42
+
9
43
  ## [0.6.2] — 2026-10-07
10
44
 
11
45
  ### Changed
package/README.md CHANGED
@@ -8,6 +8,22 @@ for cross-model, parallel, evidence-gated feature delivery — on Claude Code, C
8
8
  > `examples/` have all landed (`docs/reference/blocks.json` lists every build block). What changed
9
9
  > in each version is in [CHANGELOG.md](CHANGELOG.md).
10
10
 
11
+ ## What's new in 0.7
12
+
13
+ Next release:
14
+
15
+ - **Short clean reviews count.** A clean pass (no findings, every hunk acknowledged, a summary)
16
+ needs only 40 output tokens on any diff size, so a correct short "no defect" review is no
17
+ longer refused as `too_short`.
18
+
19
+ In 0.7.0:
20
+
21
+ - **The close gate now checks every file.** `block close` could close a block with critical
22
+ findings still open: files inside an owned folder were skipped, and so was work committed
23
+ before `block open` or done in another worktree. Now every reviewed file is checked, a newer
24
+ failed review beats an older approval, a critical needs your waiver, a change outside the
25
+ block's files is refused, and `--no-require-reviews` asks you at a terminal.
26
+
11
27
  ## What's new in 0.6
12
28
 
13
29
  In 0.6.1:
@@ -4,7 +4,7 @@
4
4
  "plans/code-forge-plan-v1.3.md §10.4",
5
5
  "plans/NIGHT-LOG.md"
6
6
  ],
7
- "block_count": 54,
7
+ "block_count": 56,
8
8
  "baseline_ids": ["B0", "B1", "B2", "B3", "B4", "B5", "B6", "B7", "B8"],
9
9
  "baseline_note": "Wave 0-1 blocks, landed before v1.3; depends_on may name them, but they have no entry under blocks.",
10
10
  "blocks": {
@@ -732,6 +732,36 @@
732
732
  "B49b"
733
733
  ],
734
734
  "note": "root-added 2026-10-06; also amends skill/SKILL.md, skill/references/{autopilot,decisions,ledger}.md, README.md, docs/*"
735
+ },
736
+ "B52": {
737
+ "title": "Block close gate hardening (issue #2 field note: a block closed with open criticals): the file set is the diff since base plus every file with a review row of the block, matched against owned paths including directories; an approval is stale after a later non-approved review of the same content; not_approved per open finding (a critical needs a human waiver); old-ledger rows judged by the signed result file; no_changes for an empty set; unowned_change for files changed after open outside owned paths (tree_at_open stamps); triage never demotes a critical; --no-require-reviews needs the owner at a terminal",
738
+ "wave": 12,
739
+ "level": "L2",
740
+ "status": "landed",
741
+ "owned": [
742
+ "src/review/gate-check.mjs",
743
+ "src/cli/block.mjs",
744
+ "test/review/close-gate.test.mjs",
745
+ "test/review/close-gate-field.test.mjs"
746
+ ],
747
+ "depends_on": [
748
+ "B50"
749
+ ],
750
+ "note": "root-added 2026-10-07; GitHub issue #2 field note 2026-10-07; also amends src/state/block.mjs, src/review/triage.mjs, src/worker/loop.mjs, skill/SKILL.md, skill/references/{code,review,decisions}.md, skill/references/adapters/{claude-code,solo}.md, docs/how-it-works.md, docs/tutorial-first-test.md, docs/concepts.md"
751
+ },
752
+ "B53": {
753
+ "title": "Clean-pass token floor for any diff size (issue #6): a structured clean pass (passed: true, no findings, hunks acknowledged in order, a non-blank summary) needs min(review.min_tokens_out, 40) output tokens; any finding, passed: false or a blank summary keeps the full floor",
754
+ "wave": 12,
755
+ "level": "L1",
756
+ "status": "landed",
757
+ "owned": [
758
+ "src/review/validate-review.mjs",
759
+ "test/review/validate-review.test.mjs"
760
+ ],
761
+ "depends_on": [
762
+ "B52"
763
+ ],
764
+ "note": "root-added 2026-10-07; GitHub issue #6; also amends src/review/engine.mjs (smallDiff removed), schema/code-forge.schema.json, docs/reference/config.md, docs/how-it-works.md, skill/references/review.md"
735
765
  }
736
766
  }
737
767
  }
@@ -105,7 +105,7 @@ Every other key defaults as shown, or is entirely absent from a minimal file. `a
105
105
  | `review.context.min_context_lines` | integer | | `10` | min 0 |
106
106
  | `review.block_budget_tokens` | integer | | `150000` | min 1 |
107
107
  | `review.block_budget_usd` | number \| null | | `null` | |
108
- | `review.min_tokens_out` | integer | | `120` | min 1 |
108
+ | `review.min_tokens_out` | integer | | `120` | min 1; The stub guard: a review answer with fewer output tokens is unavailable (too_short). A clean pass (passed: true, no findings, a non-blank summary) needs only min(this, 40), whatever the diff size (B53). |
109
109
  | `review.batch_small_files` | boolean | | `true` | |
110
110
  | `review.session_timeout_s` | integer | | `300` | min 1 |
111
111
  | `review.max_rounds_per_file` | integer | | `4` | min 2; max 6 |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@codedology/code-forge",
3
- "version": "0.6.2",
3
+ "version": "0.7.1",
4
4
  "description": "Plan/harden/code/review orchestration CLI and Agent Skill for cross-model, parallel, evidence-gated feature delivery.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -136,7 +136,7 @@
136
136
  "anyOf": [{ "type": "number", "exclusiveMinimum": 0 }, { "type": "null" }],
137
137
  "default": null
138
138
  },
139
- "min_tokens_out": { "type": "integer", "minimum": 1, "default": 120 },
139
+ "min_tokens_out": { "type": "integer", "minimum": 1, "default": 120, "description": "The stub guard: a review answer with fewer output tokens is unavailable (too_short). A clean pass (passed: true, no findings, a non-blank summary) needs only min(this, 40), whatever the diff size (B53)." },
140
140
  "batch_small_files": { "type": "boolean", "default": true },
141
141
  "session_timeout_s": { "type": "integer", "minimum": 1, "default": 300 },
142
142
  "max_rounds_per_file": { "type": "integer", "minimum": 2, "maximum": 6, "default": 4 },
package/skill/SKILL.md CHANGED
@@ -41,7 +41,7 @@ State lives in the plan file's header — `facts → planned → hardened → co
41
41
  4. Per block, in dispatch order, never more than `caps.coders` alive: `forge block open <id> --run <r> --level L<n> --owned <paths…> --acceptance <file> --brief <file> --lines <n> [--kind code|docs|contract]` prints the pointer `BRIEF <path> lines=<n> sha=<sha8> <<<EOM>>>`. Send the pointer per the engine's adapter — `solo` → `references/adapters/solo.md`, `harness` → `references/adapters/claude-code.md`, `subprocess` → `references/adapters/subprocess.md` — and wait for `ACK <sha8> lines=<n>`.
42
42
  5. The coder's first message is its **facts diff**, then its forecast (cases and lines, `templates/block-record.md`), then code, file by file: after each file `forge review-file <path> --block <id> --run <r>`, then `forge review-file --wait <ticket>` before the next file. Rounds ≥ 2 re-check only the fix hunks (`references/review.md`).
43
43
  6. `===BLOCK <id> COMPLETE===` or `===BLOCK <id> FAILED: <reason>===` ends the coder's turn. Its report is a claim sheet: re-measure it (`references/code.md` §5).
44
- 7. `forge block close <id> --run <r> --report <coder report>` is the gate: a report without a `reviewed <path> ticket <id>` line per changed file is FAILED; a signed `review.approved` row per changed file, gates green, every clause covered, forecast vs actual, no `rule_break`. Red ⇒ `references/decisions.md` (S1 `next`, escalation). Green ⇒ one commit for the block in the project's house style; `forge block rebase <id> --run <r>` first when HEAD moved.
44
+ 7. `forge block close <id> --run <r> --report <coder report>` is the gate: a report without a `reviewed <path> ticket <id>` line per changed file is FAILED; a signed `review.approved` row per changed file that no later review of the same content overrode (`not_approved`), no `unowned_change`, no `no_changes` (the files are the owned diff plus every owned file reviewed for the block), gates green, every clause covered, forecast vs actual, no `rule_break`. Red ⇒ `references/decisions.md` (S1 `next`, escalation). Green ⇒ one commit for the block in the project's house style; `forge block rebase <id> --run <r>` first when HEAD moved.
45
45
  8. `forge run end --run <r>` when every block is `done`; `forge report --slug <slug>` prints cost per block, escalations, review rounds and the spend per run (`budget.usd`, `references/ledger.md` §3.1).
46
46
 
47
47
  ## §4 Hard rules (the headings of `references/rules-core.md`)
@@ -12,7 +12,7 @@ The harness's `Agent` tool runs the coder as a subagent; everything else — the
12
12
 
13
13
  The tool return is the completion signal; the report must begin with `ACK <sha8>` and end with `===BLOCK <id> COMPLETE===` (+ test summary + `git diff --stat`) or `===BLOCK <id> FAILED: <reason>===`. A report without the sentinel is a failed block, never a partial success.
14
14
 
15
- **Between completion and close come the review rounds.** The subagent ran `forge review-file <path> --block <id> --run <r>` per file and `--wait`ed on each ticket inside its turn, so most rounds happen before the sentinel; when the tool returns, first drain every `pending` ticket with `forge review-file --wait <ticket>` yourself — a pending ticket is a review in flight, not an open finding, and waiting on it costs no round. Then only an open `fix_now` finding, or a file the coder never submitted, starts a fix round (each one counts toward the file's cap): dispatch a new `Agent` call at the level the ladder says (`review.md` §5 — the coder is gone with its tool return, so the fix brief carries the open findings and the fix history) and it runs `review-file` again on the fixed file. Rounds are bounded: four rounds per file, then the L3 rung once, then `stopped: review_cap` for the human. Only when every changed file has a signed `review.approved` row do you run the re-measurement of `code.md` §5 and `forge block close <id> --run <r> --report <file>` (the subagent's final report, saved to a file) — the transcript grep reads `.code-forge/runs/<run>/<block>.log`; pass `--transcript <file>` when the harness saved the subagent's transcript elsewhere. Closing with a file still unreviewed is a human decision: `forge block waive <id> <finding> --run <r> --file <path> --reason "<why>"` per finding, or `forge block close … --no-require-reviews`, both audited as their own rows.
15
+ **Between completion and close come the review rounds.** The subagent ran `forge review-file <path> --block <id> --run <r>` per file and `--wait`ed on each ticket inside its turn, so most rounds happen before the sentinel; when the tool returns, first drain every `pending` ticket with `forge review-file --wait <ticket>` yourself — a pending ticket is a review in flight, not an open finding, and waiting on it costs no round. Then only an open `fix_now` finding, or a file the coder never submitted, starts a fix round (each one counts toward the file's cap): dispatch a new `Agent` call at the level the ladder says (`review.md` §5 — the coder is gone with its tool return, so the fix brief carries the open findings and the fix history) and it runs `review-file` again on the fixed file. Rounds are bounded: four rounds per file, then the L3 rung once, then `stopped: review_cap` for the human. Only when every changed file has a signed `review.approved` row do you run the re-measurement of `code.md` §5 and `forge block close <id> --run <r> --report <file>` (the subagent's final report, saved to a file) — the transcript grep reads `.code-forge/runs/<run>/<block>.log`; pass `--transcript <file>` when the harness saved the subagent's transcript elsewhere. Closing with a file still unreviewed is a human decision: `forge block waive <id> <finding> --run <r> --file <path> --reason "<why>"` per finding, or `forge block close … --no-require-reviews`, which the human runs at their own terminal (it asks for a yes, has no `--yes`, and is refused under an active autopilot grant), both audited as their own rows.
16
16
 
17
17
  ## §2.1 Plan approval
18
18
 
@@ -22,7 +22,7 @@ Solo adds spawn, watch, timers, scratchpads, locks and `kv` on top of the CLI; i
22
22
  5. **Wake channels, event-driven, never fixed polls:** record your own pid once (`whoami` → `kv_set("forge:orchestrator_pid")`); coders that can call Solo carry the push contract (post-sentinel `kv_set("block:<id>", …)` + `timer_set(1s, body, delivery_process_id=<your pid>)`); for every group arm `timer_fire_when_idle_any([coders], max_wait≈20–25 min, delivery_process_id=<your pid>)` right after the launch gate (idle-any ignores already-idle processes, so arm while they are provably running); keep one long dead-man fallback per 30–40 min with an L0 triage first. On wake: sentinel ⇒ gate; no sentinel and active again ⇒ re-arm; no sentinel and idle ⇒ parked, intervene now.
23
23
  6. Completion = `search_output(pattern="===BLOCK <id> COMPLETE===")`, last occurrence (the `kv` key is the truncation backup). Completion is not closure.
24
24
  7. **Review rounds, with the coder still alive.** The coder ran `forge review-file <path> --block <id> --run <r>` per file and `--wait`ed on each ticket; read the ledger (`forge ledger tail --slug <slug>`) for what is still open — a `fix_now` finding, a `pending` ticket, a file never submitted. Each open finding is a fix round sent to the **warm coder** by `send_input` (the findings by id, `<<<EOM>>>`), which fixes and re-runs `review-file` on that file; round ≥ 2 re-checks only the fix hunks (`review.md` §5). **One ladder** (`decisions.md` §5): a stall or exhausted rounds *below L2* climbs one level — close this coder and respawn a fresh one at the higher level with the open findings and the fix history in its brief; **the respawn keeps the per-file round count**, the cap does not reset. L2 is the highest running level: a trigger at L2, or the four-round cap on a file, goes to the **L3 rung once** — a fresh L3 patch session, never this coder and never a coder at L3 — and if findings stay open after the `patch_check` round the file is `stopped: review_cap` for the human. No other path exists.
25
- 8. **Close** only when every changed file has a signed `review.approved` row: the re-measurement of `code.md` §5, then `forge block close <id> --run <r> --report <file>` (the coder's final report) — the transcript grep reads the Solo output. Closing with a file still unreviewed is a human decision, audited: `forge block waive <id> <finding> --run <r> --file <path> --reason "<why>"` per finding, or `forge block close … --no-require-reviews`.
25
+ 8. **Close** only when every changed file has a signed `review.approved` row: the re-measurement of `code.md` §5, then `forge block close <id> --run <r> --report <file>` (the coder's final report) — the transcript grep reads the Solo output. Closing with a file still unreviewed is a human decision, audited: `forge block waive <id> <finding> --run <r> --file <path> --reason "<why>"` per finding, or `forge block close … --no-require-reviews`, which the human runs at their own terminal (it asks for a yes, has no `--yes`, and is refused under an active autopilot grant).
26
26
  9. **Reap after the close**: `close_process` the coder and confirm `lock_status` shows no lease before the next group. Locks: a coder that can call Solo holds `lock_acquire` on its file area while editing; coders that cannot never overlap another block.
27
27
 
28
28
  ## §4 Latency overlaps (standing)
@@ -30,8 +30,8 @@ The coder's report is a claim sheet. The gate reproduces each number in the meas
30
30
 
31
31
  | claim | re-measured by |
32
32
  |---|---|
33
- | only owned paths touched | file set = tracked changes ∪ untracked, minus other active blocks' owned sets ⊆ `owned_files`; anything else is an orphan that stops every gate until `forge block claim <id> <path> --run <r>` or the human deletes it |
34
- | every file reviewed | the report names a `review-file` ticket id per changed file (`--report`, which the orchestrator always passes) and a **signed** `review.approved` row for the current content hash of every changed or new file, or an architect-ruled disposition to a named successor block |
33
+ | only owned paths touched | file set = tracked changes since the base ∪ untracked (not ignored), listed from the repository top level, outside the repository top-level `.code-forge/` (keep transcripts and reports there; a subdirectory workspace's own `.code-forge/` is source tree except this run's record mirror and the block's default transcript: a `--transcript` or `--report` path in the source tree is a change like any other); each file must be in `owned_files`, unchanged since `block open` recorded the tree (`tree_at_open`; a path unreadable at open or at close never counts as unchanged), owned by another block of the run that is still **open**, or a closed block's file still exactly at the content its gate approved — else `unowned_change <file>` until `forge block claim <id> <path> --run <r>` or the human puts it back (not lifted by `--no-require-reviews`). The gate cannot tell which coder wrote a file an open sibling owns: it passes it to that sibling's gate and prints `WARN block <id>: <file> changed and is owned by open block <other>` for each one — read those lines |
34
+ | every file reviewed | file set = owned files changed since the base ∪ owned files with any review row of the block (`review.result`/`round`/`approved`/`triage`/`cap`/`late_finding`), an owned entry with no glob covering the paths below it; every path is repo-root-relative (what the worker signs), and an owned entry also matches relative to a workspace in a subdirectory; an empty set refuses `no_changes` (only the owner's `--no-require-reviews`, confirmed at a terminal, lifts it), and an empty diff with review rows WARNs (work committed before `block open`, or in another worktree — open blocks on a base without the work, in the workspace the coder edits). The report names a `review-file` ticket id per changed file (`--report`, which the orchestrator always passes) and a **signed** `review.approved` row for the current content hash of every changed or new file that no later review of the same content overrode (a `review.result` with `approved: false`, a `review.round` with findings open, a `review.cap`): else `not_approved <file> <finding>` per open finding, cleared only by a fix and a fresh approval or a waiver — a critical only by the human's `block waive` — or an architect-ruled disposition to a named successor block |
35
35
  | tests green | every gate command run once in the export, each exit status read |
36
36
  | clauses covered | every clause names ≥ 1 test id; each test exists and passed in this run; S1 `scope` on the hunks outside the named tests says `false` |
37
37
  | test count | the filtered run's delta equals the new tests' own count |
@@ -39,9 +39,9 @@ The coder's report is a claim sheet. The gate reproduces each number in the meas
39
39
  | no rule break | the transcript grep for forbidden verbs and paths (`--transcript <file>`, else the run record's log); a missing transcript ⇒ a signed `gate.transcript_missing` row and a WARN, never silence |
40
40
  | high tier proven | every changed high-tier file (`proof.md` §1) has a signed, proven red→green row with `red_kind: assertion` for this block whose `covers` names it (light tier: optional), else `unproven <file>`; only the human's `block waive <id> proof --file <path>` clears it |
41
41
  | rows genuine | the MAC of every gate-relevant row verifies; the live worker matches the pinned pid |
42
- | nothing left open | no unruled `late_findings`; no file at `review_cap` without a signed `review.waived` row |
42
+ | nothing left open | no unruled `late_findings` (a ruling clears only a warning or nit ruled `nit`; a critical late finding needs the human's waiver); no file at `review_cap` without a signed `review.waived` row |
43
43
 
44
- `--no-require-reviews` is the human's way out, written as its own audit row; the orchestrator runs it only after the human said so in chat. A finding counts only under the gate's own configuration; a tautology (an expected value computed by the code under test) is a finding. Reproduce disclosures: a coder that reports its own rule break did the hard part.
44
+ `--no-require-reviews` is the human's way out, written as its own audit row. The human runs it themselves at a terminal: it asks for a yes, has no `--yes`, exits 2 unless stdin and stdout are both terminals, lifts `no_changes` and is refused while an autopilot grant is active — so never run it for them; tell them the command when they say so in chat. A finding counts only under the gate's own configuration; a tautology (an expected value computed by the code under test) is a finding. Reproduce disclosures: a coder that reports its own rule break did the hard part.
45
45
 
46
46
  ## §6 Budget — the stop is the feature (R6)
47
47
 
@@ -50,6 +50,6 @@ A fresh L3 session receives the failure evidence — the last attempt's diff, th
50
50
 
51
51
  ## §7 What reaches the human
52
52
 
53
- `next = stop`, `review.over_budget`, an orphan file, `worker_down`, `review_cap`, `redecompose`, `ask_human`, a security question, consensus unavailable, and one budget raise per run. Everything else is decided by S1, S2 or the rules above and recorded. A change to a rule, limit or check is never decided here: it always reaches the owner (`rules-core.md` R17).
53
+ `next = stop`, `review.over_budget`, an orphan file or `unowned_change`, a `not_approved` critical finding, `--no-require-reviews`, `worker_down`, `review_cap`, `redecompose`, `ask_human`, a security question, consensus unavailable, and one budget raise per run. Everything else is decided by S1, S2 or the rules above and recorded. A change to a rule, limit or check is never decided here: it always reaches the owner (`rules-core.md` R17).
54
54
 
55
55
  **Autopilot:** while a grant is active, a warning or nit waiver, one extra round at `review_cap` and a coder level go first to the delegate (`forge autopilot ask`, then `forge autopilot waive|round|level --decision <id>` only when it acted, `autopilot.md` §2). Never delegated: `next = stop`, `ask_human`, a security question, a critical finding, a budget raise, `redecompose` — they wait for the owner (`autopilot.md` §5).
@@ -11,7 +11,7 @@ The topology is decided in one place (`planSessions`). Consensus (§6) runs only
11
11
  1. Tools on the file first: lint, types, the file's own tests. Red ⇒ `tools_red` back to the coder, no model review, ledger `gate.red`.
12
12
  2. S1 `risk` (path floors first; `confidence < check` ⇒ S2 picks the depth).
13
13
  3. Planned depth: `risk < 1` ⇒ one L2 `quick` · `1 ≤ risk < 2`, or `risk ≤ review.single_reviewer_max_risk` ⇒ one L2 `full`, no judge · otherwise two blind L2 sessions (lens A correctness, lens B contracts and security) plus an L3 judge that reads the two reports — and the diff only when `review.judge_sees_diff` is true.
14
- 4. Triage: findings from a single L2 session go to S1 `defect` (`thresholds.findings.fix` fix now · mid-band batched to one L3 ruling per file · below `thresholds.findings.nit` a nit). Findings that come from a judge are final.
14
+ 4. Triage: findings from a single L2 session go to S1 `defect` (`thresholds.findings.fix` fix now · mid-band batched to one L3 ruling per file · below `thresholds.findings.nit` a nit). A `critical` finding is always fix now — neither S1 nor the L3 ruling makes it a nit. Findings that come from a judge are final.
15
15
 
16
16
  ## §3 Isolation and the packet
17
17
 
@@ -19,7 +19,7 @@ Every reviewer, recheck, judge and S2 session runs with cwd = a fresh empty temp
19
19
 
20
20
  ## §4 The stub guard — what counts as a review (R11)
21
21
 
22
- A result is a review only if the process exited 0, the JSON validates against the finding schema, `reviewed_hunks` equals the packet's hunk headers exactly and in order, and the output has at least `review.min_tokens_out` tokens. Anything else is ledger `review.unavailable` (`exit`, `schema`, `hunks_mismatch`, `too_short`, `timeout`, `budget` — the run reached `budget.usd`, `ledger.md` §3.1) and **is not approval**; the worker retries once on the level's fallback, then the file stays unreviewed and the block cannot close. A session that hangs is killed at `review.session_timeout_s` (default 300), process group and all, and is spawned once more on the same packet and level; only a second timeout is `unavailable: timeout`. Each timed-out attempt leaves a `review.session_timeout` ledger note with the last 4 KB of its stderr (redacted, never the packet) and only the byte count and last event types of its stdout.
22
+ A result is a review only if the process exited 0, the JSON validates against the finding schema, `reviewed_hunks` equals the packet's hunk headers exactly and in order, and the output has at least `review.min_tokens_out` tokens (a clean pass — `passed: true`, no findings, a non-blank summary — needs only 40, whatever the diff size). Anything else is ledger `review.unavailable` (`exit`, `schema`, `hunks_mismatch`, `too_short`, `timeout`, `budget` — the run reached `budget.usd`, `ledger.md` §3.1) and **is not approval**; the worker retries once on the level's fallback, then the file stays unreviewed and the block cannot close. A session that hangs is killed at `review.session_timeout_s` (default 300), process group and all, and is spawned once more on the same packet and level; only a second timeout is `unavailable: timeout`. Each timed-out attempt leaves a `review.session_timeout` ledger note with the last 4 KB of its stderr (redacted, never the packet) and only the byte count and last event types of its stdout.
23
23
 
24
24
  ## §5 The convergence rule — bounded fix rounds (R15)
25
25
 
@@ -31,7 +31,7 @@ Per file, per block:
31
31
  3a. **Heavy rounds climb at once** (`review_warnings`, `decisions.md` §5 rule 3a): below L2, a round — round 1 included — that leaves ≥ `escalation.warning_threshold` (2) warnings or any `critical` open, `escalation.after_rounds_with_warnings` (1) times at the current level, climbs one level for the next fix. One round with a stronger model is faster and cheaper than three with a cheap one. The per-file cap (step 5) stays the outer limit.
32
32
  4. **Ladder**: the running ceiling is `min(L2, escalation.stop_at)` — L3 is never a *running* level. A trigger — rule 1 (`retries`), rule 2 (`escalation.review_rounds_per_level` exhausted with an open `fix_now`), rule 2b (`review_stall`) or step 3a (`review_warnings`) — below the ceiling climbs one level, coded by a fresh session with the open findings and the fix history in its brief (an L1 block's rounds 3–4 run at L2). At the ceiling — an L2 block, or an L1 block that has climbed to L2 — it takes the **L3 rung**: a patch ≤ 80 lines, owned files only, reviewed as a `patch_check` round outside the cap. Under `escalation.l3_mode: code` (opt-in) the rung is instead one L3 coding turn. Either way the rung fires **once per block** and the same post-patch order follows it. After it, in order: `patch_check` clean ⇒ complete; open set not smaller than before the patch ⇒ `stopped: l3_patch_exhausted` (`review_stall`); round ≥ `review.max_rounds_per_file` ⇒ `stopped: review_cap`; otherwise the fixes continue at L2 with `rounds_at_level` reset and the patch in the brief. Any later trigger in that block stops with its named reason (`l3_patch_exhausted`, `review_cap`) and never patches again.
33
33
  5. **Cap — four rounds per file** (`review.max_rounds_per_file`, default 4, range 2–6). Round 4 still open ⇒ the L3 rung if the block has not used it (same post-patch order as step 4), else `stopped: review_cap`. A stop means the block cannot close, the ledger lists the open findings, and the human decides — fix by hand, `forge block waive <id> <finding-id> --run <r> --file <path> --reason <text>` (a signed `review.waived` row, `by: human`), or re-decompose. The coder never sees a `waive` verb; it is on the forbidden list. **Autopilot:** under a grant, a warning or nit finding may be waived by the delegate (`forge autopilot waive … --decision <id>`, written `by: autopilot`) and a file at the cap may get one extra round (`forge autopilot round`); a critical or proof waiver is never delegated (`autopilot.md`).
34
- 6. **Block-close sweep**: `forge block close` batches the `late_findings` per file to one L3 judge ruling (`fix_now | nit`); a `fix_now` opens a round on that file at the block's current level and counts toward its cap.
34
+ 6. **Block-close sweep**: `forge block close` batches the `late_findings` per file to one L3 judge ruling (`fix_now | nit`); a `fix_now` opens a round on that file at the block's current level and counts toward its cap. The gate counts a ruling only when it is `nit` on a warning or nit finding; a critical late finding stays open until the human waives it.
35
35
  7. Ledger: one `review.round` row per round with `open_before`, `closed`, `new_in_hunks`, `late`, `open_after`; `forge report` prints rounds per file and rounds that stalled.
36
36
 
37
37
  Rechecks are never degraded by the budget: a round-2+ packet is already the minimum.
@@ -10,7 +10,7 @@
10
10
  # copied install with no package next to it); `test/skill/shim.test.mjs` asserts the pin
11
11
  # equals package.json's version.
12
12
  # The shim holds no logic of its own: every verb, flag and message is the CLI's.
13
- PINNED_VERSION="0.6.2"
13
+ PINNED_VERSION="0.7.1"
14
14
  set -eu
15
15
 
16
16
  self="$0"
package/src/cli/block.mjs CHANGED
@@ -13,9 +13,26 @@
13
13
  *
14
14
  * `block close [--transcript <file>] [--report <file>] [--no-require-reviews]` also runs B12b's review rows
15
15
  * (`review/gate-check.mjs`): a signed `review.approved` row for the current content hash of every
16
- * changed owned file (deletions included) — `--no-require-reviews` is the human's way out, is
17
- * forbidden to coders (`mergeForbidden`), and leaves a signed `gate.reviews_waived {by: human}`
18
- * row before the close (never a silent skip); late findings ruled; `review_cap` findings waived; no
16
+ * changed owned file (deletions included) that no later review of the same content made stale
17
+ * (B52: `not_approved <file> <finding>` per open finding of that later review, unless waived — a
18
+ * critical only by the human's `block waive`) — `--no-require-reviews` is the human's way out, is
19
+ * forbidden to coders (`mergeForbidden`), needs the human to confirm at a terminal (B52: stdin
20
+ * or stdout not a TTY ⇒ exit 2, an active autopilot grant ⇒ refused, there is no `--yes`; a block
21
+ * that is not open is refused first, exactly as without the flag), lifts `no_changes`, and leaves a signed
22
+ * `gate.reviews_waived {by: human}` row before the close (never a silent skip; a declined prompt
23
+ * prints `block <id> close: cancelled …`). B52: a changed or new file no block of the run
24
+ * accounts for is `unowned_change <file>` (`unownedChanges`, listed from the repository top
25
+ * level; not lifted by `--no-require-reviews`; only the repository top-level `.code-forge/` is
26
+ * exempt — plus, for a workspace in a subdirectory, this run's record mirror and this block's
27
+ * default transcript there — never a `--transcript` or `--report` path in the source tree; a
28
+ * closed block's file still exactly at the content its gate approved is accounted for); a file another OPEN block of the run owns
29
+ * is left to that block's gate with one `WARN` line per file (the close cannot tell which coder
30
+ * wrote it); an old ledger's `review.result` rows are judged by their signed result files
31
+ * (`resultFilesFor`). B52 (field run): the file set is the diff since base ∪ every owned file
32
+ * with review rows of the block (`reviewedFiles`; an owned entry with no glob owns the paths below
33
+ * it, `ownsAt`), so work committed before `block open` or done in another worktree is still
34
+ * checked (with a WARN naming the likely cause when the diff shows none of it); an empty set is
35
+ * `no_changes`; late findings ruled; `review_cap` findings waived; no
19
36
  * `rule_break`, plus the transcript grep. The transcript is `--transcript`, else the block's
20
37
  * `transcript` in the run record, else `.code-forge/runs/<run>/<block>.log`; none ⇒ a signed
21
38
  * `gate.transcript_missing` row and a WARN line (never a silent pass, never a refusal). An id the
@@ -48,13 +65,13 @@ import path from 'node:path';
48
65
  import { parse as parseYAML } from 'yaml';
49
66
  import { approvalCheckText, expiryChecks } from '../autopilot/limits.mjs';
50
67
  import { appendRow, readAllRows } from '../ledger/write.mjs';
51
- import { attemptBlock, claimPath, closeBlock, openBlock, rebaseBlock, stopBlock } from '../state/block.mjs';
68
+ import { attemptBlock, claimPath, closeBlock, openBlock, rebaseBlock, repoTop, stopBlock } from '../state/block.mjs';
52
69
  import { intFlag, parseFlags } from '../state/cli-args.mjs';
53
70
  import { StateError } from '../state/paths.mjs';
54
- import { findOverlap } from '../state/registry.mjs';
55
71
  import { readRun, writeSigned } from '../state/run.mjs';
56
72
  import { loadKey, verifyRow } from '../state/signer.mjs';
57
- import { blockFileSet, reviewGateCheck, waiveFinding } from '../review/gate-check.mjs';
73
+ import { blockFileSet, changedFiles, ownsAt, resultFilesFor, reviewedFiles, reviewGateCheck, unownedChanges, waiveFinding } from '../review/gate-check.mjs';
74
+ import { checkExpiry } from '../autopilot/grant.mjs';
58
75
  import { writeSafe } from '../util/redact.mjs';
59
76
  import { DEFAULT_CONFIG_FILENAME, loadProjectConfig } from '../config/load.mjs';
60
77
  import { validateConfig } from '../config/validate.mjs';
@@ -73,8 +90,13 @@ const FLAG_VALUES = ['run', 'level', 'acceptance', 'brief', 'attempt', 'base', '
73
90
 
74
91
  /**
75
92
  * @param {string[]} args
76
- * @param {{stdout?: {write: (s: string) => unknown}, stderr?: {write: (s: string) => unknown}, probe?: import('../state/run.mjs').StartTimeProbe, now?: () => Date}} [deps] -
77
- * `now` (B48): the clock of the autopilot expiry checks.
93
+ * @param {{
94
+ * stdout?: {write: (s: string) => unknown}, stderr?: {write: (s: string) => unknown}, probe?: import('../state/run.mjs').StartTimeProbe, now?: () => Date,
95
+ * isTTY?: boolean, stdinIsTTY?: boolean, stdoutIsTTY?: boolean,
96
+ * ui?: {confirm: (o: {message: string, initialValue?: boolean}) => Promise<unknown>, isCancel: (v: unknown) => boolean},
97
+ * }} [deps] - `now` (B48): the clock of the autopilot expiry checks. `stdinIsTTY` / `stdoutIsTTY`
98
+ * (default `process.stdin.isTTY` / `process.stdout.isTTY`; `isTTY` sets both) and `ui` (default
99
+ * `@clack/prompts`): B52's confirmation of `--no-require-reviews`, which needs both to be TTYs.
78
100
  * @returns {Promise<number>}
79
101
  */
80
102
  export async function runBlock(args, deps = {}) {
@@ -167,13 +189,44 @@ export async function runBlock(args, deps = {}) {
167
189
  if (sub === 'close') {
168
190
  const entry = record.blocks?.[id];
169
191
  if (!entry) throw new StateError('unknown_block', `block ${id} is not in run ${runId}`);
170
- const rows = (await readAllRows(record.project)).filter((row) => row.run === runId);
192
+ // B52: a block that is not open is refused here, with or without --no-require-reviews, by
193
+ // the same check and message as `closeBlock` — and the owner is never asked about it
194
+ if (entry.status !== 'open') throw new StateError('no-block', `block ${id} is not open in run ${runId}`);
171
195
  const reviewsWaived = flags['no-require-reviews'] === true;
172
- // the block's file set is listed once and shared by the review and report checks; a failure
173
- // there is the usual `git-failed` refusal
174
- /** @type {ReturnType<typeof blockFileSet> | undefined} */
175
- let fileSetOnce;
176
- const blockFiles = () => (fileSetOnce ??= blockFileSet({ repoRoot: record.workspace, base: entry.base_sha, owned: entry.owned_files, matchOwned: (f) => findOverlap(entry.owned_files, [f]) !== null }));
196
+ // B52: the human's way out is confirmed by the human, at a terminal, before anything is written
197
+ if (reviewsWaived) {
198
+ const asked = await confirmNoReviews(runId, id, deps);
199
+ if (asked === 'cancelled') {
200
+ err(`block ${id} close: cancelled — the review checks stay on\n`);
201
+ return 1;
202
+ }
203
+ }
204
+ const rows = (await readAllRows(record.project)).filter((row) => row.run === runId);
205
+ // B52: ONE path base and ONE listing, memoised as a promise and shared by every check. The
206
+ // base is the repository top level: the worker signs every review row with a repo-root-
207
+ // relative `file` and keeps its queue and signed result files under `<top>/.code-forge/`
208
+ // (`repoRootOf`), and `tree_at_open` is stamped there too. An owned entry matches a path
209
+ // as given or, for a workspace in a subdirectory, relative to the workspace (`ownsAt`).
210
+ // A failure here is the usual `git-failed` refusal.
211
+ /** @type {Promise<{top: string, wsPrefix: string, owns: (owned: ReadonlyArray<string>, file: string) => boolean, changed: string[], fileSet: Array<{file: string, content_hash: string}>, reviewed: string[]}> | undefined} */
212
+ let treeOnce;
213
+ const tree = () =>
214
+ (treeOnce ??= (async () => {
215
+ const top = await repoTop(record.workspace);
216
+ // both sides as realpaths: git prints the top resolved (/private/var on macOS) while the
217
+ // run record may keep a symlinked spelling (/var)
218
+ const real = async (/** @type {string} */ p) => realpath(p).catch(() => path.resolve(p));
219
+ const wsPrefix = path.relative(await real(top), await real(record.workspace)).split(path.sep).join('/');
220
+ const owns = ownsAt(wsPrefix);
221
+ const isMine = (/** @type {string} */ f) => owns(entry.owned_files, f);
222
+ const changed = await changedFiles({ repoRoot: top, base: entry.base_sha });
223
+ // the file set is the diff since base ∪ every owned file with review rows of this block —
224
+ // work the diff cannot see (committed before `block open`, done in another worktree) is
225
+ // still checked, at its content in the workspace
226
+ const reviewed = reviewedFiles(rows, id).filter(isMine);
227
+ const fileSet = await blockFileSet({ repoRoot: top, base: entry.base_sha, owned: entry.owned_files, matchOwned: isMine, changed: [...new Set([...changed, ...reviewed])].sort() });
228
+ return { top, wsPrefix, owns, changed, fileSet, reviewed };
229
+ })());
177
230
  const reviews = reviewGateCheck(async () => {
178
231
  const transcript = await findTranscript(record, id, typeof flags.transcript === 'string' ? flags.transcript : null);
179
232
  if (transcript === null) {
@@ -184,14 +237,37 @@ export async function runBlock(args, deps = {}) {
184
237
  if (reviewsWaived) await writeSigned(runId, writeRow, { event: 'gate.reviews_waived', block: id, by: 'human' });
185
238
  // B20: the high paths come first — a base the workspace does not know refuses the close here
186
239
  const highPaths = await highPathsAt(record.workspace, entry.base_sha);
187
- const fileSet = await blockFiles();
240
+ const { top, wsPrefix, owns, changed, fileSet, reviewed } = await tree();
241
+ const isMine = (/** @type {string} */ f) => owns(entry.owned_files, f);
242
+ const key = await loadKey(runId);
243
+ // B52: an empty diff with review rows is never silent — name the likely cause
244
+ if (reviewed.length > 0 && !changed.some(isMine)) {
245
+ err(`WARN block ${id}: no owned file differs from the block base in the workspace, but ${reviewed.length} owned file(s) have review rows — the work was likely committed before block open or done in another worktree; they are checked at their content in the workspace\n`);
246
+ }
247
+ // B52: only code-forge's own state is exempt — `<top>/.code-forge/`, where the worker keeps
248
+ // its queue and signed results — never a `--transcript` or `--report` path in the source
249
+ // tree. For a workspace in a subdirectory, `<workspace>/.code-forge/` is source tree like any
250
+ // other path, except the two files code-forge itself keeps there at fixed names: this run's
251
+ // record mirror and this block's default transcript (`.code-forge/runs/<run>.json`,
252
+ // `.code-forge/runs/<run>/<id>.log`)
253
+ const wsRel = (/** @type {string} */ p) => path.posix.join(wsPrefix, p);
254
+ const ownState = new Set([wsRel(`.code-forge/runs/${runId}.json`), wsRel(`.code-forge/runs/${runId}/${id}.log`)]);
255
+ const scope = await unownedChanges({ top, changed: changed.filter((f) => !ownState.has(f)), id, blocks: record.blocks, rows, key, isMine, owns, stateDir: '.code-forge' });
256
+ if (scope === null) err(`WARN block ${id}: opened before code-forge recorded the tree at block open; unowned changes were not checked\n`);
257
+ for (const { file, block: other } of scope?.siblings ?? []) err(`WARN block ${id}: ${file} changed and is owned by open block ${other}; not checked here (its own gate reviews it)\n`);
188
258
  return {
189
259
  block: id,
190
260
  runId,
191
261
  rows,
192
- key: await loadKey(runId),
262
+ key,
193
263
  transcript,
264
+ // B52: a change no block of the run owns is refused — `--no-require-reviews` does not lift it
265
+ unowned: scope?.unowned ?? [],
266
+ // B52: an old ledger's review.result rows are judged by their signed result files
267
+ resultFiles: await resultFilesFor({ repoRoot: top, runId, block: id, rows }),
194
268
  files: reviewsWaived ? [] : fileSet,
269
+ // B52: nothing changed and nothing reviewed is never a pass (the owner's way out is --no-require-reviews)
270
+ noChanges: !reviewsWaived && fileSet.length === 0,
195
271
  // B20: high-tier files need a proven red→green row; `--no-require-reviews` does not lift it
196
272
  proofFiles: fileSet,
197
273
  highPaths,
@@ -206,8 +282,8 @@ export async function runBlock(args, deps = {}) {
206
282
  } catch {
207
283
  return { ok: false, reason: 'coder report FAILED: --report cannot be read' };
208
284
  }
209
- const files = await blockFiles();
210
- const missing = missingReviewTickets(text, files.map((f) => f.file));
285
+ const { fileSet } = await tree();
286
+ const missing = missingReviewTickets(text, fileSet.map((f) => f.file));
211
287
  return missing.length === 0 ? { ok: true } : { ok: false, reason: `coder report FAILED: no review-file ticket id for ${missing.join(', ')}` };
212
288
  };
213
289
  const result = await closeBlock({ runId, id, rows, writeRow, livePid: intFlag(flags['worker-pid'], 'worker-pid'), probe, extraChecks: [reviews, report] });
@@ -222,6 +298,35 @@ export async function runBlock(args, deps = {}) {
222
298
  return 2;
223
299
  }
224
300
 
301
+ /** B52: the refusal when `--no-require-reviews` has no terminal to ask the human at. */
302
+ export const NO_REVIEWS_NO_TTY = '--no-require-reviews needs the human at a terminal to confirm; the block stays open (there is no --yes)';
303
+
304
+ /**
305
+ * B52: `--no-require-reviews` lifts every per-file review check, so it is the human's alone — the
306
+ * same rule as `autopilot approve`: a flag can be passed by any process (the orchestrator, a
307
+ * script, the delegate's tooling), so the human confirms at a terminal and there is NO `--yes`.
308
+ * No terminal — stdin AND stdout must both be TTYs (a piped stdout means nobody may see the
309
+ * question) — ⇒ `usage` (exit 2); an active autopilot grant on the run ⇒ `autopilot-active`
310
+ * (exit 1; the owner stops the grant first) — nothing is written either way. `deps.stdinIsTTY` /
311
+ * `deps.stdoutIsTTY` inject each side; `deps.isTTY` injects both at once.
312
+ * @param {string} runId @param {string} id
313
+ * @param {{isTTY?: boolean, stdinIsTTY?: boolean, stdoutIsTTY?: boolean, ui?: {confirm: (o: {message: string, initialValue?: boolean}) => Promise<unknown>, isCancel: (v: unknown) => boolean}, now?: () => Date}} deps
314
+ * @returns {Promise<'confirmed' | 'cancelled'>}
315
+ * @throws {StateError} `usage`, `autopilot-active`
316
+ */
317
+ async function confirmNoReviews(runId, id, deps) {
318
+ const stdinTTY = deps.stdinIsTTY ?? deps.isTTY ?? process.stdin.isTTY === true;
319
+ const stdoutTTY = deps.stdoutIsTTY ?? deps.isTTY ?? process.stdout.isTTY === true;
320
+ if (!(stdinTTY && stdoutTTY)) throw new StateError('usage', NO_REVIEWS_NO_TTY);
321
+ const grant = await checkExpiry(runId, (deps.now ?? (() => new Date()))());
322
+ if (grant.state === 'active') {
323
+ throw new StateError('autopilot-active', `--no-require-reviews is refused while an autopilot grant is active on run ${runId} (stop it first: code-forge autopilot stop --run ${runId})`);
324
+ }
325
+ const ui = deps.ui ?? /** @type {any} */ (await import('@clack/prompts'));
326
+ const go = await ui.confirm({ message: `Close block ${id} of run ${runId} WITHOUT the per-file review checks? Only the owner may say yes.`, initialValue: false });
327
+ return ui.isCancel(go) || go !== true ? 'cancelled' : 'confirmed';
328
+ }
329
+
225
330
  /**
226
331
  * B47: the level `autopilot level` chose for the block — the latest `autopilot.level` row of this
227
332
  * run for it whose MAC verifies — or null when there is none.
@@ -33,7 +33,7 @@ import { blockKind, isDocsKind } from '../decide/escalation.mjs';
33
33
  import { fallbackRisk } from '../decide/fallback-rules.mjs';
34
34
  import { tierFor } from '../proof/tiers.mjs';
35
35
  import { assembleJudgePacket, assemblePacket, attachFiles, budgetFor, readFileDiff } from './packet.mjs';
36
- import { FINDING_SCHEMA, minTokensOut, SMALL_DIFF_ADDED_LINES, validateReview } from './validate-review.mjs';
36
+ import { FINDING_SCHEMA, minTokensOut, validateReview } from './validate-review.mjs';
37
37
  import { spawnWithTimeoutRetry } from './session-retry.mjs';
38
38
  import { assertRowPath } from '../worker/ticket.mjs';
39
39
 
@@ -194,12 +194,11 @@ export async function reviewFile(input, deps) {
194
194
  if (diff.diffText.trim().length === 0) return { status: 'no_change', approved: false, ...head, sessions: [] };
195
195
  mkdirSync(workDir, { recursive: true, mode: 0o700 });
196
196
  const min = minTokensOut(cfg);
197
- const smallDiff = diff.plusCount <= SMALL_DIFF_ADDED_LINES;
198
197
 
199
198
  /** @param {SessionSpec} spec @param {{text: string, hunkHeaders: string[], tokensIn: number, contextMode: string | null}} packet */
200
199
  const run = async (spec, packet) => {
201
200
  /** @type {Record<string, any>} */
202
- let summary = await runOne(spec, packet.text, packet, { deps, workDir, min, smallDiff, cfg });
201
+ let summary = await runOne(spec, packet.text, packet, { deps, workDir, min, cfg });
203
202
  const needs = summary.review?.needs_file;
204
203
  if (spec.role === 'reviewer' && summary.status === 'ok' && Array.isArray(needs) && needs.length > 0) {
205
204
  const paths = safeNeeds(needs);
@@ -210,7 +209,7 @@ export async function reviewFile(input, deps) {
210
209
  extra = null;
211
210
  }
212
211
  if (extra === null) return { ...summary, status: 'unavailable', reason: 'needs_file-refused', review: null };
213
- summary = { ...(await runOne(spec, `${packet.text}${extra}`, packet, { deps, workDir, min, smallDiff, cfg })), needs_file_round: true };
212
+ summary = { ...(await runOne(spec, `${packet.text}${extra}`, packet, { deps, workDir, min, cfg })), needs_file_round: true };
214
213
  }
215
214
  return summary;
216
215
  };
@@ -234,7 +233,7 @@ export async function reviewFile(input, deps) {
234
233
  if (judgeSpec) {
235
234
  const bySlot = Object.fromEntries(done.map((s) => [s.slot, s.review]));
236
235
  const packet = assembleJudgePacket({ diff, reports: { A: bySlot.A, B: bySlot.B }, cfg });
237
- final = await runOne(judgeSpec, packet.text, packet, { deps, workDir, min, smallDiff, cfg });
236
+ final = await runOne(judgeSpec, packet.text, packet, { deps, workDir, min, cfg });
238
237
  sessions.push(publicSummary(final));
239
238
  if (final.status !== 'ok') {
240
239
  await note(deps, { event: 'review.unavailable', reason: final.reason, lens: 'judge' });
@@ -250,9 +249,9 @@ export async function reviewFile(input, deps) {
250
249
  * One session: packet file (0600, removed after), spawn, stub guard.
251
250
  * @param {SessionSpec} spec @param {string} text
252
251
  * @param {{hunkHeaders: string[], tokensIn: number, contextMode: string | null}} packet
253
- * @param {{deps: ReviewDeps, workDir: string, min: number, smallDiff?: boolean, cfg: Record<string, any>}} env
252
+ * @param {{deps: ReviewDeps, workDir: string, min: number, cfg: Record<string, any>}} env
254
253
  */
255
- async function runOne(spec, text, packet, { deps, workDir, min, smallDiff = false }) {
254
+ async function runOne(spec, text, packet, { deps, workDir, min }) {
256
255
  const promptPath = path.join(workDir, `${spec.lens}-${randomBytes(6).toString('hex')}.md`);
257
256
  const base = { lens: spec.lens, role: spec.role, level: spec.level, slot: spec.slot, context_mode: packet.contextMode, ctx_tokens_in: packet.tokensIn };
258
257
  writeFileSync(promptPath, text, { mode: 0o600 });
@@ -285,7 +284,7 @@ async function runOne(spec, text, packet, { deps, workDir, min, smallDiff = fals
285
284
  } finally {
286
285
  rmSync(promptPath, { force: true });
287
286
  }
288
- const verdict = validateReview(res, { hunkHeaders: packet.hunkHeaders, minTokensOut: min, smallDiff });
287
+ const verdict = validateReview(res, { hunkHeaders: packet.hunkHeaders, minTokensOut: min });
289
288
  const meta = {
290
289
  ...base,
291
290
  provider: res?.provider ?? null,