@codedology/code-forge 0.12.0 → 0.14.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 (40) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/README.md +28 -2
  3. package/docs/reference/blocks.json +354 -1
  4. package/docs/reference/config.md +4 -1
  5. package/package.json +1 -1
  6. package/schema/code-forge.schema.json +10 -1
  7. package/skill/references/adapters/claude-code.md +1 -1
  8. package/skill/references/adapters/solo.md +1 -1
  9. package/skill/references/code.md +15 -8
  10. package/skill/references/ledger.md +4 -2
  11. package/skill/references/proof.md +1 -1
  12. package/skill/references/review.md +21 -7
  13. package/skill/references/security.md +5 -3
  14. package/skill/scripts/forge +1 -1
  15. package/src/autopilot/actions.mjs +4 -5
  16. package/src/cli/block.mjs +130 -31
  17. package/src/cli/context.mjs +3 -2
  18. package/src/cli/report.mjs +2 -2
  19. package/src/cli/review-file.mjs +67 -42
  20. package/src/cli/review.mjs +2 -2
  21. package/src/context/pack.mjs +13 -9
  22. package/src/doctor/index.mjs +2 -1
  23. package/src/doctor/local.mjs +19 -0
  24. package/src/doctor/worker-probe.mjs +15 -2
  25. package/src/ledger/export.mjs +1 -1
  26. package/src/ledger/report.mjs +9 -3
  27. package/src/ledger/spend.mjs +3 -3
  28. package/src/proof/lock.mjs +5 -1
  29. package/src/review/accept-hit.mjs +78 -0
  30. package/src/review/agent-waiver.mjs +201 -0
  31. package/src/review/gate-check.mjs +62 -28
  32. package/src/review/reopen.mjs +35 -14
  33. package/src/review/schema-fallback.mjs +3 -3
  34. package/src/state/config-snapshot.mjs +1 -0
  35. package/src/util/file-locks.mjs +118 -0
  36. package/src/util/forbidden.mjs +51 -2
  37. package/src/worker/engine.mjs +2 -2
  38. package/src/worker/loop.mjs +83 -33
  39. package/src/worker/queue.mjs +141 -33
  40. package/src/worker/watch.mjs +4 -2
@@ -10,6 +10,13 @@ The coder then forecasts **cases** (tests) and **lines**. Compute the floor your
10
10
 
11
11
  ## §3 Dispatch — what the brief contains
12
12
 
13
+ Run a Codex coder with `codex exec --json` in its worktree, save stdout to a log, and pass that
14
+ log to `block close --transcript <file>`. A Codex JSON log (every non-empty line is an object
15
+ with a string `type`, first event `thread.started`) is checked by its commands: command items'
16
+ `command` fields, file-change paths as writes, and other item types whole (fail closed).
17
+ Agent messages, reasoning, command output (`aggregated_output`) and non-item events are skipped. Any other log
18
+ is checked line by line, where a mention of a forbidden command or path is a hit.
19
+
13
20
  `forge block open <id> --run <r> [--level L<n>] --owned <paths…> --acceptance <file> --brief <file> [--attempt <n>] [--base <sha>] [--kind code|docs|contract] [--tests <paths…>] --lines <n>` records the base sha, the owned files (overlap with another active block ⇒ refused; an owned entry with no glob character — `*`, `?`, `{` — owns that path and every path below it, so `src/feature` owns `src/feature/a.swift` but not `src/featureX/a.swift`, and `src/feature` overlaps `src/feature/x.swift` or `src` in another block — unless the entry is a regular file in the workspace at `block open` (or `block claim`): then it owns only itself, recorded as `owned_kinds`, and every later check reads that answer; a glob owns what it matches, and a brace entry `src/{a,b}` is a glob, so `src/a` owns nothing below it; no trailing `/`), the block kind (declared, else detected — `decisions.md` §5), the level (a docs/contract block below `levels.coder_floor_docs`, default L1, is raised to it and the CLI prints the floor), the clauses with their test ids and the forecast, writes the ledger `dispatch` row and prints the pointer `BRIEF <path> lines=<n> sha=<sha8> <<<EOM>>>` (≤ 200 B). Call it before every spawn, in every engine. `--level` may be left out only in a brief-mode run (`plan.md` §6: the rules pick the lane from the owned entries, `--lines` and `proof.tiers.high.paths`, and a signed `decision` row with `source: rules` records it) or when autopilot recorded a level; otherwise its absence is a usage error. A docs block that owns a glob (`docs/**`) must declare `--kind docs`: a glob is judged by its literal ending, so undeclared it counts as `code`. `--tests <paths…>` (same path rules as `--owned`, else `bad-tests`; exact paths only — a file or a directory; `*` and `?` are refused, and so is a path starting with `-`, both a usage error, exit 2; `[` and `{` are allowed, as in `app/[id]/page.test.tsx`) names the block's test files, stored as `test_files` on the block and the `dispatch` row; a `{test_files}` gate runs them (`proof.md` §3). Without it the owned entries that are concrete files (no glob) and look like tests are used; `--tests` is the precise way.
14
21
 
15
22
  Under `proof.isolation: worktree` (B62a, `proof.md` §3), `block open` first makes the block's own worktree. The output then has a second line, `worktree <path> · branch <branch>` (with `· reused` when a second `block open` took the earlier attempt's tree), before the pointer. Give the coder that path as its working directory. Two worktree blocks may own the same path. A refused worktree refuses the open, and nothing is recorded. A second `block open` keeps the tree's base: do not pass a different `--base`. The coder works in that tree path: it edits there, commits on the block's branch, and calls `review-file` there. Run `gates run --run <r> --block <id>` without `--cwd` (another `--cwd` is refused, exit 2). Gates, reviews and the close all measure the tree (B62b, `proof.md` §3): a change made only in the main checkout is not the block's work, and a block whose tree was deleted cannot close until the tree is restored.
@@ -18,23 +25,23 @@ Under `proof.isolation: worktree` (B62a, `proof.md` §3), `block open` first mak
18
25
 
19
26
  **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
27
 
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.
28
+ **The context pack (B63a).** The pack carries the built-in proof rule before project rules and lessons. 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 bad `context` value. Without a `context` key, the pack holds only the block header and proof rule, with a 300-line cap and no commands, rules or lessons section. 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
29
 
