@codedology/code-forge 0.4.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,48 @@ waits on a second real consumer (plan §10.4, Q17).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.5.1] — 2026-10-04
10
+
11
+ ### Changed
12
+
13
+ - Docs: README "What's new in 0.5" (with the bench numbers), the 0.5 config keys marked, how to install a tagged release
14
+ before it is on npm (getting-started), and how maintainers update their own install after a release (releasing).
15
+
16
+ ## [0.5.0] — 2026-10-04
17
+
18
+ ### Added
19
+
20
+ - Per-provider session limit `review.provider_concurrency` (defaults anthropic 4, openai 2, xai 2) and rate-limit
21
+ backoff: a rate-limited session waits about 2 s, then about 6 s (±30 %), before retrying the same step, instead of
22
+ retrying at once. New in-process keyed locks and semaphores (`src/util/locks.mjs`) prepare parallel review.
23
+ - Parallel review in the worker: `review.parallel_tickets` (1–16, default 3) review tickets run at once, oldest first.
24
+ The same file is never reviewed twice at once; a block's budget row and its one L3 rung are taken under a block lock;
25
+ a crashed ticket never stops the others; on stop, running tickets finish. `run reload` can change the pool size. `1`
26
+ keeps the serial behaviour.
27
+ - `code-forge doctor` shows a `review concurrency` row (tickets at once and provider slots); maintainers can measure the
28
+ pool with `npm run bench:review` (6 fake 2 s reviews: about 12 s at 1, about 4 s at 3).
29
+
30
+ ### Changed
31
+
32
+ - Skill: a plan that passed `plan check` is shown to the owner for approval in the harness's plan mode (Claude Code:
33
+ EnterPlanMode → plan file → ExitPlanMode); no block is dispatched before that approval.
34
+ - `budget.usd` holds when sessions start together: each session reserves its estimated cost under one lock (after taking
35
+ its provider slot) and releases it when its row is written or it fails. A refusal caused by running sessions says how
36
+ much they hold and this session's estimate; a session whose estimate alone is above the budget says so.
37
+ - `code-forge review` enqueues every file first, then waits on all of them together under ONE deadline for the whole run
38
+ (`--max`, default 900 s, now a run deadline, not per file), prints each result on stderr as it finishes, and keeps the
39
+ final table in file order. At most 8 wait children run at once.
40
+
41
+ ### Fixed
42
+
43
+ - `code-forge facts` verified nothing: delegates echo claims as `<kind>: <token>` and every answer was dropped as "no
44
+ answer". Answers now match by that form or by fact id (one answer per claim); a check that breaks the read-only rules,
45
+ or a silent command (`test`, `grep -q`), marks only that claim unverifiable with the reason instead of refusing the
46
+ whole sheet; the `<cli> <subcommand> --help | grep -c -- <flag>` check is allowed only for CLIs the brief names, never
47
+ interpreters or launchers.
48
+ - Two files of one block could both take the block's single L3 patch rung (the rung was recorded only when the patch
49
+ check saved); a pending `next: patch` on a sibling file now counts as used.
50
+
9
51
  ## [0.4.0] — 2026-10-02
10
52
 
11
53
  ### Added
package/README.md CHANGED
@@ -8,6 +8,20 @@ for cross-model, parallel, evidence-gated feature delivery — on Claude Code, C
8
8
  > `examples/` have all landed (`docs/reference/blocks.json` lists every build block). What changed
9
9
  > in each version is in [CHANGELOG.md](CHANGELOG.md).
10
10
 
11
+ ## What's new in 0.5
12
+
13
+ - **Parallel review.** The worker reviews `review.parallel_tickets` files at once (default 3), with
14
+ a session limit per provider (`review.provider_concurrency`), backoff on rate limits, and
15
+ `budget.usd` still holding. `code-forge review` now waits on all files under one `--max`
16
+ deadline and prints each result as it finishes. `code-forge doctor` shows the numbers;
17
+ `npm run bench:review` measured 6 reviews in 12 s at 1 and 4 s at 3.
18
+ - **`code-forge facts` verifies again.** It dropped every answer in 0.4.0; now answers match, and a check
19
+ that breaks the read-only rules marks only its own claim as not counted, with the reason.
20
+ - **Clearer budget refusals.** A session refused because other running sessions hold the budget says
21
+ how much they hold and what this session would cost.
22
+ - **Plan approval in plan mode.** The skill shows a checked plan to you in the harness's plan mode
23
+ (Claude Code: EnterPlanMode → ExitPlanMode) before any block starts.
24
+
11
25
  ## What's new in 0.4
