@codedology/code-forge 0.3.2 → 0.5.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 (72) hide show
  1. package/CHANGELOG.md +85 -0
  2. package/README.md +88 -21
  3. package/bin/code-forge.mjs +6 -1
  4. package/docs/README.md +5 -4
  5. package/docs/reference/blocks.json +236 -1
  6. package/docs/reference/config.md +13 -1
  7. package/package.json +6 -2
  8. package/schema/code-forge.schema.json +26 -4
  9. package/skill/SKILL.md +8 -7
  10. package/skill/references/adapters/claude-code.md +5 -1
  11. package/skill/references/adapters/solo.md +1 -1
  12. package/skill/references/adapters/subprocess.md +1 -1
  13. package/skill/references/code.md +4 -3
  14. package/skill/references/continuity.md +4 -0
  15. package/skill/references/decisions.md +5 -2
  16. package/skill/references/ledger.md +10 -2
  17. package/skill/references/plan.md +12 -3
  18. package/skill/references/review.md +13 -6
  19. package/skill/references/security.md +3 -1
  20. package/skill/scripts/forge +1 -1
  21. package/skill/templates/coder-brief.md +36 -0
  22. package/skill/templates/plan.md +2 -0
  23. package/src/cli/block.mjs +51 -8
  24. package/src/cli/facts.mjs +16 -1
  25. package/src/cli/jev.mjs +114 -17
  26. package/src/cli/ledger.mjs +99 -8
  27. package/src/cli/logs.mjs +193 -58
  28. package/src/cli/plan.mjs +27 -11
  29. package/src/cli/report.mjs +15 -1
  30. package/src/cli/resolve.mjs +16 -1
  31. package/src/cli/review-file.mjs +10 -5
  32. package/src/cli/review.mjs +40 -9
  33. package/src/cli/run.mjs +109 -8
  34. package/src/config/closed-book.mjs +75 -0
  35. package/src/config/validate.mjs +128 -7
  36. package/src/decide/escalation.mjs +112 -11
  37. package/src/decide/questions.mjs +27 -4
  38. package/src/doctor/index.mjs +2 -1
  39. package/src/doctor/local.mjs +15 -0
  40. package/src/doctor/probes.mjs +48 -6
  41. package/src/engines/builders/claude.mjs +4 -1
  42. package/src/engines/builders/codex.mjs +26 -11
  43. package/src/engines/builders/grok.mjs +3 -3
  44. package/src/engines/efforts.mjs +109 -0
  45. package/src/engines/sentinel.mjs +34 -0
  46. package/src/keys/onepassword.mjs +3 -0
  47. package/src/ledger/report.mjs +66 -29
  48. package/src/ledger/spend.mjs +288 -0
  49. package/src/review/engine.mjs +57 -16
  50. package/src/review/fixloop.mjs +29 -6
  51. package/src/review/session-retry.mjs +69 -0
  52. package/src/review/triage.mjs +26 -1
  53. package/src/review/validate-review.mjs +6 -3
  54. package/src/session/author.mjs +6 -2
  55. package/src/session/facts.mjs +218 -53
  56. package/src/session/plan-check.mjs +71 -6
  57. package/src/session/plan-sections.mjs +98 -0
  58. package/src/session/spawn.mjs +373 -20
  59. package/src/state/block.mjs +13 -4
  60. package/src/state/config-snapshot.mjs +150 -0
  61. package/src/state/run.mjs +86 -0
  62. package/src/util/breadcrumbs.mjs +145 -0
  63. package/src/util/error-hint.mjs +3 -2
  64. package/src/util/error-kind.mjs +2 -2
  65. package/src/util/error-log.mjs +109 -18
  66. package/src/util/forbidden.mjs +3 -1
  67. package/src/util/known-fixes.json +1 -0
  68. package/src/util/known-fixes.mjs +153 -0
  69. package/src/util/locks.mjs +104 -0
  70. package/src/worker/engine.mjs +122 -40
  71. package/src/worker/loop.mjs +326 -78
  72. package/src/worker/queue.mjs +68 -7
@@ -36,7 +36,8 @@
36
36
  "L0": { "$ref": "#/$defs/level" },
37
37
  "L1": { "$ref": "#/$defs/level" },
38
38
  "L2": { "$ref": "#/$defs/level" },
39
- "L3": { "$ref": "#/$defs/level" }
39
+ "L3": { "$ref": "#/$defs/level" },
40
+ "coder_floor_docs": { "type": "string", "enum": ["L0", "L1", "L2"], "default": "L1" }
40
41
  }
41
42
  },
42
43
  "orchestrator": { "$ref": "#/$defs/levelName" },
@@ -85,7 +86,9 @@
85
86
  "retries_per_level": { "type": "integer", "minimum": 0, "default": 2 },
86
87
  "review_rounds_per_level": { "type": "integer", "minimum": 0, "default": 2 },
87
88
  "stop_at": { "type": "string", "const": "L3", "default": "L3" },
88
- "l3_mode": { "type": "string", "enum": ["patch", "code"], "default": "patch" }
89
+ "l3_mode": { "type": "string", "enum": ["patch", "code"], "default": "patch" },
90
+ "after_rounds_with_warnings": { "type": "integer", "minimum": 0, "default": 1 },
91
+ "warning_threshold": { "type": "integer", "minimum": 1, "default": 2 }
89
92
  }
90
93
  },
91
94
  "review": {
@@ -93,6 +96,8 @@
93
96
  "additionalProperties": false,
94
97
  "properties": {
95
98
  "multimodel": { "type": "boolean", "default": false },
99
+ "multimodel_for_docs": { "type": "boolean", "default": false },
100
+ "single_reviewer_max_risk": { "type": "number", "minimum": 0, "maximum": 3, "default": 1 },
96
101
  "second_provider": { "anyOf": [{ "$ref": "#/$defs/provider" }, { "type": "null" }], "default": null },
97
102
  "second_levels": {
98
103
  "anyOf": [
@@ -133,10 +138,22 @@
133
138
  },
134
139
  "min_tokens_out": { "type": "integer", "minimum": 1, "default": 120 },
135
140
  "batch_small_files": { "type": "boolean", "default": true },
136
- "session_timeout_s": { "type": "integer", "minimum": 1, "default": 600 },
141
+ "session_timeout_s": { "type": "integer", "minimum": 1, "default": 300 },
137
142
  "max_rounds_per_file": { "type": "integer", "minimum": 2, "maximum": 6, "default": 4 },
138
143
  "recheck_scope": { "type": "string", "enum": ["fix_hunks", "file"], "default": "fix_hunks" },
139
- "late_findings": { "type": "string", "enum": ["sweep", "block"], "default": "sweep" }
144
+ "late_findings": { "type": "string", "enum": ["sweep", "block"], "default": "sweep" },
145
+ "allow_open_book_codex": { "type": "boolean", "default": false, "description": "B32: let openai (Codex) run reviewer/judge/S2/author with its read-only sandbox, which can read files on this machine. Off: those sessions are refused." },
146
+ "provider_concurrency": {
147
+ "type": "object",
148
+ "additionalProperties": false,
149
+ "description": "B40: how many sessions of each provider may run at once in this process; a rate-limited session waits 2 s, then 6 s (±30 %) before it retries.",
150
+ "properties": {
151
+ "anthropic": { "type": "integer", "minimum": 1, "default": 4 },
152
+ "openai": { "type": "integer", "minimum": 1, "default": 2 },
153
+ "xai": { "type": "integer", "minimum": 1, "default": 2 }
154
+ }
155
+ },
156
+ "parallel_tickets": { "type": "integer", "minimum": 1, "maximum": 16, "default": 3, "description": "B42: how many review tickets the worker runs at once (oldest first); 1 = one at a time. Tickets for the same file never overlap." }
140
157
  }
141
158
  },
142
159
  "engine": { "type": "string", "enum": ["auto", "solo", "harness", "subprocess"], "default": "auto" },
@@ -228,6 +245,11 @@
228
245
  "block_tokens_soft": { "type": "integer", "minimum": 1 },
229
246
  "block_tokens_hard": { "type": "integer", "minimum": 1 },
230
247
  "run_usd_soft": { "type": "number", "minimum": 0 },
248
+ "usd": {
249
+ "type": "number",
250
+ "exclusiveMinimum": 0,
251
+ "description": "Per-run spend stop in USD (R6). Before every session spawn the run's estimated spend is checked: one warning at 80%, new sessions refused at 100%."
252
+ },
231
253
  "block_cases": { "type": "integer", "minimum": 10, "default": 80 }
232
254
  }