23
30
  The brief file carries, in this order:
24
31
 
25
32
  1. `id`, `title`, the level, `owned_files` ("edit ONLY these"), the acceptance clauses with test ids, `test_command`.
26
33
  2. The reply contract: `ACK <sha8> lines=<n>` before any tool call; the facts diff; the forecast.
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).
34
+ 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.* Each worker has one registry entry in `<repo>/.code-forge/workers/<pid>-<start_time>.json` (0700 directory, 0600 files), outside the ticket queue. `worker_down` means no registered worker is live. An unreadable or malformed entry blocks a new worker, and doctor names its path. 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
35
  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
36
  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
37
  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
38
  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
39
 
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)**.
40
+ 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 --run <r> --block <id>` before `block close` (D2). With `gates.test` set in the config in force, close warns if the block's last gate result is not a green `gate.done`, and still closes; `gates.test: null` prints no such warning. See `proof.md`, **Fix rounds (B99)**.
34
41
 
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.
42
+ **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`, `findings`, `pending` or `not_queued` (null ticket). A current file with an earlier ticket gets its changed content enqueued with the normal ticket id after any unseen findings are delivered; the old pending ticket becomes stale when the worker starts it. `done: true` means every owned changed file has a finished ticket or delivered findings, 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
43
 
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.
44
+ **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) and reads owned content from git objects. A file's first commit is queued at once with the same ticket id as a manual request. Later commits of a file that already has a ticket of this block are held until `--wait-block`. If the last review found problems and the file changed blind, the join does not recheck it. It answers `status: findings` with that ticket and records delivery once. Read the findings with `review-file --wait <ticket>`, fix them, and the next join rechecks the latest content once. A file whose last review approved still queues its latest content at the join. A stopped file is not requeued. Each held file writes one `watch.held {block, file, content_hash}` row per new head. Older pending tickets are still marked `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
45
 
39
46
  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.
40
47
 
@@ -80,12 +87,12 @@ Without the flag the tree stays. On a block without a worktree the flag is a usa
80
87
 
81
88
  **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:
82
89
 
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.
90
+ 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`. For this comparison, `block waive` means its human path. 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.
84
91
  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.
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.
92
+ 3. **`block waive`.** Use `forge block waive <id> <finding-id> --run <r> --file <path> --reason <text>` per finding. The rule below sets who may run it and when `--yes` is allowed. A file whose findings are all waived or answered closes with a signed `gate.review_waived_all` row.
86
93
  4. **Re-decompose.** The human decides, and the architect plans new blocks.
87
94
 
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).
95
+ **`block waive` and `block reopen` (B80).** The orchestrator prints the command for the human paths of `block reopen` and `block waive`. The orchestrator may run `block reopen --yes` with evidence and agreement from a fresh judge, at most once per block and file in a run. The orchestrator may run `block waive --yes` only for a recorded warning or nit finding with evidence and agreement from a fresh judge. A coder never runs either verb. The orchestrator never runs the human paths and never answers their prompt (not through a pseudo-terminal either); the human runs them in their own terminal. The human paths ask for a yes at a terminal. Without a terminal they exit 2 and record nothing. For `block reopen` without `--yes`, the message is `block reopen needs the human at a terminal to confirm; nothing was recorded (--yes works only with evidence and agreement from a fresh judge)`. Without `--yes` and with no terminal, `block waive` prints `block waive needs the human at a terminal to confirm; nothing was recorded (--yes works only for a recorded warning or nit finding, with evidence)`. An autopilot grant does not change the human paths. 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).
89
96
 
90
97
  `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
98
 
@@ -4,11 +4,13 @@
4
4
 
5
5
  Every row has the run, the block, the event and a timestamp; the ones the block gate relies on carry `mac` (`security.md` §3). Useful fields: `level`, `provider`, `model`, `effort`, `fallback_step` (every spawn), `role ∈ {coder, reviewer, judge, s2, author, facts}`, `trigger` (escalations), `source ∈ {jev, rules, s2, s2-fallback, shadow}` (decisions), `depth_unconstrained` / `depth_chosen` / `degrade_step` and `context_mode ∈ {whole, hunks, minimal, recheck}` (reviews), `round` and `kind ∈ {full, recheck, patch_check, sweep}` (rounds), `isolation ∈ {export, lock}` (proof), `l3_patch`, `l3_fallback`, `overspend`. **Tokens are facts, dollars are estimates**: `tokens_source: reported` when the CLI's JSON carries usage, else `estimated`. File paths in rows are relative to the repository root.
6
6
 