12
26
 
13
27
  - **Cost control.** Set `budget.usd` per run: one warning at 80%, no new session at 100%. Every
@@ -176,7 +190,7 @@ CLI verbs like any other and every one of them is exercised by this package's ow
176
190
 
177
191
  `schema/code-forge.schema.json` is the source of truth for `.code-forge.yml`.
178
192
  [docs/reference/config.md](docs/reference/config.md) lists every key with its type and default.
179
- Keys added for 0.4:
193
+ Keys added in 0.4 and 0.5 (the last two rows are new in 0.5):
180
194
 
181
195
  | Key | Default | What it does |
182
196
  |---|---|---|
@@ -188,6 +202,8 @@ Keys added for 0.4:
188
202
  | `review.multimodel_for_docs` | `false` | let docs and contract blocks use multimodel review too |
189
203
  | `review.allow_open_book_codex` | `false` | let Codex run reviewer, judge, System 2 and plan author anyway |
190
204
  | `review.session_timeout_s` | `300` | a review session that runs longer is killed and retried once |
205
+ | `review.parallel_tickets` | `3` | review tickets the worker runs at once (1 to 16); `1` reviews one file at a time |
206
+ | `review.provider_concurrency` | anthropic `4`, openai `2`, xai `2` | review sessions at once per provider |
191
207
 
192
208
  `code-forge block open … --kind code|docs|contract` sets a block's kind when its file endings do
193
209
  not say it. The reference page is generated:
@@ -4,7 +4,7 @@
4
4
  "plans/code-forge-plan-v1.3.md §10.4",
5
5
  "plans/NIGHT-LOG.md"
6
6
  ],
7
- "block_count": 40,
7
+ "block_count": 46,
8
8
  "baseline_ids": ["B0", "B1", "B2", "B3", "B4", "B5", "B6", "B7", "B8"],
9
9
  "baseline_note": "Wave 0-1 blocks, landed before v1.3; depends_on may name them, but they have no entry under blocks.",
10
10
  "blocks": {
@@ -523,6 +523,89 @@
523
523
  "B37"
524
524
  ],
525
525
  "note": "root-added 2026-10-02"
526
+ },
527
+ "B39": {
528
+ "title": "facts: match answers that echo the packet form or by fact_id, one row per claim; a rule-breaking check marks only its claim UNVERIFIABLE; narrow subcommand-help form (brief-named CLIs, no interpreters or launchers); silent commands never VERIFIED",
529
+ "wave": 10,
530
+ "level": "L2",
531
+ "status": "landed",
532
+ "owned": [
533
+ "src/session/facts.mjs",
534
+ "src/cli/facts.mjs",
535
+ "test/session/facts.test.mjs"
536
+ ],
537
+ "depends_on": [
538
+ "B12c"
539
+ ],
540
+ "note": "root-added 2026-10-03; found by running code-forge facts on its own repo"
541
+ },
542
+ "B40": {
543
+ "title": "Keyed locks, per-provider session limit (review.provider_concurrency), rate-limit backoff",
544
+ "wave": 10,
545
+ "level": "L2",
546
+ "status": "landed",
547
+ "owned": [
548
+ "src/util/locks.mjs",
549
+ "test/util/locks.test.mjs",
550
+ "test/session/provider-limit.test.mjs"
551
+ ],
552
+ "depends_on": [
553
+ "B39"
554
+ ],
555
+ "note": "root-added 2026-10-03; plan plans/parallel-review (approved in plan mode)"
556
+ },
557
+ "B41": {
558
+ "title": "Budget reservation under concurrency: slot first, then check + reserve estimated cost under one lock",
559
+ "wave": 10,
560
+ "level": "L2",
561
+ "status": "landed",
562
+ "owned": [
563
+ "test/ledger/spend-concurrency.test.mjs"
564
+ ],
565
+ "depends_on": [
566
+ "B40"
567
+ ],
568
+ "note": "root-added 2026-10-03; plan plans/parallel-review (approved in plan mode)"
569
+ },
570
+ "B42": {
571
+ "title": "Worker ticket pool: review.parallel_tickets, same-file rule, block lock for budget row and L3 rung, sibling patch counts as rung used",
572
+ "wave": 10,
573
+ "level": "L2",
574
+ "status": "landed",
575
+ "owned": [
576
+ "test/worker/pool.test.mjs"
577
+ ],
578
+ "depends_on": [
579
+ "B41"
580
+ ],
581
+ "note": "root-added 2026-10-03; plan plans/parallel-review (approved in plan mode)"
582
+ },
583
+ "B43": {
584
+ "title": "code-forge review enqueues all files, waits together under one deadline, at most 8 waiters",
585
+ "wave": 10,
586
+ "level": "L1",
587
+ "status": "landed",
588
+ "owned": [
589
+ "test/cli/review-parallel.test.mjs"
590
+ ],
591
+ "depends_on": [
592
+ "B42"
593
+ ],
594
+ "note": "root-added 2026-10-03; plan plans/parallel-review (approved in plan mode)"
595
+ },
596
+ "B44": {
597
+ "title": "Parallel review docs, doctor review-concurrency row, bench:review (N=1 vs N=3)",
598
+ "wave": 10,
599
+ "level": "L1",
600
+ "status": "landed",
601
+ "owned": [
602
+ "scripts/bench-review.mjs",
603
+ "test/bench-review.test.mjs"
604
+ ],
605
+ "depends_on": [
606
+ "B43"
607
+ ],
608
+ "note": "root-added 2026-10-03; plan plans/parallel-review (approved in plan mode)"
526
609
  }
