@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/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] [--tests <paths…>]` prints the pointer `BRIEF <path> lines=<n> sha=<sha8> <<<EOM>>>`. With a `context` key, build the pack right after `block open`, before the pointer goes out: `forge context pack --run <r> --block <id>`; send the pack's text with the pointer and with every later coder task (`references/code.md` §3). 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). Just before the sentinel it carries the `TESTS <command> exit=<n>` line (`references/code.md` §3).
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; with `context.require_coder_tests` (set before `run start`) `--report` is required, and a report whose LAST `TESTS` line before its COMPLETE sentinel is missing or not `TESTS <command> exit=0` is FAILED too, waived or external reviews or not (`references/code.md` §3); 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.
44
+ 7. `forge block close <id> --run <r> --report <coder report>` is the gate. It needs every changed file accepted by the review gate — a signed `review.approved` row for its current content that no later review of the same content overrode (else `not_approved`), or every finding its review left open waived or answered (`reviewed_by_waiver`) — 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`. The final report needs no `reviewed <path> ticket <id>` lines: `block close` prints one for every file the review gate accepts (approved, waived or answered), with the queue's ticket. The close prints the reviewed lines, then the `reviewed_by_waiver` lines, then the `closed` line. With `--report`, a hand line that names another ticket prints `WARN report ticket mismatch <path>` on stderr when the block closes, and the close goes on. A file with a `review.waived` finding still gets a line; only a close with every review waived (`--no-require-reviews`) or `review.external` prints none. The printed line has no `via`: `--json` and the block's done file carry the lines as `reviewed: [{file, ticket, via}]`, with `via: approved` for a file with a signed approval, `via: waiver` when at least one of its open findings was waived (also when the others were answered), and `via: answered` when every one was answered. With `context.require_coder_tests` (set before `run start`) `--report` is required, and a report whose LAST `TESTS` line before its COMPLETE sentinel is missing or not `TESTS <command> exit=0` is FAILED, waived or external reviews or not (`references/code.md` §3). 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, the outside review rounds per PR next to their code-forge cost, and the spend per run (`budget.usd`, `references/ledger.md` §3.1); when a PR also got an outside review, record it first with `forge ledger outside-review --run <r> --pr <n> --rounds <n> --findings <n> [--link <url>] [--block <id>]…` (`references/ledger.md` §4).
46
46
 
47
47
  ## §4 Hard rules (the headings of `references/rules-core.md`)
@@ -64,6 +64,8 @@ State lives in the plan file's header — `facts → planned → hardened → co
64
64
  - **R16** Proof is exact assertions on the main behaviour
65
65
  - **R17** Never relax a rule alone
66
66
 
67
+ Red→green means an assertion failure on old code, then a pass on new code; no hand mutations; proof scales with risk; no extras beyond acceptance (R16).
68
+
67
69
  ## §5 Where to read more
68
70
 
69
71
  | when | read |
@@ -18,18 +18,24 @@ Under `proof.isolation: worktree` (B62a, `proof.md` §3), `block open` first mak
18
18
 