7
- Events you will read most: `run.start`, `run.mode {mode: brief, plan: none}` (a brief-mode run, `plan.md` §6), `review.external {required, reviewer}` (the owner's outside reviewer, `review.md`), `run.stop {reason}`, `run.end`, `dispatch` (`test_files` when `block open --tests` named them), `gate.red` and `gate.done` (with `scope: related` + `test_count` or `scope: full` + `reason: threshold | no_tests` when a gate has the `{test_files}` item, `no_tests` also without `--run/--block`; with `--run/--block` they name `run` and `block`, and for a block with a worktree also `worktree: <path>` (the gates ran there); a gate with env names only `env_keys` in its result, `proof.md` §3), `review.plan`, `review.round`, `review.done`, `review.approved`, `review.unavailable {reason}`, `review.late_finding`, `review.waived {by: human}` (the human's `block waive`, run in their own terminal after a yes, B80; no row on a "no", a cancel, no terminal, `--yes` (a usage error), or a pre-check refusal (a malformed or control-character finding id or `--file`, an unknown block, a file the block does not own, an unknown finding). 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).), `review.state {block, file, seq, state_sha, l3_rung_used, state_body}` (signed; the fix-loop state anchor, `review.md` §5 step 8; `state_body` carries `reviewed_content_sha` and `reviewed_content_blob`, the git blob id of the reviewed text in the reviewed tree's object store, or null when there was no reviewed text or no content root, or the write failed; when the signed body is over 64 KB, `state_omitted: "too_large"` replaces `state_body`, and a lost state file then refuses as `review-state-lost`), `review.state_restored {block, file, seq, from_seq}` (signed; a lost state file was rebuilt from the anchor at `from_seq` and saved as `seq`), `review.reopened {block, file, from: stopped | lost, stop_reason, at_seq, from_hash, to_hash, reason, by: human}` (signed; B72: the human's `block reopen` of a stopped or lost file, run in their own terminal after a yes (B80; no row on a "no", a cancel, no terminal, `--yes` (a usage error), or a pre-check refusal (a malformed or control-character `--file`, an unknown or not-open block, a file the block does not own)) — `at_seq` the file's latest `review.state` anchor seq, `from_hash` the content the stop reviewed (null for `lost`), `to_hash` the current content, `stop_reason` null for `lost`; at most one row per `at_seq`, the verb refuses a second one for other content as `reopen-already-recorded`; the worker starts a fresh round-1 state for it once, saved at `at_seq + 1` — for `from: stopped` only when the current content still differs from the stop's content (the state's `stop_content_hash`) and from `from_hash`, else the stop repeats; round 1 is a full review (`reviewed_content` null) and `l3_rung_used` is kept, so the file gets up to `review.max_rounds_per_file` rounds at L2 again; the worker ignores a row that does not verify, of another run, not `by: human` or at another seq), `review.answered {block, file, content_hash, finding, by_file, by_hash, ticket}` (signed; B71: an answers ticket's reviewer found that `by_file` at `by_hash` resolves the open finding `finding` of `file` at `content_hash` — exactly one row per (finding, `by_hash`, ticket): a retried ticket writes no duplicate, only the rows a failed write left out; the close counts it only while both hashes are current, and ignores a row without a valid run-key MAC or from another run; an answer may clear a critical and never approves `by_file`, `review.md` §5 step 5), `gate.reviews_waived {by: human | config}` (`by: config` from `review.external`), `gate.review_waived_all {block, file, content_hash, findings, answered?, by: human | autopilot | null}` (signed; a file with no approval closed as `reviewed_by_waiver`: a signed `review.round` at its current hash, and every finding of the latest hash-bound refusal and every open `review.cap` id cleared by an accepted waiver or a still-current signed `review.answered` row (B71) — a file cleared only by answers is `reviewed_by_waiver` too. `findings` are the sorted WAIVED ids and `answered` (B71, only when there is one) the sorted ids `review.answered` rows cleared; `by: human` only when every clearing waiver is by a human, else `autopilot`, and `by: null` only when every id was answered (`findings: []`) — a reader must handle null. One row per such file just before `block.close`, none on a refused close, `review.md` §5 step 5), `coder.tests {block, command, exit}` (signed; under `context.require_coder_tests` the close read the coder's `TESTS <command> exit=0` line from `--report`, written with the close rows just before `block.close` and only when the block closes, `code.md` §3), `decision {question: lane, source: rules}` (a brief-mode `block open` without `--level`), `worktree.created {block, path, branch, base}` (`block open` under `proof.isolation: worktree` made the block's tree, written before `dispatch`; a re-open that reuses it writes none), `worktree.removed {block, path, branch}` and `worktree.remove_failed {block, path, reason}` (after `block close --remove-worktree`; the branch is always kept, and on a failure the tree stays, `proof.md` §3), `context.pack {block, path, sha256, tokens, lines, cut}` (signed; `forge context pack` wrote a new pack for the block — the same pack again, same sha256 as the block's last row, writes none; `code.md` §3), `hook.ran {block, hook: on_block_end, argv0, exit, signal, timed_out, duration_ms}` (signed; one row per block end with `hooks.on_block_end` set — also when the hook fails, times out or is not found; none when the key is absent; `argv0` is the basename only. Exited: `exit` its code, `signal` null. Killed by a signal: `exit` null, `signal` its name. Timed out: `exit` null, `signal` `SIGKILL`, `timed_out` true. Not found (argv[0] ENOENT) or any other spawn error (a missing cwd, EACCES): `exit` null, `signal` null, `timed_out` false; `code.md` §3), `outside_review {run, pr, rounds, findings, link (or null), blocks}` (signed; `forge ledger outside-review`, §4 — the latest row per PR wins in every reader), `review.budget`, `forecast.cases_exceeded`, `proof`, `outcome`, `block.rebase`, `block.orphan`, `ledger.tamper`, `worker.replaced`, `facts.built`, `tmp.swept`.
7
+ Events you will read most: `run.start`, `run.mode {mode: brief, plan: none}` (a brief-mode run, `plan.md` §6), `review.external {required, reviewer}` (the owner's outside reviewer, `review.md`), `run.stop {reason}`, `run.end`, `dispatch` (`test_files` when `block open --tests` named them), `gate.red` and `gate.done` (with `scope: related` + `test_count` or `scope: full` + `reason: threshold | no_tests` when a gate has the `{test_files}` item, `no_tests` also without `--run/--block`; with `--run/--block` they name `run` and `block`, and for a block with a worktree also `worktree: <path>` (the gates ran there); a gate with env names only `env_keys` in its result, `proof.md` §3), `review.plan`, `review.round`, `review.done`, `review.approved`, `review.unavailable {reason}`, `review.late_finding`, `review.waived {by: human | agent}` (`block waive` writes no row on a "no", a cancel, no terminal without `--yes`, a `--yes` refusal, or a pre-check refusal. The `--yes` refusal codes are `waive-yes-critical`, `waive-yes-no-evidence`, and `waive-yes-disagreed`. Pre-checks reject a malformed or control-character finding id or `--file`, an unknown block, an unowned file, or an unknown finding. An agreed `block waive --yes` writes one signed `review.waived` row with `by: agent`. The human path writes `by: human` after a yes in their own terminal (B80). 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).), `review.state {block, file, seq, state_sha, l3_rung_used, state_body}` (signed; the fix-loop state anchor, `review.md` §5 step 8; `state_body` carries `reviewed_content_sha` and `reviewed_content_blob`, the git blob id of the reviewed text in the reviewed tree's object store, or null when there was no reviewed text or no content root, or the write failed; when the signed body is over 64 KB, `state_omitted: "too_large"` replaces `state_body`, and a lost state file then refuses as `review-state-lost`), `review.state_restored {block, file, seq, from_seq}` (signed; a lost state file was rebuilt from the anchor at `from_seq` and saved as `seq`), `review.reopened {block, file, from: stopped | lost, stop_reason, at_seq, from_hash, to_hash, reason, by: human | agent}` (signed; a reopen of a stopped or lost file. An agreed `block reopen --yes` writes one signed `review.reopened` row with `by: agent`, the reason and `verdict: {verdict, reason}`. It needs evidence and a fresh judge, at most once per block and file in a run. The refusal codes are `reopen-yes-no-evidence`, `reopen-yes-limit` and `reopen-yes-disagreed` (exit 1, no row). Without `--yes` the human confirms at a terminal; no terminal, no, cancel or a pre-check refusal writes nothing — `at_seq` the file's latest `review.state` anchor seq, `from_hash` the content the stop reviewed (null for `lost`), `to_hash` the current content, `stop_reason` null for `lost`; at most one row per `at_seq`, the verb refuses a second one for other content as `reopen-already-recorded`; the worker starts a fresh round-1 state for it once, saved at `at_seq + 1` — for `from: stopped` only when the current content still differs from the stop's content (the state's `stop_content_hash`) and from `from_hash`, else the stop repeats; round 1 is a full review (`reviewed_content` null) and `l3_rung_used` is kept, so the file gets up to `review.max_rounds_per_file` rounds at L2 again; the worker ignores a row that does not verify, of another run, neither `by: human` nor `by: agent`, or at another seq), `review.answered {block, file, content_hash, finding, by_file, by_hash, ticket}` (signed; B71: an answers ticket's reviewer found that `by_file` at `by_hash` resolves the open finding `finding` of `file` at `content_hash` — exactly one row per (finding, `by_hash`, ticket): a retried ticket writes no duplicate, only the rows a failed write left out; the close counts it only while both hashes are current, and ignores a row without a valid run-key MAC or from another run; an answer may clear a critical and never approves `by_file`, `review.md` §5 step 5), `gate.reviews_waived {by: human | config}` (`by: config` from `review.external`), `gate.review_waived_all {block, file, content_hash, findings, answered?, by: human | autopilot | agent | null}` (signed; a file with no approval closed as `reviewed_by_waiver`: a signed `review.round` at its current hash, and every finding of the latest hash-bound refusal and every open `review.cap` id cleared by an accepted waiver or a still-current signed `review.answered` row (B71) — a file cleared only by answers is `reviewed_by_waiver` too. `findings` are the sorted WAIVED ids and `answered` (B71, only when there is one) the sorted ids `review.answered` rows cleared; `by: human` means every clearing waiver is human. Otherwise, any agent waiver sets `by: agent`; the rest use `by: autopilot`. Only answers set `by: null` (`findings: []`); a reader must handle null. One row per such file just before `block.close`, none on a refused close, `review.md` §5 step 5), `coder.tests {block, command, exit}` (signed; under `context.require_coder_tests` the close read the coder's `TESTS <command> exit=0` line from `--report`, written with the close rows just before `block.close` and only when the block closes, `code.md` §3), `decision {question: lane, source: rules}` (a brief-mode `block open` without `--level`), `worktree.created {block, path, branch, base}` (`block open` under `proof.isolation: worktree` made the block's tree, written before `dispatch`; a re-open that reuses it writes none), `worktree.removed {block, path, branch}` and `worktree.remove_failed {block, path, reason}` (after `block close --remove-worktree`; the branch is always kept, and on a failure the tree stays, `proof.md` §3), `context.pack {block, path, sha256, tokens, lines, cut}` (signed; `forge context pack` wrote a new pack for the block — the same pack again, same sha256 as the block's last row, writes none; `code.md` §3), `hook.ran {block, hook: on_block_end, argv0, exit, signal, timed_out, duration_ms}` (signed; one row per block end with `hooks.on_block_end` set — also when the hook fails, times out or is not found; none when the key is absent; `argv0` is the basename only. Exited: `exit` its code, `signal` null. Killed by a signal: `exit` null, `signal` its name. Timed out: `exit` null, `signal` `SIGKILL`, `timed_out` true. Not found (argv[0] ENOENT) or any other spawn error (a missing cwd, EACCES): `exit` null, `signal` null, `timed_out` false; `code.md` §3), `outside_review {run, pr, rounds, findings, link (or null), blocks}` (signed; `forge ledger outside-review`, §4 — the latest row per PR wins in every reader), `review.budget`, `forecast.cases_exceeded`, `proof`, `outcome`, `block.rebase`, `block.orphan`, `ledger.tamper`, `worker.replaced`, `facts.built`, `tmp.swept`.
8
8
 
9
9
  ## §2 `forge report --slug <slug> [--json] [--export <dir>]`
10
10
 
11
- Terminal output in this version. Sections: cost per block — completed and still open, with `completed` and the count of unknown-price sessions (per level and lane, split review / coder / S1+S2 / facts / proof time) · lane distribution · escalations per block with `trigger` · S1 calls per block · S2 rate and overrule rate per question · the review budget table with `missed_after_degrade` per degrade step · review depth and context-mode distribution, findings by category and severity · fix rounds per file (one block round is one round for each file it touched, `review.md` §5), rounds that stalled, files stopped at `review_cap` · `review.unavailable` by reason · proof time per block · forecast vs actual lines and cases with the measured lines-per-case · blocks stopped at L3 · `overspend` and `l3_fallback` counts · runs stopped for `no_engine` · `outside_review` (14th, last): per PR sorted by number, `{pr, rounds, findings, link, blocks, code_forge_usd, unknown_usd_sessions}` — `code_forge_usd` sums the `cost_per_block` totals of the PR's blocks in its run, rounded as `cost_per_block` rounds; unknown-price sessions count 0 and are counted; a PR with no blocks shows 0 and 0 unknown; `[]` with no row; in the terminal the lines print under `== outside_review ==`: `outside review: none`, or one line per PR (`PR #12 · 3 outside rounds · 7 findings · code-forge $3.75 (+1 unknown) · B1, B2`). `--export <dir>` writes one JSON file per section — the durable dataset a future UI reads. After the sections the terminal output prints the spend per run (open blocks included) and the total. `report` checks no row's signature in any section (an unsigned row counts like a signed one); shadow rows (`source: shadow`) are excluded from every section.
11
+ `review.unwaived {block, file, finding, reason, by: human}` is a signed human undo of earlier agent waivers. `agent_waivers.acked {block, digest, count, by: human}` is a signed human acknowledgement of the current counted set. The gate verifies both rows. The digest is SHA-256 of the sorted file, finding and row signature set. New or removed waivers need a new acknowledgement when the run snapshot requires one.
12
+
13
+ Terminal output in this version. Sections: cost per block — completed and still open, with `completed` and the count of unknown-price sessions (per level and lane, split review / coder / S1+S2 / facts / proof time) · lane distribution · escalations per block with `trigger` · S1 calls per block · S2 rate and overrule rate per question · the review budget table with `missed_after_degrade` per degrade step · review depth and context-mode distribution, findings by category and severity · fix rounds per file (one block round is one round for each file it touched, `review.md` §5), rounds that stalled, files stopped at `review_cap` · `review.unavailable` by reason · proof time per block · forecast vs actual lines and cases with the measured lines-per-case · blocks stopped at L3 · `overspend` and `l3_fallback` counts · runs stopped for `no_engine` · `outside_review` (14th): per PR sorted by number, `{pr, rounds, findings, link, blocks, code_forge_usd, unknown_usd_sessions}` — `code_forge_usd` sums the `cost_per_block` totals of the PR's blocks in its run, rounded as `cost_per_block` rounds; unknown-price sessions count 0 and are counted; a PR with no blocks shows 0 and 0 unknown; `[]` with no row; in the terminal the lines print under `== outside_review ==`: `outside review: none`, or one line per PR (`PR #12 · 3 outside rounds · 7 findings · code-forge $3.75 (+1 unknown) · B1, B2`). `agent_waivers` is the 15th and last section. It lists every agent waiver as `{block, file, finding, severity, reason, verdict, unwaived}`. A later human unwaive marks earlier matching rows as `unwaived: true`. A later agent waiver is a separate entry. An empty run gives `[]`. `--export <dir>` writes one JSON file per section — the durable dataset a future UI reads. After the sections the terminal output prints the spend per run (open blocks included) and the total. `report` checks no row's signature in any section (an unsigned row counts like a signed one); shadow rows (`source: shadow`) are excluded from every section.
12
14
 
13
15
  ## §3 Live views
14
16
 
@@ -9,7 +9,7 @@ Tier per file = `high` when `risk ≥ 2`, or a `proof.tiers.high.paths` match, o
9
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
- **`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).
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. The proof waiver has unknown severity and no `--yes` path. The orchestrator prints the exact command and the human runs it in their own terminal (`review.md` §5).
13
13
 
14
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
15
 
@@ -4,7 +4,7 @@
4
4
 
5
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
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.
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) and reads owned content from git objects. A file's first commit is queued at once with the same ticket id as a manual request. Later commits of a file that already has a ticket of this block are held until `--wait-block`. If the last review found problems and the file changed blind, the join does not recheck it. It answers `status: findings` with that ticket and records delivery once. Read the findings with `review-file --wait <ticket>`, fix them, and the next join rechecks the latest content once. A file whose last review approved still queues its latest content at the join. A stopped file is not requeued. Each held file writes one `watch.held {block, file, content_hash}` row per new head. Older pending tickets are still marked `refused`, `reason: superseded`. A running ticket finishes for its old hash, which cannot approve newer bytes; `--wait-block` joins the current ticket per file. The join reruns a stale, unavailable or round-1 `split_required` review of the current content once. The file stays `pending` until that rerun finishes. No manual `--fresh` is needed. The retry is recorded per ticket across joins. If the rerun is stale again, later joins report `done`; `--wait <ticket>` shows that stale status. `--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
8
 
9
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`.
10
10
 
@@ -70,15 +70,22 @@ Only real rechecks count toward the round cap, per-level ladder and stall rules
70
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.
71
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.
72
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):
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).
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. Without `--yes` it needs human confirmation; see **Human confirmation** below. `block reopen` and `block waive` both have evidence-backed agent paths through `--yes` with a fresh judge. 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 | agent}` 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` or `by: agent`, 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`. After a verified human or agreed agent reopen and a fresh signed review of the current bytes, the old cap ids are cleared. The fresh review’s own findings still count. An approval written after the `review.cap` row also 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).
74
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.
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`.
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 a human waiver unless the cap row is superseded. A verified human or agreed agent `review.reopened` row after the cap and a signed `review.round` of the current hash after the reopen supersede it. An agent row counts only with `verdict: {verdict: agree, reason}`. 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`.
76
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}]}`.
77
77
  - **Never an approval.** The answering file still needs its own `review.approved`, and the source's fix-loop state is unchanged.
78
- - **Exit 3 — `block waive`.** The human waives each open finding: `forge block waive <id> <finding-id> --run <r> --file <path> --reason <text>` (a signed `review.waived` row, `by: human`). The coder never sees a `waive` verb; it is on the forbidden list. The human runs it in their own terminal (next paragraph). **Autopilot:** under a grant, a warning or nit finding may be waived by the delegate (`forge autopilot waive … --decision <id>`, written `by: autopilot`) and a file at the cap may get one extra round (`forge autopilot round`); a critical or proof waiver is never delegated (`autopilot.md`).
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).
78
+ - **Exit 3 — `block waive`.** The human waives each open finding: `forge block waive <id> <finding-id> --run <r> --file <path> --reason <text>` (a signed `review.waived` row, `by: human`). The coder never sees a `waive` verb; it is on the forbidden list. For the human path, see **Human confirmation** below. **Autopilot:** under a grant, a warning or nit finding may be waived by the delegate (`forge autopilot waive … --decision <id>`, written `by: autopilot`) and a file at the cap may get one extra round (`forge autopilot round`); a critical or proof waiver is never delegated (`autopilot.md`).
79
+ - **Undo and acknowledge agent waivers.** The human runs `forge block unwaive <id> <finding-id> --run <r> --file <path> --reason <text>` to reopen a finding. It requires a verified agent waiver for an owned file. Unknown blocks, unowned files and missing verified agent waivers refuse with `unwaive-no-agent-waiver` (exit 1) before any prompt. A yes writes one signed `review.unwaived {block, file, finding, reason, by: human}` row. It cancels all earlier agent waivers for that finding and file in that block. Human waivers remain in force. A later agreed agent waiver counts again. Unsigned or tampered unwaives never cancel a waiver at the gate.
80
+ Set `review.agent_waivers.require_ack: true` before run start to require human acknowledgement before close. The key is read only from the run snapshot and cannot change through run reload. The human runs `forge block ack-waivers <id> --run <r>`. It prints each counted waiver's file, finding, reason and verdict before asking. No counted waivers refuses with `ack-waivers-none` (exit 1), without a prompt. A yes writes one signed `agent_waivers.acked {block, digest, count, by: human}` row. The digest hashes the sorted file, finding and signed row identity set. A new or removed waiver changes it. Close refuses with `agent-waivers-not-acked` until a verified human ack matches the current set. With the key absent or false, close is unchanged.
81
+ Both verbs are human-only and coder-forbidden. They ask at a terminal and have no `--yes`. No terminal exits 2. A no or cancel exits 1. These paths record nothing.
82
+ - **Agent waivers.** Use `forge block waive <id> <finding-id> --run <r> --file <path> --reason <text> --yes`. The orchestrator may run `block waive --yes` only for a recorded warning or nit finding, with evidence, and a fresh judge must agree. Critical, unknown and unrecorded findings refuse with `waive-yes-critical` (exit 1). The reason must cite an existing repo-relative path with `:<line>`, an existing test file, or a numbered acceptance clause of this block. The path must exist in the block's tree. A clause number must be within its acceptance clause count. Missing evidence refuses with `waive-yes-no-evidence` (exit 1), before the judge runs. One fresh L3 judge gets the recorded finding, reason and current file bytes. It never resumes the file's reviewer session. An `agree` writes one signed `review.waived` row with `by: agent`, the recorded severity, reason, `verdict: agree` and `judge_reason`. A disagreement or broken answer refuses with `waive-yes-disagreed` (exit 1). The judge's reason is printed. No refusal writes a waiver. The finding stays open.
83
+ - **Agent reopens.** Use `block reopen <id> --run <r> --file <path> --reason <text> --yes`. It runs without a terminal or prompt. Every ordinary reopen check runs first. Evidence uses the same rule as agent waivers: an existing repo path with `:line`, an existing test file, or a numbered acceptance clause. No evidence refuses `reopen-yes-no-evidence` (exit 1). Only one verified agent reopen per block and file is allowed in a run; another refuses `reopen-yes-limit` (exit 1). Neither refusal calls the judge. One fresh L3 judge gets the stop reason, last findings, reason and current file bytes. A disagreement or invalid answer refuses `reopen-yes-disagreed` (exit 1) and prints the judge reason. The file stays stopped and nothing is written. Agreement uses the same done-marker removal and signed reopen row as the human path, with `by: agent` and `verdict: {verdict, reason}`. There is no severity restriction. The human path remains available after the agent limit. Close prints `agent reopen <file>: <reason> (verdict: agree)` for each agent reopen. Its JSON adds `agent_reopens: [{file, reason, verdict}]` only when nonempty.
84
+ - **Human confirmation.** Without `--yes`, `block waive` keeps its human confirmation and writes `by: human`. Critical and unknown-severity findings need the human terminal path. With no terminal it exits 2: `block waive needs the human at a terminal to confirm; nothing was recorded (--yes works only for a recorded warning or nit finding, with evidence)`. `block reopen --yes` needs evidence and agreement from a fresh judge, at most once per block and file in a run. With no terminal it exits 2: `block reopen needs the human at a terminal to confirm; nothing was recorded (--yes works only with evidence and agreement from a fresh judge)`. Without `--yes`, the orchestrator prints the exact command with the human's reason; the human runs it in their own terminal. The orchestrator never answers a human confirmation prompt. A coder never runs either verb. Both verbs validate the file and block before asking. A waive also checks ownership and that the finding is recorded. A no or cancel records nothing. Under an active autopilot grant these human paths still prompt. The delegate's existing `autopilot waive` path still writes `by: autopilot`. 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).
85
+ - **False transcript hits.** The human runs `forge block accept-hit <id> --run <r> --rule <rule-id> --line <n> --transcript <path> --reason <text>` in their terminal. It uses the same human confirmation and has no `--yes`. It checks the run, block, readable transcript and scanned rule/line pair before asking. A missing hit refuses `accept-hit-no-such-hit` (exit 1). Bad forms are exit 2. No terminal is exit 2. A no or cancel writes nothing. A yes writes one signed `rule_break.accepted {block, rule, line, line_sha256, reason, by: human}` row. Close matches its run, block, rule and exact line content hash, even if the line moves in a joined log. Other hits and ledger `rule_break` rows still refuse. Close prints each acceptance it used with the current line and reason. Its JSON adds `accepted_hits: [{rule, line, reason}]`. A coder never runs this verb.
86
+ - **Agent waivers at close.** A signed `by: agent` waiver with `verdict: agree` clears a warning or nit finding for `reviewed_by_waiver`. Unsigned or tampered rows never count. Its `gate.review_waived_all` row uses `by: agent`. `block close` prints one line per signed agent waiver with file, finding, reason and verdict. `--json` adds `agent_waivers: [{file, finding, severity, reason, verdict}]` when there are agent waivers.
80
87
  - **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.
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.
88
+ - **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 unsuperseded `review.cap` rows is waived or answered. A cap row adds no ids when a verified human or agreed agent `review.reopened` row follows it and a signed `review.round` of the current hash follows the reopen. 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 `agent` when an agent waiver cleared a finding, 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|agent>)`, `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.
82
89
  - **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.
83
90
  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.
84
91
  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.
@@ -98,11 +105,18 @@ Two L2 reviewers, one per effective provider (`resolve(L2)` and `review.second_l
98
105
 