527
610
  }
528
611
  }
@@ -112,6 +112,11 @@ Every other key defaults as shown, or is entirely absent from a minimal file. `a
112
112
  | `review.recheck_scope` | enum: `fix_hunks`, `file` | | `"fix_hunks"` | |
113
113
  | `review.late_findings` | enum: `sweep`, `block` | | `"sweep"` | |
114
114
  | `review.allow_open_book_codex` | boolean | | `false` | 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. |
115
+ | `review.provider_concurrency` | object | | — | |
116
+ | `review.provider_concurrency.anthropic` | integer | | `4` | min 1 |
117
+ | `review.provider_concurrency.openai` | integer | | `2` | min 1 |
118
+ | `review.provider_concurrency.xai` | integer | | `2` | min 1 |
119
+ | `review.parallel_tickets` | integer | | `3` | min 1; max 16; B42: how many review tickets the worker runs at once (oldest first); 1 = one at a time. Tickets for the same file never overlap. |
115
120
  | `engine` | enum: `auto`, `solo`, `harness`, `subprocess` | | `"auto"` | |
116
121
  | `harnesses` | array<string> | | — | |
117
122
  | `harness` | object | | — | |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@codedology/code-forge",
3
- "version": "0.4.0",
3
+ "version": "0.5.1",
4
4
  "description": "Plan/harden/code/review orchestration CLI and Agent Skill for cross-model, parallel, evidence-gated feature delivery.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -32,6 +32,7 @@
32
32
  "!scripts/release.mjs",
33
33
  "!scripts/changelog.mjs",
34
34
  "!scripts/known-fix.mjs",
35
+ "!scripts/bench-review.mjs",
35
36
  "README.md",
36
37
  "CHANGELOG.md",
37
38
  "LICENSE"
@@ -41,7 +42,8 @@
41
42
  "typecheck": "tsc --checkJs --noEmit",
42
43
  "release": "node scripts/release.mjs",
43
44
  "changelog": "node scripts/changelog.mjs",
44
- "known-fix": "node scripts/known-fix.mjs"
45
+ "known-fix": "node scripts/known-fix.mjs",
46
+ "bench:review": "node scripts/bench-review.mjs"
45
47
  },
46
48
  "dependencies": {
47
49
  "@clack/prompts": "1.8.1",
@@ -142,7 +142,18 @@
142
142
  "max_rounds_per_file": { "type": "integer", "minimum": 2, "maximum": 6, "default": 4 },
143
143
  "recheck_scope": { "type": "string", "enum": ["fix_hunks", "file"], "default": "fix_hunks" },
144
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." }
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." }
146
157
  }
147
158
  },
148
159
  "engine": { "type": "string", "enum": ["auto", "solo", "harness", "subprocess"], "default": "auto" },
package/skill/SKILL.md CHANGED
@@ -37,6 +37,7 @@ State lives in the plan file's header — `facts → planned → hardened → co
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
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).
40
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).
@@ -14,6 +14,10 @@ The tool return is the completion signal; the report must begin with `ACK <sha8>
14
14
 
15
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
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`.
20
+
17
21
  ## §3 What is lost and what is not
