@codedology/code-forge 0.6.2 → 0.7.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 +34 -0
- package/README.md +16 -0
- package/docs/reference/blocks.json +31 -1
- package/docs/reference/config.md +1 -1
- package/package.json +1 -1
- package/schema/code-forge.schema.json +1 -1
- package/skill/SKILL.md +1 -1
- package/skill/references/adapters/claude-code.md +1 -1
- package/skill/references/adapters/solo.md +1 -1
- package/skill/references/code.md +4 -4
- package/skill/references/decisions.md +1 -1
- package/skill/references/review.md +3 -3
- package/skill/scripts/forge +1 -1
- package/src/cli/block.mjs +123 -18
- package/src/review/engine.mjs +7 -8
- package/src/review/gate-check.mjs +395 -25
- package/src/review/triage.mjs +11 -4
- package/src/review/validate-review.mjs +11 -11
- package/src/state/block.mjs +139 -2
- package/src/worker/loop.mjs +24 -2
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,40 @@ waits on a second real consumer (plan §10.4, Q17).
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.7.1] — 2026-10-07
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- A structured clean pass (passed: true, no findings, every hunk acknowledged, a non-blank summary) is no longer refused
|
|
14
|
+
as too_short on a larger diff: it needs 40 output tokens whatever the diff size (clean passes measured 108-121 tokens;
|
|
15
|
+
issue #6). An answer with any finding, or a blank summary, keeps review.min_tokens_out.
|
|
16
|
+
|
|
17
|
+
## [0.7.0] — 2026-10-07
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
|
|
21
|
+
- block close checked no file at all when the block's owned_files were directories, or when the coder's work was
|
|
22
|
+
committed before block open or done in another worktree, and then closed with findings open. The gate's file set is
|
|
23
|
+
now the owned diff plus every owned file with review rows for the block, an owned entry with no glob owns the paths
|
|
24
|
+
below it, an empty diff with review rows prints a WARN naming the likely cause, and a block with no such file refuses
|
|
25
|
+
no_changes.
|
|
26
|
+
- block close no longer passes a file whose approval a later review of the same content overrode: it refuses
|
|
27
|
+
not_approved <file> <finding> per open finding until the file is fixed and approved again or the finding is waived (a
|
|
28
|
+
critical only by the human's block waive; the severity the review recorded decides). The ledger review.result row now
|
|
29
|
+
carries approved and every open finding with its worst severity (a finding without a valid id as X<n>); an
|
|
30
|
+
"approved" result that lists a critical is recorded as not approved. An old ledger's review.result row is judged by
|
|
31
|
+
the ticket's signed result file, and refuses when that file is missing or fails its MAC. Triage never turns a
|
|
32
|
+
critical finding into a nit, a late-finding ruling clears only a warning or nit ruled nit, and review_cap stands
|
|
33
|
+
unless an approval of the current content comes after it.
|
|
34
|
+
|
|
35
|
+
### Security
|
|
36
|
+
|
|
37
|
+
- block close refuses unowned_change <file> for a file outside the block's owned files that changed after block open
|
|
38
|
+
(block open now records the tree) and that no other block of the run accounts for; it used to drop such a file from
|
|
39
|
+
the gate silently. Only .code-forge/ is exempt, and a file an open sibling block owns gets a WARN line naming it.
|
|
40
|
+
--no-require-reviews now needs the owner to confirm at a terminal: it has no --yes, exits 2 without
|
|
41
|
+
a terminal and is refused while an autopilot grant is active.
|
|
42
|
+
|
|
9
43
|
## [0.6.2] — 2026-10-07
|
|
10
44
|
|
|
11
45
|
### Changed
|
package/README.md
CHANGED
|
@@ -8,6 +8,22 @@ 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.7
|
|
12
|
+
|
|
13
|
+
Next release:
|
|
14
|
+
|
|
15
|
+
- **Short clean reviews count.** A clean pass (no findings, every hunk acknowledged, a summary)
|
|
16
|
+
needs only 40 output tokens on any diff size, so a correct short "no defect" review is no
|
|
17
|
+
longer refused as `too_short`.
|
|
18
|
+
|
|
19
|
+
In 0.7.0:
|
|
20
|
+
|
|
21
|
+
- **The close gate now checks every file.** `block close` could close a block with critical
|
|
22
|
+
findings still open: files inside an owned folder were skipped, and so was work committed
|
|
23
|
+
before `block open` or done in another worktree. Now every reviewed file is checked, a newer
|
|
24
|
+
failed review beats an older approval, a critical needs your waiver, a change outside the
|
|
25
|
+
block's files is refused, and `--no-require-reviews` asks you at a terminal.
|
|
26
|
+
|
|
11
27
|
## What's new in 0.6
|
|
12
28
|
|
|
13
29
|
In 0.6.1:
|
|
@@ -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": 56,
|
|
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": {
|
|
@@ -732,6 +732,36 @@
|
|
|
732
732
|
"B49b"
|
|
733
733
|
],
|
|
734
734
|
"note": "root-added 2026-10-06; also amends skill/SKILL.md, skill/references/{autopilot,decisions,ledger}.md, README.md, docs/*"
|
|
735
|
+
},
|
|
736
|
+
"B52": {
|
|
737
|
+
"title": "Block close gate hardening (issue #2 field note: a block closed with open criticals): the file set is the diff since base plus every file with a review row of the block, matched against owned paths including directories; an approval is stale after a later non-approved review of the same content; not_approved per open finding (a critical needs a human waiver); old-ledger rows judged by the signed result file; no_changes for an empty set; unowned_change for files changed after open outside owned paths (tree_at_open stamps); triage never demotes a critical; --no-require-reviews needs the owner at a terminal",
|
|
738
|
+
"wave": 12,
|
|
739
|
+
"level": "L2",
|
|
740
|
+
"status": "landed",
|
|
741
|
+
"owned": [
|
|
742
|
+
"src/review/gate-check.mjs",
|
|
743
|
+
"src/cli/block.mjs",
|
|
744
|
+
"test/review/close-gate.test.mjs",
|
|
745
|
+
"test/review/close-gate-field.test.mjs"
|
|
746
|
+
],
|
|
747
|
+
"depends_on": [
|
|
748
|
+
"B50"
|
|
749
|
+
],
|
|
750
|
+
"note": "root-added 2026-10-07; GitHub issue #2 field note 2026-10-07; also amends src/state/block.mjs, src/review/triage.mjs, src/worker/loop.mjs, skill/SKILL.md, skill/references/{code,review,decisions}.md, skill/references/adapters/{claude-code,solo}.md, docs/how-it-works.md, docs/tutorial-first-test.md, docs/concepts.md"
|
|
751
|
+
},
|
|
752
|
+
"B53": {
|
|
753
|
+
"title": "Clean-pass token floor for any diff size (issue #6): a structured clean pass (passed: true, no findings, hunks acknowledged in order, a non-blank summary) needs min(review.min_tokens_out, 40) output tokens; any finding, passed: false or a blank summary keeps the full floor",
|
|
754
|
+
"wave": 12,
|
|
755
|
+
"level": "L1",
|
|
756
|
+
"status": "landed",
|
|
757
|
+
"owned": [
|
|
758
|
+
"src/review/validate-review.mjs",
|
|
759
|
+
"test/review/validate-review.test.mjs"
|
|
760
|
+
],
|
|
761
|
+
"depends_on": [
|
|
762
|
+
"B52"
|
|
763
|
+
],
|
|
764
|
+
"note": "root-added 2026-10-07; GitHub issue #6; also amends src/review/engine.mjs (smallDiff removed), schema/code-forge.schema.json, docs/reference/config.md, docs/how-it-works.md, skill/references/review.md"
|
|
735
765
|
}
|
|
736
766
|
}
|
|
737
767
|
}
|
package/docs/reference/config.md
CHANGED
|
@@ -105,7 +105,7 @@ Every other key defaults as shown, or is entirely absent from a minimal file. `a
|
|
|
105
105
|
| `review.context.min_context_lines` | integer | | `10` | min 0 |
|
|
106
106
|
| `review.block_budget_tokens` | integer | | `150000` | min 1 |
|
|
107
107
|
| `review.block_budget_usd` | number \| null | | `null` | |
|
|
108
|
-
| `review.min_tokens_out` | integer | | `120` | min 1 |
|
|
108
|
+
| `review.min_tokens_out` | integer | | `120` | min 1; The stub guard: a review answer with fewer output tokens is unavailable (too_short). A clean pass (passed: true, no findings, a non-blank summary) needs only min(this, 40), whatever the diff size (B53). |
|
|
109
109
|
| `review.batch_small_files` | boolean | | `true` | |
|
|
110
110
|
| `review.session_timeout_s` | integer | | `300` | min 1 |
|
|
111
111
|
| `review.max_rounds_per_file` | integer | | `4` | min 2; max 6 |
|
package/package.json
CHANGED
|
@@ -136,7 +136,7 @@
|
|
|
136
136
|
"anyOf": [{ "type": "number", "exclusiveMinimum": 0 }, { "type": "null" }],
|
|
137
137
|
"default": null
|
|
138
138
|
},
|
|
139
|
-
"min_tokens_out": { "type": "integer", "minimum": 1, "default": 120 },
|
|
139
|
+
"min_tokens_out": { "type": "integer", "minimum": 1, "default": 120, "description": "The stub guard: a review answer with fewer output tokens is unavailable (too_short). A clean pass (passed: true, no findings, a non-blank summary) needs only min(this, 40), whatever the diff size (B53)." },
|
|
140
140
|
"batch_small_files": { "type": "boolean", "default": true },
|
|
141
141
|
"session_timeout_s": { "type": "integer", "minimum": 1, "default": 300 },
|
|
142
142
|
"max_rounds_per_file": { "type": "integer", "minimum": 2, "maximum": 6, "default": 4 },
|
package/skill/SKILL.md
CHANGED
|
@@ -41,7 +41,7 @@ State lives in the plan file's header — `facts → planned → hardened → co
|
|
|
41
41
|
4. Per block, in dispatch order, never more than `caps.coders` alive: `forge block open <id> --run <r> --level L<n> --owned <paths…> --acceptance <file> --brief <file> --lines <n> [--kind code|docs|contract]` 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>`.
|
|
42
42
|
5. The coder's first message is its **facts diff**, then its forecast (cases and lines, `templates/block-record.md`), then code, file by file: after each file `forge review-file <path> --block <id> --run <r>`, then `forge review-file --wait <ticket>` before the next file. Rounds ≥ 2 re-check only the fix hunks (`references/review.md`).
|
|
43
43
|
6. `===BLOCK <id> COMPLETE===` or `===BLOCK <id> FAILED: <reason>===` ends the coder's turn. Its report is a claim sheet: re-measure it (`references/code.md` §5).
|
|
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.
|
|
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 that no later review of the same content overrode (`not_approved`), no `unowned_change`, no `no_changes` (the files are the owned diff plus every owned file reviewed for the block), gates green, every clause covered, forecast vs actual, no `rule_break`. Red ⇒ `references/decisions.md` (S1 `next`, escalation). Green ⇒ one commit for the block in the project's house style; `forge block rebase <id> --run <r>` first when HEAD moved.
|
|
45
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).
|
|
46
46
|
|
|
47
47
|
## §4 Hard rules (the headings of `references/rules-core.md`)
|
|
@@ -12,7 +12,7 @@ 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> --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.
|
|
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`, which the human runs at their own terminal (it asks for a yes, has no `--yes`, and is refused under an active autopilot grant), both audited as their own rows.
|
|
16
16
|
|
|
17
17
|
## §2.1 Plan approval
|
|
18
18
|
|
|
@@ -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> --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
|
|
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`, which the human runs at their own terminal (it asks for a yes, has no `--yes`, and is refused under an active autopilot grant).
|
|
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)
|
package/skill/references/code.md
CHANGED
|
@@ -30,8 +30,8 @@ The coder's report is a claim sheet. The gate reproduces each number in the meas
|
|
|
30
30
|
|
|
31
31
|
| claim | re-measured by |
|
|
32
32
|
|---|---|
|
|
33
|
-
| only owned paths touched | file set = tracked changes ∪ untracked,
|
|
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 |
|
|
33
|
+
| only owned paths touched | file set = tracked changes since the base ∪ untracked (not ignored), listed from the repository top level, outside the repository top-level `.code-forge/` (keep transcripts and reports there; a subdirectory workspace's own `.code-forge/` is source tree except this run's record mirror and the block's default transcript: a `--transcript` or `--report` path in the source tree is a change like any other); each file must be in `owned_files`, unchanged since `block open` recorded the tree (`tree_at_open`; a path unreadable at open or at close never counts as unchanged), owned by another block of the run that is still **open**, or a closed block's file still exactly at the content its gate approved — else `unowned_change <file>` until `forge block claim <id> <path> --run <r>` or the human puts it back (not lifted by `--no-require-reviews`). The gate cannot tell which coder wrote a file an open sibling owns: it passes it to that sibling's gate and prints `WARN block <id>: <file> changed and is owned by open block <other>` for each one — read those lines |
|
|
34
|
+
| every file reviewed | file set = owned files changed since the base ∪ owned files with any review row of the block (`review.result`/`round`/`approved`/`triage`/`cap`/`late_finding`), an owned entry with no glob covering the paths below it; every path is repo-root-relative (what the worker signs), and an owned entry also matches relative to a workspace in a subdirectory; an empty set refuses `no_changes` (only the owner's `--no-require-reviews`, confirmed at a terminal, lifts it), and an empty diff with review rows WARNs (work committed before `block open`, or in another worktree — open blocks on a base without the work, in the workspace the coder edits). The report names a `review-file` ticket id per changed file (`--report`, which the orchestrator always passes) and a **signed** `review.approved` row for the current content hash of every changed or new file that no later review of the same content overrode (a `review.result` with `approved: false`, a `review.round` with findings open, a `review.cap`): else `not_approved <file> <finding>` per open finding, cleared only by a fix and a fresh approval or a waiver — a critical only by the human's `block waive` — or an architect-ruled disposition to a named successor block |
|
|
35
35
|
| tests green | every gate command run once in the export, each exit status read |
|
|
36
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` |
|
|
37
37
|
| test count | the filtered run's delta equals the new tests' own count |
|
|
@@ -39,9 +39,9 @@ The coder's report is a claim sheet. The gate reproduces each number in the meas
|
|
|
39
39
|
| no rule break | the transcript grep for forbidden verbs and paths (`--transcript <file>`, else the run record's log); a missing transcript ⇒ a signed `gate.transcript_missing` row and a WARN, never silence |
|
|
40
40
|
| high tier proven | every changed high-tier file (`proof.md` §1) has a signed, proven red→green row with `red_kind: assertion` for this block whose `covers` names it (light tier: optional), else `unproven <file>`; only the human's `block waive <id> proof --file <path>` clears it |
|
|
41
41
|
| rows genuine | the MAC of every gate-relevant row verifies; the live worker matches the pinned pid |
|
|
42
|
-
| nothing left open | no unruled `late_findings`; no file at `review_cap` without a signed `review.waived` row |
|
|
42
|
+
| nothing left open | no unruled `late_findings` (a ruling clears only a warning or nit ruled `nit`; a critical late finding needs the human's waiver); no file at `review_cap` without a signed `review.waived` row |
|
|
43
43
|
|
|
44
|
-
`--no-require-reviews` is the human's way out, written as its own audit row
|
|
44
|
+
`--no-require-reviews` is the human's way out, written as its own audit row. The human runs it themselves at a terminal: it asks for a yes, has no `--yes`, exits 2 unless stdin and stdout are both terminals, lifts `no_changes` and is refused while an autopilot grant is active — so never run it for them; tell them the command when they say so in chat. A finding counts only under the gate's own configuration; a tautology (an expected value computed by the code under test) is a finding. Reproduce disclosures: a coder that reports its own rule break did the hard part.
|
|
45
45
|
|
|
46
46
|
## §6 Budget — the stop is the feature (R6)
|
|
47
47
|
|
|
@@ -50,6 +50,6 @@ A fresh L3 session receives the failure evidence — the last attempt's diff, th
|
|
|
50
50
|
|
|
51
51
|
## §7 What reaches the human
|
|
52
52
|
|
|
53
|
-
`next = stop`, `review.over_budget`, an orphan file, `worker_down`, `review_cap`, `redecompose`, `ask_human`, a security question, consensus unavailable, and one budget raise per run. Everything else is decided by S1, S2 or the rules above and recorded. A change to a rule, limit or check is never decided here: it always reaches the owner (`rules-core.md` R17).
|
|
53
|
+
`next = stop`, `review.over_budget`, an orphan file or `unowned_change`, a `not_approved` critical finding, `--no-require-reviews`, `worker_down`, `review_cap`, `redecompose`, `ask_human`, a security question, consensus unavailable, and one budget raise per run. Everything else is decided by S1, S2 or the rules above and recorded. A change to a rule, limit or check is never decided here: it always reaches the owner (`rules-core.md` R17).
|
|
54
54
|
|
|
55
55
|
**Autopilot:** while a grant is active, a warning or nit waiver, one extra round at `review_cap` and a coder level go first to the delegate (`forge autopilot ask`, then `forge autopilot waive|round|level --decision <id>` only when it acted, `autopilot.md` §2). Never delegated: `next = stop`, `ask_human`, a security question, a critical finding, a budget raise, `redecompose` — they wait for the owner (`autopilot.md` §5).
|
|
@@ -11,7 +11,7 @@ The topology is decided in one place (`planSessions`). Consensus (§6) runs only
|
|
|
11
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`.
|
|
12
12
|
2. S1 `risk` (path floors first; `confidence < check` ⇒ S2 picks the depth).
|
|
13
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.
|
|
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.
|
|
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). A `critical` finding is always fix now — neither S1 nor the L3 ruling makes it a nit. Findings that come from a judge are final.
|
|
15
15
|
|
|
16
16
|
## §3 Isolation and the packet
|
|
17
17
|
|
|
@@ -19,7 +19,7 @@ Every reviewer, recheck, judge and S2 session runs with cwd = a fresh empty temp
|
|
|
19
19
|
|
|
20
20
|
## §4 The stub guard — what counts as a review (R11)
|
|
21
21
|
|
|
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.
|
|
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 (a clean pass — `passed: true`, no findings, a non-blank summary — needs only 40, whatever the diff size). 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.
|
|
23
23
|
|
|
24
24
|
## §5 The convergence rule — bounded fix rounds (R15)
|
|
25
25
|
|
|
@@ -31,7 +31,7 @@ Per file, per block:
|
|
|
31
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
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.
|
|
33
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. **Autopilot:** under a grant, a warning or nit finding may be waived by the delegate (`forge autopilot waive … --decision <id>`, written `by: autopilot`) and a file at the cap may get one extra round (`forge autopilot round`); a critical or proof waiver is never delegated (`autopilot.md`).
|
|
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.
|
|
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. The gate counts a ruling only when it is `nit` on a warning or nit finding; a critical late finding stays open until the human waives it.
|
|
35
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.
|
|
36
36
|
|
|
37
37
|
Rechecks are never degraded by the budget: a round-2+ packet is already the minimum.
|
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.7.1"
|
|
14
14
|
set -eu
|
|
15
15
|
|
|
16
16
|
self="$0"
|
package/src/cli/block.mjs
CHANGED
|
@@ -13,9 +13,26 @@
|
|
|
13
13
|
*
|
|
14
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
|
-
* changed owned file (deletions included)
|
|
17
|
-
*
|
|
18
|
-
*
|
|
16
|
+
* changed owned file (deletions included) that no later review of the same content made stale
|
|
17
|
+
* (B52: `not_approved <file> <finding>` per open finding of that later review, unless waived — a
|
|
18
|
+
* critical only by the human's `block waive`) — `--no-require-reviews` is the human's way out, is
|
|
19
|
+
* forbidden to coders (`mergeForbidden`), needs the human to confirm at a terminal (B52: stdin
|
|
20
|
+
* or stdout not a TTY ⇒ exit 2, an active autopilot grant ⇒ refused, there is no `--yes`; a block
|
|
21
|
+
* that is not open is refused first, exactly as without the flag), lifts `no_changes`, and leaves a signed
|
|
22
|
+
* `gate.reviews_waived {by: human}` row before the close (never a silent skip; a declined prompt
|
|
23
|
+
* prints `block <id> close: cancelled …`). B52: a changed or new file no block of the run
|
|
24
|
+
* accounts for is `unowned_change <file>` (`unownedChanges`, listed from the repository top
|
|
25
|
+
* level; not lifted by `--no-require-reviews`; only the repository top-level `.code-forge/` is
|
|
26
|
+
* exempt — plus, for a workspace in a subdirectory, this run's record mirror and this block's
|
|
27
|
+
* default transcript there — never a `--transcript` or `--report` path in the source tree; a
|
|
28
|
+
* closed block's file still exactly at the content its gate approved is accounted for); a file another OPEN block of the run owns
|
|
29
|
+
* is left to that block's gate with one `WARN` line per file (the close cannot tell which coder
|
|
30
|
+
* wrote it); an old ledger's `review.result` rows are judged by their signed result files
|
|
31
|
+
* (`resultFilesFor`). B52 (field run): the file set is the diff since base ∪ every owned file
|
|
32
|
+
* with review rows of the block (`reviewedFiles`; an owned entry with no glob owns the paths below
|
|
33
|
+
* it, `ownsAt`), so work committed before `block open` or done in another worktree is still
|
|
34
|
+
* checked (with a WARN naming the likely cause when the diff shows none of it); an empty set is
|
|
35
|
+
* `no_changes`; late findings ruled; `review_cap` findings waived; no
|
|
19
36
|
* `rule_break`, plus the transcript grep. The transcript is `--transcript`, else the block's
|
|
20
37
|
* `transcript` in the run record, else `.code-forge/runs/<run>/<block>.log`; none ⇒ a signed
|
|
21
38
|
* `gate.transcript_missing` row and a WARN line (never a silent pass, never a refusal). An id the
|
|
@@ -48,13 +65,13 @@ import path from 'node:path';
|
|
|
48
65
|
import { parse as parseYAML } from 'yaml';
|
|
49
66
|
import { approvalCheckText, expiryChecks } from '../autopilot/limits.mjs';
|
|
50
67
|
import { appendRow, readAllRows } from '../ledger/write.mjs';
|
|
51
|
-
import { attemptBlock, claimPath, closeBlock, openBlock, rebaseBlock, stopBlock } from '../state/block.mjs';
|
|
68
|
+
import { attemptBlock, claimPath, closeBlock, openBlock, rebaseBlock, repoTop, stopBlock } from '../state/block.mjs';
|
|
52
69
|
import { intFlag, parseFlags } from '../state/cli-args.mjs';
|
|
53
70
|
import { StateError } from '../state/paths.mjs';
|
|
54
|
-
import { findOverlap } from '../state/registry.mjs';
|
|
55
71
|
import { readRun, writeSigned } from '../state/run.mjs';
|
|
56
72
|
import { loadKey, verifyRow } from '../state/signer.mjs';
|
|
57
|
-
import { blockFileSet, reviewGateCheck, waiveFinding } from '../review/gate-check.mjs';
|
|
73
|
+
import { blockFileSet, changedFiles, ownsAt, resultFilesFor, reviewedFiles, reviewGateCheck, unownedChanges, waiveFinding } from '../review/gate-check.mjs';
|
|
74
|
+
import { checkExpiry } from '../autopilot/grant.mjs';
|
|
58
75
|
import { writeSafe } from '../util/redact.mjs';
|
|
59
76
|
import { DEFAULT_CONFIG_FILENAME, loadProjectConfig } from '../config/load.mjs';
|
|
60
77
|
import { validateConfig } from '../config/validate.mjs';
|
|
@@ -73,8 +90,13 @@ const FLAG_VALUES = ['run', 'level', 'acceptance', 'brief', 'attempt', 'base', '
|
|
|
73
90
|
|
|
74
91
|
/**
|
|
75
92
|
* @param {string[]} args
|
|
76
|
-
* @param {{
|
|
77
|
-
*
|
|
93
|
+
* @param {{
|
|
94
|
+
* stdout?: {write: (s: string) => unknown}, stderr?: {write: (s: string) => unknown}, probe?: import('../state/run.mjs').StartTimeProbe, now?: () => Date,
|
|
95
|
+
* isTTY?: boolean, stdinIsTTY?: boolean, stdoutIsTTY?: boolean,
|
|
96
|
+
* ui?: {confirm: (o: {message: string, initialValue?: boolean}) => Promise<unknown>, isCancel: (v: unknown) => boolean},
|
|
97
|
+
* }} [deps] - `now` (B48): the clock of the autopilot expiry checks. `stdinIsTTY` / `stdoutIsTTY`
|
|
98
|
+
* (default `process.stdin.isTTY` / `process.stdout.isTTY`; `isTTY` sets both) and `ui` (default
|
|
99
|
+
* `@clack/prompts`): B52's confirmation of `--no-require-reviews`, which needs both to be TTYs.
|
|
78
100
|
* @returns {Promise<number>}
|
|
79
101
|
*/
|
|
80
102
|
export async function runBlock(args, deps = {}) {
|
|
@@ -167,13 +189,44 @@ export async function runBlock(args, deps = {}) {
|
|
|
167
189
|
if (sub === 'close') {
|
|
168
190
|
const entry = record.blocks?.[id];
|
|
169
191
|
if (!entry) throw new StateError('unknown_block', `block ${id} is not in run ${runId}`);
|
|
170
|
-
|
|
192
|
+
// B52: a block that is not open is refused here, with or without --no-require-reviews, by
|
|
193
|
+
// the same check and message as `closeBlock` — and the owner is never asked about it
|
|
194
|
+
if (entry.status !== 'open') throw new StateError('no-block', `block ${id} is not open in run ${runId}`);
|
|
171
195
|
const reviewsWaived = flags['no-require-reviews'] === true;
|
|
172
|
-
// the
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
196
|
+
// B52: the human's way out is confirmed by the human, at a terminal, before anything is written
|
|
197
|
+
if (reviewsWaived) {
|
|
198
|
+
const asked = await confirmNoReviews(runId, id, deps);
|
|
199
|
+
if (asked === 'cancelled') {
|
|
200
|
+
err(`block ${id} close: cancelled — the review checks stay on\n`);
|
|
201
|
+
return 1;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
const rows = (await readAllRows(record.project)).filter((row) => row.run === runId);
|
|
205
|
+
// B52: ONE path base and ONE listing, memoised as a promise and shared by every check. The
|
|
206
|
+
// base is the repository top level: the worker signs every review row with a repo-root-
|
|
207
|
+
// relative `file` and keeps its queue and signed result files under `<top>/.code-forge/`
|
|
208
|
+
// (`repoRootOf`), and `tree_at_open` is stamped there too. An owned entry matches a path
|
|
209
|
+
// as given or, for a workspace in a subdirectory, relative to the workspace (`ownsAt`).
|
|
210
|
+
// A failure here is the usual `git-failed` refusal.
|
|
211
|
+
/** @type {Promise<{top: string, wsPrefix: string, owns: (owned: ReadonlyArray<string>, file: string) => boolean, changed: string[], fileSet: Array<{file: string, content_hash: string}>, reviewed: string[]}> | undefined} */
|
|
212
|
+
let treeOnce;
|
|
213
|
+
const tree = () =>
|
|
214
|
+
(treeOnce ??= (async () => {
|
|
215
|
+
const top = await repoTop(record.workspace);
|
|
216
|
+
// both sides as realpaths: git prints the top resolved (/private/var on macOS) while the
|
|
217
|
+
// run record may keep a symlinked spelling (/var)
|
|
218
|
+
const real = async (/** @type {string} */ p) => realpath(p).catch(() => path.resolve(p));
|
|
219
|
+
const wsPrefix = path.relative(await real(top), await real(record.workspace)).split(path.sep).join('/');
|
|
220
|
+
const owns = ownsAt(wsPrefix);
|
|
221
|
+
const isMine = (/** @type {string} */ f) => owns(entry.owned_files, f);
|
|
222
|
+
const changed = await changedFiles({ repoRoot: top, base: entry.base_sha });
|
|
223
|
+
// the file set is the diff since base ∪ every owned file with review rows of this block —
|
|
224
|
+
// work the diff cannot see (committed before `block open`, done in another worktree) is
|
|
225
|
+
// still checked, at its content in the workspace
|
|
226
|
+
const reviewed = reviewedFiles(rows, id).filter(isMine);
|
|
227
|
+
const fileSet = await blockFileSet({ repoRoot: top, base: entry.base_sha, owned: entry.owned_files, matchOwned: isMine, changed: [...new Set([...changed, ...reviewed])].sort() });
|
|
228
|
+
return { top, wsPrefix, owns, changed, fileSet, reviewed };
|
|
229
|
+
})());
|
|
177
230
|
const reviews = reviewGateCheck(async () => {
|
|
178
231
|
const transcript = await findTranscript(record, id, typeof flags.transcript === 'string' ? flags.transcript : null);
|
|
179
232
|
if (transcript === null) {
|
|
@@ -184,14 +237,37 @@ export async function runBlock(args, deps = {}) {
|
|
|
184
237
|
if (reviewsWaived) await writeSigned(runId, writeRow, { event: 'gate.reviews_waived', block: id, by: 'human' });
|
|
185
238
|
// B20: the high paths come first — a base the workspace does not know refuses the close here
|
|
186
239
|
const highPaths = await highPathsAt(record.workspace, entry.base_sha);
|
|
187
|
-
const fileSet = await
|
|
240
|
+
const { top, wsPrefix, owns, changed, fileSet, reviewed } = await tree();
|
|
241
|
+
const isMine = (/** @type {string} */ f) => owns(entry.owned_files, f);
|
|
242
|
+
const key = await loadKey(runId);
|
|
243
|
+
// B52: an empty diff with review rows is never silent — name the likely cause
|
|
244
|
+
if (reviewed.length > 0 && !changed.some(isMine)) {
|
|
245
|
+
err(`WARN block ${id}: no owned file differs from the block base in the workspace, but ${reviewed.length} owned file(s) have review rows — the work was likely committed before block open or done in another worktree; they are checked at their content in the workspace\n`);
|
|
246
|
+
}
|
|
247
|
+
// B52: only code-forge's own state is exempt — `<top>/.code-forge/`, where the worker keeps
|
|
248
|
+
// its queue and signed results — never a `--transcript` or `--report` path in the source
|
|
249
|
+
// tree. For a workspace in a subdirectory, `<workspace>/.code-forge/` is source tree like any
|
|
250
|
+
// other path, except the two files code-forge itself keeps there at fixed names: this run's
|
|
251
|
+
// record mirror and this block's default transcript (`.code-forge/runs/<run>.json`,
|
|
252
|
+
// `.code-forge/runs/<run>/<id>.log`)
|
|
253
|
+
const wsRel = (/** @type {string} */ p) => path.posix.join(wsPrefix, p);
|
|
254
|
+
const ownState = new Set([wsRel(`.code-forge/runs/${runId}.json`), wsRel(`.code-forge/runs/${runId}/${id}.log`)]);
|
|
255
|
+
const scope = await unownedChanges({ top, changed: changed.filter((f) => !ownState.has(f)), id, blocks: record.blocks, rows, key, isMine, owns, stateDir: '.code-forge' });
|
|
256
|
+
if (scope === null) err(`WARN block ${id}: opened before code-forge recorded the tree at block open; unowned changes were not checked\n`);
|
|
257
|
+
for (const { file, block: other } of scope?.siblings ?? []) err(`WARN block ${id}: ${file} changed and is owned by open block ${other}; not checked here (its own gate reviews it)\n`);
|
|
188
258
|
return {
|
|
189
259
|
block: id,
|
|
190
260
|
runId,
|
|
191
261
|
rows,
|
|
192
|
-
key
|
|
262
|
+
key,
|
|
193
263
|
transcript,
|
|
264
|
+
// B52: a change no block of the run owns is refused — `--no-require-reviews` does not lift it
|
|
265
|
+
unowned: scope?.unowned ?? [],
|
|
266
|
+
// B52: an old ledger's review.result rows are judged by their signed result files
|
|
267
|
+
resultFiles: await resultFilesFor({ repoRoot: top, runId, block: id, rows }),
|
|
194
268
|
files: reviewsWaived ? [] : fileSet,
|
|
269
|
+
// B52: nothing changed and nothing reviewed is never a pass (the owner's way out is --no-require-reviews)
|
|
270
|
+
noChanges: !reviewsWaived && fileSet.length === 0,
|
|
195
271
|
// B20: high-tier files need a proven red→green row; `--no-require-reviews` does not lift it
|
|
196
272
|
proofFiles: fileSet,
|
|
197
273
|
highPaths,
|
|
@@ -206,8 +282,8 @@ export async function runBlock(args, deps = {}) {
|
|
|
206
282
|
} catch {
|
|
207
283
|
return { ok: false, reason: 'coder report FAILED: --report cannot be read' };
|
|
208
284
|
}
|
|
209
|
-
const
|
|
210
|
-
const missing = missingReviewTickets(text,
|
|
285
|
+
const { fileSet } = await tree();
|
|
286
|
+
const missing = missingReviewTickets(text, fileSet.map((f) => f.file));
|
|
211
287
|
return missing.length === 0 ? { ok: true } : { ok: false, reason: `coder report FAILED: no review-file ticket id for ${missing.join(', ')}` };
|
|
212
288
|
};
|
|
213
289
|
const result = await closeBlock({ runId, id, rows, writeRow, livePid: intFlag(flags['worker-pid'], 'worker-pid'), probe, extraChecks: [reviews, report] });
|
|
@@ -222,6 +298,35 @@ export async function runBlock(args, deps = {}) {
|
|
|
222
298
|
return 2;
|
|
223
299
|
}
|
|
224
300
|
|
|
301
|
+
/** B52: the refusal when `--no-require-reviews` has no terminal to ask the human at. */
|
|
302
|
+
export const NO_REVIEWS_NO_TTY = '--no-require-reviews needs the human at a terminal to confirm; the block stays open (there is no --yes)';
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* B52: `--no-require-reviews` lifts every per-file review check, so it is the human's alone — the
|
|
306
|
+
* same rule as `autopilot approve`: a flag can be passed by any process (the orchestrator, a
|
|
307
|
+
* script, the delegate's tooling), so the human confirms at a terminal and there is NO `--yes`.
|
|
308
|
+
* No terminal — stdin AND stdout must both be TTYs (a piped stdout means nobody may see the
|
|
309
|
+
* question) — ⇒ `usage` (exit 2); an active autopilot grant on the run ⇒ `autopilot-active`
|
|
310
|
+
* (exit 1; the owner stops the grant first) — nothing is written either way. `deps.stdinIsTTY` /
|
|
311
|
+
* `deps.stdoutIsTTY` inject each side; `deps.isTTY` injects both at once.
|
|
312
|
+
* @param {string} runId @param {string} id
|
|
313
|
+
* @param {{isTTY?: boolean, stdinIsTTY?: boolean, stdoutIsTTY?: boolean, ui?: {confirm: (o: {message: string, initialValue?: boolean}) => Promise<unknown>, isCancel: (v: unknown) => boolean}, now?: () => Date}} deps
|
|
314
|
+
* @returns {Promise<'confirmed' | 'cancelled'>}
|
|
315
|
+
* @throws {StateError} `usage`, `autopilot-active`
|
|
316
|
+
*/
|
|
317
|
+
async function confirmNoReviews(runId, id, deps) {
|
|
318
|
+
const stdinTTY = deps.stdinIsTTY ?? deps.isTTY ?? process.stdin.isTTY === true;
|
|
319
|
+
const stdoutTTY = deps.stdoutIsTTY ?? deps.isTTY ?? process.stdout.isTTY === true;
|
|
320
|
+
if (!(stdinTTY && stdoutTTY)) throw new StateError('usage', NO_REVIEWS_NO_TTY);
|
|
321
|
+
const grant = await checkExpiry(runId, (deps.now ?? (() => new Date()))());
|
|
322
|
+
if (grant.state === 'active') {
|
|
323
|
+
throw new StateError('autopilot-active', `--no-require-reviews is refused while an autopilot grant is active on run ${runId} (stop it first: code-forge autopilot stop --run ${runId})`);
|
|
324
|
+
}
|
|
325
|
+
const ui = deps.ui ?? /** @type {any} */ (await import('@clack/prompts'));
|
|
326
|
+
const go = await ui.confirm({ message: `Close block ${id} of run ${runId} WITHOUT the per-file review checks? Only the owner may say yes.`, initialValue: false });
|
|
327
|
+
return ui.isCancel(go) || go !== true ? 'cancelled' : 'confirmed';
|
|
328
|
+
}
|
|
329
|
+
|
|
225
330
|
/**
|
|
226
331
|
* B47: the level `autopilot level` chose for the block — the latest `autopilot.level` row of this
|
|
227
332
|
* run for it whose MAC verifies — or null when there is none.
|
package/src/review/engine.mjs
CHANGED
|
@@ -33,7 +33,7 @@ import { blockKind, isDocsKind } from '../decide/escalation.mjs';
|
|
|
33
33
|
import { fallbackRisk } from '../decide/fallback-rules.mjs';
|
|
34
34
|
import { tierFor } from '../proof/tiers.mjs';
|
|
35
35
|
import { assembleJudgePacket, assemblePacket, attachFiles, budgetFor, readFileDiff } from './packet.mjs';
|
|
36
|
-
import { FINDING_SCHEMA, minTokensOut,
|
|
36
|
+
import { FINDING_SCHEMA, minTokensOut, validateReview } from './validate-review.mjs';
|
|
37
37
|
import { spawnWithTimeoutRetry } from './session-retry.mjs';
|
|
38
38
|
import { assertRowPath } from '../worker/ticket.mjs';
|
|
39
39
|
|
|
@@ -194,12 +194,11 @@ export async function reviewFile(input, deps) {
|
|
|
194
194
|
if (diff.diffText.trim().length === 0) return { status: 'no_change', approved: false, ...head, sessions: [] };
|
|
195
195
|
mkdirSync(workDir, { recursive: true, mode: 0o700 });
|
|
196
196
|
const min = minTokensOut(cfg);
|
|
197
|
-
const smallDiff = diff.plusCount <= SMALL_DIFF_ADDED_LINES;
|
|
198
197
|
|
|
199
198
|
/** @param {SessionSpec} spec @param {{text: string, hunkHeaders: string[], tokensIn: number, contextMode: string | null}} packet */
|
|
200
199
|
const run = async (spec, packet) => {
|
|
201
200
|
/** @type {Record<string, any>} */
|
|
202
|
-
let summary = await runOne(spec, packet.text, packet, { deps, workDir, min,
|
|
201
|
+
let summary = await runOne(spec, packet.text, packet, { deps, workDir, min, cfg });
|
|
203
202
|
const needs = summary.review?.needs_file;
|
|
204
203
|
if (spec.role === 'reviewer' && summary.status === 'ok' && Array.isArray(needs) && needs.length > 0) {
|
|
205
204
|
const paths = safeNeeds(needs);
|
|
@@ -210,7 +209,7 @@ export async function reviewFile(input, deps) {
|
|
|
210
209
|
extra = null;
|
|
211
210
|
}
|
|
212
211
|
if (extra === null) return { ...summary, status: 'unavailable', reason: 'needs_file-refused', review: null };
|
|
213
|
-
summary = { ...(await runOne(spec, `${packet.text}${extra}`, packet, { deps, workDir, min,
|
|
212
|
+
summary = { ...(await runOne(spec, `${packet.text}${extra}`, packet, { deps, workDir, min, cfg })), needs_file_round: true };
|
|
214
213
|
}
|
|
215
214
|
return summary;
|
|
216
215
|
};
|
|
@@ -234,7 +233,7 @@ export async function reviewFile(input, deps) {
|
|
|
234
233
|
if (judgeSpec) {
|
|
235
234
|
const bySlot = Object.fromEntries(done.map((s) => [s.slot, s.review]));
|
|
236
235
|
const packet = assembleJudgePacket({ diff, reports: { A: bySlot.A, B: bySlot.B }, cfg });
|
|
237
|
-
final = await runOne(judgeSpec, packet.text, packet, { deps, workDir, min,
|
|
236
|
+
final = await runOne(judgeSpec, packet.text, packet, { deps, workDir, min, cfg });
|
|
238
237
|
sessions.push(publicSummary(final));
|
|
239
238
|
if (final.status !== 'ok') {
|
|
240
239
|
await note(deps, { event: 'review.unavailable', reason: final.reason, lens: 'judge' });
|
|
@@ -250,9 +249,9 @@ export async function reviewFile(input, deps) {
|
|
|
250
249
|
* One session: packet file (0600, removed after), spawn, stub guard.
|
|
251
250
|
* @param {SessionSpec} spec @param {string} text
|
|
252
251
|
* @param {{hunkHeaders: string[], tokensIn: number, contextMode: string | null}} packet
|
|
253
|
-
* @param {{deps: ReviewDeps, workDir: string, min: number,
|
|
252
|
+
* @param {{deps: ReviewDeps, workDir: string, min: number, cfg: Record<string, any>}} env
|
|
254
253
|
*/
|
|
255
|
-
async function runOne(spec, text, packet, { deps, workDir, min
|
|
254
|
+
async function runOne(spec, text, packet, { deps, workDir, min }) {
|
|
256
255
|
const promptPath = path.join(workDir, `${spec.lens}-${randomBytes(6).toString('hex')}.md`);
|
|
257
256
|
const base = { lens: spec.lens, role: spec.role, level: spec.level, slot: spec.slot, context_mode: packet.contextMode, ctx_tokens_in: packet.tokensIn };
|
|
258
257
|
writeFileSync(promptPath, text, { mode: 0o600 });
|
|
@@ -285,7 +284,7 @@ async function runOne(spec, text, packet, { deps, workDir, min, smallDiff = fals
|
|
|
285
284
|
} finally {
|
|
286
285
|
rmSync(promptPath, { force: true });
|
|
287
286
|
}
|
|
288
|
-
const verdict = validateReview(res, { hunkHeaders: packet.hunkHeaders, minTokensOut: min
|
|
287
|
+
const verdict = validateReview(res, { hunkHeaders: packet.hunkHeaders, minTokensOut: min });
|
|
289
288
|
const meta = {
|
|
290
289
|
...base,
|
|
291
290
|
provider: res?.provider ?? null,
|