99
106
  ## §7 The review budget per block
100
107
 
108
+ Ticket claims and locks for files, blocks and the budget row now hold across processes.
109
+ They live in `~/.code-forge/locks`. A dead owner's lock is broken using its pid and process
110
+ start time, comparing its bytes again under a per-key break mutex before removal.
111
+ An unreadable or malformed lock blocks its key until a human removes it.
112
+ `code-forge doctor` names each bad lock by path.
113
+ A break mutex left by a crashed process blocks its key until a human removes it, and doctor names it.
114
+
101
115
  `review.block_budget_tokens` (and optionally `review.block_budget_usd`) bounds every review, recheck, judge and S2 session made for the block. When the forecast exceeds it the worker degrades one step at a time, cheapest first: batch small files of one tier into one packet (`review.batch_small_files`) → one judge per block instead of per file → standard band `full` → `quick` → high tier `A + B + judge` → one `full` L2 + judge, the floor. Tools-only is never a review; a light `quick` stays `quick`. Still over ⇒ `review.over_budget`: raise the budget once per run (a config edit by the human, on record) or split the block — never silently, never below the floors. **Autopilot:** a budget raise is never delegated; a paused category waits for the owner (`autopilot.md` §5). The ledger keeps `depth_unconstrained` and `depth_chosen` per file so `forge report` can show `missed_after_degrade`.