18
22
 
19
23
  Lost: live observation of the coder (only the progress log and the final report), a resumable transcript (a lost subagent is re-dispatched with the fix history in a new brief — `continuity.md` §2). Not lost: session isolation, signed rows, the gate, the ladder, the facts rule, the convergence rule (`degraded.md` §2).
@@ -18,7 +18,9 @@ Terminal output in this version. Sections: cost per block — completed and stil
18
18
 
19
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
20
 
21
- 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>`; `forge run status` shows `unknown_usd_sessions` and prints `N sessions have no known price; the budget does not count them` when N > 0. Two sessions spawned at the same moment can each write a `budget.warning` row (a rare duplicate; the warning still means the same thing).
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.
22
24
 
23
25
  ## §4 Calibration — `forge ledger calibration --slug <slug> [--question <id>]`
24
26
 
@@ -38,6 +38,15 @@ Every clause of the parent's acceptance maps to exactly one block; an unmapped c
38
38
 
39
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
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.
49
+
41
50
  ## §5 Cost
42
51
 
43
52
  One L0 session per plan for the facts (typically a claim list of 40–80 items) and one L3 session per author round; `forge report --slug <slug>` shows both as `facts $` and coder/review splits.
@@ -44,6 +44,10 @@ Two L2 reviewers, one per effective provider (`resolve(L2)` and `review.second_l
44
44
 
45
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`.
46
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
+
47
51
  ## §8 Review only
48
52
 
49
- 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`.
@@ -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.4.0"
13
+ PINNED_VERSION="0.5.1"
14
14
  set -eu
15
15
 
16
16
  self="$0"
package/src/cli/facts.mjs CHANGED
@@ -16,7 +16,7 @@ import { buildFacts, FactsError } from '../session/facts.mjs';
16
16
  import { runRootFor, SessionError } from '../session/spawn.mjs';
17
17
  import { intFlag, parseFlags } from '../state/cli-args.mjs';
18
18
  import { StateError } from '../state/paths.mjs';
19
- import { writeSafe } from '../util/redact.mjs';
19
+ import { redact, writeSafe } from '../util/redact.mjs';
20
20
  import { slugFor } from './run.mjs';
21
21
  import { exitCodeFor } from './spawn.mjs';
22
22
 
@@ -34,6 +34,19 @@ function readable(file, { fileOnly = false } = {}) {
34
34
  }
35
35
  }
36
36
 
37
+ /**
38
+ * One stderr line for a downgraded row: the id must be `F<n>` (else `F?`), the reason is redacted
39
+ * and every control character (newlines included) becomes a space, so a row is always one line.
40
+ * @param {{fact_id: string, reason: string}} d
41
+ * @returns {string}
42
+ */
43
+ export function downgradeLine(d) {
44
+ const id = /^F\d+$/.test(String(d.fact_id)) ? d.fact_id : 'F?';
45
+ // eslint-disable-next-line no-control-regex
46
+ const reason = redact(String(d.reason).replace(/[\u0000-\u001f\u007f-\u009f]/g, ' '));
47
+ return `facts: ${id} not counted — ${reason}\n`;
48
+ }
49
+
37
50
  const USAGE = 'usage: code-forge facts --brief <file> [--sources <path>...] [--out <file>] [--run <id>] [--timeout <seconds>]\n';
38
51
 