19
19
  **Block end: done file and `on_block_end` hook (B64b).** Every block end — `block close` that closes or stops it (a pin event or `ledger.tamper`) and `block stop` — writes `<run root>/blocks/<id>.done.json` (the run's state folder, `~/.code-forge/runs/<run>/`) atomically after the end's rows, overwritten on a later end; its absolute path is in the hook's `CODE_FORGE_DONE_FILE` and in `done_file` of `close`/`stop --json`. Content: `{block, run_id, status, event, reason, commit, spent_usd, unknown_usd_sessions, findings, at}` — `status` `closed`/`stopped`; `event` `block.close`, `block.stop` or the close's own stop event (e.g. `worker.replaced`, `ledger.tamper`); `reason` null for a plain close; `commit` the HEAD of the block's tree or the repository, null when unreadable; `spent_usd`/`unknown_usd_sessions` from the block's own rows as `forge status` computes them, both null when the ledger cannot be read; `findings` the open review findings, null when the ledger cannot be read or the count fails. It never holds transcript text, diff text or secrets. A failed write changes nothing else and prints `warning: done file not written: <reason>` on stderr. Wait for that file (or the hook) instead of polling `forge status` or the coder's transcript. A done file from an earlier end stays after a second `block open` of the block, so count it only when its `at` is later than the current dispatch; if no fresh done file appears by the time the coder agent reports, run `forge status` once. With `hooks.on_block_end: {argv, timeout_s}` set before `run start` (immutable; `run reload` refuses a change), the CLI runs `argv` once per end — also when a later step such as `--remove-worktree` fails — after the done file and outside the run lock: no shell, cwd = the repository top, stdin closed, output discarded, the process group killed at `timeout_s` (1–600, default 60), with `CODE_FORGE_RUN`, `CODE_FORGE_BLOCK`, `CODE_FORGE_BLOCK_STATUS` and `CODE_FORGE_DONE_FILE` (`""` when the done file was not written) added to its env; one signed `hook.ran` row per end (`ledger.md`). A hook that does not succeed (exit 0) only warns: `warning: on_block_end hook exit N` | `timed out` | `not found` | `signal <NAME>` | `failed to start (<code>)`; if the `hook.ran` row itself cannot be written, one more line `warning: on_block_end hook row not written (<code>)` follows (an error code only, never message text). With the hook set, `close --json` and `stop --json` add `done_file` (the absolute path, or null when it was not written); without it the output is byte-identical to before. The hook is the owner's command and runs outside coders: a coder never runs it (the close gate flags a transcript line that runs its argv[0] basename plus argv[1] when present and not blank — both trimmed, argv[1] one literal token even with spaces inside, which a transcript line split on spaces never matches — as `rule_break … code-forge-on-block-end-hook`), and `.code-forge.yml` is non-ownable (the same guard as `review.external.required`) with the hook set — also when `hooks.on_block_end` is the only key that guards it.
20
20
 
21
- **The context pack (B63a).** With a `context` key in `.code-forge.yml` (`rules`, `lessons`, `commands`, `max_lines`), build the block's pack right after `block open`, before the pointer is sent, and again before every later coder task (fix rounds too): `forge context pack --run <r> --block <id>` writes `<run root>/context/<id>.md` and prints its path and `tokens ~<n>` (`--json`: `{"path":…,"tokens":…,"lines":…,"cut":…,"sha256":…}`). The pack file lives under the run root, which a brief never names (see below): paste the pack's **text** into the coder task with the pointer and with every later coder task (or copy the file to a location outside the run root and name that), and keep the run-root path out of the brief. The pack holds, in order: the block header (run, block, level, owned files, the test files when present, the worktree path and branch when present), the project's build, test, lint and format commands as argv text in POSIX shell quoting and redacted like the rules (code-forge never runs them; a control character in a token, a path or a header value is refused), each rules file, then the lessons file, each under its source path (an empty or whitespace-only file gives its heading alone). Over `max_lines` (default 300, minimum 20) the lessons are cut first, then the rules files from the last one backwards, each cut section ending in `[cut: N lines dropped]`; the header and the commands are never cut. Rules and lessons are read from the main tree. A missing file, or a path outside the repository (`..`, an absolute path, a symlink leading out — a broken symlink is judged by where it points), refuses the pack (exit 1, naming the key and the path, nothing written); so does a config without `context` or with a bad `context` value. Each new pack writes a signed `context.pack` row; the same pack again writes none and prints the same output. The key is read from the live config, not the run's snapshot: it is not authority, and `run reload` takes a change to it — except `context.require_coder_tests` (the close check for the TESTS line, below), which is fixed for the run. The coder may run the verb too, but a pack the coder builds (and its path) is for the coder's own use only, never what the orchestrator sends; `.code-forge.yml` stays non-ownable.
21
+ **The context pack (B63a).** The pack carries the built-in proof rule before project rules and lessons. With a `context` key in `.code-forge.yml` (`rules`, `lessons`, `commands`, `max_lines`), build the block's pack right after `block open`, before the pointer is sent, and again before every later coder task (fix rounds too): `forge context pack --run <r> --block <id>` writes `<run root>/context/<id>.md` and prints its path and `tokens ~<n>` (`--json`: `{"path":…,"tokens":…,"lines":…,"cut":…,"sha256":…}`). The pack file lives under the run root, which a brief never names (see below): paste the pack's **text** into the coder task with the pointer and with every later coder task (or copy the file to a location outside the run root and name that), and keep the run-root path out of the brief. The pack holds, in order: the block header (run, block, level, owned files, the test files when present, the worktree path and branch when present), the project's build, test, lint and format commands as argv text in POSIX shell quoting and redacted like the rules (code-forge never runs them; a control character in a token, a path or a header value is refused), the built-in proof rule, each rules file, then the lessons file, each under its source path (an empty or whitespace-only file gives its heading alone). Over `max_lines` (default 300, minimum 20) the lessons are cut first, then the rules files from the last one backwards, each cut section ending in `[cut: N lines dropped]`; the header, commands and proof rule are never cut, and all count toward the budget. Rules and lessons are read from the main tree. A missing file, or a path outside the repository (`..`, an absolute path, a symlink leading out — a broken symlink is judged by where it points), refuses the pack (exit 1, naming the key and the path, nothing written); so does a config without `context` or with a bad `context` value. Each new pack writes a signed `context.pack` row; the same pack again writes none and prints the same output. The key is read from the live config, not the run's snapshot: it is not authority, and `run reload` takes a change to it — except `context.require_coder_tests` (the close check for the TESTS line, below), which is fixed for the run. The coder may run the verb too, but a pack the coder builds (and its path) is for the coder's own use only, never what the orchestrator sends; `.code-forge.yml` stays non-ownable.
22
22
 
23
23
  The brief file carries, in this order:
24
24
 
25
25
  1. `id`, `title`, the level, `owned_files` ("edit ONLY these"), the acceptance clauses with test ids, `test_command`.
26
26
  2. The reply contract: `ACK <sha8> lines=<n>` before any tool call; the facts diff; the forecast.
27
27
  3. The file loop: *after you finish each file in `owned_files` (tests written and green) run `forge review-file <path> --block <id> --run <r>`; before the next file run `forge review-file --wait <ticket> [--max 90s]` and act on the packet.* A `worker_down` answer ⇒ print `===BLOCK <id> FAILED: worker down===` (never approval). A cached result that never counted (`stale`, `unavailable`, or `refused` with `other-run`, `context-unreadable` or `worktree-*`) is run again with `forge review-file <path> --block <id> --run <r> --fresh` (B75); anything else (a counted round, `review-state-lost`, or an unknown result) answers `fresh-needs-reopen` (`fresh-tampered` for a tampered state), and the coder stops and reports. A finding whose fix belongs in another owned file (a missing test, say) is fixed there and sent with `forge review-file <that file> --block <id> --run <r> --answers <ids> --of <source>` (`review.md` §5).
28
- 4. The project's own rules digest (`CLAUDE.md`/`AGENTS.md`, ≤ 60 lines) — left out with a `context` key, because the pasted pack carries the rules and lessons — the forbidden list verbatim (`security.md` §2), and the sentinel contract: the `TESTS` line of item 6, then `===BLOCK <id> COMPLETE===` followed by the test summary, `git diff --stat` and one `reviewed <path> ticket <ticket-id>` line per changed file, or `===BLOCK <id> FAILED: <reason>===`.
29
- 5. **FAILED unless** (`templates/coder-brief.md`): a final report — first attempt or fix round — that does not list the `review-file` ticket id of every changed file counts as FAILED whatever its sentinel says. Pass the report to the gate with `forge block close <id> --run <r> --report <file>`; it refuses `coder report FAILED: no review-file ticket id for <files>`.
30
- 6. **The green test run.** The coder runs the block's test command and prints one line that is exactly `TESTS <command> exit=<n>` just before `===BLOCK <id> COMPLETE===` — on the first attempt and again, freshly run, in every fix round. `<command>` is the command it ran (not empty); `<n>` is its exit code as a decimal 0–255 with no leading zeros. The line is matched after trimming; a line quoted inside prose, a non-numeric, signed or out-of-range `<n>`, or a leading zero does not count.
28
+ 4. The project's own rules digest (`CLAUDE.md`/`AGENTS.md`, ≤ 60 lines) — left out with a `context` key, because the pasted pack carries the rules and lessons — the forbidden list verbatim (`security.md` §2), and the sentinel contract: the `TESTS` line of item 6, then `===BLOCK <id> COMPLETE===` followed by the test summary and `git diff --stat`, or `===BLOCK <id> FAILED: <reason>===`.
29
+ 5. **The close writes the ticket lines (B96)** (`templates/coder-brief.md`): The final report needs no `reviewed <path> ticket <id>` lines: `block close` prints one for every file the review gate accepts (approved, waived or answered), with the queue's ticket. The close prints the reviewed lines, then the `reviewed_by_waiver` lines, then the `closed` line. With `--report`, a hand line that names another ticket prints `WARN report ticket mismatch <path>` on stderr when the block closes, and the close goes on. A file with a `review.waived` finding still gets a line; only a close whose every review the human waived, or a `review.external` close, prints none. This holds for the first attempt and every fix round. Pass the report to the gate with `forge block close <id> --run <r> --report <file>`. The printed line has no `via`: `--json` and the block's done file carry the lines as `reviewed: [{file, ticket, via}]`, with `via: approved` for a file with a signed approval, `via: waiver` when at least one of its open findings was waived (also when the others were answered), and `via: answered` when every one was answered. A file the gate accepts gets no line when the queue cannot give its ticket (its content changed after the gate read it, or its path cannot be resolved): the close prints `WARN no ticket for <path>`, or `WARN bad ticket for <path>` when the ticket is not 24 lowercase hex characters. If the lines cannot be built at all, the close still goes on, with no line and one `WARN reviewed lines unavailable: <message>`. Like the mismatch WARN, these go to stderr and print only when the block closes; a refused close prints none of them. The close refuses `coder report FAILED: no review-file ticket id for <files>` only when, under `--report`, a file the review gate accepts has no reviewed line from the close and no hand line for it in the report. For that check a hand line is a report line that names that file, no other file of the block, and a ticket id of 24 lowercase hex characters. So when the lines cannot be built at all, every file the gate accepts needs a hand line, and no hand line is compared with a ticket. For the mismatch WARN only a line that is exactly `reviewed <path> ticket <id>` counts (a `- `, `* ` or `1. ` list marker and backticks are fine), and since a ticket id is 24 lowercase hex characters, an id in capitals names another ticket.
30
+ 6. **The green test run.** The coder runs the block's test command and prints one line that is exactly `TESTS <command> exit=<n>` just before `===BLOCK <id> COMPLETE===` — on the first attempt and again, freshly run, in every fix round. `<command>` is the command it ran (not empty); `<n>` is its exit code as a decimal 0–255 with no leading zeros. The line is matched after trimming; a line quoted inside prose, a non-numeric, signed or out-of-range `<n>`, or a leading zero does not count. Put pass/fail counts on a separate line: nothing may follow `exit=<n>` on the TESTS line.
31
31
  7. **Tests prove behaviour (B92).** The brief says: proof is behaviour, not text: never read a source file in a test to assert on its text. A test calls the code and checks what it does; reading a fixture is fine. A test file that reads a source file and asserts on its text gets a `## test-quality` section in its review packet (`review.md` §3). The section is a hint, not a finding: the reviewer checks those lines and, when the test really reads source text to assert on it, raises a `warning` (unless the file read is a fixture).
32
32
 
33
+ In a fix round, the fresh TESTS line may use `code-forge gates run --run <r> --block <id> --changed` (B99). Any green `code-forge gates run --run <r> --block <id>` records the owned file hashes on `gate.done`, including runs without `--changed`; when `--slug` is omitted, these rows go to the run's project ledger. More than 200 owned files means no record, so the next `--changed` falls back with `no_baseline`; unreadable inputs also leave no record. `{test_files}` expands to changed tests and stem-paired tests of changed sources; `{changed_files}` expands to changed lint/format paths. Deleted files still count for pairing and the threshold, but both tokens receive only files that exist now. Both tokens must be whole argv items and cannot be the command or the only item; config validation refuses a gate whose command or only item is `{changed_files}`. Without `--changed`, `{changed_files}` is dropped. Gates without tokens run as before. Rows carry `scope: changed`, including fallback rows with a `reason`: `no_baseline` uses today's scope, `unpaired` uses today's test scope when a changed source has no test pair, `threshold` runs the full suite above `gates.full_suite_threshold_files` changed files, and `no_changes` uses today's scope when nothing is left to run. The orchestrator runs the full `code-forge gates run` before `block close` (D2). See `proof.md`, **Fix rounds (B99)**.
34
+
35
+ **Overlap loop (B100).** `review.file_loop` accepts `wait` (default), `overlap` or `watch`. With `overlap`, the brief tells the coder to enqueue each green file and continue, then run `forge review-file --wait-block <id> --run <r> [--max 90s]` before the completion sentinel, including fix rounds. The one JSON line is `{block, files: [{file, ticket, status}], done}`; statuses are `done`, `pending` or `not_queued` (null ticket). A current file with an earlier ticket gets its changed content enqueued with the normal ticket id; the old pending ticket becomes stale when the worker starts it. `done: true` means every owned changed file has a finished ticket for its current content, not that reviews approved it: inspect each ticket with `--wait` and address findings. Timeout returns exit 0, `done: false`, `reason: wait_timeout`; wait again. `not_queued` returns `done: false`, `reason: not_queued`; enqueue those files. No live worker for this run returns exit 3, `status: worker_down`, `done: false`, even when all markers exist: print the FAILED sentinel. Block wait reads workspace scope, queue markers and owned content; it never reviews in process. An unreadable scope, ticket or completion marker refuses (exit 1). The orchestrator must leave the run's scope mirror readable in the workspace.
36
+
37
+ **Commit watch (B104).** With `review.file_loop: watch` (B104), commit each owned file after its tests are green and continue coding. Watch replaces per-file enqueueing. Before the completion sentinel, including fix rounds, run `forge review-file --wait-block <id> --run <r> [--max 90s]`, inspect finished tickets with `--wait <ticket>`, and fix findings. The worker watches the block worktree HEAD (otherwise repository HEAD), queues owned content from git objects with the same ticket ids as a manual request, and marks older pending tickets `refused`, `reason: superseded`. A running ticket finishes for its old hash, which cannot approve newer bytes; `--wait-block` joins the current ticket per file. `--fresh` refuses a superseded marker; enqueueing that content again removes only its superseded marker so it can run, while a real result stays cached. `queue/watch/<block>.json` stores the last seen commit; missing or unreadable state restarts at `base_sha` with idempotent tickets. A rewound tick queues nothing and drops the last seen commit so the next tick restarts at `base_sha`. A `watch.skipped {block, reason}` row is written once per reason until a healthy head clears the skip reasons: `unreadable_head` (head, tree or objects cannot be read), `not_descendant` (head does not descend from the block base), `rewound` (head no longer descends from the last seen commit), or `unowned_path` (unowned paths are ignored; owned paths still queue). A skip never fails the block. After a `watch.skipped` row, use `forge review-file <path> --block <id> --run <r>` when the file needs a manual review; use `forge review-file <fix file> --block <id> --run <r> --answers <ids> --of <source>` for a fix in another owned file that answers findings. Set watch before starting the worker. With any `review.file_loop` value other than `watch` (`wait`, the default, or `overlap`), the worker does no watch work and runs no git for it.
38
+
33
39
  The brief never mentions `block waive`, `block reopen`, keys, or `~/.code-forge/runs/`. The coder process gets no Jev key (the System 1 model's key, `keys.jev`) and no signer key.
34
40
 
35
41
  **The close check for the TESTS line (B63b).** The gate reads the LAST `TESTS` line in the segment of the block's last sentinel: after the previous sentinel line of any block (an earlier attempt, or another block in a shared log) and before this one; a line after the sentinel is ignored. Set `context.require_coder_tests: true` in `.code-forge.yml` **before `run start`** to make it a close check: it is read from the run's config snapshot only, never the live file, and `run reload` refuses a change to it (`context.require_coder_tests cannot change mid-run`). With it on, `--report` is required, and `block close` refuses (exit 1, the block stays open, no `block.close` row) with one of the messages below. This check runs before the other close checks and only reads the report, so a refusal here writes no row at all — not even the human waiver row of `--no-require-reviews`:
@@ -38,6 +44,7 @@ The brief never mentions `block waive`, `block reopen`, keys, or `~/.code-forge/
38
44
  - `coder report FAILED: --report cannot be read`;
39
45
  - `coder report FAILED: no ===BLOCK <id> COMPLETE=== sentinel` — none for the block, or its last one is FAILED;
40
46
  - `coder report FAILED: no TESTS line` — none in that sentinel's segment;
47
+ - `coder report FAILED: TESTS line malformed: <line>` — a line starts with `TESTS ` but fails the form above; the diagnostic cuts the line to 120 characters;
41
48
  - `coder report FAILED: TESTS exit=<n>` — `<n>` is not 0.
42
49
 
43
50
  Waived or external reviews (`--no-require-reviews`, `review.external`) do not lift it: they waive reviews, not tests. A close that passes writes a signed `coder.tests {block, command, exit}` row (the command redacted) under the run lock with the close rows, only when every other check passed — after any `gate.reviews_waived {by: config}` row, just before `block.close`. With the key false or absent the brief still asks for the line, but the gate ignores it and writes no row.
@@ -46,7 +53,7 @@ Waived or external reviews (`--no-require-reviews`, `review.external`) do not li
46
53
 
47
54
  Each `review-file` ticket is served by the worker `forge run start` launched: tools first (the project's gates on that file — red ⇒ `tools_red`, no model review), then S1 `risk`, then the planned depth (`review.md` §2). You watch the run, not the coder's output: `forge status` (B64a) is the progress check — read-only (no ledger row, never the run lock), every run of this repository that is not ended, newest first, with a blank line between runs, at most 10 lines per run: `run <id> · <status> · <plan|brief> · spent $<x>[ of $<budget>]` (money with 2 decimals; `$<x>+?` when some session has no known price, `$?` when the ledger cannot be read), then per open or in-attempt block `<id> <open|attempt-<n>> <level> · <model or -> · gate <pass|fail|none>[ (<gate>)] · findings <n>` (the gate field is `gate pass`, `gate fail (<gate>)` with the failing gate's name, `gate fail` when no gate was named, or `gate none` when no gate ran; findings = the count of open review findings the close gate would refuse today), `… +<k> more blocks` as the last block line when they do not fit, and `closed <a> · stopped <b>` only when either is non-zero — printed LAST, but it keeps its place in the cap ahead of block lines (block lines are dropped first). No such run: `no active run`, exit 0. `--run <id>` shows one run (ended too); an unknown run exits 1, a usage error exits 2. `--json` prints one line, `{"runs":[…]}`, with every open block (no cap, so `more` is always 0), `unknown_usd_sessions`, and per block `gate: {result, name}` (`name` null for `pass`, for `none` and for an unnamed fail). Use it instead of reading logs or polling the coder's transcript: it never prints transcript or diff text. `forge ledger tail --slug <slug>` shows the rows themselves (`review.plan`, `review.round`, `review.done`). Idle is a wake, never proof; the sentinel is the only completion signal (`adapters/*.md`).
48
55
 
49
- **One fix round per block (B94).** After the coder's first COMPLETE, wait until every `review-file` ticket of the block is answered and the block's tests ran; then send ONE fix message with every failing test (name and first error line) and every open finding (by file and id). Never one finding or one file at a time. A finding that arrives after that message waits for the next round. In that round the coder fixes, runs the block's tests, re-runs `review-file` on every changed file, and prints its `TESTS` line. The per-file cap still counts per file: one block round is one round for each file it touched (`review.md` §5).
56
+ **One fix round per block (B94).** After the coder's first COMPLETE, wait until every `review-file` ticket of the block is answered and the block's tests ran; then send ONE fix message with every failing test (name and first error line) and every open finding (by file and id). Never one finding or one file at a time. A finding that arrives after that message waits for the next round. In that round the coder fixes, runs the block's tests, follows the selected file loop on every changed file, and prints its `TESTS` line. In fix rounds, follow the selected loop: `wait` uses `forge review-file` and `--wait`, `overlap` enqueues each green file, and `watch` commits each green file. Both `overlap` and `watch` run `forge review-file --wait-block <id> --run <r>` before the sentinel. The per-file cap still counts per file: one block round is one round for each file it touched (`review.md` §5).
50
57
 
51
58
  ## §5 The block gate is a re-measurement — `forge block close <id> --run <r>`
52
59
 
@@ -55,7 +62,7 @@ The coder's report is a claim sheet. The gate reproduces each number in the meas
55
62
  | claim | re-measured by |
56
63
  |---|---|
57
64
  | 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 |
58
- | 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 |
65
+ | 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 close prints a `reviewed <path> ticket <id>` line per file the review gate accepts (the `--report` the orchestrator always passes needs none). The gate accepts a changed or new file with a **signed** `review.approved` row for its current content hash that no later review of the same content overrode (a `review.result` with `approved: false`, a `review.round` with findings open, a `review.cap`), or with every finding its review left open waived or answered (`reviewed_by_waiver`): 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 |
59
66
  | tests green | every gate command run once in the export, each exit status read |
60
67
  | 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` |
61
68
  | test count | the filtered run's delta equals the new tests' own count |
@@ -73,14 +80,16 @@ Without the flag the tree stays. On a block without a worktree the flag is a usa
73
80
 
74
81
  **When a file stops** (`review_cap`, `l3_patch_exhausted`, `split_required`, …), the block cannot close. The stop's `exits` name the ways out. Take them in this order; `review.md` §5 gives each one's rules and refusals:
75
82
 
76
- 1. **Fix, then `block reopen`.** When the fix belongs in the stopped file, have it fixed. Then print the exact command, `forge block reopen <id> --file <path> --run <r> --reason <text>` with the human's reason, and the human runs it in their own terminal, exactly like `block waive`. Never run it yourself, and never for a coder (it is on the coder forbidden list). After it, send the coder's `review-file` again: the next round is a full round 1 when the content differs from what the stop reviewed; on the stop's own content the stop repeats. A `reopen-same-content` refusal means the content is still what the stop reviewed: offer the waive instead.
83
+ 1. **Fix, then `block reopen`.** When the fix belongs in the stopped file, have it fixed. Then print the exact command, `forge block reopen <id> --file <path> --run <r> --reason <text>` with the human's reason, and the human runs it in their own terminal, exactly like `block waive`. Never run it yourself, and never for a coder (it is on the coder forbidden list). After it, send the coder's `review-file` again: the next round is a full round 1 when the content differs from what the stop reviewed; on the stop's own content the stop repeats. For known stop content, `reopen-same-content` means the content is still what the stop reviewed: offer the waive instead. A legacy stop with unknown content accepts a current hash without a verified `review.round` of this run/block/file and records `from_hash: null`. If that hash already has such a round, change the file and reopen, or the owner can use `block close --no-require-reviews`; waivers at an old hash cannot clear the current one.
77
84
  2. **`review-file --answers`.** Use it when the fix belongs in another owned file (a missing test, say). The coder fixes it there and runs `forge review-file <that file> --block <id> --run <r> --answers <ids> --of <source>`. A fresh reviewer judges the fix. It needs no human step.
78
85
  3. **`block waive`.** The human waives each open finding: print the exact `forge block waive <id> <finding-id> --run <r> --file <path> --reason <text>` command per finding, and the human runs it in their own terminal. A file whose findings are all waived or answered closes with a signed `gate.review_waived_all` row.
79
86
  4. **Re-decompose.** The human decides, and the architect plans new blocks.
80
87
 
81
88
  **`block waive` and `block reopen` are run by the human, never by you (B80).** Both ask for a yes at a terminal. You print the exact command; the human runs it in their own terminal. The rule: a coder or the orchestrator never runs it and never answers its prompt. Separately, as a fact of the verb: when stdin or stdout is not a terminal (a pipe, or a process started with no terminal) it exits 2 with `block <verb> needs the human at a terminal to confirm; nothing was recorded (there is no --yes)` and records nothing. There is no `--yes`, and an autopilot grant does not change this (the delegate waives through `forge autopilot waive`). The prompt checks only that stdin and stdout are terminals; a pseudo-terminal passes it, so it does not prove a human (`security.md` §3).
82
89
 
83
- `block attempt` never resets review state: it only raises the attempt counter, and a stopped file stays stopped. Use `review-file --fresh` only after a result that never counted (`stale`, `unavailable`, or `refused` with `other-run`, `context-unreadable` or `worktree-*`); it never re-runs a counted round. A missing state file needs no exit: the worker first rebuilds it from its ledger anchor (`review.state_restored`). Only a state whose anchor has no `state_body` is `lost` (`review-state-lost`); that one needs the human's `block reopen`, on its current content. A `review-state-tampered` file has none of these exits: only the owner's `block close --no-require-reviews` gets past it.
90
+ `block attempt` never resets review state: it only raises the attempt counter, and a stopped file stays stopped. Use `review-file --fresh` only after a result that never counted (`stale`, `unavailable`, round-1 `split_required`, or `refused` with `other-run`, `context-unreadable` or `worktree-*`); it never re-runs a counted round. A missing state file needs no exit: the worker first rebuilds it from its ledger anchor (`review.state_restored`). Only a state whose anchor has no `state_body` is `lost` (`review-state-lost`); that one needs the human's `block reopen`, on its current content. A `review-state-tampered` file has none of these exits: only the owner's `block close --no-require-reviews` gets past it.
91
+
92
+ A cached round-1 `split_required` ran no session and counted no round (B110). Plain `review-file` re-queues it with `retry: true`, and `--fresh` re-queues it too, with the same ticket id. After a budget change and `run reload`, the worker uses the live config. If the budget is still too small, it returns `split_required` again without a session. A recheck stop (`status: stopped`, `stopped: split_required`) keeps its `review.cap` row and still needs `block reopen` or `block waive`; plain review returns the cached stop and `--fresh` refuses `fresh-needs-reopen`.
84
93
 
85
94
  `--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. The owner's other way out is `review.external` in the config before `run start` (`review.md` §1): the gate then skips the review rows and the report's ticket ids and writes `gate.reviews_waived {by: config}`; every other check, `no_changes` included, still applies. 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.
86
95
 
@@ -6,17 +6,21 @@ Tier per file = `high` when `risk ≥ 2`, or a `proof.tiers.high.paths` match, o
6
6
 
7
7
  | tier | proof required | when |
8
8
  |---|---|---|
9
- | light | red→green recommended for every new or changed test; **optional at the gate** | the coder or orchestrator runs the runner per test (§2) |
9
+ | light | red→green required for every new or changed test; **optional at the gate** | the coder or orchestrator runs the runner per test (§2) |
10
10
  | high | red→green **required at the gate** for every changed high-tier file, plus the deeper **review** depth of `review.md` §2 (two lenses and a judge) | as above; `block close` refuses `unproven <file>` |
11
11
 
12
12
  **`forge block close` enforces the high tier:** tier inputs are `proof.tiers.high.paths` (the union of the config at the block's base and the current one), the recorded `risk`, and `security_sensitive`; a row naming the file whose MAC fails makes it high. Each changed high-tier file needs a signed `proof` row of this run and block with `step: red-green`, `proven: true`, `red_kind: assertion` whose `covers` list names the file (`revert` covers the sources it put back; `assertion-deletion` covers only its test), else `unproven <file>`; only the human's `forge block waive <id> proof --file <path>` clears it (it asks for a yes at a terminal: the orchestrator prints the exact command and the human runs it in their own terminal; there is no `--yes`, `review.md` §5).
13
13
 
14
- **Mutation testing is not part of this product.** The question whether tool-made mutants on changed lines (Infection, Stryker) should be a user-facing proof tier was decided: **cut**. There is no `proof mutate`, no nightly mutation run, no `min_msi` floor, and no mutation-tool key in `.code-forge.yml`; a consumer that runs a mutation tool does so outside code-forge and never beside a coder's test run in the same tree. What replaces it as proof is the rule itself: one exact test per acceptance clause plus one negative or boundary where the clause has a failure mode — assertions that say **which** element and **how many**, never merely whether something exists — and the isolated per-file review with the convergence rule, which on this package's own build found the defects green suites had missed.
14
+ **Mutation testing is not part of this product.** The question whether tool-made mutants on changed lines (Infection, Stryker) should be a user-facing proof tier was decided: **cut**. There is no `proof mutate`, no nightly mutation run, no `min_msi` floor, and no mutation-tool key in `.code-forge.yml`; a consumer that runs a mutation tool does so outside code-forge and never beside a coder's test run in the same tree. What replaces it as proof is the rule itself: one exact test per acceptance clause plus one negative or boundary where the clause has a failure mode — assertions that say **which** element and **how many**, never merely whether something exists — and the isolated per-file review with the convergence rule, which on this package's own build found the defects green suites had missed. The same holds for hand mutations by a coder: they cost about one test run each and a coder tends to pick mutants its own tests already kill, so a coder proves each new or changed test red→green (§2) instead; when a review finds a weak assertion, the fix is a stronger assertion, not a mutant.
15
+
16
+ Proof scales with risk; do not add more than the acceptance asks for.
15
17
 
16
18
  `proof.extra` (an argv array) keeps a consumer's legacy proof commands running inside the gate.
17
19
 
18
20
  ## §2 Red→green — journaled, killable, RED must mean an assertion
19
21
 
22
+ Red→green: each new or changed test must FAIL on the old code first (red), then PASS on the new code (green). A red caused by an import, compile or crash error proves nothing; the red must be an assertion failure.
23
+
20
24
  **One call proves one test** — a test file, or one case of it (`<file>::<case>` is the label) — with one of two mechanisms: `revert` (the block's changed source files are put back to their base version, so the test must fail against the code it was written for) or `assertion-deletion` (a characterization test, proven by deleting its assertions). The runner does not distinguish a new test from a changed one: the caller runs it once per test the acceptance names, new or changed alike, and the count of `RED` rows is the count of proven tests — there is no separate "new cases" tally. In this version the runner is invoked per test from the coder's or the orchestrator's side; the block gate reads the resulting signed `proof` rows but does not launch the runner itself.
21
25
 
22
26
  The runner works in the export (§3) or under the lock with a journal: it refuses when a journal already exists (an earlier run was interrupted) → journals every source file the test covers (bytes, sha256, mode — or `existed: false`) → writes the **base** version of each (`git show <base>:<path>`, never `stash` or `checkout --`); **a file the block added has no base version, so it is removed for the red run** → runs the filtered tests → **parses** the output: an assertion failure ⇒ `RED`; a fatal, compile or import error ⇒ `RED_INVALID`, which proves nothing (a removed module that the test imports is the usual cause — such a test needs an assertion that fails against the base, not an import that crashes) → restores every journaled file byte for byte, **verifies each sha256**, deletes the journal (in a `finally`, so an ordinary error restores too) → when the red step was `RED`, runs again ⇒ `GREEN`. A characterization test (`proof: characterization`) is proven by assertion deletion. The ledger `proof` row records `mechanism` and `red_kind`; one `RED` + `GREEN` pair per proven test. SIGTERM mid-run leaves the journal; `forge proof restore <block> --run <r>` restores the exact hashes and refuses when no journal exists.
@@ -128,6 +132,8 @@ Test-count delta (blocks without a worktree) = the count on the block's filtered
128
132
 
129
133
  **Related tests (B60).** A gate argv item that is exactly `{test_files}` (one whole item, never part of one, never the command, never the only item — `validate` refuses those — never a shell string) runs only the block's tests under `forge gates run --cwd <dir> --run <r> --block <id> [--slug <slug>]`: the paths given at `block open --tests` (the precise way), else the owned entries that are concrete files (no glob, no trailing `/`) and look like tests (a `test`/`tests`/`__tests__`/`spec` directory, or a name ending `.test.*`, `.spec.*`, `Test.php`, `_test.go`, or `test_*.py`). When the block owns more files than `gates.full_suite_threshold_files` (default 10; owned files: globs and directories count the tracked and new (not ignored) files they cover, a path not created yet counts as 1; outside a git repository each entry counts 1, and any other git failure runs the full suite with `reason: threshold`), the item is dropped with `reason: threshold`; when no test file is known, or `gates run` has no `--run/--block`, it is dropped with `reason: no_tests`. Dropped, the gate runs the full suite. The `gate.red` and `gate.done` rows say which: `scope: related` with `test_count`, or `scope: full` with `reason`; with `--run/--block` they also name `run` and `block`. A gate without the item runs exactly as before (no scope is worked out and its rows carry none). A test path that starts with `-` is refused, never passed as an option. Example: `test: [npx, vitest, run, "{test_files}"]`. For a worktree block (`proof.isolation: worktree`), leave `--cwd` out (`gates run --run <r> --block <id>`): the gate runs in the tree, `{test_files}` resolves the tests there, and the threshold counts the owned files in the tree, never the main tree's.
130
134
 
135
+ **Fix rounds (B99).** `code-forge gates run --run <r> --block <id> --changed` compares owned file hashes with the block's last green `gate.done` from `gates run --run <r> --block <id>`. Any green `code-forge gates run --run <r> --block <id>` records the owned file hashes on `gate.done`, including runs without `--changed`; when `--slug` is omitted, these rows go to the run's project ledger. More than 200 owned files means no record, so the next `--changed` falls back with `no_baseline`; unreadable inputs also leave no record. `{test_files}` expands to changed tests and stem-paired tests of changed sources; `{changed_files}` expands to changed lint/format paths. Deleted files still count for pairing and the threshold, but both tokens receive only files that exist now. Both tokens must be whole argv items and cannot be the command or the only item; config validation refuses a gate whose command or only item is `{changed_files}`. Without `--changed`, `{changed_files}` is dropped. Gates without tokens run as before. Rows carry `scope: changed`, including fallback rows with a `reason`: `no_baseline` uses today's scope, `unpaired` uses today's test scope when a changed source has no test pair, `threshold` runs the full suite above `gates.full_suite_threshold_files` changed files, and `no_changes` uses today's scope when nothing is left to run. The coder's fix-round TESTS line may come from `code-forge gates run --run <r> --block <id> --changed`; the orchestrator runs the full `code-forge gates run --run <r> --block <id>` before `block close` (D2).
136
+
131
137
  **Gate env (B60).** A gate may be `{argv: [...], env: {NAME: value}}`; the variables are added to that gate's environment (`env: {}` is no env). code-forge never prints or records an env value. Gate env is not a secret store. Child output is masked only for values of 8 or more characters. `gates detect`, results, ledger rows, `doctor` and `init` show only the names (`env_keys`); put secrets in the environment or a secret manager (`validate` refuses secret-looking values). A short value (`CI=1`, `MODE=test`) is not masked, so `npm test` stays readable. A block that edits `.code-forge.yml` shows the values in its own diff.
132
138
 
133
139
  ## §4 Verified and unverified stacks
@@ -1,8 +1,12 @@
1
1
  # Review — per file, fresh L2 session, bounded rounds
2
2
 
3
- ## §1 The trigger is a CLI call, and the file set is complete
3
+ ## §1 The trigger is a CLI call or commit watch, and the file set is complete
4
4
 
5
- `forge review-file <path> --block <id> --run <r>` enqueues a ticket and returns `{ticket, status: queued}` in under a second; `forge review-file --wait <ticket> [--max 90s]` polls it. The same content answers the cached result; `review-file <path> --block <id> --fresh` (B75) drops that cached result and queues the ticket again only when it never counted as a review (`stale`, `unavailable`, or `refused` with `other-run`, `context-unreadable` or `worktree-*`). Use it after such a transient answer to get a real review; a coder may run it, and with no cached result it is a plain `review-file`. A cached `review-state-tampered` is refused `fresh-tampered` (only the owner's `block close --no-require-reviews` gets past it); any other cached result is refused `fresh-needs-reopen` (the human's exits are `block reopen` and `block waive`). A refused marker is read in place and never moved; only a re-runnable one is claimed (an atomic rename, then a byte compare) and dropped. The worker computes the diff itself against the base sha `forge block open` recorded: tracked changes plus untracked files, filtered by `owned_files` for the review and unfiltered for the scope check. A coder that never calls `review-file` gains nothing: the block gate refuses any changed or new file without a signed `review.approved` row for its final content hash. The reviewer is always a new OS process (R1, R10) — identical under Solo, a harness subagent, a detached CLI process, or a human typing.
5
+ `forge review-file <path> --block <id> --run <r>` enqueues a ticket and returns `{ticket, status: queued}` in under a second; `forge review-file --wait <ticket> [--max 90s]` polls it. The same content answers the cached result; `review-file <path> --block <id> --fresh` (B75) drops that cached result and queues the ticket again only when it never counted as a review (`stale`, `unavailable`, round-1 `split_required`, or `refused` with `other-run`, `context-unreadable` or `worktree-*`). Use it after such a transient answer to get a real review; a coder may run it, and with no cached result it is a plain `review-file`. A cached `review-state-tampered` is refused `fresh-tampered` (only the owner's `block close --no-require-reviews` gets past it); any other cached result is refused `fresh-needs-reopen` (the human's exits are `block reopen` and `block waive`). A refused marker is read in place and never moved; only a re-runnable one is claimed (an atomic rename, then a byte compare) and dropped. The worker computes the diff itself against the base sha `forge block open` recorded: tracked changes plus untracked files, filtered by `owned_files` for the review and unfiltered for the scope check. Without commit watch, a coder that never calls `review-file` gains nothing: the block gate refuses any changed or new file without a signed `review.approved` row for its final content hash. The reviewer is always a new OS process (R1, R10) — identical under Solo, a harness subagent, a detached CLI process, or a human typing.
6
+
7
+ With `review.file_loop: watch` (B104), commit each owned file after its tests are green and continue coding. Watch replaces per-file enqueueing. Before the completion sentinel, including fix rounds, run `forge review-file --wait-block <id> --run <r> [--max 90s]`, inspect finished tickets with `--wait <ticket>`, and fix findings. The worker watches the block worktree HEAD (otherwise repository HEAD), queues owned content from git objects with the same ticket ids as a manual request, and marks older pending tickets `refused`, `reason: superseded`. A running ticket finishes for its old hash, which cannot approve newer bytes; `--wait-block` joins the current ticket per file. `--fresh` refuses a superseded marker; enqueueing that content again removes only its superseded marker so it can run, while a real result stays cached. `queue/watch/<block>.json` stores the last seen commit; missing or unreadable state restarts at `base_sha` with idempotent tickets. A rewound tick queues nothing and drops the last seen commit so the next tick restarts at `base_sha`. A `watch.skipped {block, reason}` row is written once per reason until a healthy head clears the skip reasons: `unreadable_head` (head, tree or objects cannot be read), `not_descendant` (head does not descend from the block base), `rewound` (head no longer descends from the last seen commit), or `unowned_path` (unowned paths are ignored; owned paths still queue). A skip never fails the block. After a `watch.skipped` row, use `forge review-file <path> --block <id> --run <r>` when the file needs a manual review; use `forge review-file <fix file> --block <id> --run <r> --answers <ids> --of <source>` for a fix in another owned file that answers findings. Set watch before starting the worker. Without the `review.file_loop: watch` setting, the default `wait` loop performs no watch work or git calls.
8
+
9
+ A cached round-1 `split_required` ran no session and counted no round. Plain `review-file` drops its marker and re-queues the same ticket with `retry: true`; `--fresh` also re-queues it (B110). After the human raises the budget and runs `run reload`, the worker re-plans with the live config; the ticket id stays the same. If it still does not fit, it returns `split_required` again with no session or cost. A recheck stop (`status: stopped`, `stopped: split_required`) has a `review.cap` row and still needs `block reopen` or `block waive`; plain review keeps it cached and `--fresh` refuses `fresh-needs-reopen`.
6
10
 
7
11
  A ticket whose session breaks is not a review. A broken result is a `schema` miss or a timed-out attempt (B91): a timeout counts as a broken result, like a schema miss. Misses and timeouts of the same packet, level and role are counted together, so one miss plus one timeout is two. A packet's second broken result is tried once on the ticket's second level, in the same ticket, only when a second level exists (`review.second_levels.<level>`, or for a routed ticket the rule's `second`) and never in consensus mode (§4).
8
12
 
@@ -21,12 +25,33 @@ The topology is decided in one place (`planSessions`). Consensus (§6) runs only
21
25
 
22
26
  ## §3 Isolation and the packet
23
27
 
24
- Every reviewer, recheck, judge and S2 session runs with cwd = a fresh empty temp dir under the run's temp root and the packet **on stdin** — a no-tools session never opens a path and the packet never enters argv. Packet order is fixed so provider prompt caching applies: lens, project rules digest (≤ 60 lines), facts-sheet excerpt for the symbols in the diff, context, diff. With a `context` key in the config (B63a, `code.md` §3) the project rules digest is the rules files' text and then, under a `### lessons` subheading, the lessons file, read from the main tree, never from a block worktree, each time a packet is built. `context` is read from the live config; `run reload` accepts a change to it. Rules and lessons share the one 60-line cap and are trimmed together, from the end. Without the key the slot is `(none)`, as before. A rules or lessons file missing, outside the repository or unreadable when the ticket is reviewed (or a bad `context` value) makes the ticket `review.unavailable` with `reason: context-unreadable`: no session runs and its packet directory is removed. The worker does not retry it: the ticket is done with that signed result. `review-file` on the same content is a fresh attempt (`retry: true`) each time it is run, with no cap (no session ran, so the one re-run of a failed packet is not used up); once the file is back, that attempt is reviewed normally. Context: a file at or under `review.context.whole_file_max_lines` travels whole; above it each hunk carries ± `review.context.hunk_context_lines` lines; over `review.budgets.full_in` the context shrinks to ± `review.context.min_context_lines`, then the digest is trimmed, never the diff; a diff alone over budget ⇒ `split_required`, with `tokens_in` and `budget` in the result, its ledger `review.result` row and the `review-file --wait` answer (`stop: split_required`) — except a Markdown file (`.md`, `.markdown`), which is reviewed **section by section**: the diff is cut at its headings (`#`..`######`, not inside fenced code), consecutive sections share a packet while it fits, every section packet runs the file's whole plan with its own stub guard, the file is approved only when every section is, and findings are merged with ids `S<section>.<id>`; a recheck over budget is split the same way, each open finding listed in its own section's packet. A section that alone is over budget keeps the file `split_required` (with that `section`), and so does a deleted Markdown file or one with no heading (fail-safe: one section); nothing is ever truncated. Moved code (B56): code moved between two changed files of the same block is named in a `## moved code` section before the diff (`- base lines a-b removed here were added in <file> lines c-d in this block.` / `- lines c-d were moved here from <file> (its base lines a-b) in this block.`), in round 1, rechecks, patch checks and the judge's packet. Detection is deterministic line matching against the block's other changed owned files diffed against the same base: lines trimmed with whitespace collapsed, lines with fewer than 4 letters or digits ignored, a moved stretch needs ≥ 3 such lines or ≥ 120 such characters; at most 12 ranges are listed; a Markdown section packet lists only the moves under its own headings (none ⇒ no section); the peers are listed and read once per review (one `git ls-tree <base>`, one `git diff <base>` over the peers that exist in the base — a peer deleted in the block, staged or not, is all removed lines — one `git check-ignore` and one `git diff --no-index` per new peer; `a/`/`b/` prefixes forced, `core.quotePath=false`; a peer name with a control, bidi or separator character, a quote or a backslash is skipped; a peer whose diff is over 2 MiB is skipped, never buffered past that); a removed block cut right above a heading belongs to the section holding its removed hunk; a move inside one file is not detected. It is extra text counted in the packet budget: every hunk stays, `reviewed_hunks` is unchanged, and a removal listed there is not a defect by itself — only stale references or imports are. The closed-book rule is in every lens: judge only what is in the packet; a file you cannot see goes in `needs_file`, and the worker attaches it (read-only, trimmed) for **one** more round.
28
+ The review engine, fix-loop recheck and worker send `## paired` after `## context`
29
+ and before moved code and the diff, using the block's owned changed peers plus its explicit
30
+ `test_files` (`block open --tests`), even when their stems differ. Missing or unreadable pairs
31
+ are skipped; no pairs means no section. It names paired files and summarizes test names or exported
32
+ symbols: at most 40 entries per file, each at most 120 characters, with secret-looking and
33
+ registered values redacted. Stem matching supports `.mjs`/`.ts` (`.test`, `.spec`, and the `test/`
34
+ tree), PHP (`FooTest.php`), Go (`foo_test.go`), Python (`test_foo.py` or `foo_test.py`, with a non-empty stem), and Swift (`FooTests.swift`).
35
+ The entire section is capped at `review.budgets.full_in / 16` tokens. Over budget it is dropped
36
+ before shrinking context or trimming rules; it never causes `split_required` or cuts the diff.
37
+ Without paired input the packet bytes stay the same. The summary names tests; request their
38
+ bodies with `needs_file` when needed. A named test exists and must not be reported as unseen.
39
+
40
+ The attachment helper accepts a new untracked file only when the caller supplies it in both
41
+ the owned and changed concrete file lists of the same block and tree. The review engine passes
42
+ these lists to `attachFiles` in `reviewFile` for round 1 and full rounds, including those called
43
+ from the worker's fix loop. The fix-loop recheck has no `needs_file` path. A git-ignored file or a
44
+ secret path is refused, even when those lists name it; a failed git check also refuses it.
45
+ Without those lists, attachments remain tracked-only. Existing path and read-only checks apply.
46
+
47
+ Every reviewer, recheck, judge and S2 session runs with cwd = a fresh empty temp dir under the run's temp root and the packet **on stdin** — a no-tools session never opens a path and the packet never enters argv. Packet order is fixed so provider prompt caching applies: lens, project rules digest (≤ 60 lines), facts-sheet excerpt for the symbols in the diff, context, `## paired` (optional), `## moved code` (optional), diff. With a `context` key in the config (B63a, `code.md` §3) the project rules digest is the rules files' text and then, under a `### lessons` subheading, the lessons file, read from the main tree, never from a block worktree, each time a packet is built. `context` is read from the live config; `run reload` accepts a change to it. Rules and lessons share the one 60-line cap and are trimmed together, from the end. Without the key the slot is `(none)`, as before. A rules or lessons file missing, outside the repository or unreadable when the ticket is reviewed (or a bad `context` value) makes the ticket `review.unavailable` with `reason: context-unreadable`: no session runs and its packet directory is removed. The worker does not retry it: the ticket is done with that signed result. `review-file` on the same content is a fresh attempt (`retry: true`) each time it is run, with no cap (no session ran, so the one re-run of a failed packet is not used up); once the file is back, that attempt is reviewed normally. Context: a file at or under `review.context.whole_file_max_lines` travels whole; above it each hunk carries ± `review.context.hunk_context_lines` lines; over `review.budgets.full_in` the context shrinks to ± `review.context.min_context_lines`, then the digest is trimmed, never the diff; a diff alone over budget ⇒ `split_required`, with `tokens_in` and `budget` in the result, its ledger `review.result` row and the `review-file --wait` answer (`stop: split_required`) — except a Markdown file (`.md`, `.markdown`), which is reviewed **section by section**: the diff is cut at its headings (`#`..`######`, not inside fenced code), consecutive sections share a packet while it fits, every section packet runs the file's whole plan with its own stub guard, the file is approved only when every section is, and findings are merged with ids `S<section>.<id>`; a recheck over budget is split the same way, each open finding listed in its own section's packet. A section that alone is over budget keeps the file `split_required` (with that `section`), and so does a deleted Markdown file or one with no heading (fail-safe: one section); nothing is ever truncated. Moved code (B56): code moved between two changed files of the same block is named in a `## moved code` section before the diff (`- base lines a-b removed here were added in <file> lines c-d in this block.` / `- lines c-d were moved here from <file> (its base lines a-b) in this block.`), in round 1, rechecks, patch checks and the judge's packet. Detection is deterministic line matching against the block's other changed owned files diffed against the same base: lines trimmed with whitespace collapsed, lines with fewer than 4 letters or digits ignored, a moved stretch needs ≥ 3 such lines or ≥ 120 such characters; at most 12 ranges are listed; a Markdown section packet lists only the moves under its own headings (none ⇒ no section); the peers are listed and read once per review (one `git ls-tree <base>`, one `git diff <base>` over the peers that exist in the base — a peer deleted in the block, staged or not, is all removed lines — one `git check-ignore` and one `git diff --no-index` per new peer; `a/`/`b/` prefixes forced, `core.quotePath=false`; a peer name with a control, bidi or separator character, a quote or a backslash is skipped; a peer whose diff is over 2 MiB is skipped, never buffered past that); a removed block cut right above a heading belongs to the section holding its removed hunk; a move inside one file is not detected. It is extra text counted in the packet budget: every hunk stays, `reviewed_hunks` is unchanged, and a removal listed there is not a defect by itself — only stale references or imports are. The closed-book rule is in every lens: judge only what is in the packet; a file you cannot see goes in `needs_file`, and the worker attaches it (read-only, trimmed) for **one** more round.
25
48
 
26
49
  Big files (B90): to send a big file to a long-context reviewer, set `review.routes` once — do not edit `.code-forge.yml` per file and restore it after. A rule is `{min_lines, level?, model, provider?, effort?, fallback?, session_timeout_s?, second?}`; `min_lines` and `model` are required. A rule's level decides which sessions it changes: an L2 rule (`level: L2`, the default) changes only the reviewers' model, and in consensus mode only slot A — slot B stays `review.second_levels.L2`; an L3 rule (`level: L3`) changes only the judge's model. What is counted: the file's lines at the reviewed state (the bytes in the review packet) — its newline bytes, plus one for a last line with no newline — once per ticket. A deleted file is never routed. Rules compete only within their level: among the rules of one level, the one with the largest `min_lines` at or under the count wins, and one ticket can take one L2 rule and one L3 rule; two rules with the same `level` and `min_lines` are refused. Left out, an optional key gets: `provider` — the top-level `provider` (the rule replaces the level entry, so the level's own `provider` is not inherited), and validation checks the rule with that same provider; `effort` — none (the CLI's default); `fallback` — none; `session_timeout_s` — the rule does not count in the timeout; `second` — `review.second_levels.<level>`. The timeout is one value for every session of the ticket: the largest `session_timeout_s` among the matched rules that set one (a matched rule without one is left out of that max, it does not count as `review.session_timeout_s`), else `review.session_timeout_s`. The rule's `second` is the second-level try's model (§4) for its level, in place of `review.second_levels.<level>` — except an L2 rule in consensus mode, whose `second` is not used. One signed `review.route {level, min_lines, lines, provider, model, session_timeout_s, second_model}` row per routed level is written before the ticket's first session: `provider` is the one the routed level runs on (the inherited one when the rule names none), and `second_model` is the rule's `second` model when that second is used, else null — always null for an L2 rule in consensus mode, because slot B is `review.second_levels.L2`, not the rule's. A retry of the same ticket never writes its rows twice. A row that cannot be written ⇒ `review.unavailable` with `reason: route-row`, and a routes list in the snapshot that cannot be used (or a routed level that cannot be resolved) ⇒ `reason: route-invalid` — no session runs. No rule matches (or no `review.routes`) ⇒ the ticket runs exactly as before. `run reload` accepts a change to it; validation refuses two rules with the same `level` and `min_lines`, and, with `review.multimodel`, an L2 rule whose provider (its own, else the inherited top-level one) equals the second reviewer's (slot B's, or the rule's own `second.provider`) (`review-routes-invalid`).
27
50
 
28
51
  Deleted files (B74): a deleted owned file is reviewed at the content hash `deleted`; its round-1 packets (each reviewer's and the judge's; a file that stays deleted has no later round, since its content cannot change) have a `## deleted file` section before the diff saying the file is deleted and listing the tree paths that still name its basename — one `git grep -l -z --untracked -F -e <basename> -- .` with the reviewed working tree (the block's worktree or the main tree, the one the diff is read from) as cwd, at most 20 paths sorted, never the deleted path itself or a secret-like path; a path with a control character is never printed, and it and the paths past 20 are counted as `(<n> more not listed)`. No match ⇒ `no references found`; a failed grep, a basename under 3 characters or a deleted path with a control character (printed JSON-escaped) ⇒ `references not checked`. A file is "deleted" only when its diff is not empty and the path is absent from the reviewed tree (a binary file that still exists never gets the note); a note reader that fails gives `references not checked` and the review goes on. No exemption: the deletion still needs a signed approval (or the waiver/answers exits) to close, and an approval at `deleted` stops counting once the file comes back.
29
52
 
53
+ Budget step-up (B109): the `quick` lens uses `review.budgets.quick_in` (default 6,000). A non-Markdown quick file whose diff packet exceeds that budget is planned again at risk 1 for the same block kind. If the new plan is `full`, the packet is rebuilt with `review.budgets.full_in` (default 32,000), and one full L2 session runs when it fits. Risk is unchanged. The result's `depth` and the `review.plan` row's `depth_unconstrained` and `depth_chosen` are `full`; the row adds `step_up: {from: 'quick', tokens_in, budget}` with the original quick packet's size and budget. Still over `full_in` ⇒ `split_required` with the full packet's size and budget and no session; nothing is truncated. A second plan that is not `full` keeps the original stop. Fitting quick files, Markdown section splitting and risk-1 files keep their plans and have no `step_up`; rechecks already use `full_in`.
54
+
30
55
  Test quality (B92): a test file that reads a source file and asserts on its text gets a `## test-quality` section before the diff, in each reviewer's round-1 packet and the judge's. Rechecks get the lens rule but no `## test-quality` section. The section names the file and the flagged line numbers — at most 10, the rest counted as `(<n> more not listed)` — never the file's text. Detection is a text match with no model call: a test file (a test path, a `Tests/` dir, or a name ending in `Tests.<ext>` or `Test.<ext>`; never a `.md` or `.txt` file or a file under a `fixtures`, `fixture` or `__fixtures__` dir) with a line holding a read call and a line holding a contains or match call (`includes(`, `contains(`, `toContain(`, `toMatch(`, `assert.match(`, `assertIn(`, `strings.Contains(`). The read calls that count are `readFileSync`, `readFile`, `open(`, `read_text`, `String(contentsOf`, `contentsOfFile`, `File.read`, `os.ReadFile`, `fs::read_to_string`, `readText(`, `readString(` and `ReadAllText(`, matched as text. The call counts when its span — its own line, plus up to 3 more lines while the read call's own `(` is still open (a formatter split) — holds a quoted source path (`.swift`, `.ts`, `.tsx`, `.js`, `.mjs`, `.cjs`, `.py`, `.go`, `.rs`, `.kt`, `.java`, `.rb`, `.php`, `.cs`) or a name bound on an earlier line to one; the call's own line is the one listed. A name is bound only when its value IS the source-path literal: the whole right-hand side, or the literal passed straight to `join`, `path.join`, `resolve` or `path.resolve` (`spawnSync('node', ['bin/cli.mjs'])` binds nothing); the binding may carry modifiers and a type (`private val X`, `const val X`, `private const val X`, `private static let X`, `private static final String X`, `private const string X`, `const string X`, `export const X`). A fixture read never counts: a read whose span holds a string literal with a `fixtures`, `fixture` or `__fixtures__` path segment (`'test/fixtures/a.mjs'`, `join(dir, 'fixtures', 'a.mjs')`), or names a name bound on a line holding such a literal (`const FIXTURES = join(__dirname, 'fixtures')`). The section is a hint, not a finding: it writes no row and changes no gate. The lens rule tells the reviewer to check those lines and raise such a test as a `warning` ("test reads source text; prove behaviour instead") unless the read file is a fixture. It is extra text counted in the packet budget, like the moved-code section; a file with no flag gets no section. Over budget it takes the path any extra takes: the context shrinks, then the digest is trimmed; when the diff plus the extras alone are over budget, the file is `split_required` (with `tokens_in` and `budget`, no session; a Markdown file is split by section instead).
31
56
 
32
57
  ## §4 The stub guard — what counts as a review (R11)
@@ -37,13 +62,15 @@ A result is a review only if the process exited 0, the JSON validates against th
37
62
 
38
63
  Per file, per block:
39
64
 
65
+ Only real rechecks count toward the round cap, per-level ladder and stall rules (B111). An unchanged recheck runs no session, writes no `review.round`, and returns `reviewed`, `approved: false`, `reason: 'no-fix-hunks'` with the open findings; round counters stay unchanged. A recheck answer that skips a `resolved` entry for an open finding in its packet is a schema miss. It follows §4's fallback: when eligible (the packet's second broken result), that schema miss tries the second level once. A successful second-level answer is accepted; only a failed second-level try, or no available second level, leaves `review.unavailable` and retry. An unavailable recheck closes nothing, counts no round and never stalls. Markdown packets check only their section's listed findings. A real `resolved: false` still counts and can stall; an unchanged `patch_check` still stops with `l3_patch_exhausted` (B73). The worker's recheck session summary shows `status`, `tokens_in`, `tokens_out`, and `reason` for a string status other than `ok`; missing status is `null` with no reason.
66
+
40
67
  1. Round 1 is the tiered review of §2 on the full packet.
41
68
  2. **Round n ≥ 2 reviews only the fix hunks**: the diff between the content hash reviewed in round n−1 and the current content, ± `review.context.hunk_context_lines`, plus the open findings by id (`review.recheck_scope: fix_hunks`, the default; `file` re-sends the whole packet for a consumer that wants it). The `recheck` lens answers `resolved[]` per finding — S1 `resolved` at or above `thresholds.findings.resolved` closes it, else the fresh L2 recheck decides — and may raise a new finding **only inside a fix hunk**; a finding outside becomes a `late_finding`, ruled at block close (`review.late_findings: sweep`; `block` makes it block the round).
42
69
  3. **Strictly shrinking**: the open `fix_now` set after round n is smaller than after round n−1, counting closures minus new in-hunk findings. Equal or larger ⇒ `review_stall` (rule 2b), a trigger at once.
43
70
  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.
44
71
  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; the patch closed no finding that was open before it (ids compared, not counts) ⇒ `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. A patch that closed at least one old finding is progress, not a stall, even when new findings in its fix hunks keep the open set from shrinking (B73). Any later trigger in that block stops with its named reason (`l3_patch_exhausted`, `review_cap`) and never patches again.
45
72
  5. **Cap — four rounds per file** (`review.max_rounds_per_file`, default 4, range 2–6). The per-file cap still counts per file: the orchestrator sends one fix message per block (`code.md` §4), and one block round is one round for each file it touched. 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 stopped file (`review_cap`, `l3_patch_exhausted`, `split_required`, …) keeps the block from closing; the ledger lists its open findings. The stop names its ways out: a `stopped` answer from `review-file` carries `exits: ["block reopen", "block waive", "review-file --answers", "re-decompose"]`, and the close's `review_cap` detail and `unreviewed` detail for a stopped file end with `exits: block reopen, block waive, review-file --answers, re-decompose` (B73). That printed list is the code's list, not an order; follow Exit 1–4 below. **The ways out of a stop, in this order** (take the first that fits):
46
- - **Exit 1 — fix, then `block reopen` (B72).** The file is fixed by hand, then the human restarts its review: `forge block reopen <id> --file <path> --run <r> --reason <text>`. It also restarts a `lost` state (step 8) on its current content. It is human-only like `block waive`: on the coder forbidden list, and run by the human in their own terminal (see **The human runs `block waive` and `block reopen`** below). A stopped file must differ from the content its stop reviewed. Every stop records that content as `stop_content_hash` in the signed state; a state from before B72 has none, and then only the `review.cap` row of that stop (after the file's latest reopen, same round and reason) names it — otherwise the content is unknown and the reopen is refused. The verb first removes the done marker of the current content's ticket, so the next `review-file` runs instead of returning the cached stop. Then it writes one signed `review.reopened {block, file, from: stopped|lost, stop_reason, at_seq, from_hash, to_hash, reason, by: human}` row (`at_seq` is the file's latest `review.state` anchor seq; `from_hash` the content the stop reviewed, null for `lost`; `to_hash` the current content). There is at most one row per `at_seq`: a retry on the same content finds its row and writes no second one, and other content at the same `at_seq` is refused. The worker starts a new state for a row whose MAC verifies, of this run, `by: human`, whose `at_seq` is the latest anchor seq: round 1, a full review (no fix hunks), the level from the run record, up to `review.max_rounds_per_file` rounds again. It saves at `at_seq + 1`, so a row works once. For a stop the worker checks again that the content changed; on the stop's own content the stop repeats. The block's one L3 rung stays spent when it was used, so a reopened file that reaches the cap stops at `review_cap`. The gate needs nothing new: an approval written after the `review.cap` row clears the cap. Refusals (exit 1, no row): `reopen-same-content` (a stopped file still at the content its stop reviewed, or a stop whose content is unknown: change the file, or waive its findings instead), `reopen-tampered` (an integrity failure stays a security event), `reopen-not-stopped` (no state, or an open or complete one), `reopen-block-not-open` (the block is stopped or closed), `reopen-not-owned`, `unknown_block` (the block lookup code `block waive` uses; it and `reopen-block-not-open` are checked before anything else is read), `reopen-config` (the run's config snapshot or `tmp.root` cannot be read), `reopen-already-recorded` (a row for this `at_seq` already names other content: run `review-file`, or the content changed since that reopen), `reopen-write-failed` (the row was not written). A missing `--file` or `--reason`, or a `--file` that is not a plain repo-root-relative path (no `..`, `./`, `//` or `/./`, and no control character), is `usage` (exit 2).
73
+ - **Exit 1 — fix, then `block reopen` (B72).** The file is fixed by hand, then the human restarts its review: `forge block reopen <id> --file <path> --run <r> --reason <text>`. It also restarts a `lost` state (step 8) on its current content. It is human-only like `block waive`: on the coder forbidden list, and run by the human in their own terminal (see **The human runs `block waive` and `block reopen`** below). A stopped file must differ from the content its stop reviewed. Every stop records that content as `stop_content_hash` in the signed state; a state written before B72, or before B114 by a fix-loop stop, has none, and then only the `review.cap` row of that stop (after the file's latest reopen, same round and reason) names it — otherwise the content is unknown. With unknown stop content, reopen accepts only when no verified `review.round` of this run, block and file names the current hash (B114). It records `from_hash: null`; the worker enforces the same check. If that hash was reviewed, change the file and reopen, or the owner can use `block close --no-require-reviews`. Waiving findings at an old hash cannot clear unreviewed current bytes. The verb first removes the done marker of the current content's ticket, so the next `review-file` runs instead of returning the cached stop. Then it writes one signed `review.reopened {block, file, from: stopped|lost, stop_reason, at_seq, from_hash, to_hash, reason, by: human}` row (`at_seq` is the file's latest `review.state` anchor seq; `from_hash` the content the stop reviewed, null for `lost` or unknown stop content; `to_hash` the current content). There is at most one row per `at_seq`: a retry on the same content finds its row and writes no second one, and other content at the same `at_seq` is refused. The worker starts a new state for a row whose MAC verifies, of this run, `by: human`, whose `at_seq` is the latest anchor seq: round 1, a full review (no fix hunks), the level from the run record, up to `review.max_rounds_per_file` rounds again. It saves at `at_seq + 1`, so a row works once. For a stop the worker checks again that the content changed; on the stop's own content the stop repeats. The block's one L3 rung stays spent when it was used, so a reopened file that reaches the cap stops at `review_cap`. The gate needs nothing new: an approval written after the `review.cap` row clears the cap. Refusals (exit 1, no row): `reopen-same-content` (a stopped file still at the content its stop reviewed, or unknown stop content whose current hash has a verified round: for unknown content, change the file and reopen, or the owner can use `block close --no-require-reviews`), `reopen-tampered` (an integrity failure stays a security event), `reopen-not-stopped` (no state, or an open or complete one), `reopen-block-not-open` (the block is stopped or closed), `reopen-not-owned`, `unknown_block` (the block lookup code `block waive` uses; it and `reopen-block-not-open` are checked before anything else is read), `reopen-config` (the run's config snapshot or `tmp.root` cannot be read), `reopen-already-recorded` (a row for this `at_seq` already names other content: run `review-file`, or the content changed since that reopen), `reopen-write-failed` (the row was not written). A missing `--file` or `--reason`, or a `--file` that is not a plain repo-root-relative path (no `..`, `./`, `//` or `/./`, and no control character), is `usage` (exit 2).
47
74
  - **Exit 2 — `review-file --answers` (B71).** A finding fixed in ANOTHER file of the block (a missing test added in the test file, say) is answered with `forge review-file <answering file> --block <id> --answers <id,id,…> --of <source> --run <r>` (1 to 10 ids, none twice; `--of` names the source because finding ids are numbered per file). A coder may run it.
48
75
  - **Refusals.** The checks run when the ticket is queued and again in the worker, whose check is the one that counts. The block must own both files (`answers-not-owned`), they must be two files (`answers-same-file`), and every id must be open on the source's CURRENT hash (`answers-not-open`). Open means named by the latest signed refusal row whose `content_hash` is the current hash, or by ANY signed `review.cap` row whose `content_hash` is the current hash, not waived or answered yet, and one the close could count as answered: never the proof finding (a proof finding is cleared by its proof, not by a reviewer) and never an id read at an unknown severity (a cap id no signed refusal at that hash records, or a value outside critical/warning/nit). A cap id from an older hash (or a cap row with no `content_hash`) is never open for an answer: the close still requires it, and the human waive exit clears it. A missing `--of`, no ids, more than 10 ids, an id given twice or a malformed id is `usage` (exit 2). The other refusals exit 1 and queue nothing. In the worker, an answering file that is gone or cannot be read is `answers-file-missing`.
49
76
  - **The review.** The ticket is its own kind (`kind: answers`); its id also covers the source, the source hash and the sorted ids, so the same request is the same ticket. The worker sends ONE fresh L2 reviewer session (lens `answers`) a packet with the findings, the source lines around each, and the answering file's diff against the block base (its full content when the base has no such file). Each file is read ONCE: those bytes are hashed and the packet is built from them, so the row's `by_hash` is the hash of what the reviewer saw. The packet file is removed after the session; a packet that cannot be built is `unavailable` (`packet-unreadable`), one that cannot be written `unavailable` (`packet-write-failed`). The reviewer answers `resolved` or `not_resolved` per id. A missing, repeated or broken verdict is `not_resolved`. A failed session (no result, not `ok`, a non-zero exit, or a spawn that throws: `session-error`) is `unavailable` (retry it; it is not a verdict) and writes no row. An answering file changed since the ticket is `stale` (`answering-file-changed`); a source changed since the ticket is `stale` (`source-changed`). All verdicts are built first, then each resolved id gets one signed `review.answered {block, file, content_hash, finding, by_file, by_hash, ticket}` row. If a write fails the ticket is `unavailable` (`ledger-write-failed`); the same request again retries the same ticket, the ids it already answered stay open for it, and it writes only the missing rows, so it ends with one row per id. `review-file --wait` prints `{answers: [{id, verdict, reason}]}`.
@@ -52,7 +79,7 @@ Per file, per block:
52
79
  - **The human runs `block waive` and `block reopen` (B80).** Both verbs ask for a yes at a terminal. The orchestrator prints the exact command, with the human's reason, and never answers the prompt; the human types the command in their own terminal. The rule: a coder or the orchestrator never runs either verb. With stdin or stdout not a terminal (a pipe, or a process started with no terminal) the waive exits 2 with `block waive needs the human at a terminal to confirm; nothing was recorded (there is no --yes)` and the reopen with `block reopen needs the human at a terminal to confirm; nothing was recorded (there is no --yes)`; neither writes a row or removes a done marker. At a terminal the verb first refuses what it would refuse anyway, before it asks: a malformed `--file` or finding id, or one with a control character (exit 2), an unknown block (`unknown_block`), for a reopen a block that is not open (`reopen-block-not-open`) or a file it does not own (`reopen-not-owned`), and for a waive a file the block does not own (`waive-not-owned`) or a finding id that no review row of the block names for that file (`waive-unknown-finding`; the `proof` finding is exempt) — exit 1, nothing asked, nothing written. A "no" or a cancel prints `block <id> <verb>: cancelled — nothing was recorded` (exit 1). A "yes" writes the same signed row as before. There is no `--yes`: it is an unknown flag (exit 2). Under an active autopilot grant both verbs still prompt the same way (only `block close --no-require-reviews` is refused); the delegate's waivers go through `forge autopilot waive` (`by: autopilot`), which needs no terminal. The prompt checks only that stdin and stdout are terminals; a pseudo-terminal passes it, so it does not prove a human (`security.md` §3).
53
80
  - **Exit 4 — re-decompose.** The human decides, and the architect plans the work again as new blocks (`decisions.md` §6, `redecompose`). Each new block reviews its files from round 1.
54
81
  - **A fully waived or answered file closes with a `gate.review_waived_all` row.** A file with no approval for its current hash counts as reviewed — `reviewed_by_waiver`, never approved — when a signed `review.round` exists for that hash (a repeated stop or an `unavailable` result never counts), the latest refusal row for the hash is signed, carries `content_hash` equal to that hash (a refusal row with no `content_hash` still refuses) and names at least one finding, and every finding it names and every open id of the file's `review.cap` rows is waived or answered. A critical waiver counts only `by: human`; an autopilot waiver counts only when `autopilotWaiverProblem` finds nothing, and only for a finding a signed refusal row at that hash records as warning or nit (or records with no severity at all). A cap id no refusal row records a severity for, or a cap row whose `open` is not a list of ids, is the human's alone. A signed, still-current answer clears its finding there, and in the late-finding and cap checks only at the severity the rows RECORD for that id (a late finding's own severity; a cap id's from the signed refusals at the current hash) — a missing or unknown one is never cleared by an answer. An answer counts only while BOTH the source and the answering file have the row's hashes (a change to either makes it stale), and it may clear a critical. Otherwise the file stays `unreviewed`. The close writes one signed `gate.review_waived_all {block, file, content_hash, findings, by}` row per such file just before `block.close`: `findings` are the sorted waived ids, `by: human` when every clearing waiver is the human's, else `autopilot`; `answered` lists the answered ids (only when there is one); with every id answered, `findings` is `[]` and `by` is null. When an id is both waived and answered, a human waiver is reported as the waiver; otherwise the answer is listed under `answered` instead of an autopilot waiver. The close line prints only the parts that apply: `block <id>: <file> reviewed_by_waiver (<ids>; by <human|autopilot>)`, `reviewed_by_waiver (W1; by human; answered C1)`, or `reviewed_by_waiver (answered C1, C2)` (ids sorted and joined by `, `). A refused close writes no such row, and a close where no file needs it writes none.
55
- - **Not exits.** `block attempt` never resets review state: it only raises the attempt counter (a coder may run it), and a stopped file stays stopped, with no session. New content without a reopen row still gets the cached stop, with no session. `review-file --fresh` (§1) re-runs only a result that never counted as a review (`stale`, `unavailable`, or `refused` with `other-run`, `context-unreadable` or `worktree-*`); on a stop it refuses `fresh-needs-reopen`. A `tampered` state (step 8) is not a stop and gets no exits: only the owner's `block close --no-require-reviews` gets past it.
82
+ - **Not exits.** `block attempt` never resets review state: it only raises the attempt counter (a coder may run it), and a stopped file stays stopped, with no session. New content without a reopen row still gets the cached stop, with no session. `review-file --fresh` (§1) re-runs only a result that never counted as a review (`stale`, `unavailable`, round-1 `split_required`, or `refused` with `other-run`, `context-unreadable` or `worktree-*`); on a recheck stop it refuses `fresh-needs-reopen`. A `tampered` state (step 8) is not a stop and gets no exits: only the owner's `block close --no-require-reviews` gets past it.
56
83
  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.
57
84
  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.
58
85
  8. **A state file that goes missing is rebuilt from the ledger.** The worker keeps each file's round, level, open and late findings and stop in a signed state file. Every save first writes a signed `review.state` anchor row, and the anchor carries the state, so a missing file is rebuilt from it (Restore, below). Only an anchor with no state leaves the file `lost` (Lost, below).
@@ -48,7 +48,7 @@ Gates, test-count deltas and red→green run in `.code-forge/export/<block>/`, n
48
48
  Round n ≥ 2 reviews the fix hunks and the open findings; the open set shrinks every round or the coder climbs; `review.max_rounds_per_file` rounds, then the L3 rung once, then `stopped: review_cap` (`review.md` §5). Why: whole-file re-checks added new defects every round.
49
49
 
50
50
  ### R16 — Proof is exact assertions on the main behaviour
51
- One exact test per clause plus a negative or boundary where the clause has a failure mode; red→green for every new test; there is no mutation tier (`proof.md` §1). Why: exhaustive suites hid the defects the isolated review found.
51
+ Red→green: each new or changed test must FAIL on the old code first (red), then PASS on the new code (green). A red caused by an import, compile or crash error proves nothing; the red must be an assertion failure. No hand mutations. One exact test per acceptance clause plus one negative or boundary where it can fail. Assertions say which element and how many. Proof scales with risk; no extras the acceptance does not ask for. Light-tier files need red→green per new or changed test; high-tier files must have it at the gate. Fix weak assertions with stronger assertions, not more tests or mutants (`proof.md` §1). Why: exhaustive suites hid the defects the isolated review found.
52
52
 
53
53
  ### R17 — Never relax a rule alone
54
54
  The orchestrator and any delegate never change, relax or remove a rule, limit, permission or check without the owner: config limits, thresholds, budgets, the forbidden list, gates, review requirements, or a local patch that loosens a check. When a limit blocks work: stop, show the data, propose options, wait. Only the owner raises a limit (the budget once per run, R6); during autopilot only the owner's own `forge autopilot approve` (`autopilot.md` §5). Why: a limit relaxed to finish the work protects nothing, and the owner learns of it too late.
@@ -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.10.0"
13
+ PINNED_VERSION="0.12.0"
14
14
  set -eu
15
15
 
16
16
  self="$0"
@@ -13,13 +13,25 @@
13
13
  2. Your facts diff: every contradiction between this brief and the code, in one message.
14
14
  3. Your forecast: cases and lines.
15
15
 
16
+ ## Proof
17
+
18
+ - Red→green: each new or changed test must FAIL on the old code first (red), then PASS on the new code (green). A red caused by an import, compile or crash error proves nothing; the red must be an assertion failure. `code-forge proof red-green` does this with the block's base code.
19
+ - No hand mutations. Do not break the code on purpose to test the tests. Red→green is the proof.
20
+ - One exact test per acceptance clause, plus one negative or boundary test where the clause can fail. Assertions say which element and how many, never only "it exists".
21
+ - Proof scales with risk: a light-tier file needs red→green per new test; a high-tier file must have it at the gate. Do not add tests, mutants, layers, options or abstractions that the acceptance does not ask for.
22
+ - A weak assertion is fixed by a stronger assertion, not by more tests or mutants.
23
+
16
24
  ## The file loop
17
25
 
18
26
  After you finish each file in `owned_files` (tests written and green) run `forge review-file <path> --block <id> --run <r>`; before the next file run `forge review-file --wait <ticket> [--max 90s]` and act on the packet. A finding whose fix belongs in another owned file (a missing test, say) is fixed there and sent with `forge review-file <that file> --block <id> --run <r> --answers <ids> --of <source>`. A `worker_down` answer ⇒ print `===BLOCK <id> FAILED: worker down===` (never approval).
19
27
 
28
+ With `review.file_loop: overlap`, enqueue after each green file and continue coding. Before the completion sentinel, run `forge review-file --wait-block <id> --run <r> [--max 90s]`. It prints one JSON line `{block, files: [{file, ticket, status}], done}`. Inspect each finished ticket with `--wait <ticket>` and act on its findings; `done: true` means finished, not approved. A timeout has `done: false`, `reason: wait_timeout` and pending files (exit 0): wait again. `not_queued` names an owned changed file you must enqueue. Changed content with an earlier ticket is queued automatically; the worker marks an old pending ticket stale when it starts it. `worker_down` exits 3, always with `done: false`: print the FAILED sentinel. Without a `review.file_loop` setting, or with `wait`, use the per-file loop above. Fix rounds use the same selected loop before their sentinel.
29
+
30
+ With `review.file_loop: watch` (B104), commit each owned file after its tests are green and continue coding. Watch replaces per-file enqueueing. Before the completion sentinel, including fix rounds, run `forge review-file --wait-block <id> --run <r> [--max 90s]`, inspect finished tickets with `--wait <ticket>`, and fix findings. The worker watches the block worktree HEAD (otherwise repository HEAD), queues owned content from git objects with the same ticket ids as a manual request, and marks older pending tickets `refused`, `reason: superseded`. A running ticket finishes for its old hash, which cannot approve newer bytes; `--wait-block` joins the current ticket per file. `--fresh` refuses a superseded marker; enqueueing that content again removes only its superseded marker so it can run, while a real result stays cached. `queue/watch/<block>.json` stores the last seen commit; missing or unreadable state restarts at `base_sha` with idempotent tickets. A rewound tick queues nothing and drops the last seen commit so the next tick restarts at `base_sha`. A `watch.skipped {block, reason}` row is written once per reason until a healthy head clears the skip reasons: `unreadable_head` (head, tree or objects cannot be read), `not_descendant` (head does not descend from the block base), `rewound` (head no longer descends from the last seen commit), or `unowned_path` (unowned paths are ignored; owned paths still queue). A skip never fails the block. After a `watch.skipped` row, use `forge review-file <path> --block <id> --run <r>` when the file needs a manual review; use `forge review-file <fix file> --block <id> --run <r> --answers <ids> --of <source>` for a fix in another owned file that answers findings. Set watch before starting the worker. With any `review.file_loop` value other than `watch` (`wait`, the default, or `overlap`), the worker does no watch work and runs no git for it.
31
+
20
32
  Proof is behaviour, not text: never read a source file in a test to assert on its text. Call the code and check what it does; a test may read a fixture. A test file that reads a source file and asserts on its text gets a `## test-quality` section in its review packet. The section is a hint, not a finding: the reviewer checks those lines and, when the test really reads source text to assert on it, raises a `warning` (unless the file read is a fixture).
21
33
 
22
- ## Final report — FAILED unless it lists the ticket ids
34
+ ## Final report — the close prints the ticket ids
23
35
 
24
36
  Run the block's test command (`test_command`) and print its result on a line of its own, exactly:
25
37
 
@@ -34,21 +46,19 @@ TESTS npm test exit=0
34
46
 
35
47
  The `TESTS` line stays ABOVE the sentinel; only the test summary comes after it.
36
48
 
37
- End with `===BLOCK <id> COMPLETE===`, the test summary and `git diff --stat`, then one line per changed file:
38
-
39
- `reviewed <path> ticket <ticket-id>`
49
+ End with `===BLOCK <id> COMPLETE===`, the test summary and `git diff --stat`.
40
50
 
41
- **FAILED unless:** the report lists the `review-file` ticket id of every changed file. A report with no ticket ids, or one that misses a changed file, counts as FAILED whatever its sentinel says: the orchestrator treats it as `===BLOCK <id> FAILED: no review-file ticket ids===` and `forge block close <id> --run <r> --report <file>` refuses it (`coder report FAILED: no review-file ticket id for <files>`). "All findings addressed" is not a report.
51
+ The final report needs no `reviewed <path> ticket <id>` lines: `block close` prints one for every file the review gate accepts (approved, waived or answered), with the queue's ticket. The close prints the reviewed lines, then the `reviewed_by_waiver` lines, then the `closed` line. With `--report`, a hand line that names another ticket prints `WARN report ticket mismatch <path>` on stderr when the block closes, and the close goes on. A file with a `review.waived` finding still gets a line; only a close whose every review the human waived, or a `review.external` close, prints none. The printed line has no `via`: `--json` and the block's done file carry the lines as `reviewed: [{file, ticket, via}]`, with `via: approved` for a file with a signed approval, `via: waiver` when at least one of its open findings was waived (also when the others were answered), and `via: answered` when every one was answered. Every changed file still needs a review through the selected file loop: a file the review did not accept keeps the block open. "All findings addressed" is not a report.
42
52
 
43
53
  Or `===BLOCK <id> FAILED: <reason>===`.
44
54
 
45
55
  ## Fix-round brief (round ≥ 2, or a respawn after a climb)
46
56
 
47
- The open findings by id, the fix history, the files they sit in. Fix only those hunks, then run `forge review-file <path> --block <id> --run <r>` and `--wait` on **every file the round changed** — a fix the reviewer never saw is not a fix. A fix made in another owned file is sent with `--answers <ids> --of <source>` (The file loop). The final report follows the same rule: one `reviewed <path> ticket <ticket-id>` line per file changed in the round, or the round counts as FAILED. Re-run the block's test command after the fixes and print a fresh `TESTS <command> exit=<n>` line just before the round's sentinel: a TESTS line from an earlier report does not count.
57
+ The open findings by id, the fix history, the files they sit in. Fix only those hunks, then follow the selected file loop on **every file the round changed** (`wait`: run `forge review-file <path> --block <id> --run <r>` and `--wait`; `overlap`: enqueue each green file and continue; `watch`: commit each green file; both `overlap` and `watch`: run `forge review-file --wait-block <id> --run <r>` before the sentinel) — a fix the reviewer never saw is not a fix. A fix made in another owned file is sent with `--answers <ids> --of <source>` (The file loop). The round's final report follows the same rule as the first: it needs no ticket lines. Re-run the block's test command after the fixes and print a fresh `TESTS <command> exit=<n>` line just before the round's sentinel: a TESTS line from an earlier report does not count. In a fix round, you may use `code-forge gates run --run <r> --block <id> --changed` as the block's test command; the TESTS line names the command actually run (substitute the run and block ids). Any green `code-forge gates run --run <r> --block <id>` records the owned file hashes on `gate.done`, including runs without `--changed`; when `--slug` is omitted, these rows go to the run's project ledger. More than 200 owned files means no record, so the next `--changed` falls back with `no_baseline`; unreadable inputs also leave no record. `{test_files}` expands to changed tests and stem-paired tests of changed sources; `{changed_files}` expands to changed lint/format paths. Deleted files still count for pairing and the threshold, but both tokens receive only files that exist now. Both tokens must be whole argv items and cannot be the command or the only item; config validation refuses a gate whose command or only item is `{changed_files}`. Without `--changed`, `{changed_files}` is dropped. Gates without tokens run as before. Rows carry `scope: changed`, including fallback rows with a `reason`: `no_baseline` uses today's scope, `unpaired` uses today's test scope when a changed source has no test pair, `threshold` runs the full suite above `gates.full_suite_threshold_files` changed files, and `no_changes` uses today's scope when nothing is left to run. The orchestrator runs the full `code-forge gates run` before `block close` (D2).
48
58
 
49
59
  ## Context pack
50
60
 
51
- <!-- the block's context pack, pasted as text: the block header (run, block, level, owned and test files, worktree), the project's build, test, lint and format commands, the rules files and the lessons file. `forge context pack --run <r> --block <id>` writes it; paste its contents, never its run-root path (`references/code.md` §3). Without a `context` key, leave this section out. -->
61
+ <!-- the block's context pack, pasted as text: the block header (run, block, level, owned and test files, worktree), the project's build, test, lint and format commands, the built-in proof rule, the rules files and the lessons file. `forge context pack --run <r> --block <id>` writes it; paste its contents, never its run-root path (`references/code.md` §3). Without a `context` key, leave this section out. -->
52
62
 
53
63
  ## Project rules
54
64