102
116
 
103
117
  ## §7a Parallel tickets
104
118
 
105
- The worker runs `review.parallel_tickets` (1–16, default 3) tickets at once, oldest first; a finished ticket frees its slot for the next pending one. It never reviews the same (block, file) twice at once: a ticket for a file that is already in review stays pending without taking a slot. The budget row and the block's one L3 rung are taken under the **block lock**, so two files of one block cannot both take the rung. Each session also takes a provider slot (`review.provider_concurrency`, defaults anthropic 4 · openai 2 · xai 2) and reserves its estimated cost under a lock (`budget.usd`). A rate-limited session backs off about 2 s, then 6 s, before retrying the same step. A throw in one ticket is that ticket's `unavailable`; on stop, running tickets finish. `1` is the serial worker. The orchestrator needs no change: it still calls `review-file` per file and waits on the tickets.
119
+ The worker runs `review.parallel_tickets` (1–16, default 3) tickets at once, oldest first; a finished ticket frees its slot for the next pending one. More parallel tickets raise the spend rate. With `review.fair_share: true` (default false), each free slot goes to the block with the fewest running tickets, with oldest first for ties. It never reviews the same (block, file) twice at once: a ticket for a file that is already in review stays pending without taking a slot. The budget row and the block's one L3 rung are taken under the **block lock**, so two files of one block cannot both take the rung. Each session also takes a provider slot (`review.provider_concurrency`, defaults anthropic 4 · openai 2 · xai 2) and reserves its estimated cost under a lock (`budget.usd`). A rate-limited session backs off about 2 s, then 6 s, before retrying the same step. A throw in one ticket is that ticket's `unavailable`; on stop, running tickets finish. `1` is the serial worker. The orchestrator needs no change: it still calls `review-file` per file and waits on the tickets.
106
120
 