39
52
  /**
@@ -107,6 +120,8 @@ export async function runFactsVerb(args, deps = {}) {
107
120
  return exitCodeFor(result);
108
121
  }
109
122
  const facts = result.facts ?? [];
123
+ // B39: a row whose check broke a rule is kept as UNVERIFIABLE (not counted); say so, exit 0
124
+ for (const d of result.downgraded ?? []) writeSafe(stderr, downgradeLine(d));
110
125
  writeSafe(stdout, `${JSON.stringify({ out: outPath, claims: result.claims.length, verified: facts.filter((f) => f.tag === 'VERIFIED').length })}\n`);
111
126
  return 0;
112
127
  } catch (err) {
@@ -16,7 +16,9 @@
16
16
  * default clause. A generated file lives in this command's temp root, never in the tree.
17
17
  * 4. Flow, every step a `code-forge` child with a `GIT_*`-free env: `run start` (launches the
18
18
  * worker) → `block open R-<stamp> --level L2 --base <sha> --owned <files…>` → `review-file`
19
- * per file → `review-file --wait` per ticket (`--max`, default 900 s per file) → summary →
19
+ * per file (all enqueued first) → one `review-file --wait` per ticket, all together under ONE run
20
+ * deadline (`--max`, default 900 s for the whole review; a ticket pending at the deadline is
21
+ * reported `stopped: timeout`, exit 1), results announced on stderr as they finish → summary in file order →
20
22
  * `block stop` (a review never closes a block) → `run end` unless `--keep-run`. The cleanup runs
21
23
  * in a `finally`, on SIGINT/SIGTERM and after a timeout, and no worker outlives the command.
22
24
  * Each cleanup step is independent (a failed or throwing step is reported on stderr and the
@@ -42,6 +44,7 @@ import { parseFlags } from '../state/cli-args.mjs';
42
44
  import { assertRunId, newRunId, StateError } from '../state/paths.mjs';
43
45
  import { processStartTime, readRun } from '../state/run.mjs';
44
46
  import { exec } from '../util/exec.mjs';
47
+ import { semaphore } from '../util/locks.mjs';
45
48
  import { writeSafe } from '../util/redact.mjs';
46
49
  import { isAlive, listEntries, readStartTime, UNKNOWN_START_TIME } from '../util/reaper.mjs';
47
50
  import { pidsDir, runRoot, setRunRoot, tmpBase } from '../util/tmp.mjs';
@@ -54,6 +57,8 @@ export const USAGE =
54
57
  'usage: code-forge review [--base <ref>] [--files <path…>] [--acceptance <file> | --intent "<text>"] [--run <id>] [--max <seconds>] [--json] [--keep-run]\n';
55
58
 
56
59
  export const DEFAULT_MAX_S = 900;
60
+ /** The most `review-file --wait` children that run at once. */
61
+ export const MAX_WAITERS = 8;
57
62
  export const DEFAULT_CLAUSE = 'Review for correctness, security and test quality; no intent was stated.';
58
63
  const GIT_TIMEOUT_MS = 30_000;
59
64
  const STEP_TIMEOUT_MS = 120_000;
@@ -511,14 +516,40 @@ export async function runReview(args, deps = {}) {
511
516
  const answer = lastJSON(res.stdout);
512
517
  queued.push({ file, ticket: typeof answer?.ticket === 'string' ? answer.ticket : null, answer });
513
518
  }
514
- for (const q of queued) {
515
- if (q.ticket === null) {
516
- outcomes.push(classify(q.file, { status: q.answer?.status ?? 'refused', reason: q.answer?.reason ?? q.answer?.status ?? 'not queued' }));
517
- continue;
518
- }
519
- const res = await cf(['review-file', '--wait', q.ticket, '--max', `${maxS}s`], maxS * 1000 + 60_000);
520
- outcomes.push(classify(q.file, lastJSON(res.stdout)));
521
- }
519
+ // Every file is enqueued above before any wait starts. One run deadline (`--max`, measured from
520
+ // here) covers all the waits, which run together: each `review-file --wait` gets the time still
521
+ // left, and a ticket still pending at the deadline is reported `stopped: timeout`. A result is
522
+ // announced on stderr as its file finishes; the table below keeps the file order.
523
+ // At most MAX_WAITERS `review-file --wait` children at once (a review of many files must not
524
+ // start one Node process per file); a wait that starts late still counts down to the same
525
+ // deadline, and one that starts after it is reported pending without a child.
526
+ const deadline = Date.now() + maxS * 1000;
527
+ const waiters = semaphore(MAX_WAITERS);
528
+ /** @type {Array<FileOutcome | undefined>} */
529
+ const byIndex = new Array(queued.length);
530
+ const finish = (/** @type {number} */ i, /** @type {FileOutcome} */ o) => {
531
+ byIndex[i] = o;
532
+ note(`${o.file} ${label(o)}`);
533
+ };
534
+ const settled = await Promise.allSettled(
535
+ queued.map(async (q, i) => {
536
+ if (q.ticket === null) {
537
+ finish(i, classify(q.file, { status: q.answer?.status ?? 'refused', reason: q.answer?.reason ?? q.answer?.status ?? 'not queued' }));
538
+ return;
539
+ }
540
+ await waiters.run(async () => {
541
+ const left = deadline - Date.now();
542
+ if (left <= 0) {
543
+ finish(i, classify(q.file, { status: 'pending' }));
544
+ return;
545
+ }
546
+ const res = await cf(['review-file', '--wait', q.ticket, '--max', `${Math.ceil(left / 1000)}s`], left + 60_000);
547
+ finish(i, classify(q.file, lastJSON(res.stdout)));
548
+ });
549
+ }),
550
+ );
551
+ for (const s of settled) if (s.status === 'rejected') throw s.reason;
552
+ for (const o of byIndex) outcomes.push(/** @type {FileOutcome} */ (o));
522
553
  const allOk = outcomes.every((o) => o.result === 'approved' || o.result === 'unchanged');