233
255
  },
package/skill/SKILL.md CHANGED
@@ -18,7 +18,7 @@ You are the **orchestrator**: the session that invoked this skill. You dispatch,
18
18
 
19
19
  `/code-forge plan|harden|code|full [brief file | plan file]`. No job given ⇒ infer it: a brief ⇒ `plan`; a plan whose header says `status: planned` ⇒ `harden`; `status: hardened` ⇒ `code`. `full` runs the three in order with one human checkpoint after `harden`.
20
20
 
21
- State lives in the plan file's header — `facts → planned → hardened → coding → done` — and in the run record: `forge run status --run <id>` prints the active blocks, `forge ledger tail --slug <slug>` the last rows. Takeover after a lost session: `references/continuity.md`.
21
+ State lives in the plan file's header — `facts → planned → hardened → coding → done` — and in the run record: `forge run status --run <id>` prints the active blocks, `forge ledger tail --slug <slug>` the last rows. Takeover after a lost session: `references/continuity.md`. A config change mid-run (a model swap, the review topology) is `forge run reload --run <id>` — never `block stop` + `run end` + `run start` (`references/continuity.md` §4).
22
22
 
23
23
  ## §2 Roles by level (never by model name)
24
24
 
@@ -27,7 +27,7 @@ State lives in the plan file's header — `facts → planned → hardened → co
27
27
  | facts delegate | L0 | read-only tools, empty cwd | `forge facts` |
28
28
  | coder | the lane S1 picks (L0–L2); climbs one rung per escalation | the project tree | the engine adapter |
29
29
  | reviewer, recheck | L2 | closed-book, empty cwd, packet on stdin | the worker |
30
- | judge, S2, author | L3 | closed-book, one turn | the worker, `forge s2`, `forge author` |
30
+ | judge, S2, author | L3 | closed-book, one turn (never Codex: `references/security.md` §4) | the worker, `forge s2`, `forge author` |
31
31
  | L3 rung | L3 | a patch ≤ 80 lines, once per block | `forge spawn --level L3 --role coder` |
32
32
 
33
33
  `forge resolve L<n>` prints what a level resolves to (provider, model, effort, fallback); `forge models` lists the catalog. With `review.multimodel` on, the second reviewer is another provider's L2 and the judge comes from a third provider.
@@ -36,12 +36,13 @@ State lives in the plan file's header — `facts → planned → hardened → co
36
36
 
37
37
  1. `forge facts --brief <brief> --out <plans_dir>/<slug>.facts.md` — an L0 delegate checks every claim with a read-only command. You read the sheet; you never read the sources yourself (`references/plan.md` §0).
38
38
  2. `forge author --job plan --brief <brief> --facts <sheet> --out <plan>` — refuses without `--facts` (exit 2) and refuses a sheet older than the brief. The draft's §0 is the sheet, verbatim.
39
- 3. Harden: `forge author --job harden --brief <brief> --facts <sheet> --draft <plan> --answers <file>` until `questions[]` is empty. The human answers; you relay, never answer for them (`references/harden.md`). Exit gate: `forge plan check <plan>` green.
40
- 4. Per block, in dispatch order, never more than `caps.coders` alive: `forge block open <id> --run <r> --level L<n> --owned <paths…> --acceptance <file> --brief <file> --lines <n>` prints the pointer `BRIEF <path> lines=<n> sha=<sha8> <<<EOM>>>`. Send the pointer per the engine's adapter — `solo` → `references/adapters/solo.md`, `harness` → `references/adapters/claude-code.md`, `subprocess` → `references/adapters/subprocess.md` — and wait for `ACK <sha8> lines=<n>`.
39
+ 3. Harden: `forge author --job harden --brief <brief> --facts <sheet> --draft <plan> --answers <file>` until `questions[]` is empty. The human answers; you relay, never answer for them (`references/harden.md`). Before the exit gate, record each block's lane: `forge jev ask lane --block <id> --plan <plan> --state <file>` (`--rules` without Jev); the plan's level must equal it. Exit gate: `forge plan check <plan>` green, with a lane for every block on its `lanes:` line (`references/plan.md`).
40
+ Then show the plan to the owner for approval in the harness's plan mode, never as chat text (Claude Code: EnterPlanMode, write the plan, ExitPlanMode); a harness without a plan mode shows it and waits for an explicit yes. No block is dispatched before that approval (`references/plan.md` §4.1).
41
+ 4. Per block, in dispatch order, never more than `caps.coders` alive: `forge block open <id> --run <r> --level L<n> --owned <paths…> --acceptance <file> --brief <file> --lines <n> [--kind code|docs|contract]` prints the pointer `BRIEF <path> lines=<n> sha=<sha8> <<<EOM>>>`. Send the pointer per the engine's adapter — `solo` → `references/adapters/solo.md`, `harness` → `references/adapters/claude-code.md`, `subprocess` → `references/adapters/subprocess.md` — and wait for `ACK <sha8> lines=<n>`.
41
42
  5. The coder's first message is its **facts diff**, then its forecast (cases and lines, `templates/block-record.md`), then code, file by file: after each file `forge review-file <path> --block <id> --run <r>`, then `forge review-file --wait <ticket>` before the next file. Rounds ≥ 2 re-check only the fix hunks (`references/review.md`).
42
43
  6. `===BLOCK <id> COMPLETE===` or `===BLOCK <id> FAILED: <reason>===` ends the coder's turn. Its report is a claim sheet: re-measure it (`references/code.md` §5).
43
- 7. `forge block close <id> --run <r>` is the gate: a signed `review.approved` row per changed file, gates green, every clause covered, forecast vs actual, no `rule_break`. Red ⇒ `references/decisions.md` (S1 `next`, escalation). Green ⇒ one commit for the block in the project's house style; `forge block rebase <id> --run <r>` first when HEAD moved.
44
- 8. `forge run end --run <r>` when every block is `done`; `forge report --slug <slug>` prints cost per block, escalations and review rounds.
44
+ 7. `forge block close <id> --run <r> --report <coder report>` is the gate: a report without a `reviewed <path> ticket <id>` line per changed file is FAILED; a signed `review.approved` row per changed file, gates green, every clause covered, forecast vs actual, no `rule_break`. Red ⇒ `references/decisions.md` (S1 `next`, escalation). Green ⇒ one commit for the block in the project's house style; `forge block rebase <id> --run <r>` first when HEAD moved.
45
+ 8. `forge run end --run <r>` when every block is `done`; `forge report --slug <slug>` prints cost per block, escalations, review rounds and the spend per run (`budget.usd`, `references/ledger.md` §3.1).
45
46
 
46
47
  ## §4 Hard rules (the headings of `references/rules-core.md`)
47
48
 
@@ -68,7 +69,7 @@ State lives in the plan file's header — `facts → planned → hardened → co
68
69
  |---|---|
69
70
  | decomposing, forecasting, `plan check` | `references/plan.md`, `templates/plan.md`, `templates/facts-sheet.md` |
70
71
  | the author loop and the tranche gate | `references/harden.md` |
71
- | briefing a coder, the block gate, the PR contract, Definition of Done | `references/code.md`, `templates/block-record.md` |
72
+ | briefing a coder, the block gate, the PR contract, Definition of Done | `references/code.md`, `templates/coder-brief.md`, `templates/block-record.md` |
72
73
  | review depth, packets, the convergence rule, `block waive` | `references/review.md` |
73
74
  | System 1 / System 2, thresholds, escalation, the L3 rung | `references/decisions.md` |
74
75
  | proof tiers, red→green, the measurement export | `references/proof.md` |
@@ -12,7 +12,11 @@ The harness's `Agent` tool runs the coder as a subagent; everything else — the
12
12
 
13
13
  The tool return is the completion signal; the report must begin with `ACK <sha8>` and end with `===BLOCK <id> COMPLETE===` (+ test summary + `git diff --stat`) or `===BLOCK <id> FAILED: <reason>===`. A report without the sentinel is a failed block, never a partial success.
14
14
 