107
121
  ## §8 Review only
108
122
 
@@ -6,15 +6,17 @@
6
6
 
7
7
  ## §2 The forbidden list — one list, rendered per CLI at spawn, grepped from every transcript
8
8
 
9
- `gh pr ready|merge|close` and base retargets · `git push --force*` · `git reset --hard` · `git checkout --` · `git restore` · `git clean` · `git stash` · `git branch -D` · `git rm` · `rm -rf` outside `.code-forge/` · any argv containing a `production.markers` token or a `production.names` name · any read or write of `~/.code-forge/runs/` · `forge worker`, `run start`, `run reload`, `block waive` and `block reopen` from a coder (also via `npx`) · `--remove-worktree` from a coder (B62a) · any write under `.code-forge/reviews/` or `.code-forge/queue/*.done`.
9
+ `gh pr ready|merge|close` and base retargets · `git push --force*` · `git reset --hard` · `git checkout --` · `git restore` · `git clean` · `git stash` · `git branch -D` · `git rm` · `rm -rf` outside `.code-forge/` · any argv containing a `production.markers` token or a `production.names` name · any read or write of `~/.code-forge/runs/` · `forge worker`, `run start`, `run reload`, `block waive`, `block reopen`, `block accept-hit`, `block unwaive` and `block ack-waivers` from a coder (also via `npx`) · `--remove-worktree` from a coder (B62a) · any write under `.code-forge/reviews/` or `.code-forge/queue/*.done`.
10
10
 
