@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 +42 -0
- package/README.md +17 -1
- package/docs/reference/blocks.json +84 -1
- package/docs/reference/config.md +5 -0
- package/package.json +4 -2
- package/schema/code-forge.schema.json +12 -1
- package/skill/SKILL.md +1 -0
- package/skill/references/adapters/claude-code.md +4 -0
- package/skill/references/ledger.md +3 -1
- package/skill/references/plan.md +9 -0
- package/skill/references/review.md +5 -1
- package/skill/scripts/forge +1 -1
- package/src/cli/facts.mjs +16 -1
- package/src/cli/review.mjs +40 -9
- package/src/doctor/index.mjs +2 -1
- package/src/doctor/local.mjs +15 -0
- package/src/ledger/spend.mjs +123 -6
- package/src/session/facts.mjs +218 -53
- package/src/session/spawn.mjs +164 -20
- package/src/util/locks.mjs +104 -0
- package/src/worker/engine.mjs +109 -32
- package/src/worker/loop.mjs +197 -45
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
|
|
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":
|
|
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
|
}
|
package/docs/reference/config.md
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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
|
|
package/skill/references/plan.md
CHANGED
|
@@ -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
|
|
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`.
|
package/skill/scripts/forge
CHANGED
|
@@ -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.
|
|
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) {
|
package/src/cli/review.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
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`);
|
package/src/doctor/index.mjs
CHANGED
|
@@ -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);
|
package/src/doctor/local.mjs
CHANGED
|
@@ -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
|
+
}
|