15
- **Between completion and close come the review rounds.** The subagent ran `forge review-file <path> --block <id> --run <r>` per file and `--wait`ed on each ticket inside its turn, so most rounds happen before the sentinel; when the tool returns, first drain every `pending` ticket with `forge review-file --wait <ticket>` yourself — a pending ticket is a review in flight, not an open finding, and waiting on it costs no round. Then only an open `fix_now` finding, or a file the coder never submitted, starts a fix round (each one counts toward the file's cap): dispatch a new `Agent` call at the level the ladder says (`review.md` §5 — the coder is gone with its tool return, so the fix brief carries the open findings and the fix history) and it runs `review-file` again on the fixed file. Rounds are bounded: four rounds per file, then the L3 rung once, then `stopped: review_cap` for the human. Only when every changed file has a signed `review.approved` row do you run the re-measurement of `code.md` §5 and `forge block close <id> --run <r>` — the transcript grep reads `.code-forge/runs/<run>/<block>.log`; pass `--transcript <file>` when the harness saved the subagent's transcript elsewhere. Closing with a file still unreviewed is a human decision: `forge block waive <id> <finding> --run <r> --file <path> --reason "<why>"` per finding, or `forge block close … --no-require-reviews`, both audited as their own rows.
15
+ **Between completion and close come the review rounds.** The subagent ran `forge review-file <path> --block <id> --run <r>` per file and `--wait`ed on each ticket inside its turn, so most rounds happen before the sentinel; when the tool returns, first drain every `pending` ticket with `forge review-file --wait <ticket>` yourself — a pending ticket is a review in flight, not an open finding, and waiting on it costs no round. Then only an open `fix_now` finding, or a file the coder never submitted, starts a fix round (each one counts toward the file's cap): dispatch a new `Agent` call at the level the ladder says (`review.md` §5 — the coder is gone with its tool return, so the fix brief carries the open findings and the fix history) and it runs `review-file` again on the fixed file. Rounds are bounded: four rounds per file, then the L3 rung once, then `stopped: review_cap` for the human. Only when every changed file has a signed `review.approved` row do you run the re-measurement of `code.md` §5 and `forge block close <id> --run <r> --report <file>` (the subagent's final report, saved to a file) — the transcript grep reads `.code-forge/runs/<run>/<block>.log`; pass `--transcript <file>` when the harness saved the subagent's transcript elsewhere. Closing with a file still unreviewed is a human decision: `forge block waive <id> <finding> --run <r> --file <path> --reason "<why>"` per finding, or `forge block close … --no-require-reviews`, both audited as their own rows.
16
+
17
+ ## §2.1 Plan approval
18
+
19
+ The plan goes to the owner through plan mode (`plan.md` §4.1): `EnterPlanMode` → write the plan file → `ExitPlanMode`. The approval returned by `ExitPlanMode` is the go signal for the first `forge block open`.
16
20
 
17
21
  ## §3 What is lost and what is not
18
22
 
@@ -22,7 +22,7 @@ Solo adds spawn, watch, timers, scratchpads, locks and `kv` on top of the CLI; i
22
22
  5. **Wake channels, event-driven, never fixed polls:** record your own pid once (`whoami` → `kv_set("forge:orchestrator_pid")`); coders that can call Solo carry the push contract (post-sentinel `kv_set("block:<id>", …)` + `timer_set(1s, body, delivery_process_id=<your pid>)`); for every group arm `timer_fire_when_idle_any([coders], max_wait≈20–25 min, delivery_process_id=<your pid>)` right after the launch gate (idle-any ignores already-idle processes, so arm while they are provably running); keep one long dead-man fallback per 30–40 min with an L0 triage first. On wake: sentinel ⇒ gate; no sentinel and active again ⇒ re-arm; no sentinel and idle ⇒ parked, intervene now.
23
23
  6. Completion = `search_output(pattern="===BLOCK <id> COMPLETE===")`, last occurrence (the `kv` key is the truncation backup). Completion is not closure.
24
24
  7. **Review rounds, with the coder still alive.** The coder ran `forge review-file <path> --block <id> --run <r>` per file and `--wait`ed on each ticket; read the ledger (`forge ledger tail --slug <slug>`) for what is still open — a `fix_now` finding, a `pending` ticket, a file never submitted. Each open finding is a fix round sent to the **warm coder** by `send_input` (the findings by id, `<<<EOM>>>`), which fixes and re-runs `review-file` on that file; round ≥ 2 re-checks only the fix hunks (`review.md` §5). **One ladder** (`decisions.md` §5): a stall or exhausted rounds *below L2* climbs one level — close this coder and respawn a fresh one at the higher level with the open findings and the fix history in its brief; **the respawn keeps the per-file round count**, the cap does not reset. L2 is the highest running level: a trigger at L2, or the four-round cap on a file, goes to the **L3 rung once** — a fresh L3 patch session, never this coder and never a coder at L3 — and if findings stay open after the `patch_check` round the file is `stopped: review_cap` for the human. No other path exists.
25
- 8. **Close** only when every changed file has a signed `review.approved` row: the re-measurement of `code.md` §5, then `forge block close <id> --run <r>` — the transcript grep reads the Solo output. Closing with a file still unreviewed is a human decision, audited: `forge block waive <id> <finding> --run <r> --file <path> --reason "<why>"` per finding, or `forge block close … --no-require-reviews`.
25
+ 8. **Close** only when every changed file has a signed `review.approved` row: the re-measurement of `code.md` §5, then `forge block close <id> --run <r> --report <file>` (the coder's final report) — the transcript grep reads the Solo output. Closing with a file still unreviewed is a human decision, audited: `forge block waive <id> <finding> --run <r> --file <path> --reason "<why>"` per finding, or `forge block close … --no-require-reviews`.
26
26
  9. **Reap after the close**: `close_process` the coder and confirm `lock_status` shows no lease before the next group. Locks: a coder that can call Solo holds `lock_acquire` on its file area while editing; coders that cannot never overlap another block.
27
27
 
28
28
  ## §4 Latency overlaps (standing)
@@ -8,7 +8,7 @@ Never selected automatically (`degraded.md` §1). Coders are detached CLI proces
8
8
 
9
9
  ## §2 Completion and evidence
10
10
 
11
- The coder's stdout is captured; completion is the **last** `===BLOCK <id> COMPLETE===` or `===BLOCK <id> FAILED: <reason>===` line in it (a quoted sentinel inside a sentence never matches). Process exit without a sentinel is a failed block. Then the re-measurement of `code.md` §5 and `forge block close <id> --run <r> --transcript <captured stdout>` — the transcript grep runs on the capture. A coder that hangs is killed at `--timeout` together with its process group; the pid entry is cleared.
11
+ The coder's stdout is captured; completion is the **last** `===BLOCK <id> COMPLETE===` or `===BLOCK <id> FAILED: <reason>===` line in it (a quoted sentinel inside a sentence never matches). Process exit without a sentinel is a failed block. Then the re-measurement of `code.md` §5 and `forge block close <id> --run <r> --transcript <captured stdout> --report <captured stdout>` — the transcript grep runs on the capture. A coder that hangs is killed at `--timeout` together with its process group; the pid entry is cleared.
12
12
 
13
13
  ## §3 What the coder's sandbox looks like per provider
14
14
 
@@ -10,12 +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
- `forge block open <id> --run <r> --level L<n> --owned <paths…> --acceptance <file> --brief <file> [--attempt <n>] [--base <sha>] --lines <n>` records the base sha, the owned files (overlap with another active block ⇒ refused), the level, 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. The brief file carries, in this order:
13
+ `forge block open <id> --run <r> --level L<n> --owned <paths…> --acceptance <file> --brief <file> [--attempt <n>] [--base <sha>] [--kind code|docs|contract] --lines <n>` records the base sha, the owned files (overlap with another active block ⇒ refused), 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. 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`. The brief file carries, in this order:
14
14
 
15
15
  1. `id`, `title`, the level, `owned_files` ("edit ONLY these"), the acceptance clauses with test ids, `test_command`.
16
16
  2. The reply contract: `ACK <sha8> lines=<n>` before any tool call; the facts diff; the forecast.
17
17
  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).
18
- 4. The project's own rules digest (`CLAUDE.md`/`AGENTS.md`, ≤ 60 lines), the forbidden list verbatim (`security.md` §2), and the sentinel contract: `===BLOCK <id> COMPLETE===` followed by the test summary and `git diff --stat`, or `===BLOCK <id> FAILED: <reason>===`.
18
+ 4. The project's own rules digest (`CLAUDE.md`/`AGENTS.md`, ≤ 60 lines), the forbidden list verbatim (`security.md` §2), and the sentinel contract: `===BLOCK <id> COMPLETE===` followed by the test summary, `git diff --stat` and one `reviewed <path> ticket <ticket-id>` line per changed file, or `===BLOCK <id> FAILED: <reason>===`.
19
+ 5. **FAILED unless** (`templates/coder-brief.md`): a final report — first attempt or fix round — that does not list the `review-file` ticket id of every changed file counts as FAILED whatever its sentinel says. Pass the report to the gate with `forge block close <id> --run <r> --report <file>`; it refuses `coder report FAILED: no review-file ticket id for <files>`.
19
20
 
20
21
  The brief never mentions `block waive`, keys, or `~/.code-forge/runs/`. The coder process gets no Jev key (the System 1 model's key, `keys.jev`) and no signer key.
21
22
 
@@ -30,7 +31,7 @@ The coder's report is a claim sheet. The gate reproduces each number in the meas
30
31
  | claim | re-measured by |
31
32
  |---|---|
32
33
  | only owned paths touched | file set = tracked changes ∪ untracked, minus other active blocks' owned sets ⊆ `owned_files`; anything else is an orphan that stops every gate until `forge block claim <id> <path> --run <r>` or the human deletes it |
33
- | every file reviewed | a **signed** `review.approved` row for the current content hash of every changed or new file, or an architect-ruled disposition to a named successor block |
34
+ | every file reviewed | the report names a `review-file` ticket id per changed file (`--report`, which the orchestrator always passes) and a **signed** `review.approved` row for the current content hash of every changed or new file, or an architect-ruled disposition to a named successor block |
34
35
  | tests green | every gate command run once in the export, each exit status read |
35
36
  | clauses covered | every clause names ≥ 1 test id; each test exists and passed in this run; S1 `scope` on the hunks outside the named tests says `false` |
36
37
  | test count | the filtered run's delta equals the new tests' own count |
@@ -29,3 +29,7 @@ Symptoms: a PR flips to conflicting, or its diff suddenly shows the whole stack;
29
29
  - **Duplicate-append is the common conflict** in append-only files: read every hunk anyway — one in four is a genuinely different block.
30
30
  - **Prove with a tree comparison:** `git diff <pre> <post>` empty when the merge reconciles history only; `git merge-base --is-ancestor`; the line count back to block size. Merged ≠ in the default branch: check both.
31
31
  - **Re-run classifiers and forecasts after any base change**: a size or hot-file figure taken across a stale base is not a number.
32
+
33
+ ## §4 A config change mid-run
34
+
35
+ Edit `.code-forge.yml`, then `forge run reload --run <id>`. It validates the file (an invalid one is refused, nothing changes), prints the changed key paths — never values — and writes a signed `run.reload` row with the old and new snapshot hashes; no change prints `no config change` and writes nothing. Open blocks stay open at their attempt numbers; review tickets already queued finish on the config they were enqueued with, new tickets use the new one, and the worker picks it up without a restart. `project.slug`, `engine`, `tmp.root`, `keys`, `system1.key` and `version` are fixed for the run: a change to one is refused by name — put it back, or end the run and start a new one. Never `block stop` + `run end` + `run start` + `block open --attempt n+1` for a config change.
@@ -6,7 +6,7 @@ Deterministic tools answer facts (gates, diffs, counts). **System 1 (S1)** answe
6
6
 
7
7
  | id | type | fires | answers |
8
8
  |---|---|---|---|
9
- | `lane` | choice | plan, per block; code, on re-decomposition | `L0` trivial · `L1` a plain feature following an existing pattern · `L2` money, dates, tenancy, migrations, concurrency, unknown failure · `split` too large or two concerns. **L3 is not a lane** |
9
+ | `lane` | choice | plan, per block (`--block <id>`: `plan check` reads it); code, on re-decomposition | `L0` trivial · `L1` a plain feature following an existing pattern · `L2` money, dates, tenancy, migrations, concurrency, unknown failure · `split` too large or two concerns. **L3 is not a lane** |
10
10
  | `risk` | score 0–3 | plan, per block; code, per finished file | `0` cosmetic · `1` wrong behaviour, easy to notice and revert · `2` silent wrong data for some users · `3` silent wrong money or a cross-tenant leak |
11
11
  | `security_sensitive` | yes/no | plan, per block; code, per file unless path floors already forced `high` | touches authentication, authorization, tenancy, secrets, money movement or webhook verification |
12
12
  | `next` | choice | code, after each attempt's tool gate | `complete` all green, tests added or `no_new_tests_reason` given · `retry` a contained failure the same coder fixes in one more attempt · `escalate` repeated or spreading failures, or a risky area · `stop` the evidence contradicts the brief — the human decides |
@@ -33,10 +33,13 @@ No key ⇒ a banner at run start; 401 ⇒ stop; 422 ⇒ stop with the request id
33
33
  1. **Attempts:** attempts at the current level equal `escalation.retries_per_level` and not green ⇒ +1 level, whatever S1 said (`retries`).
34
34
  2. **Review rounds:** `escalation.review_rounds_per_level` exhausted with an open `fix_now` ⇒ +1 level (`review_rounds`).
35
35
  3. **Review stall:** a re-check round whose open `fix_now` set did not strictly shrink ⇒ +1 level at once (`review_stall`, `review.md` §5).
36
- 4. **Security floor, at dispatch:** `security_sensitive` ⇒ first-attempt level `min(max(lane, L1) + 1, L2)` (`security`).
36
+ 3a. **Heavy rounds:** below L2, `escalation.after_rounds_with_warnings` (default 1) rounds at the current level whose open `fix_now` set holds ≥ `escalation.warning_threshold` (default 2) warnings or any `critical` ⇒ +1 level at once (`review_warnings`). A cheap coder that fails a round with several warnings climbs; it never spends three rounds at the same level. At L2 it does not fire — the L3 rung keeps its own triggers (rules 2, 3, 6). `after_rounds_with_warnings: 0` turns it off.
37
+ 4. **Security floor, at dispatch:** `security_sensitive` ⇒ first-attempt level `min(max(lane, L1) + 1, L2)` (`security`). Then the **docs floor**: a `docs` or `contract` block (below) is never dispatched under `levels.coder_floor_docs` (default L1) — `forge block open` raises the level and the `dispatch` row carries `requested_level` and `trigger: docs_floor`. L0 never codes docs or contracts.
37
38
  5. **S2 ruling:** S1 `next = escalate` is never acted on directly — it goes to S2 as a check (`s2_ruling`); S1 `next = stop` ⇒ the human.
38
39
  6. **Review cap:** `review.max_rounds_per_file` reached on a file with an open `fix_now` ⇒ the L3 rung for that file, once per block, always as a patch whatever `escalation.l3_mode` says (`review_cap`); checked last, only when no rule above returned an action this attempt.
39
40
 
41
+ **Block kind.** `forge block open --kind code|docs|contract` declares it; without the flag it is detected from the owned paths: every path ends in `.md .mdx .markdown .rst .adoc` ⇒ `docs` (`.txt` is not docs: `requirements.txt`); every path is docs or a contract (`*.schema.json|yaml|yml`, `.proto`, `.graphql`, `.gql`, `.avsc`, `*.openapi.json|yaml|yml`, or a `.json/.yaml/.yml` under a `schema/`, `schemas/`, `contract/` or `contracts/` directory) and one is a contract ⇒ `contract`; anything else ⇒ `code`. A glob is judged by its literal ending, so a block owning `docs/**` declares its kind. The kind is recorded in the run record and drives the docs floor (rule 4) and the review topology (`review.md` §2).
42
+
40
43
  **The ceiling constraint (not a ranked rule).** *Never skip a level* constrains every rule above: a "+1 level" is always the next level, and **the highest running level is L2**. When rule 1, 2 or 3 fires at L2 there is no coding level above, and the **L3 rung** takes over (§6): a patch session, never a coder at L3 — the block continues at L2 afterwards. `escalation.stop_at` = L3 (fixed) names that rung, not a level a coder runs at; `l3_mode: code` is the only way a coder runs at L3, and it is not the default. The rung is one per block from either source (a trigger at L2 or the review cap, rule 6): a second hit from either always `stop`s (`review_cap` for the cap; the ceiling reason for the trigger), so `stop_at` means something in every mode. A fresh coder spawned by a level climb inherits the file's round count: the cap counts rounds per file, not per coder.
41
44
 
42
45
  **Own-family adjudication (R9).** When the orchestrator and the coder share a model family, every ruling the orchestrator makes about that coder's work is a judgement inside the family: it is written as a ledger row named as such, and it is asymmetric — the orchestrator may **accept** a finding alone; it may **not dismiss, defer or waive** one. Dismissals, cap raises and scope changes go to the architect (an L3 session) or the human.
@@ -8,11 +8,19 @@ Events you will read most: `run.start`, `run.stop {reason}`, `run.end`, `dispatc
8
8
 
9
9
  ## §2 `forge report --slug <slug> [--json] [--export <dir>]`
10
10
 
11
- Terminal output in this version. Sections: cost per completed block (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, 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`. `--export <dir>` writes one JSON file per section — the durable dataset a future UI reads.
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, 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`. `--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.
12
12
 
13
13
  ## §3 Live views
14
14
 
15
- `forge ledger tail --slug <slug> [--n <count>]` prints the last rows — the orchestrator's live view when no Solo process list exists. `forge run status --run <id>` prints the run record: active blocks, orphans, whether the worker is pinned.
15
+ `forge ledger tail --slug <slug> [--n <count> | -n <count>]` prints the last rows — the orchestrator's live view when no Solo process list exists. `forge run status --run <id>` prints the run record: active blocks, orphans, whether the worker is pinned, and `spent_usd` / `budget_usd` / `unknown_usd_sessions`.
16
+
17
+ ## §3.1 Spend and `budget.usd`
18
+
19
+ Every `session` row carries `usd` (tokens × the static price table) or `usd: null, usd_unknown: true` when the provider/level has no price — never a guess; a `session.background` coder's price is always unknown. Spend code-forge never saw (a cloud or hand-run coder) goes in with `forge ledger add coder --run <r> --block <b> --usd <n> [--note "..."]`, a signed `session` row with `manual: true`. With `budget.usd` set, every session spawn first sums the run's spend: at 80 % one `budget.warning` row and one stderr line per run; at 100 % the session is refused (`budget.usd 20.00 reached (spent 20.13); raise budget.usd or end the run`), a `budget.refused` row is written, nothing is spawned, and a review reports `review.unavailable {reason: budget}`. A budget whose spend cannot be read refuses too (fail closed).
20
+
21
+ Sessions that run at the same time (B41): each session first takes its provider slot, then — under one in-process lock per run — reads the spend, checks it and reserves its estimated cost (the packet's bytes / 4 in, plus a fixed tokens_out per role and level, priced with the same table; a session waiting for a slot reserves nothing). A session is refused when spent + reserved by running sessions + its own estimate passes the budget: `budget.usd 20.00 would be exceeded: spent 3.10, reserved by running sessions 15.80, this session about 1.92; it starts when running sessions finish or when budget.usd is raised`, or, when its estimate alone is above the budget, `this session's estimated cost (about 64.00) is above budget.usd 20.00; raise budget.usd`; the `budget.refused` row then carries `reserved_usd` and `estimate_usd`. The reservation is released when the session's row is written, or when the spawn fails or throws. The 80 % warning is checked under the same lock, so it is written once per run.
22
+
23
+ Known limits, accepted: unknown-price sessions and `session.background` coders add **0** to the spent total — record their real cost with `forge ledger add coder --usd <n>`; a background coder's reservation is released at its start row, since its cost is never seen; a session refused because of running sessions' reservations is refused, not queued — run it again once they finish; `forge run status` shows `unknown_usd_sessions` and prints `N sessions have no known price; the budget does not count them` when N > 0. The lock and the reservations live in one process only: two separate code-forge processes starting a session of one run at the same moment can still each pass the check or each write a `budget.warning` row.
16
24
 
17
25
  ## §4 Calibration — `forge ledger calibration --slug <slug> [--question <id>]`
18
26
 
@@ -13,7 +13,7 @@ Run `forge facts --brief <brief> [--sources <path>...] --out <plans_dir>/<slug>.
13
13
 
14
14
  ## §1 The author job
15
15
 
16
- `forge author --job plan --brief <brief> --facts <sheet> --out <plan>` runs a fresh L3 closed-book session (empty cwd, the packet on stdin) and returns `{draft, questions[], cost}`. It refuses without `--facts` (exit 2, `facts sheet required: run forge facts first`) and refuses a sheet older than the brief. The draft follows `templates/plan.md`; its §0 **is** the facts sheet, verbatim, and it must contain the section "Acceptance clauses the facts sheet cannot back" — `none` when empty, never absent.
16
+ `forge author --job plan --brief <brief> --facts <sheet> --out <plan>` runs a fresh L3 closed-book session (empty cwd, the packet on stdin) and returns `{draft, questions[], cost}`. It refuses without `--facts` (exit 2, `facts sheet required: run forge facts first`) and refuses a sheet older than the brief. The draft follows `templates/plan.md`; its §0 **is** the facts sheet, verbatim, and it must contain the section "Acceptance clauses the facts sheet cannot back" — `none` when empty, never absent. The author's packet carries the exact required headings from the same list `plan check` reads (`src/session/plan-sections.mjs`).
17
17
 
18
18
  ## §2 Decomposition — what every block carries
19
19
 
@@ -22,7 +22,7 @@ Run `forge facts --brief <brief> [--sources <path>...] --out <plans_dir>/<slug>.
22
22
  | `id`, `title` | one deliverable per block |
23
23
  | `owned_files` | exact paths or disjoint globs; a glob never contains `[ ] ( ) ! + @` (an exact path may); pairwise disjoint across blocks that can run together |
24
24
  | `depends_on` | acyclic; a block imports only modules of blocks it depends on — a batch-mate's module is a `next = stop` |
25
- | `level` | the S1 `lane` answer (`L0` trivial · `L1` a plain feature following a pattern · `L2` money, dates, tenancy, migrations, concurrency · `split` too large or two concerns); **L3 is not a lane** |
25
+ | `level` | the S1 `lane` answer recorded for the block — `forge jev ask lane --block <id> --plan <plan> --state <file>`, or `--rules` (the deterministic fallback) when Jev is unavailable; never a block category (`L0` trivial · `L1` a plain feature following a pattern · `L2` money, dates, tenancy, migrations, concurrency · `split` too large or two concerns); **L3 is not a lane** |
26
26
  | `acceptance` | clauses that each name ≥ 1 test id; a clause citing a flag, path, version or API cites a VERIFIED fact id or sits in the unbackable list with a tolerance naming this block |
27
27
  | `cases`, `lines` | forecasts: `cases = 2 × counted clauses`, `lines = 26 × cases` with a floor of 1 200 for a greenfield block and 500 for an amendment; above `budget.block_cases` or `budget.block_lines` the block is split in the plan, never "split-ready" |
28
28
  | `test_command` | a quoted glob or an explicit file list, never a bare directory |
@@ -36,7 +36,16 @@ Every clause of the parent's acceptance maps to exactly one block; an unmapped c
36
36
 
37
37
  ## §4 The deterministic exit — `forge plan check <plan> [--facts <sheet>]`
38
38
 
39
- Prints every failing row by name and exits 1 on any of: an acceptance clause with a claim token that resolves to no VERIFIED fact and no tolerance row (`unbackable clause without tolerance: <block> <clause>`); a missing caller map or a block absent from it; a block without `cases` and `lines`; a forecast above `budget.block_cases` / `budget.block_lines` without a named split; overlapping `owned_files` in one wave; a cyclic `depends_on` or an import of a batch-mate; a glob with `[ ] ( ) ! + @`; more than `caps.coders` concurrent blocks in a slot; `questions[]` still open; the facts sheet's sha absent from the draft. Green ⇒ set `status: hardened` after the harden job (`harden.md`), never before.
39
+ Prints every failing row by name and exits 1 on any of: an acceptance clause with a claim token that resolves to no VERIFIED fact and no tolerance row (`unbackable clause without tolerance: <block> <clause>`); a missing caller map or a block absent from it; a block without `cases` and `lines`; a forecast above `budget.block_cases` / `budget.block_lines` without a named split; overlapping `owned_files` in one wave; a cyclic `depends_on` or an import of a batch-mate; a glob with `[ ] ( ) ! + @`; more than `caps.coders` concurrent blocks in a slot; `questions[]` still open; the facts sheet's sha absent from the draft; a block whose level has no recorded lane, or differs from it (`block <id>: level L2 differs from the recorded lane L1 (jev)`). A required section under a near-miss heading at its template position (§0.x, §3) passes with a `WARN` naming the exact heading. stdout ends with the lane recorded per block (`lanes: B1 L1 (jev) · B2 L0 (rules)`). Green ⇒ set `status: hardened` after the harden job (`harden.md`), never before.
40
+
41
+ ### §4.1 Owner approval in plan mode
42
+
43
+ A plan that passed `forge plan check` still needs the owner's yes before any block is dispatched. Show it in the harness's plan mode so the owner reviews the whole plan in one place and approves or edits it there:
44
+
45
+ - **Claude Code:** call `EnterPlanMode`, write the plan (context, blocks with their levels and lanes, dispatch order, risks, verification) to the plan file, then call `ExitPlanMode`. Its approval is the go signal; a rejection or an edit sends you back to `harden`.
46
+ - **A harness without a plan mode:** print the plan's §1–§4 and §6 and wait for an explicit yes. Silence is not approval.
47
+
48
+ Never treat a chat summary of the plan as the approval step, and never start `forge block open` before it.
40
49
 
41
50
  ## §5 Cost
42
51
 
@@ -4,11 +4,13 @@
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 worker computes the diff itself against the base sha `forge block open` recorded: tracked changes plus untracked files, filtered by `owned_files` for the review and unfiltered for the scope check. A coder that never calls `review-file` gains nothing: the block gate refuses any changed or new file without a signed `review.approved` row for its final content hash. The reviewer is always a new OS process (R1, R10) — identical under Solo, a harness subagent, a detached CLI process, or a human typing.
6
6
 
7
- ## §2 Depth by risk (adaptive mode, `review.multimodel` off)
7
+ ## §2 Depth by risk (adaptive mode)
8
+
9
+ The topology is decided in one place (`planSessions`). Consensus (§6) runs only when `review.multimodel` is on **and** the file's risk is above `review.single_reviewer_max_risk` (default 1) **and** the block is not a `docs`/`contract` block (`decisions.md` §5) unless `review.multimodel_for_docs` is true (default false). Everything else is adaptive, below. Why: a consensus review of a four-file docs block cost $16.70 — review cost dominates, and a low-risk file gains nothing from a second provider.
8
10
 
9
11
  1. Tools on the file first: lint, types, the file's own tests. Red ⇒ `tools_red` back to the coder, no model review, ledger `gate.red`.
10
12
  2. S1 `risk` (path floors first; `confidence < check` ⇒ S2 picks the depth).
11
- 3. Planned depth: `risk < 1` ⇒ one L2 `quick` · `1 ≤ risk < 2` ⇒ one L2 `full` · `risk ≥ 2` ⇒ two blind L2 sessions (lens A correctness, lens B contracts and security) plus an L3 judge that reads the two reports — and the diff only when `review.judge_sees_diff` is true.
13
+ 3. Planned depth: `risk < 1` ⇒ one L2 `quick` · `1 ≤ risk < 2`, or `risk ≤ review.single_reviewer_max_risk` ⇒ one L2 `full`, no judge · otherwise two blind L2 sessions (lens A correctness, lens B contracts and security) plus an L3 judge that reads the two reports — and the diff only when `review.judge_sees_diff` is true.
12
14
  4. Triage: findings from a single L2 session go to S1 `defect` (`thresholds.findings.fix` fix now · mid-band batched to one L3 ruling per file · below `thresholds.findings.nit` a nit). Findings that come from a judge are final.
13
15
 
14
16
  ## §3 Isolation and the packet
@@ -17,7 +19,7 @@ Every reviewer, recheck, judge and S2 session runs with cwd = a fresh empty temp
17
19
 
18
20
  ## §4 The stub guard — what counts as a review (R11)
19
21
 
20
- A result is a review only if the process exited 0, the JSON validates against the finding schema, `reviewed_hunks` equals the packet's hunk headers exactly and in order, and the output has at least `review.min_tokens_out` tokens. Anything else is ledger `review.unavailable` (`exit`, `schema`, `hunks_mismatch`, `too_short`, `timeout`) and **is not approval**; the worker retries once on the level's fallback, then the file stays unreviewed and the block cannot close. A session that hangs is killed at `review.session_timeout_s`, process group and all.
22
+ A result is a review only if the process exited 0, the JSON validates against the finding schema, `reviewed_hunks` equals the packet's hunk headers exactly and in order, and the output has at least `review.min_tokens_out` tokens. Anything else is ledger `review.unavailable` (`exit`, `schema`, `hunks_mismatch`, `too_short`, `timeout`, `budget` — the run reached `budget.usd`, `ledger.md` §3.1) and **is not approval**; the worker retries once on the level's fallback, then the file stays unreviewed and the block cannot close. A session that hangs is killed at `review.session_timeout_s` (default 300), process group and all, and is spawned once more on the same packet and level; only a second timeout is `unavailable: timeout`. Each timed-out attempt leaves a `review.session_timeout` ledger note with the last 4 KB of its stderr (redacted, never the packet) and only the byte count and last event types of its stdout.
21
23
 
22
24
  ## §5 The convergence rule — bounded fix rounds (R15)
23
25
 
@@ -26,14 +28,15 @@ Per file, per block:
26
28
  1. Round 1 is the tiered review of §2 on the full packet.
27
29
  2. **Round n ≥ 2 reviews only the fix hunks**: the diff between the content hash reviewed in round n−1 and the current content, ± `review.context.hunk_context_lines`, plus the open findings by id (`review.recheck_scope: fix_hunks`, the default; `file` re-sends the whole packet for a consumer that wants it). The `recheck` lens answers `resolved[]` per finding — S1 `resolved` at or above `thresholds.findings.resolved` closes it, else the fresh L2 recheck decides — and may raise a new finding **only inside a fix hunk**; a finding outside becomes a `late_finding`, ruled at block close (`review.late_findings: sweep`; `block` makes it block the round).
28
30
  3. **Strictly shrinking**: the open `fix_now` set after round n is smaller than after round n−1, counting closures minus new in-hunk findings. Equal or larger ⇒ `review_stall` (rule 2b), a trigger at once.
29
- 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`) or rule 2b (`review_stall`) — below the ceiling climbs one level, coded by a fresh session with the open findings and the fix history in its brief (an L1 block's rounds 3–4 run at L2). At the ceiling — an L2 block, or an L1 block that has climbed to L2 — it takes the **L3 rung**: a patch ≤ 80 lines, owned files only, reviewed as a `patch_check` round outside the cap. Under `escalation.l3_mode: code` (opt-in) the rung is instead one L3 coding turn. Either way the rung fires **once per block** and the same post-patch order follows it. After it, in order: `patch_check` clean ⇒ complete; open set not smaller than before the patch ⇒ `stopped: l3_patch_exhausted` (`review_stall`); round ≥ `review.max_rounds_per_file` ⇒ `stopped: review_cap`; otherwise the fixes continue at L2 with `rounds_at_level` reset and the patch in the brief. Any later trigger in that block stops with its named reason (`l3_patch_exhausted`, `review_cap`) and never patches again.
31
+ 3a. **Heavy rounds climb at once** (`review_warnings`, `decisions.md` §5 rule 3a): below L2, a round — round 1 included — that leaves ≥ `escalation.warning_threshold` (2) warnings or any `critical` open, `escalation.after_rounds_with_warnings` (1) times at the current level, climbs one level for the next fix. One round with a stronger model is faster and cheaper than three with a cheap one. The per-file cap (step 5) stays the outer limit.
32
+ 4. **Ladder**: the running ceiling is `min(L2, escalation.stop_at)` — L3 is never a *running* level. A trigger — rule 1 (`retries`), rule 2 (`escalation.review_rounds_per_level` exhausted with an open `fix_now`), rule 2b (`review_stall`) or step 3a (`review_warnings`) — below the ceiling climbs one level, coded by a fresh session with the open findings and the fix history in its brief (an L1 block's rounds 3–4 run at L2). At the ceiling — an L2 block, or an L1 block that has climbed to L2 — it takes the **L3 rung**: a patch ≤ 80 lines, owned files only, reviewed as a `patch_check` round outside the cap. Under `escalation.l3_mode: code` (opt-in) the rung is instead one L3 coding turn. Either way the rung fires **once per block** and the same post-patch order follows it. After it, in order: `patch_check` clean ⇒ complete; open set not smaller than before the patch ⇒ `stopped: l3_patch_exhausted` (`review_stall`); round ≥ `review.max_rounds_per_file` ⇒ `stopped: review_cap`; otherwise the fixes continue at L2 with `rounds_at_level` reset and the patch in the brief. Any later trigger in that block stops with its named reason (`l3_patch_exhausted`, `review_cap`) and never patches again.
30
33
  5. **Cap — four rounds per file** (`review.max_rounds_per_file`, default 4, range 2–6). Round 4 still open ⇒ the L3 rung if the block has not used it (same post-patch order as step 4), else `stopped: review_cap`. A stop means the block cannot close, the ledger lists the open findings, and the human decides — fix by hand, `forge block waive <id> <finding-id> --run <r> --file <path> --reason <text>` (a signed `review.waived` row, `by: human`), or re-decompose. The coder never sees a `waive` verb; it is on the forbidden list.
31
34
  6. **Block-close sweep**: `forge block close` batches the `late_findings` per file to one L3 judge ruling (`fix_now | nit`); a `fix_now` opens a round on that file at the block's current level and counts toward its cap.
32
35
  7. Ledger: one `review.round` row per round with `open_before`, `closed`, `new_in_hunks`, `late`, `open_after`; `forge report` prints rounds per file and rounds that stalled.
33
36
 
34
37
  Rechecks are never degraded by the budget: a round-2+ packet is already the minimum.
35
38
 
36
- ## §6 Consensus mode (`review.multimodel` on)
39
+ ## §6 Consensus mode (`review.multimodel` on, risk above `review.single_reviewer_max_risk`, not a docs/contract block unless `review.multimodel_for_docs`)
37
40
 
38
41
  Two L2 reviewers, one per effective provider (`resolve(L2)` and `review.second_levels`), each a fresh blind session with the `full` lens; then a fresh L3 judge **from the third provider** reads the two reports and returns the findings plus `agreement: both|A-only|B-only`. The judge's `fix_now` list is final. Second provider unavailable at run start ⇒ `consensus unavailable: <reason>`; ask the human once per run whether to continue adaptive, stop when non-interactive. Rechecks run on the provider that raised the finding; §5 applies unchanged.
39
42
 
@@ -41,6 +44,10 @@ Two L2 reviewers, one per effective provider (`resolve(L2)` and `review.second_l
41
44
 
42
45
  `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. The ledger keeps `depth_unconstrained` and `depth_chosen` per file so `forge report` can show `missed_after_degrade`.
43
46
 
47
+ ## §7a Parallel tickets
48
+
49
+ 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.
50
+
44
51
  ## §8 Review only
45
52
 
46
- A human who wants a review and nothing else runs `forge review`: it reviews the changed files in one `R-<timestamp>` block at L2, prints the verdicts, then stops the block and ends the run — no plan, no coder, no proof, never a close. Inside a block the orchestrator keeps using `forge review-file`.
53
+ A human who wants a review and nothing else runs `forge review`: it reviews the changed files in one `R-<timestamp>` block at L2, prints each verdict as it finishes (all files wait together under one `--max` deadline, default 900 s), then stops the block and ends the run — no plan, no coder, no proof, never a close. Inside a block the orchestrator keeps using `forge review-file`.
@@ -6,7 +6,7 @@
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` and `block waive` from a coder (also via `npx`) · 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` and `block waive` from a coder (also via `npx`) · any write under `.code-forge/reviews/` or `.code-forge/queue/*.done`.
10
10
 
11
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.
12
12
 
@@ -24,6 +24,8 @@ Rendered into every coder spawn as the CLI's own deny rules (path rules widen a
24
24
 
25
25
  Reviewer, recheck, judge, S2, author and facts sessions run in print mode with the provider's own isolation flags, cwd = an empty temp dir under the run's temp root, and the packet on stdin; `doctor` proves that no project document is visible (it asks the reviewer builder to echo the first line of the project's rules file and expects nothing) and that the coder builder cannot read a known file outside the workspace (`claude: path deny rules not honoured` when it can). The facts delegate gets read-only shell tools with every write verb denied.
26
26
 
27
+ Codex has no mode without a shell: `-s read-only` still lets it read any absolute path. So openai is refused for reviewer, judge, S2 and author. `validate` warns, `resolve` prints `closed_book: "refused"`, the spawner refuses the session (an openai fallback is skipped), and `doctor` FAILs the isolation row with `codex cannot run closed-book yet: it always has a shell; use anthropic or xai for reviewer, judge, S2 and plan author`. Coders and the facts delegate still run on Codex. The opt-in `review.allow_open_book_codex: true` (default off) lets Codex run those roles with its read-only argv; `validate` then warns `codex reviewer is not closed-book: it can read files on this machine (review.allow_open_book_codex)`, `resolve` prints `closed_book: "open-book (allowed)"`, and `doctor` runs the isolation probe and reports WARN.
28
+
27
29
  ## §5 What the package itself never does
28
30
 
29
31
  No telemetry (`telemetry: off`, reserved). No `postinstall`. No secret in argv — a keychain write goes through stdin. Temp dirs live under one per-run root and are swept by the next `run start`; the reaper kills only processes this package registered whose start time still matches — never a foreign pid. Every child `git` runs with inherited `GIT_*` variables stripped, so a gate invoked from a hook still sees the right repository.
@@ -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.3.2"
13
+ PINNED_VERSION="0.5.0"
14
14
  set -eu
15
15
 
16
16
  self="$0"
@@ -0,0 +1,36 @@
1
+ # Block `<id>` — `<title>` (coder brief)
2
+
3
+ | field | value |
4
+ |---|---|
5
+ | level | `L<n>` (the lane recorded by `forge jev ask lane --block <id>`) |
6
+ | owned_files | `<paths…>` — edit ONLY these |
7
+ | acceptance | `<clause (test ids)>` · … |
8
+ | test_command | `<quoted glob or explicit file list>` |
9
+
10
+ ## Reply contract
11
+
12
+ 1. `ACK <sha8> lines=<n>` before any tool call.
13
+ 2. Your facts diff: every contradiction between this brief and the code, in one message.
14
+ 3. Your forecast: cases and lines.
15
+
16
+ ## The file loop
17
+
18
+ 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).
19
+
20
+ ## Final report — FAILED unless it lists the ticket ids
21
+
22
+ End with `===BLOCK <id> COMPLETE===`, the test summary and `git diff --stat`, then one line per changed file:
23
+
24
+ `reviewed <path> ticket <ticket-id>`
25
+
26
+ **FAILED unless:** the report lists the `review-file` ticket id of every changed file. A report with no ticket ids, or one that misses a changed file, counts as FAILED whatever its sentinel says: the orchestrator treats it as `===BLOCK <id> FAILED: no review-file ticket ids===` and `forge block close <id> --run <r> --report <file>` refuses it (`coder report FAILED: no review-file ticket id for <files>`). "All findings addressed" is not a report.
27
+
28
+ Or `===BLOCK <id> FAILED: <reason>===`.
29
+
30
+ ## Fix-round brief (round ≥ 2, or a respawn after a climb)
31
+
32
+ The open findings by id, the fix history, the files they sit in. Fix only those hunks, then run `forge review-file <path> --block <id> --run <r>` and `--wait` on **every file the round changed** — a fix the reviewer never saw is not a fix. The final report follows the same rule: one `reviewed <path> ticket <ticket-id>` line per file changed in the round, or the round counts as FAILED.
33
+
34
+ ## Project rules
35
+
36
+ <!-- the project's rules digest (≤ 60 lines) and the forbidden list verbatim (`references/security.md` §2); what a brief never carries is in `references/code.md` §3 -->
@@ -29,6 +29,8 @@ Chosen approach and why · rejected alternatives (one line each) · the project
29
29
  |---|---|---|---|---|---|---|---|---|---|
30
30
  | B1 | … | L1 | — | `src/x.mjs`, `test/x.test.mjs` | 12 | 1 200 | (1) … (`test/x.test.mjs`, F3) · (2) … | `node --test 'test/x/**/*.test.mjs'` | … |
31
31
 
32
+ `level` is the lane recorded for the block by `forge jev ask lane --block <id>` (or `--rules`), never a category; `forge plan check` refuses any other.
33
+
32
34
  Split rule: `cases = 2 × clauses`, `lines = 26 × cases` (floor 1 200 greenfield, 500 amendment); above `budget.block_cases` or `budget.block_lines` the block is split here. A glob in `owned_files` never contains `[ ] ( ) ! + @`.
33
35
 
34
36
  ## §3 Caller map
package/src/cli/block.mjs CHANGED
@@ -3,7 +3,7 @@
3
3
  * `block open` before every spawn in every engine; it prints the brief pointer (≤ 200 bytes).
4
4
  *
5
5
  * block open <id> --run <r> --level L<n> --owned <paths…> --acceptance <file>
6
- * [--brief <file>] [--attempt <n>] [--base <sha>] [--lines <forecast>]
6
+ * [--brief <file>] [--attempt <n>] [--base <sha>] [--lines <forecast>] [--kind code|docs|contract]
7
7
  * block attempt <id> --run <r>
8
8
  * block rebase <id> --run <r> [--head <ref>]
9
9
  * block claim <id> <path> --run <r>
@@ -11,7 +11,7 @@
11
11
  * block stop <id> --run <r> --reason <text>
12
12
  * block waive <id> <finding-id> --run <r> --file <path> --reason <text> (human-only, §4.9)
13
13
  *
14
- * `block close [--transcript <file>] [--no-require-reviews]` also runs B12b's review rows
14
+ * `block close [--transcript <file>] [--report <file>] [--no-require-reviews]` also runs B12b's review rows
15
15
  * (`review/gate-check.mjs`): a signed `review.approved` row for the current content hash of every
16
16
  * changed owned file (deletions included) — `--no-require-reviews` is the human's way out, is
17
17
  * forbidden to coders (`mergeForbidden`), and leaves a signed `gate.reviews_waived {by: human}`
@@ -27,6 +27,12 @@
27
27
  * but does not load or validate refuses the close (a typo must never drop a high path), and a
28
28
  * base that is missing or not a commit of the workspace refuses it too (`git-failed`).
29
29
  *
30
+ * B36: `--report <file>` is the coder's final report. The check is enforced when `--report` is
31
+ * given (the orchestrator always passes it, `skill/references/code.md` §3): a changed owned file
32
+ * whose `review-file` ticket id the report does not name refuses the close with `coder report
33
+ * FAILED: no review-file ticket id for <files>` (skipped under `--no-require-reviews`, the human's
34
+ * way out). It runs after the review rows and reuses their file set (listed once).
35
+ *
30
36
  * `--acceptance` is a YAML/JSON list of `{clause, tests: [test ids…]}`. `block close` runs B8's
31
37
  * rows of the gate (worker pin, MACs, orphans); the gate rows owned by later blocks join it
32
38
  * through `closeBlock`'s `extraChecks`.
@@ -49,14 +55,15 @@ import { validateConfig } from '../config/validate.mjs';
49
55
  import { migrateConfig } from '../config/migrate.mjs';
50
56
  import { exec } from '../util/exec.mjs';
51
57
  import { gitChildEnv } from '../worker/ticket.mjs';
58
+ import { missingReviewTickets } from '../engines/sentinel.mjs';
52
59
 
53
60
  const USAGE =
54
- 'usage: code-forge block open <id> --run <r> --level L<n> --owned <paths…> --acceptance <file> [--brief <file>] [--attempt <n>] [--base <sha>] [--lines <n>]\n' +
55
- ' code-forge block attempt|rebase|close <id> --run <r> · block claim <id> <path> --run <r> · block stop <id> --run <r> --reason <text>\n' +
61
+ 'usage: code-forge block open <id> --run <r> --level L<n> --owned <paths…> --acceptance <file> [--brief <file>] [--attempt <n>] [--base <sha>] [--lines <n>] [--kind code|docs|contract]\n' +
62
+ ' code-forge block attempt|rebase <id> --run <r> · block close <id> --run <r> [--transcript <file>] [--report <file>] [--no-require-reviews] · block claim <id> <path> --run <r> · block stop <id> --run <r> --reason <text>\n' +
56
63
  ' code-forge block waive <id> <finding-id> --run <r> --file <path> --reason <text>\n';
57
64
 
58
65
  const SUBCOMMANDS = ['open', 'attempt', 'rebase', 'claim', 'close', 'stop', 'waive'];
59
- const FLAG_VALUES = ['run', 'level', 'acceptance', 'brief', 'attempt', 'base', 'lines', 'head', 'worker-pid', 'reason', 'file', 'transcript'];
66
+ const FLAG_VALUES = ['run', 'level', 'acceptance', 'brief', 'attempt', 'base', 'lines', 'head', 'worker-pid', 'reason', 'file', 'transcript', 'kind', 'report'];
60
67
 
61
68
  /**
62
69
  * @param {string[]} args
@@ -106,9 +113,12 @@ export async function runBlock(args, deps = {}) {
106
113
  base: typeof flags.base === 'string' ? flags.base : undefined,
107
114
  lines: intFlag(flags.lines, 'lines') ?? null,
108
115
  brief,
116
+ ...(typeof flags.kind === 'string' ? { kind: flags.kind } : {}),
117
+ cfg: await openConfig(record.workspace),
109
118
  writeRow,
110
119
  });
111
- out(`block ${id} open · ${block.level} · attempt ${block.attempt} · base ${block.base_sha.slice(0, 12)}\n`);
120
+ const raised = block.level !== flags.level ? ` (${block.kind} floor, asked ${flags.level})` : '';
121
+ out(`block ${id} open · ${block.level}${raised} · ${block.kind} · attempt ${block.attempt} · base ${block.base_sha.slice(0, 12)}\n`);
112
122
  out(`${pointer ?? 'brief: none given (--brief <file>)'}\n`);
113
123
  return 0;
114
124
  }
@@ -141,6 +151,11 @@ export async function runBlock(args, deps = {}) {
141
151
  if (!entry) throw new StateError('unknown_block', `block ${id} is not in run ${runId}`);
142
152
  const rows = (await readAllRows(record.project)).filter((row) => row.run === runId);
143
153
  const reviewsWaived = flags['no-require-reviews'] === true;
154
+ // the block's file set is listed once and shared by the review and report checks; a failure
155
+ // there is the usual `git-failed` refusal
156
+ /** @type {ReturnType<typeof blockFileSet> | undefined} */
157
+ let fileSetOnce;
158
+ const blockFiles = () => (fileSetOnce ??= blockFileSet({ repoRoot: record.workspace, base: entry.base_sha, owned: entry.owned_files, matchOwned: (f) => findOverlap(entry.owned_files, [f]) !== null }));
144
159
  const reviews = reviewGateCheck(async () => {
145
160
  const transcript = await findTranscript(record, id, typeof flags.transcript === 'string' ? flags.transcript : null);
146
161
  if (transcript === null) {
@@ -151,7 +166,7 @@ export async function runBlock(args, deps = {}) {
151
166
  if (reviewsWaived) await writeSigned(runId, writeRow, { event: 'gate.reviews_waived', block: id, by: 'human' });
152
167
  // B20: the high paths come first — a base the workspace does not know refuses the close here
153
168
  const highPaths = await highPathsAt(record.workspace, entry.base_sha);
154
- const fileSet = await blockFileSet({ repoRoot: record.workspace, base: entry.base_sha, owned: entry.owned_files, matchOwned: (f) => findOverlap(entry.owned_files, [f]) !== null });
169
+ const fileSet = await blockFiles();
155
170
  return {
156
171
  block: id,
157
172
  runId,
@@ -164,7 +179,20 @@ export async function runBlock(args, deps = {}) {
164
179
  highPaths,
165
180
  };
166
181
  });
167
- const result = await closeBlock({ runId, id, rows, writeRow, livePid: intFlag(flags['worker-pid'], 'worker-pid'), probe, extraChecks: [reviews] });
182
+ // B36: the coder's report names a review-file ticket for every changed file, or it is FAILED
183
+ const report = async () => {
184
+ if (typeof flags.report !== 'string' || reviewsWaived) return { ok: true };
185
+ let text;
186
+ try {
187
+ text = await readFile(flags.report, 'utf8');
188
+ } catch {
189
+ return { ok: false, reason: 'coder report FAILED: --report cannot be read' };
190
+ }
191
+ const files = await blockFiles();
192
+ const missing = missingReviewTickets(text, files.map((f) => f.file));
193
+ return missing.length === 0 ? { ok: true } : { ok: false, reason: `coder report FAILED: no review-file ticket id for ${missing.join(', ')}` };
194
+ };
195
+ const result = await closeBlock({ runId, id, rows, writeRow, livePid: intFlag(flags['worker-pid'], 'worker-pid'), probe, extraChecks: [reviews, report] });
168
196
  (result.ok ? out : err)(`block ${id} ${result.status}${result.event ? ` (${result.event})` : ''}${result.reason ? `: ${result.reason}` : ''}\n`);
169
197
  return result.ok ? 0 : 1;
170
198
  }
@@ -176,6 +204,21 @@ export async function runBlock(args, deps = {}) {
176
204
  return 2;
177
205
  }
178
206
 
207
+ /**
208
+ * The project config for `block open`'s coder floor (B34): the loaded `.code-forge.yml`, or
209
+ * undefined when there is none or it does not load — the floor then takes its default (L1), the
210
+ * safe side.
211
+ * @param {string} workspace @returns {Promise<Record<string, any> | undefined>}
212
+ */
213
+ async function openConfig(workspace) {
214
+ try {
215
+ const loaded = await loadProjectConfig(workspace);
216
+ return loaded.ok && loaded.config ? loaded.config : undefined;
217
+ } catch {
218
+ return undefined;
219
+ }
220
+ }
221
+
179
222
  /**
180
223
  * The block's coder transcript: `--transcript`, else the run record's `blocks.<id>.transcript`
181
224
  * (workspace-relative), else `.code-forge/runs/<run>/<id>.log`; null when none exists.