11
- Rendered into every coder spawn as the CLI's own deny rules (path rules widen a directory to `<dir>/**`; a read-write path denies read, edit and write); carried verbatim in the brief; refused by the package's own process spawner; and at block close the transcript (captured stdout, the harness log, or the Solo output) is grepped for the forbidden verbs **and paths** — a hit is a `rule_break` row that fails the gate. Where a CLI cannot load a rules file, `doctor` says so and the transcript grep remains the detector.
11
+ Rendered into every coder spawn as the CLI's own deny rules (path rules widen a directory to `<dir>/**`; a read-write path denies read, edit and write); carried verbatim in the brief; refused by the package's own process spawner; and at block close the transcript (captured stdout, the harness log, or the Solo output) is grepped for the forbidden verbs **and paths**. A hit fails the gate as `rule_break`. Only the human can accept a false hit with `block accept-hit`, after terminal confirmation. That verb is also coder-forbidden. It writes one signed `rule_break.accepted` row for the run, block, rule and SHA-256 of the exact transcript line text. Close verifies the signature and matches by line content, so moving the line does not invalidate it. Unsigned, tampered or other-run or other-block rows never count. A ledger `rule_break` row still refuses. Where a CLI cannot load a rules file, `doctor` says so and the transcript grep remains the detector.
12
12
 
13
13
  ## §3 Signed ledger rows — the threat model, stated honestly
14
14
 