523
554
  code = allOk ? 0 : 1;
524
555
  if (json) out(`${JSON.stringify({ ok: allOk, base: base.sha, base_from: base.from, run: runId, block: blockId, files: outcomes, skipped, totals: totals(outcomes) })}\n`);
@@ -13,7 +13,7 @@ import path from 'node:path';
13
13
  import { cliNameForProvider } from '../engines/provider-cli.mjs';
14
14
  import { newRunId } from '../state/paths.mjs';
15
15
  import { runRoot } from '../util/tmp.mjs';
16
- import { checkCommands, checkConfig, checkJev, checkKeys, checkLedger, checkLinks, checkTmp, checkTools } from './local.mjs';
16
+ import { checkCommands, checkConfig, checkJev, checkKeys, checkLedger, checkLinks, checkReviewConcurrency, checkTmp, checkTools } from './local.mjs';
17
17
  import { configuredProviders, pingRoles, probeCli, probeCodexRules, probeIsolation, probePathDeny } from './probes.mjs';
18
18
  import { row } from './rows.mjs';
19
19
  import { probeWorkerAndSigner, SIGNER_ROW } from './worker-probe.mjs';
@@ -68,6 +68,7 @@ export async function runDoctor(opts = {}, deps = {}) {
68
68
  rows.push(...(await checkTools(toolOpts)));
69
69
  rows.push(...(await checkLedger()));
70
70
  rows.push(...checkTmp(cfg));
71
+ rows.push(checkReviewConcurrency(cfg));
71
72
  if (!cfg) {
72
73
  rows.push(row('probes', 'FAIL', 'probes', 'skipped: no valid config'));
73
74
  rows.push(SIGNER_ROW);
@@ -17,8 +17,10 @@ import { missingTools } from '../install/tools.mjs';
17
17
  import { OP_MESSAGES } from '../keys/onepassword.mjs';
18
18
  import { createDefaultKeyStore, resolveKey } from '../keys/store.mjs';
19
19
  import { ledgerDir } from '../ledger/paths.mjs';
20
+ import { DEFAULT_PROVIDER_CONCURRENCY, providerLimit } from '../session/spawn.mjs';
20
21
  import { listEntries } from '../util/reaper.mjs';
21
22
  import { ownerState, tmpBase } from '../util/tmp.mjs';
23
+ import { parallelTickets } from '../worker/loop.mjs';
22
24
  import { row } from './rows.mjs';
23
25
 
24
26
  /** @typedef {import('./rows.mjs').Row} Row */
@@ -188,3 +190,16 @@ export function checkTmp(cfg) {
188
190
  const detail = `${roots} stale roots, ${pids} stale pids`;
189
191
  return [row('tmp', roots + pids > 0 ? 'WARN' : 'OK', 'tmp', roots + pids > 0 ? `${detail} (the next code-forge run start removes them)` : detail)];
190
192
  }
193
+
194
+ /**
195
+ * B44: the `review concurrency` INFO row — how many review tickets run at once and each provider's
196
+ * session slots, from the effective config (defaults when a key is unset). Never throws.
197
+ * @param {Record<string, any> | null} cfg @returns {import('./rows.mjs').Row}
198
+ */
199
+ export function checkReviewConcurrency(cfg) {
200
+ const c = cfg ?? {};
201
+ const n = parallelTickets(c);
202
+ const extra = Object.keys(c.review?.provider_concurrency ?? {}).filter((p) => !Object.hasOwn(DEFAULT_PROVIDER_CONCURRENCY, p));
203
+ const slots = [...Object.keys(DEFAULT_PROVIDER_CONCURRENCY), ...extra].map((p) => `${p} ${providerLimit(c, p)}`).join(' · ');
204
+ return row('review-concurrency', 'INFO', 'review concurrency', `${n} ${n === 1 ? 'ticket' : 'tickets'} at once; provider slots ${slots}`);
205
+ }