15
+ `block unwaive` and `block ack-waivers` are human-only and coder-forbidden with both `forge` and `code-forge` prefixes. Both require a yes at a terminal and have no `--yes`. No terminal exits 2 with no row. A no, cancel or pre-check refusal writes nothing. Only verified human undo and acknowledgement rows count at close.
16
+
15
17
  **Mechanism.** A per-run HMAC key (32 random bytes) lives in `~/.code-forge/runs/<run>.key`, mode 0600, outside the workspace; `forge run start` generates it, the worker and `forge block close` read it, and it is never placed in a coder's environment or workspace. Gate-relevant rows carry `mac`; a verification failure is a `ledger.tamper` row and the block is `stopped`. Key rotation = a new run.
16
18
 
17
- **What it defends against.** A coder that never called `review-file` and appends or edits rows or result files with its ordinary tools — no valid MAC, refused. A coder that edits a review result after the worker wrote it — the MAC no longer matches. A stale approval reused after the file changed — rows are keyed by content hash. A coder that starts its own worker to sign rows — the pinned pid and start time in the run record no longer match, `worker.replaced`, block stopped. A coder that waives its own finding — `block waive` is forbidden and the row requires `by: human` signed by the worker's key. An agent that runs `block waive` or `block reopen` where stdin or stdout is not a terminal (a pipe, or a process started with no terminal) — both ask for a yes at a terminal, so they exit 2 with `block <verb> needs the human at a terminal to confirm; nothing was recorded (there is no --yes)` and record nothing (B80). The orchestrator prints the exact command and never answers the prompt; the human types the command in their own terminal. There is no `--yes`. Under an active autopilot grant `block waive` and `block reopen` still prompt the same way; only `block close --no-require-reviews` is refused under a grant. Autopilot's own waive path, `forge autopilot waive` (written `by: autopilot`), needs no terminal.
19
+ **What it defends against.** A coder that never called `review-file` and appends or edits rows or result files with its ordinary tools — no valid MAC, refused. A coder that edits a review result after the worker wrote it — the MAC no longer matches. A stale approval reused after the file changed — rows are keyed by content hash. A coder that starts its own worker to sign rows — the pinned pid and start time in the run record no longer match, `worker.replaced`, block stopped. A coder that waives its own finding — `block waive` is forbidden. Waivers need a valid signature from the run key. The orchestrator prints the command for the human paths of `block reopen` and `block waive`. The orchestrator may run `block reopen --yes` with evidence and agreement from a fresh judge, at most once per block and file in a run. The orchestrator may run `block waive --yes` only for a recorded warning or nit finding with evidence and agreement from a fresh judge. A coder never runs either verb. The human paths ask for a yes at a terminal. Without a terminal they exit 2 and record nothing (B80). For `block reopen` without `--yes`, the message is `block reopen needs the human at a terminal to confirm; nothing was recorded (--yes works only with evidence and agreement from a fresh judge)`. Under an active autopilot grant the human paths still prompt. Only `block close --no-require-reviews` is refused under a grant. Autopilot's own waive path, `forge autopilot waive` (written `by: autopilot`), needs no terminal.
18
20
 
19
21
  **What it does not defend against, and the product does not claim.** A coder running as the same OS user with a shell can read the key file and mint a MAC, or edit the run record. **The honest limit of the `block waive` / `block reopen` prompt:** the prompt checks only that stdin and stdout are terminals. It does not prove a human: a process that runs the verb in a pseudo-terminal (`script`, `expect`, a pty library, or an agent shell that is itself a pty, as Solo processes may be) passes the check and can answer yes, and a same-user process can read the run key and sign a row. The signer is a same-user boundary only. Within one OS user there is no boundary that a determined bypass-permissions process cannot cross; the deny rules make it a deliberate, named rule break rather than an accident, and the transcript grep makes the attempt visible (`rule_break: key_path`). `doctor` prints `signer: same-user boundary only` on every full run so nobody reads "signed" as "unforgeable".
20
22
 
@@ -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.12.0"
13
+ PINNED_VERSION="0.14.0"
14
14
  set -eu
15
15
 
16
16
  self="$0"
@@ -40,12 +40,11 @@
40
40
  * stopped or expired in between (e.g. while gh ran) means no row; a waiver issue already created
41
41
  * then gets one best-effort comment "waiver cancelled: autopilot grant ended".
42
42
  *
43
- * The per-finding lock is in-process only (`keyedLock`); two separate processes waiving the same
44
- * finding at once rely on the marker search alone (accepted, B47 fix round 1).
43
+ * The per-finding lock holds across processes (`withLock`).
45
44
  *
46
45
  * Tracked issues: the dedupe searches the exact quoted marker with up to 100 results and confirms
47
46
  * it in the bodies; a full page without it is `search-failed` (no new issue). Two waives of one
48
- * finding in this process run one at a time (`keyedLock`), so they open one issue.
47
+ * finding run one at a time across processes (`withLock`), so they open one issue.
49
48
  */
50
49
 
51
50
  import { readFile } from 'node:fs/promises';
@@ -58,7 +57,7 @@ import { readRun, withRunLock, writeSigned } from '../state/run.mjs';
58
57
  import { loadKey, verifyRow } from '../state/signer.mjs';
59
58
  import { exec as realExec } from '../util/exec.mjs';
60
59
  import { findReported, findSecret, ghRun, ghWithBodyFile, pathLookup } from '../util/gh.mjs';
61
- import { keyedLock } from '../util/locks.mjs';
60
+ import { withLock } from '../util/file-locks.mjs';
62
61
  import { scrub } from './delegate.mjs';
63
62
  import { grantFor, grantState } from './grant.mjs';
64
63
  import { isFixedDeny } from './scopes.mjs';
@@ -328,7 +327,7 @@ export async function waiveForOwner(req, deps = {}) {
328
327
 
329
328
  // one finding at a time in this process: the dedupe search, the create and the waiver row of
330
329
  // two concurrent waives never interleave, so they open one issue
331
- return keyedLock(`autopilot-waive\0${req.runId}\0${req.block}\0${file}\0${req.finding}`, async () => {
330
+ return withLock(`autopilot-waive\0${req.runId}\0${req.block}\0${file}\0${req.finding}`, async () => {
332
331
  const issue = await trackWaiverIssue({ workspace: record.workspace, text }, deps);
333
332
  // the gh work may have outlived the grant: re-check grant and decision under the run lock with
334
333
  // a fresh clock, and write the waiver there, dated then