@codedology/code-forge 0.6.2 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,32 @@ waits on a second real consumer (plan §10.4, Q17).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.7.0] — 2026-10-07
10
+
11
+ ### Fixed
12
+
13
+ - block close checked no file at all when the block's owned_files were directories, or when the coder's work was
14
+ committed before block open or done in another worktree, and then closed with findings open. The gate's file set is
15
+ now the owned diff plus every owned file with review rows for the block, an owned entry with no glob owns the paths
16
+ below it, an empty diff with review rows prints a WARN naming the likely cause, and a block with no such file refuses
17
+ no_changes.
18
+ - block close no longer passes a file whose approval a later review of the same content overrode: it refuses
19
+ not_approved <file> <finding> per open finding until the file is fixed and approved again or the finding is waived (a
20
+ critical only by the human's block waive; the severity the review recorded decides). The ledger review.result row now
21
+ carries approved and every open finding with its worst severity (a finding without a valid id as X<n>); an
22
+ "approved" result that lists a critical is recorded as not approved. An old ledger's review.result row is judged by
23
+ the ticket's signed result file, and refuses when that file is missing or fails its MAC. Triage never turns a
24
+ critical finding into a nit, a late-finding ruling clears only a warning or nit ruled nit, and review_cap stands
25
+ unless an approval of the current content comes after it.
26
+
27
+ ### Security
28
+
29
+ - block close refuses unowned_change <file> for a file outside the block's owned files that changed after block open
30
+ (block open now records the tree) and that no other block of the run accounts for; it used to drop such a file from
31
+ the gate silently. Only .code-forge/ is exempt, and a file an open sibling block owns gets a WARN line naming it.
32
+ --no-require-reviews now needs the owner to confirm at a terminal: it has no --yes, exits 2 without
33
+ a terminal and is refused while an autopilot grant is active.
34
+
9
35
  ## [0.6.2] — 2026-10-07
10
36
 
11
37
  ### Changed
package/README.md CHANGED
@@ -10,6 +10,14 @@ for cross-model, parallel, evidence-gated feature delivery — on Claude Code, C
10
10
 
11
11
  ## What's new in 0.6
12
12
 
13
+ Next release:
14
+
15
+ - **The close gate now checks every file.** `block close` could close a block with critical
16
+ findings still open: files inside an owned folder were skipped, and so was work committed
17
+ before `block open` or done in another worktree. Now every reviewed file is checked, a newer
18
+ failed review beats an older approval, a critical needs your waiver, a change outside the
19
+ block's files is refused, and `--no-require-reviews` asks you at a terminal.
20
+
13
21
  In 0.6.1:
14
22
 
15
23
  - **Never relax a rule alone.** A new skill rule (R17): the orchestrator and the delegate never
@@ -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": 54,
7
+ "block_count": 55,
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,22 @@
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"
735
751
  }
736
752
  }
737
753
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@codedology/code-forge",
3
- "version": "0.6.2",
3
+ "version": "0.7.0",
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",
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)
@@ -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, minus other active blocks' owned sets ⊆ `owned_files`; anything else is an orphan that stops every gate until `forge block claim <id> <path> --run <r>` or the human deletes it |
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; the orchestrator runs it only after the human said 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.
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
 
@@ -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.
@@ -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.6.2"
13
+ PINNED_VERSION="0.7.0"
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) — `--no-require-reviews` is the human's way out, is
17
- * forbidden to coders (`mergeForbidden`), and leaves a signed `gate.reviews_waived {by: human}`
18
- * row before the close (never a silent skip); late findings ruled; `review_cap` findings waived; no
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 {{stdout?: {write: (s: string) => unknown}, stderr?: {write: (s: string) => unknown}, probe?: import('../state/run.mjs').StartTimeProbe, now?: () => Date}} [deps] -
77
- * `now` (B48): the clock of the autopilot expiry checks.
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
- const rows = (await readAllRows(record.project)).filter((row) => row.run === runId);
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 block's file set is listed once and shared by the review and report checks; a failure
173
- // there is the usual `git-failed` refusal
174
- /** @type {ReturnType<typeof blockFileSet> | undefined} */
175
- let fileSetOnce;
176
- const blockFiles = () => (fileSetOnce ??= blockFileSet({ repoRoot: record.workspace, base: entry.base_sha, owned: entry.owned_files, matchOwned: (f) => findOverlap(entry.owned_files, [f]) !== null }));
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 blockFiles();
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: await loadKey(runId),
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 files = await blockFiles();
210
- const missing = missingReviewTickets(text, files.map((f) => f.file));
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.
@@ -1,16 +1,43 @@
1
1
  /**
2
2
  * The block gate's review rows (plan §4.6 rows 2, 9 and 11; §4.9 `block waive`; §4.11; block B12b).
3
3
  *
4
- * `checkBlockReviews` refuses the block when:
4
+ * `checkBlockReviews` refuses the block when (the file set is the close's: the diff since base ∪
5
+ * every owned file with review rows of the block, {@link reviewedFiles}, owned through
6
+ * {@link ownsPath}):
7
+ * - `no_changes`: (B52) that set is empty — the block changed no owned file and has no reviews;
8
+ * a close that checked nothing is never a pass (`--no-require-reviews` is the owner's way out);
9
+ * - `unowned_change`: (B52) a changed or new file outside the block's `owned_files` that no other
10
+ * block of the run accounts for ({@link unownedChanges}: it is as it was when the block opened,
11
+ * another block of the run that is still OPEN owns it — the gate cannot tell which coder wrote
12
+ * it, so the close WARNs naming each such file — or a closed one owns it and its gate approved
13
+ * exactly the current content);
5
14
  * - `unreviewed`: a changed or new file has NO `review.approved` row for its CURRENT content hash;
6
15
  * - `unsigned`: the file's approvals for that hash exist but none carries a valid MAC;
16
+ * - `not_approved`: (B52) the file's signed approval for its current hash is STALE — a later row of
17
+ * the block (ledger order) for the same file and hash refuses it ({@link denialOf}: a
18
+ * `review.result` with `approved: false` whatever its status, except the outcomes that never
19
+ * reviewed the content, {@link neverReviewed}; a `review.round` with `open_after` not 0; a
20
+ * `review.cap`; a refusal row with no `content_hash` counts for every hash of its file). One
21
+ * refusal per open finding the latest such row names, each cleared only by a valid waiver for
22
+ * the severity that row RECORDS (anything but warning/nit — a critical — only by a signed
23
+ * `by: human` waiver); a row that names no finding is one `not_approved <file>` that only a
24
+ * fresh approval clears. An old ledger's `review.result` row has no `approved` field and is
25
+ * trusted neither way: the ticket's signed result file decides ({@link resultFilesFor}); a
26
+ * file that is missing or fails its MAC refuses `not_approved <file>`. Refusal rows count
27
+ * whether or not they are signed (fail closed); approvals only when signed. The check is per
28
+ * content hash: an approval of the current bytes is not made stale by a review of other bytes
29
+ * (a file edited and then put back);
7
30
  * - `rule_break`: a `rule_break` row names the block, or the coder transcript hits the forbidden
8
31
  * list (`scanTranscript`; a read of `~/.code-forge/runs/` — the signer key — is the named case);
9
32
  * - `late_unruled`: a `review.late_finding` has neither a signed `review.late_ruling` (the
10
- * block-close sweep's L3 ruling) nor a signed `review.waived` row;
33
+ * block-close sweep's L3 ruling) nor a signed `review.waived` row. B52: a ruling clears it only
34
+ * when it rules `nit` AND the late finding is recorded `warning` or `nit` — a `fix_now` ruling
35
+ * clears nothing, and a critical (or unrecorded severity) late finding needs a waiver;
11
36
  * - `review_cap`: a file stopped at `review_cap` has an open finding with no signed
12
- * `review.waived {by: human}` row — unless the file has a signed approval at its current hash
13
- * (the human fixed it by hand and it was reviewed);
37
+ * `review.waived {by: human}` row — unless a signed approval of the file's current hash comes
38
+ * AFTER the cap row (ledger order) and no later refusal row made it stale (the human fixed it
39
+ * by hand and it was reviewed); a finding already refused as `not_approved` for that file is
40
+ * not repeated;
14
41
  * - `unproven`: a changed owned file of tier `high` (B20, Ricardo 2026-09-25) has no covering
15
42
  * red→green proof (`checkBlockProof`, below). Light-tier files need no proof row.
16
43
  * Rulings and waivers only count when their MAC verifies AND name the finding's file exactly
@@ -20,7 +47,7 @@
20
47
  * B47 (autopilot): a `review.waived {by: autopilot}` row (written by `autopilot waive`) clears a
21
48
  * finding ONLY when {@link autopilotWaiverProblem} finds nothing wrong with it: its MAC verifies;
22
49
  * the finding is not `proof`; the finding's severity recorded in the block's signed review rows
23
- * (`review.triage`, `review.late_finding`; {@link recordedSeverity}) is `warning` or `nit` and
50
+ * (`review.triage`, `review.late_finding`, B52: `review.result.open`; {@link recordedSeverity}) is `warning` or `nit` and
24
51
  * equals the waiver's `severity`; a signed `autopilot.grant` row of this run with the waiver's
25
52
  * `grant_id` exists, its window (`ts` ≤ waiver `ts` < `until`) holds the waiver, and its scopes
26
53
  * include `waive:<severity>`; no `autopilot.stop` / `autopilot.expire` row for that grant is
@@ -41,12 +68,15 @@ import { mergeForbidden, scanTranscript } from '../util/forbidden.mjs';
41
68
  import { exec } from '../util/exec.mjs';
42
69
  import { readRun, writeSigned } from '../state/run.mjs';
43
70
  import { StateError } from '../state/paths.mjs';
71
+ import { findOverlap } from '../state/registry.mjs';
72
+ import { sameStamp, treeStamp } from '../state/block.mjs';
44
73
  import { verifyRow } from '../state/signer.mjs';
45
74
  import { tierFor } from '../proof/tiers.mjs';
46
75
  import { DELETED_HASH, contentHash, gitChildEnv } from '../worker/ticket.mjs';
76
+ import { verifyResult } from '../worker/queue.mjs';
47
77
 
48
78
  /**
49
- * @typedef {{code: 'unreviewed' | 'unsigned' | 'rule_break' | 'late_unruled' | 'review_cap' | 'unproven' | 'autopilot_waiver_invalid', file?: string, finding?: string, detail?: string}} Refusal
79
+ * @typedef {{code: 'no_changes' | 'unowned_change' | 'not_approved' | 'unreviewed' | 'unsigned' | 'rule_break' | 'late_unruled' | 'review_cap' | 'unproven' | 'autopilot_waiver_invalid', file?: string, finding?: string, detail?: string}} Refusal
50
80
  */
51
81
 
52
82
  /** @param {Record<string, any>} row @param {Buffer} key */
@@ -57,15 +87,19 @@ const verified = (row, key) => verifyRow(row, key).ok;
57
87
  * block: string, runId: string, files: Array<{file: string, content_hash: string}>,
58
88
  * rows: Array<Record<string, any>>, key: Buffer, transcript?: string | null,
59
89
  * extraTokens?: string[], proofFiles: Array<{file: string, content_hash: string}>,
60
- * highPaths: ReadonlyArray<string>,
61
- * }} opts `proofFiles` is the set the proof check reads — `--no-require-reviews` empties `files`
90
+ * highPaths: ReadonlyArray<string>, unowned?: ReadonlyArray<string>, resultFiles?: Record<string, ResultFile>, noChanges?: boolean,
91
+ * }} opts `noChanges` (B52): the block changed no owned file and no owned file has review rows —
92
+ * refused `no_changes`. `resultFiles` (B52): the signed result files of old-ledger `review.result` rows
93
+ * ({@link resultFilesFor}; a row whose file is absent here refuses). `unowned` (B52): the
94
+ * changed files no block accounts for ({@link unownedChanges}),
95
+ * refused even when `files` is empty. `proofFiles` is the set the proof check reads — `--no-require-reviews` empties `files`
62
96
  * but never the proof check; `highPaths` is `proof.tiers.high.paths`. Both are REQUIRED: a
63
97
  * caller that leaves one out gets a TypeError, never a silent default that skips the proof.
64
98
  * `runId` is required too: the proof check matches its rows on this run only (fix round 4).
65
99
  * @returns {{ok: boolean, refusals: Refusal[]}}
66
100
  * @throws {TypeError} `runId`, `proofFiles` or `highPaths` undefined
67
101
  */
68
- export function checkBlockReviews({ block, runId, files, rows, key, transcript = null, extraTokens = [], proofFiles, highPaths }) {
102
+ export function checkBlockReviews({ block, runId, files, rows, key, transcript = null, extraTokens = [], proofFiles, highPaths, unowned = [], resultFiles = {}, noChanges = false }) {
69
103
  if (runId === undefined) throw new TypeError('checkBlockReviews: runId is required (the proof check matches rows on this run only)');
70
104
  if (proofFiles === undefined) throw new TypeError('checkBlockReviews: proofFiles is required (the changed owned files the proof check reads)');
71
105
  if (highPaths === undefined) throw new TypeError('checkBlockReviews: highPaths is required (proof.tiers.high.paths)');
@@ -74,11 +108,58 @@ export function checkBlockReviews({ block, runId, files, rows, key, transcript =
74
108
  const refusals = [];
75
109
  const approvedFor = (/** @type {string} */ file, /** @type {string} */ hash) => mine.filter((r) => r.event === 'review.approved' && r.file === file && r.content_hash === hash);
76
110
  const hashOf = new Map(files.map((f) => [f.file, f.content_hash]));
111
+ /**
112
+ * B52: the index of the last signed approval and the latest refusal AFTER it for (file, hash);
113
+ * `denial` is null when that approval stands (or there is none — `unreviewed` / `unsigned` say
114
+ * so). A refusal row with no `content_hash` counts for every hash of its file (fail closed).
115
+ * `latest` is the latest refusal for (file, hash) whether or not an approval exists.
116
+ * @param {string} file @param {string} hash
117
+ * @returns {{lastApproval: number, denial: Denial | null, latest: Denial | null}}
118
+ */
119
+ const latestOf = (file, hash) => {
120
+ let lastApproval = -1;
121
+ /** @type {Denial | null} */
122
+ let denial = null;
123
+ let lastDenial = -1;
124
+ mine.forEach((r, i) => {
125
+ if (r.file !== file) return;
126
+ if (r.event === 'review.approved') {
127
+ if (r.content_hash === hash && verified(r, key)) lastApproval = i;
128
+ return;
129
+ }
130
+ if (r.content_hash !== hash && r.content_hash !== undefined) return;
131
+ const d = denialOf(r, resultFiles);
132
+ if (d !== null) {
133
+ denial = d;
134
+ lastDenial = i;
135
+ }
136
+ });
137
+ return { lastApproval, denial: lastApproval >= 0 && lastDenial > lastApproval ? denial : null, latest: denial };
138
+ };
139
+ const staleBy = (/** @type {string} */ file, /** @type {string} */ hash) => latestOf(file, hash).denial;
140
+
141
+ for (const file of unowned) refusals.push({ code: 'unowned_change', file, detail: `${file} changed but no block of the run owns it — block claim it, or put it back` });
142
+
143
+ if (noChanges) refusals.push({ code: 'no_changes', detail: `block ${block} changed no owned file and has no reviews` });
77
144
 
145
+ /** @type {Array<{file: string, denial: Denial}>} */
146
+ const stale = [];
78
147
  for (const { file, content_hash } of files) {
79
148
  const approvals = approvedFor(file, content_hash);
149
+ const signedApproval = approvals.some((r) => verified(r, key));
80
150
  if (approvals.length === 0) refusals.push({ code: 'unreviewed', file, detail: `no review.approved row for ${file} at its current content` });
81
- else if (!approvals.some((r) => verified(r, key))) refusals.push({ code: 'unsigned', file, detail: `the review.approved row for ${file} has no valid MAC` });
151
+ else if (!signedApproval) refusals.push({ code: 'unsigned', file, detail: `the review.approved row for ${file} has no valid MAC` });
152
+ if (signedApproval) {
153
+ const denial = staleBy(file, content_hash);
154
+ if (denial !== null) stale.push({ file, denial });
155
+ } else {
156
+ // B52: never approved — its latest review's open findings are named too (the file stays
157
+ // refused by `unreviewed` / `unsigned` whatever is waived: only an approval clears that)
158
+ // (a file stopped at the cap is named by the `review_cap` check below instead)
159
+ const latest = latestOf(file, content_hash).latest;
160
+ const capped = mine.some((r) => r.event === 'review.cap' && r.file === file);
161
+ if (latest !== null && latest.open.length > 0 && !capped) stale.push({ file, denial: latest });
162
+ }
82
163
  }
83
164
 
84
165
  for (const r of mine.filter((row) => row.event === 'rule_break')) refusals.push({ code: 'rule_break', detail: `rule_break row: ${String(r.id ?? r.rule ?? 'unnamed')}` });
@@ -109,8 +190,33 @@ export function checkBlockReviews({ block, runId, files, rows, key, transcript =
109
190
  const orInvalid = (refusal, finding, invalid) =>
110
191
  invalid === null ? refusal : { code: 'autopilot_waiver_invalid', file: refusal.file, finding, detail: `the autopilot waiver of ${finding} in ${refusal.file} does not count: ${invalid}` };
111
192
 
193
+ /** B52: a ruling clears a late finding only when it rules `nit` on one recorded warning or nit. @param {Record<string, any>} late */
194
+ const ruledNit = (late) => (late.severity === 'warning' || late.severity === 'nit') && rulings.some((r) => matches(r, late.file, late.finding) && r.ruling === 'nit');
195
+
196
+ /** @type {Set<string>} `file\0finding` pairs already refused as not_approved */
197
+ const notApproved = new Set();
198
+ for (const { file, denial } of stale) {
199
+ if (denial.open.length === 0) {
200
+ refusals.push({ code: 'not_approved', file, detail: `a later review of ${file}'s current content did not approve it (${denial.event}) and names no finding; review it again` });
201
+ continue;
202
+ }
203
+ for (const { id: finding, severity } of denial.open) {
204
+ notApproved.add(`${file}\0${finding}`);
205
+ const refusal = /** @type {Refusal} */ ({ code: 'not_approved', file, finding, detail: `the latest review of ${file}'s current content left ${finding} open and it is not waived` });
206
+ // the severity the refusal row records wins: anything but warning/nit is the human's alone
207
+ if (severity !== null && !DELEGABLE_SEVERITIES.includes(severity)) {
208
+ if (waivers.some((r) => matches(r, file, finding))) continue;
209
+ const delegated = autopilotWaivers.some((r) => matches(r, file, finding));
210
+ refusals.push(delegated ? { code: 'autopilot_waiver_invalid', file, finding, detail: `the autopilot waiver of ${finding} in ${file} does not count: the latest review records it ${severity}; only the human waives it` } : refusal);
211
+ continue;
212
+ }
213
+ const w = waiverFor(file, finding);
214
+ if (!w.waived) refusals.push(orInvalid(refusal, finding, w.invalid));
215
+ }
216
+ }
217
+
112
218
  for (const late of mine.filter((r) => r.event === 'review.late_finding')) {
113
- if (rulings.some((r) => matches(r, late.file, late.finding))) continue;
219
+ if (ruledNit(late)) continue;
114
220
  const w = waiverFor(late.file, late.finding);
115
221
  if (w.waived) continue;
116
222
  refusals.push(orInvalid({ code: 'late_unruled', file: late.file, finding: late.finding, detail: `late finding ${late.finding} in ${late.file} has no ruling` }, late.finding, w.invalid));
@@ -118,8 +224,13 @@ export function checkBlockReviews({ block, runId, files, rows, key, transcript =
118
224
 
119
225
  for (const cap of mine.filter((r) => r.event === 'review.cap')) {
120
226
  const hash = hashOf.get(cap.file);
121
- if (hash !== undefined && approvedFor(cap.file, hash).some((r) => verified(r, key))) continue;
227
+ // honoured unless a signed approval of the current content comes AFTER the cap and is not stale
228
+ if (hash !== undefined) {
229
+ const latest = latestOf(cap.file, hash);
230
+ if (latest.lastApproval > mine.indexOf(cap) && latest.denial === null) continue;
231
+ }
122
232
  for (const finding of Array.isArray(cap.open) ? cap.open : []) {
233
+ if (notApproved.has(`${cap.file}\0${finding}`)) continue;
123
234
  const w = waiverFor(cap.file, finding);
124
235
  if (!w.waived) refusals.push(orInvalid({ code: 'review_cap', file: cap.file, finding, detail: `${cap.file} stopped at review_cap; finding ${finding} is open and not waived` }, finding, w.invalid));
125
236
  }
@@ -131,6 +242,122 @@ export function checkBlockReviews({ block, runId, files, rows, key, transcript =
131
242
  return { ok: refusals.length === 0, refusals };
132
243
  }
133
244
 
245
+ /**
246
+ * B52: the `review.result` outcomes that never reviewed the content, and so are no verdict on it,
247
+ * whatever they carry: `stale` (the file moved before the review), `unavailable` (no session
248
+ * answered, or the worker failed) and `refused` with reason `other-run` (the ticket was another
249
+ * run's). Every other status — `reviewed`, `stopped`, `refused` for any other reason,
250
+ * `no_change`, `split_required`, … — is a verdict, and anything but `approved: true` refuses.
251
+ * @param {Record<string, any>} r @returns {boolean}
252
+ */
253
+ export const neverReviewed = (r) => r.status === 'stale' || r.status === 'unavailable' || (r.status === 'refused' && r.reason === 'other-run');
254
+
255
+ /**
256
+ * @typedef {{event: string, open: Array<{id: string, severity: string | null}>}} Denial
257
+ * a refusal row: the findings it leaves open (empty = it names none: only a fresh approval
258
+ * clears it) with the severity it records (null = it records none; the triage rows decide).
259
+ * @typedef {{status: unknown, approved: unknown, findings: unknown} | null} ResultFile
260
+ * a ticket's signed result file as the gate read it — null when it is missing or its MAC fails.
261
+ */
262
+
263
+ /**
264
+ * B52: what a row says about a review of its (file, content_hash), or null when it is no refusal:
265
+ * - `review.result` (the worker's): no verdict when {@link neverReviewed} — those outcomes never
266
+ * reviewed the content, so they neither approve nor refuse it. `approved: true` with no
267
+ * critical in its `open` is no refusal (the approval itself is the `review.approved` row);
268
+ * `approved: true` whose `open` names a critical, and `approved: false`, refuse with every
269
+ * finding of `open`. A row WITHOUT `approved` (an old ledger) cannot be trusted either way: the
270
+ * ticket's signed result file (`resultFiles[ticket]`, read and MAC-checked by
271
+ * {@link resultFilesFor}) decides by the same rule. In particular a file that says `approved:
272
+ * true` and lists no critical is no refusal: that is the fix loop's normal approval — the
273
+ * remaining warnings and nits were accepted by the reviewer and triage (a critical never is,
274
+ * `triage.mjs`). A file that is missing, fails its MAC or names another status refuses with no
275
+ * finding (fail closed);
276
+ * - `review.round` (the fix loop's): `open_after` not 0, with no finding named;
277
+ * - `review.cap`: the file stopped; its `open` ids (severity from the triage rows).
278
+ * @param {Record<string, any>} r @param {Record<string, ResultFile>} [resultFiles]
279
+ * @returns {Denial | null}
280
+ */
281
+ export function denialOf(r, resultFiles = {}) {
282
+ if (r?.event === 'review.result') {
283
+ if (neverReviewed(r)) return null;
284
+ /** @param {unknown} approved @param {Array<{id: string, severity: string}>} open @returns {Denial | null} */
285
+ const judge = (approved, open) => (approved === true && !open.some((f) => f.severity === 'critical') ? null : { event: r.event, open });
286
+ if (r.approved === true || r.approved === false) return judge(r.approved, normalizeOpen(r.open));
287
+ const file = typeof r.ticket === 'string' && Object.hasOwn(resultFiles, r.ticket) ? resultFiles[r.ticket] : null;
288
+ if (!file || file.status !== r.status) return { event: r.event, open: [] };
289
+ return judge(file.approved, openFindings(file.findings));
290
+ }
291
+ if (r?.event === 'review.round') return r.open_after === 0 ? null : { event: r.event, open: [] };
292
+ if (r?.event === 'review.cap') {
293
+ const ids = Array.isArray(r.open) ? r.open.filter((/** @type {unknown} */ id) => typeof id === 'string' && id.length > 0) : [];
294
+ return { event: r.event, open: [...new Set(/** @type {string[]} */ (ids))].map((id) => ({ id, severity: null })) };
295
+ }
296
+ return null;
297
+ }
298
+
299
+ /**
300
+ * B52: a ledger row's `open` list as `{id, severity}` — an entry without a string id is kept
301
+ * under a synthetic id by position (`X<n>`) and an unknown severity as `unknown`, so nothing
302
+ * vanishes; repeated ids keep the worst severity.
303
+ * @param {unknown} open @returns {Array<{id: string, severity: string}>}
304
+ */
305
+ function normalizeOpen(open) {
306
+ return Array.isArray(open) ? openFindings(open) : [];
307
+ }
308
+
309
+ /** The finding id form `block waive` accepts. */
310
+ const FINDING_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$/;
311
+ /** B52: severity order for {@link openFindings}; an unknown severity ranks above critical (fail closed). */
312
+ const OPEN_RANK = Object.freeze({ nit: 0, warning: 1, critical: 2, unknown: 3 });
313
+
314
+ /**
315
+ * B52: the open findings of a review as `{id, severity}`, for the ledger's `review.result` row
316
+ * (the worker) and the gate (`not_approved <file> <id>`). Nothing vanishes: a finding with a
317
+ * missing or malformed id (not the form `block waive` takes) is kept as `X<n>`, n its 1-based
318
+ * position in the list; a severity outside critical/warning/nit is `unknown` (only the human
319
+ * waives it). An id given more than once keeps its WORST severity (unknown > critical > warning >
320
+ * nit), at its first position.
321
+ * @param {unknown} findings @returns {Array<{id: string, severity: string}>}
322
+ */
323
+ export function openFindings(findings) {
324
+ if (!Array.isArray(findings)) return [];
325
+ /** @type {Map<string, string>} */
326
+ const out = new Map();
327
+ /** @type {Record<string, number>} */
328
+ const rank = OPEN_RANK;
329
+ findings.forEach((f, i) => {
330
+ const id = typeof f?.id === 'string' && FINDING_ID.test(f.id) ? f.id : `X${i + 1}`;
331
+ const severity = typeof f?.severity === 'string' && Object.hasOwn(rank, f.severity) && f.severity !== 'unknown' ? f.severity : 'unknown';
332
+ const had = out.get(id);
333
+ if (had === undefined || rank[severity] > rank[had]) out.set(id, severity);
334
+ });
335
+ return [...out].map(([id, severity]) => ({ id, severity }));
336
+ }
337
+
338
+ /**
339
+ * B52: the signed result files {@link denialOf} needs — one per `review.result` row of the block
340
+ * that has no `approved` field (an old ledger) and is a verdict — keyed by ticket. A file that is
341
+ * missing, unparsable, names another run or ticket, or fails its MAC is null.
342
+ * @param {{repoRoot: string, runId: string, block: string, rows: Array<Record<string, any>>}} opts
343
+ * `repoRoot`: the repository root the worker writes `.code-forge/reviews/` under.
344
+ * @returns {Promise<Record<string, ResultFile>>}
345
+ */
346
+ export async function resultFilesFor({ repoRoot, runId, block, rows }) {
347
+ /** @type {Record<string, ResultFile>} */
348
+ const out = {};
349
+ for (const r of rows) {
350
+ if (r?.event !== 'review.result' || r.block !== block || Object.hasOwn(r, 'approved') || neverReviewed(r) || typeof r.ticket !== 'string' || Object.hasOwn(out, r.ticket)) continue;
351
+ try {
352
+ const checked = await verifyResult(repoRoot, runId, r.ticket);
353
+ out[r.ticket] = checked.ok && checked.result ? { status: checked.result.status, approved: checked.result.approved, findings: checked.result.findings } : null;
354
+ } catch {
355
+ out[r.ticket] = null; // a malformed ticket id, an unreadable file: fail closed
356
+ }
357
+ }
358
+ return out;
359
+ }
360
+
134
361
  /** The finding severities autopilot may waive (B47); anything else is never delegated. */
135
362
  export const DELEGABLE_SEVERITIES = Object.freeze(['warning', 'nit']);
136
363
 
@@ -139,7 +366,8 @@ const SEVERITY_RANK = Object.freeze({ nit: 0, warning: 1, critical: 2, blocker:
139
366
 
140
367
  /**
141
368
  * The severity the block's signed review rows record for one finding of one file (B47): every
142
- * `review.triage` and `review.late_finding` row with that `block`, `file` and `finding`; the worst
369
+ * `review.triage` and `review.late_finding` row with that `block`, `file` and `finding`, and (B52)
370
+ * every `review.result` row whose `open` list names it (with the severity recorded there); the worst
143
371
  * one wins. Fails closed: no row ⇒ `not-recorded`; any such row whose MAC does not verify ⇒
144
372
  * `unverified`; a severity outside critical/blocker/warning/nit ⇒ `unknown` — each with
145
373
  * `severity: null`.
@@ -148,15 +376,24 @@ const SEVERITY_RANK = Object.freeze({ nit: 0, warning: 1, critical: 2, blocker:
148
376
  * @returns {{severity: string, why: null} | {severity: null, why: 'not-recorded' | 'unverified' | 'unknown'}}
149
377
  */
150
378
  export function recordedSeverity(rows, { block, file, finding, key }) {
151
- const named = rows.filter((r) => (r?.event === 'review.triage' || r?.event === 'review.late_finding') && r.block === block && r.file === file && r.finding === finding);
379
+ /** @type {Array<{row: Record<string, any>, severity: unknown}>} */
380
+ const named = [];
381
+ for (const r of rows) {
382
+ if (r?.block !== block || r.file !== file) continue;
383
+ if ((r.event === 'review.triage' || r.event === 'review.late_finding') && r.finding === finding) named.push({ row: r, severity: r.severity });
384
+ // B52: the worker's review.result rows record the open findings' severities too
385
+ else if (r.event === 'review.result' && Array.isArray(r.open)) {
386
+ for (const o of r.open) if (o?.id === finding) named.push({ row: r, severity: o.severity });
387
+ }
388
+ }
152
389
  if (named.length === 0) return { severity: null, why: 'not-recorded' };
153
- if (named.some((r) => !verified(r, key))) return { severity: null, why: 'unverified' };
390
+ if (named.some((n) => !verified(n.row, key))) return { severity: null, why: 'unverified' };
154
391
  /** @type {Record<string, number>} */
155
392
  const rank = SEVERITY_RANK;
156
393
  let worst = '';
157
- for (const r of named) {
158
- if (typeof r.severity !== 'string' || !Object.hasOwn(rank, r.severity)) return { severity: null, why: 'unknown' };
159
- if (worst === '' || rank[r.severity] > rank[worst]) worst = r.severity;
394
+ for (const { severity } of named) {
395
+ if (typeof severity !== 'string' || !Object.hasOwn(rank, severity)) return { severity: null, why: 'unknown' };
396
+ if (worst === '' || rank[severity] > rank[worst]) worst = severity;
160
397
  }
161
398
  return { severity: worst, why: null };
162
399
  }
@@ -330,10 +567,24 @@ export function repoRelativePath(raw) {
330
567
  * The block's file set (§4.1): changed tracked files since `base` (deletions included, hashed
331
568
  * `DELETED_HASH`) ∪ untracked files, filtered to `owned` (exact paths; a glob owned entry matches through `matchOwned`), each with its content hash.
332
569
  * Every path goes through `repoRelativePath`.
333
- * @param {{repoRoot: string, base: string, owned: string[], matchOwned?: (file: string) => boolean}} opts
570
+ * @param {{repoRoot: string, base: string, owned: string[], matchOwned?: (file: string) => boolean, changed?: string[]}} opts
571
+ * `changed`: the tree's changed files when the caller already listed them ({@link changedFiles}).
334
572
  * @returns {Promise<Array<{file: string, content_hash: string}>>}
335
573
  */
336
- export async function blockFileSet({ repoRoot, base, owned, matchOwned }) {
574
+ export async function blockFileSet({ repoRoot, base, owned, matchOwned, changed }) {
575
+ const all = changed ?? (await changedFiles({ repoRoot, base }));
576
+ const isOwned = matchOwned ?? ((/** @type {string} */ f) => owned.includes(f));
577
+ return all.filter((f) => isOwned(f)).map((file) => ({ file, content_hash: currentHash(repoRoot, file) }));
578
+ }
579
+
580
+ /**
581
+ * Every changed tracked file since `base` (deletions included) ∪ every untracked, not ignored
582
+ * file, through `repoRelativePath`, sorted — the set {@link blockFileSet} filters to the block.
583
+ * @param {{repoRoot: string, base: string}} opts
584
+ * @returns {Promise<string[]>}
585
+ * @throws {StateError} `bad-ref`, `git-failed`
586
+ */
587
+ export async function changedFiles({ repoRoot, base }) {
337
588
  if (typeof base !== 'string' || !/^[0-9a-f]{7,64}$/.test(base)) throw new StateError('bad-ref', 'the block base must be a commit id');
338
589
  const env = gitChildEnv();
339
590
  const changed = await exec(['git', 'diff', '--name-only', '-z', '--no-renames', base, '--'], { cwd: repoRoot, env, timeoutMs: 30000 });
@@ -342,11 +593,130 @@ export async function blockFileSet({ repoRoot, base, owned, matchOwned }) {
342
593
  const all = new Set(
343
594
  [...changed.stdout.split('\0'), ...untracked.stdout.split('\0')].filter((f) => f.length > 0).map((f) => repoRelativePath(f)),
344
595
  );
345
- const isOwned = matchOwned ?? ((/** @type {string} */ f) => owned.includes(f));
346
- return [...all]
347
- .filter((f) => isOwned(f))
348
- .sort()
349
- .map((file) => ({ file, content_hash: isOnDisk(repoRoot, file) ? contentHash(repoRoot, file) : DELETED_HASH }));
596
+ return [...all].sort();
597
+ }
598
+
599
+ /**
600
+ * B52: whether `owned` (a block's `owned_files`) covers `file` at the gate: the `findOverlap`
601
+ * match `block open` uses (exact paths and globs), and — what the field run needed — an entry with
602
+ * no glob character also covers every path below it as a directory (`src/feature` owns
603
+ * `src/feature/Fan.swift`). A wider match only ever puts more files under the gate's checks.
604
+ * @param {ReadonlyArray<string>} owned @param {string} file @returns {boolean}
605
+ */
606
+ export function ownsPath(owned, file) {
607
+ if (findOverlap(owned, [file]) !== null) return true;
608
+ return owned.some((o) => typeof o === 'string' && o.length > 0 && !/[*?[\]{}]/.test(o) && file.startsWith(`${o.replace(/\/+$/, '')}/`));
609
+ }
610
+
611
+ /**
612
+ * B52: {@link ownsPath} for the close's one path base, the repository top level: `file` is
613
+ * repo-root-relative (as git lists it and the worker signs it); an owned entry matches it as
614
+ * given, or — for a workspace in a subdirectory (`wsPrefix`, the workspace relative to the top;
615
+ * `''` when they are the same) — relative to the workspace. A file outside the workspace is
616
+ * matched as given only.
617
+ * @param {string} wsPrefix @returns {(owned: ReadonlyArray<string>, file: string) => boolean}
618
+ */
619
+ export function ownsAt(wsPrefix) {
620
+ const prefix = wsPrefix.replace(/\/+$/, '');
621
+ return (owned, file) => ownsPath(owned, file) || (prefix !== '' && prefix !== '.' && file.startsWith(`${prefix}/`) && ownsPath(owned, file.slice(prefix.length + 1)));
622
+ }
623
+
624
+ /** B52: the review rows that name a file the block's worker reviewed. */
625
+ const REVIEW_EVENTS = new Set(['review.result', 'review.round', 'review.approved', 'review.triage', 'review.cap', 'review.late_finding']);
626
+
627
+ /**
628
+ * B52: every file with ANY review row (`review.result`, `review.round`, `review.approved`,
629
+ * `review.triage`, `review.cap`, `review.late_finding`) for `block` among the run's `rows`,
630
+ * through `repoRelativePath` (a path it
631
+ * refuses is left out), sorted. The close adds them to the diff's file set, so work the diff
632
+ * cannot see — committed before `block open`, done in another worktree — is still checked.
633
+ * @param {Array<Record<string, any>>} rows @param {string} block @returns {string[]}
634
+ */
635
+ export function reviewedFiles(rows, block) {
636
+ /** @type {Set<string>} */
637
+ const out = new Set();
638
+ for (const r of rows) {
639
+ if (r?.block !== block || !REVIEW_EVENTS.has(r.event) || typeof r.file !== 'string') continue;
640
+ try {
641
+ out.add(repoRelativePath(r.file));
642
+ } catch {
643
+ // not a repo path: nothing on disk to check it against
644
+ }
645
+ }
646
+ return [...out].sort();
647
+ }
648
+
649
+ /** @param {string} repoRoot @param {string} file @returns {string} the content hash, `DELETED_HASH` when gone. */
650
+ const currentHash = (repoRoot, file) => (isOnDisk(repoRoot, file) ? contentHash(repoRoot, file) : DELETED_HASH);
651
+
652
+ /**
653
+ * B52: the changed files of the tree (`changed`, from {@link changedFiles} run at the repository
654
+ * top level `top`, so top-relative) that NO block of the run accounts for — each one is an
655
+ * `unowned_change` refusal at `block close`. A file is accounted for when:
656
+ * - this block owns it (`isMine`, the close's own `owned_files` match);
657
+ * - it is exactly as it was when this block opened: its `tree_at_open` stamp (`treeStamp`) is
658
+ * the same now (`sameStamp`; an `unreadable` stamp at open or now never is) — a change made
659
+ * before the coder started (plan files, `init`'s config, a skill link) is not the coder's; any
660
+ * later edit to it is;
661
+ * - another block of the run that is still OPEN owns it (parallel coders share the tree and its
662
+ * own gate reviews it). The gate cannot tell which coder wrote it, so such files are returned
663
+ * in `siblings` and the close WARNs naming each one;
664
+ * - a CLOSED block of the run owns it AND a signed `review.approved` row of that block names the
665
+ * file at its current content hash (that block's work, exactly as its gate approved it — a
666
+ * later edit makes it unowned again);
667
+ * - it is code-forge's own state: under `.code-forge/` at the top level or under `stateDir` (the
668
+ * workspace's `.code-forge/`, top-relative). Nothing else is exempt — not the close's own
669
+ * `--transcript` or `--report` when they sit in the source tree.
670
+ * A stopped block accounts for nothing. Paths match through {@link ownsPath} (globs as at `block
671
+ * open`). A stamp that throws counts as not accounted (refused). A block opened before B52 has no
672
+ * `tree_at_open`: null — nothing can tell the coder's changes from earlier ones, so the caller
673
+ * warns that the check did not run (never a silent pass).
674
+ * @param {{
675
+ * top: string, changed: ReadonlyArray<string>, id: string, blocks: Record<string, any>,
676
+ * rows: Array<Record<string, any>>, key: Buffer, isMine: (file: string) => boolean, owns?: (owned: ReadonlyArray<string>, file: string) => boolean, stateDir?: string,
677
+ * }} opts `blocks`: the run record's `blocks`; `rows`: the run's ledger rows; `owns` (default {@link ownsPath}): how another block's `owned_files` match a path.
678
+ * @returns {Promise<{unowned: string[], siblings: Array<{file: string, block: string}>} | null>}
679
+ */
680
+ export async function unownedChanges({ top, changed, id, blocks, rows, key, isMine, owns = ownsPath, stateDir = '.code-forge' }) {
681
+ const atOpen = blocks?.[id]?.tree_at_open;
682
+ if (!atOpen || typeof atOpen !== 'object' || Array.isArray(atOpen)) return null;
683
+ const others = Object.entries(blocks ?? {}).filter(([other, b]) => other !== id && b && Array.isArray(b.owned_files));
684
+ /** @param {string} file @returns {boolean} */
685
+ const approvedClosed = (file) => {
686
+ let hash;
687
+ try {
688
+ hash = currentHash(top, file);
689
+ } catch {
690
+ return false; // a symlink or an unreadable path was never approved
691
+ }
692
+ return others.some(
693
+ ([other, b]) =>
694
+ b.status === 'closed' &&
695
+ owns(b.owned_files, file) &&
696
+ rows.some((r) => r?.event === 'review.approved' && r.block === other && r.file === file && r.content_hash === hash && verified(r, key)),
697
+ );
698
+ };
699
+ /** @param {string} file @returns {Promise<boolean>} */
700
+ const unchangedSinceOpen = async (file) => {
701
+ if (!Object.hasOwn(atOpen, file)) return false;
702
+ try {
703
+ return sameStamp(atOpen[file], await treeStamp(top, file));
704
+ } catch {
705
+ return false;
706
+ }
707
+ };
708
+ const state = (/** @type {string} */ f) => [...new Set(['.code-forge', stateDir])].some((dir) => f === dir || f.startsWith(`${dir}/`));
709
+ /** @type {string[]} */
710
+ const unowned = [];
711
+ /** @type {Array<{file: string, block: string}>} */
712
+ const siblings = [];
713
+ for (const file of changed) {
714
+ if (state(file) || isMine(file) || (await unchangedSinceOpen(file))) continue;
715
+ const sibling = others.find(([, b]) => b.status === 'open' && owns(b.owned_files, file));
716
+ if (sibling) siblings.push({ file, block: sibling[0] });
717
+ else if (!approvedClosed(file)) unowned.push(file);
718
+ }
719
+ return { unowned, siblings };
350
720
  }
351
721
 
352
722
  /**
@@ -11,7 +11,11 @@
11
11
  * to ONE L3 ruling per file (`deps.rule`). Jev unavailable or `defect` disabled ⇒ §3.5 fallback:
12
12
  * `defect` has no rule (`alwaysResidue`), so every finding is residue for the L3 ruling;
13
13
  * - fail closed: a mid-band finding with no ruling available (no `deps.rule`, or it failed or
14
- * left the id out) is `fix_now` unless its severity is `nit`.
14
+ * left the id out) is `fix_now` unless its severity is `nit`;
15
+ * - B52: a `critical` finding is ALWAYS `fix_now` — neither S1's band nor the L3 ruling makes it
16
+ * a nit (both are still asked and logged in the row's `p` / `band`). Only a fix (the recheck)
17
+ * or the human's `block waive` ends a critical; otherwise the loop would write
18
+ * `review.approved` with the reviewer's critical still standing.
15
19
  * One `review.triage` row per finding: `{file, finding, severity, source, p, band, verdict}`.
16
20
  */
17
21
 
@@ -110,6 +114,8 @@ export async function triageFindings({ file, findings, fromJudge, diffText = '',
110
114
  /** @type {Array<{finding: Finding, p: number | null}>} */
111
115
  const mid = [];
112
116
  const put = (/** @type {Finding} */ f, /** @type {'fix_now' | 'nit'} */ verdict) => (verdict === 'fix_now' ? fixNow : nits).push(f);
117
+ /** B52: a critical is never demoted. @param {Finding} f @param {'fix_now' | 'nit'} verdict @returns {'fix_now' | 'nit'} */
118
+ const keepCritical = (f, verdict) => (f.severity === 'critical' ? 'fix_now' : verdict);
113
119
 
114
120
  for (const finding of findings) {
115
121
  const p = await askNoul('defect', { file_diff_hunk: diffText, finding }, { jev, cfg });
@@ -129,8 +135,9 @@ export async function triageFindings({ file, findings, fromJudge, diffText = '',
129
135
  mid.push({ finding, p });
130
136
  continue;
131
137
  }
132
- put(finding, band);
133
- await note(writeRow, { event: 'review.triage', file, finding: finding.id, severity: finding.severity, source: 'jev', p, band, verdict: band });
138
+ const verdict = keepCritical(finding, band);
139
+ put(finding, verdict);
140
+ await note(writeRow, { event: 'review.triage', file, finding: finding.id, severity: finding.severity, source: 'jev', p, band, verdict });
134
141
  }
135
142
 
136
143
  let rulings = 0;
@@ -147,7 +154,7 @@ export async function triageFindings({ file, findings, fromJudge, diffText = '',
147
154
  }
148
155
  for (const { finding, p } of mid) {
149
156
  const given = ruled?.[finding.id];
150
- const verdict = given === 'fix_now' || given === 'nit' ? given : severityVerdict(finding);
157
+ const verdict = keepCritical(finding, given === 'fix_now' || given === 'nit' ? given : severityVerdict(finding));
151
158
  put(finding, verdict);
152
159
  await note(writeRow, {
153
160
  event: 'review.triage',
@@ -12,7 +12,9 @@
12
12
  */
13
13
 
14
14
  import { createHash } from 'node:crypto';
15
+ import { createReadStream, existsSync, lstatSync, readlinkSync, realpathSync } from 'node:fs';
15
16
  import os from 'node:os';
17
+ import path from 'node:path';
16
18
  import { BLOCK_KINDS, blockKind, dispatchLevel } from '../decide/escalation.mjs';
17
19
  import { exec } from '../util/exec.mjs';
18
20
  import { isAncestorArgv, revParseArgv } from '../util/git.mjs';
@@ -48,6 +50,133 @@ async function git(argv, cwd, okExitCodes) {
48
50
  return res;
49
51
  }
50
52
 
53
+ /** B52: the stamp of a path whose state could not be read; never equal to anything (see {@link sameStamp}). */
54
+ export const UNREADABLE = 'unreadable';
55
+
56
+ /**
57
+ * B52: a stamp of one path's current state, never following a symlink and never throwing:
58
+ * - `deleted` — nothing at the path;
59
+ * - `file:<x|->:<sha256 of the bytes>` — a regular file, with its executable bit (hash streamed);
60
+ * - `link:<sha256 of the target text>` — a symlink, by where it points;
61
+ * - `repo:<HEAD commit>:<sha256 of its changes>` — a directory holding a git repository (a
62
+ * checked-out submodule, a nested repo): its checked-out commit, and the status, the binary
63
+ * diff against HEAD and every untracked file's content (`repoStamp`) — so a repo already dirty
64
+ * at open and edited again later stamps differently;
65
+ * - `gitlink:<commit>` — a directory with no `.git` that the index records as a gitlink (an
66
+ * uninitialised submodule);
67
+ * - {@link UNREADABLE} — anything else (a plain directory, a FIFO, a read or git failure).
68
+ * Two stamps are the same only when the path did not change between them, and an `unreadable`
69
+ * stamp never matches — a read failure must never make a file look unchanged.
70
+ * @param {string} top - the repository top level (`repoTop`). @param {string} file - top-relative.
71
+ * @returns {Promise<string>}
72
+ */
73
+ export async function treeStamp(top, file) {
74
+ const full = path.join(top, file);
75
+ let stat;
76
+ try {
77
+ stat = lstatSync(full);
78
+ } catch (err) {
79
+ return /** @type {NodeJS.ErrnoException} */ (err).code === 'ENOENT' ? 'deleted' : UNREADABLE;
80
+ }
81
+ try {
82
+ if (stat.isSymbolicLink()) return `link:${sha256(readlinkSync(full))}`;
83
+ if (stat.isFile()) return `file:${(stat.mode & 0o111) !== 0 ? 'x' : '-'}:${await fileSha256(full)}`;
84
+ if (stat.isDirectory() && existsSync(path.join(full, '.git'))) return await repoStamp(full);
85
+ if (stat.isDirectory()) return await gitlinkStamp(top, file);
86
+ } catch {
87
+ return UNREADABLE;
88
+ }
89
+ return UNREADABLE;
90
+ }
91
+
92
+ /** @param {Buffer | string} data @returns {string} */
93
+ const sha256 = (data) => createHash('sha256').update(data).digest('hex');
94
+
95
+ /**
96
+ * B52: a file's sha256, streamed (a large file is never read whole into memory).
97
+ * @param {string} full @returns {Promise<string>}
98
+ */
99
+ async function fileSha256(full) {
100
+ const hash = createHash('sha256');
101
+ for await (const chunk of createReadStream(full)) hash.update(/** @type {Buffer} */ (chunk));
102
+ return hash.digest('hex');
103
+ }
104
+
105
+ /**
106
+ * B52: a nested repository's stamp — its HEAD, and a sha256 over its own changes: `git status
107
+ * --porcelain=v1 -z`, `git diff HEAD --binary` (the content of every tracked change) and each
108
+ * untracked file's path and sha256. Any git failure is {@link UNREADABLE}.
109
+ * @param {string} dir @returns {Promise<string>}
110
+ */
111
+ async function repoStamp(dir) {
112
+ const run = (/** @type {string[]} */ argv) => exec(argv, { cwd: dir, env: gitEnv(), timeoutMs: 30000 });
113
+ const head = await run(['git', 'rev-parse', '--verify', 'HEAD']);
114
+ const status = await run(['git', 'status', '--porcelain=v1', '-z', '--untracked-files=all']);
115
+ const diff = await run(['git', 'diff', 'HEAD', '--binary']);
116
+ const untracked = await run(['git', 'ls-files', '-z', '--others', '--exclude-standard']);
117
+ if (head.result !== 'ok' || status.result !== 'ok' || diff.result !== 'ok' || untracked.result !== 'ok') return UNREADABLE;
118
+ const hash = createHash('sha256').update(status.stdout).update('\0diff\0').update(diff.stdout);
119
+ for (const rel of untracked.stdout.split('\0').filter((f) => f.length > 0).sort()) {
120
+ const inner = path.join(dir, rel);
121
+ const st = lstatSync(inner);
122
+ hash.update(`\0${rel}\0`).update(st.isFile() ? await fileSha256(inner) : st.isSymbolicLink() ? `link:${sha256(readlinkSync(inner))}` : 'other');
123
+ }
124
+ return `repo:${head.stdout.trim()}:${hash.digest('hex')}`;
125
+ }
126
+
127
+ /**
128
+ * B52: a directory with no `.git` of its own that the index records as a gitlink (an uninitialised
129
+ * submodule): `gitlink:<commit>` from `git ls-files -s`; anything else is {@link UNREADABLE}.
130
+ * @param {string} top @param {string} file @returns {Promise<string>}
131
+ */
132
+ async function gitlinkStamp(top, file) {
133
+ const res = await exec(['git', 'ls-files', '-s', '-z', '--', file], { cwd: top, env: gitEnv(), timeoutMs: 30000 });
134
+ if (res.result !== 'ok') return UNREADABLE;
135
+ const entry = res.stdout.split('\0').find((e) => e.endsWith(`\t${file}`));
136
+ const m = entry ? /^160000 ([0-9a-f]{40,64}) \d+\t/.exec(entry) : null;
137
+ return m ? `gitlink:${m[1]}` : UNREADABLE;
138
+ }
139
+
140
+ /**
141
+ * B52: whether two stamps say the path did not change: equal, and neither {@link UNREADABLE}.
142
+ * @param {unknown} a @param {unknown} b @returns {boolean}
143
+ */
144
+ export const sameStamp = (a, b) => typeof a === 'string' && a === b && a !== UNREADABLE;
145
+
146
+ /**
147
+ * B52: the repository's top level (`git rev-parse --show-toplevel`, realpath'd) — the root both
148
+ * git listings run from and every stamp is taken against, so a workspace in a subdirectory of
149
+ * the repository lines up with the repo-root-relative paths git prints.
150
+ * @param {string} cwd @returns {Promise<string>}
151
+ * @throws {StateError} `git-failed`
152
+ */
153
+ export async function repoTop(cwd) {
154
+ const res = await git(['git', 'rev-parse', '--show-toplevel'], cwd);
155
+ return realpathSync(res.stdout.trim());
156
+ }
157
+
158
+ /**
159
+ * B52: the tree's changes when a block opens — every file changed since `base` (deletions
160
+ * included) and every untracked, not ignored file, listed from the repository top level, each
161
+ * with its {@link treeStamp}. Recorded in the block's entry (`tree_at_open`, top-relative paths):
162
+ * at `block close`, a file outside the block's `owned_files` whose stamp is still the same
163
+ * ({@link sameStamp}) is not the coder's change (plan files, `init`'s config, a skill link…); one
164
+ * that changed since, or could not be read either time, is `unowned_change` (`review/gate-check.mjs`).
165
+ * @param {string} cwd @param {string} base - a full commit sha.
166
+ * @returns {Promise<Record<string, string>>}
167
+ * @throws {StateError} `git-failed`
168
+ */
169
+ export async function treeAtOpen(cwd, base) {
170
+ const top = await repoTop(cwd);
171
+ const changed = await git(['git', 'diff', '--name-only', '-z', '--no-renames', base, '--'], top);
172
+ const untracked = await git(['git', 'ls-files', '-z', '--others', '--exclude-standard'], top);
173
+ const files = [...new Set([...changed.stdout.split('\0'), ...untracked.stdout.split('\0')].filter((f) => f.length > 0))].sort();
174
+ /** @type {Record<string, string>} */
175
+ const out = {};
176
+ for (const file of files) out[file] = await treeStamp(top, file);
177
+ return out;
178
+ }
179
+
51
180
  /** @param {string} ref @param {string} cwd @returns {Promise<string>} the full commit sha */
52
181
  async function resolveCommit(ref, cwd) {
53
182
  if (typeof ref !== 'string' || ref.length === 0 || ref.startsWith('-')) {
@@ -130,7 +259,8 @@ function activeBlocks(record) {
130
259
  * block kind (B34: the declared `kind`, else `blockKind` of the owned files). A docs/contract
131
260
  * block asked for below `levels.coder_floor_docs` (default L1) is opened AT the floor: the entry
132
261
  * and the dispatch row carry the floor level, and the row adds `requested_level` and
133
- * `trigger: 'docs_floor'`.
262
+ * `trigger: 'docs_floor'`. B52: the entry also records `tree_at_open` ({@link treeAtOpen}), the
263
+ * tree's changes at open, which `block close` reads to tell the coder's changes from earlier ones.
134
264
  * @param {{
135
265
  * runId: string, id: string, level: string, owned: string[], acceptance: unknown,
136
266
  * attempt?: number, base?: string, lines?: number, brief?: {path: string, content: Buffer | string},
@@ -151,6 +281,12 @@ export async function openBlock(opts) {
151
281
  const dispatched = dispatchLevel({ lane: level, kind, cfg: opts.cfg });
152
282
  const pointer = opts.brief ? briefPointer(opts.brief.path, opts.brief.content) : null;
153
283
 
284
+ // B52: the tree is stamped BEFORE the run lock (hashing can be slow; other block commands must
285
+ // not wait on it); under the lock the base is resolved again and must be the one stamped against
286
+ const { workspace } = await readRun(runId);
287
+ const stampedBase = await resolveCommit(opts.base ?? 'HEAD', workspace);
288
+ const treeOpen = await treeAtOpen(workspace, stampedBase);
289
+
154
290
  const block = await mutateRun(runId, null, writeRow, async (record) => {
155
291
  if (record.blocks[id]?.status === 'open') throw new StateError('already-open', `block ${id} is already open`);
156
292
  for (const [otherId, other] of Object.entries(activeBlocks(record))) {
@@ -158,7 +294,8 @@ export async function openBlock(opts) {
158
294
  if (pair) throw new StateError('overlap', `block ${id} owns ${pair[0]}, which overlaps ${pair[1]} owned by open block ${otherId}`);
159
295
  }
160
296
  const baseSha = await resolveCommit(opts.base ?? 'HEAD', record.workspace);
161
- const entry = { block: id, base_sha: baseSha, owned_files: [...owned], level: dispatched.level, kind, attempt, opened_at: now.toISOString(), acceptance, lines_forecast: lines, status: 'open' };
297
+ if (baseSha !== stampedBase || record.workspace !== workspace) throw new StateError('base-moved', `the base moved while block ${id} was being opened; run block open again`);
298
+ const entry = { block: id, base_sha: baseSha, owned_files: [...owned], level: dispatched.level, kind, attempt, opened_at: now.toISOString(), acceptance, lines_forecast: lines, status: 'open', tree_at_open: treeOpen };
162
299
  record.blocks[id] = entry;
163
300
  const floor = dispatched.trigger === 'docs_floor' ? { requested_level: level, trigger: 'docs_floor' } : {};
164
301
  return {
@@ -49,6 +49,7 @@ import { logWarning } from '../util/error-log.mjs';
49
49
  import { keyedLock } from '../util/locks.mjs';
50
50
  import { writeSafe } from '../util/redact.mjs';
51
51
  import { runRoot, setRunRoot } from '../util/tmp.mjs';
52
+ import { openFindings } from '../review/gate-check.mjs';
52
53
  import { reviewTicket } from './engine.mjs';
53
54
  import { announceWorker, beatWorker, HEARTBEAT_MS, liveWorker, pendingTickets, readTicket, retractWorker, writeResult } from './queue.mjs';
54
55
  import { contentHash, DELETED_HASH, repoRootOf, WorkerError } from './ticket.mjs';
@@ -158,6 +159,21 @@ export function loopResult(outcome) {
158
159
  return out;
159
160
  }
160
161
 
162
+ /**
163
+ * B52: the verdict a result is recorded with (its signed file and its ledger row), always with
164
+ * `approved` and `open`: `approved: true, open: []` only when the outcome said so AND it lists no
165
+ * critical finding. Otherwise `approved: false` and `open` = ALL its findings (`openFindings`:
166
+ * criticals, warnings and nits; a missing or malformed id as `X<n>` by position; the worst
167
+ * severity per id; `[]` when it names none, which the gate refuses until a fresh approval) — so
168
+ * an "approved" result that names a critical is downgraded with every one of its findings open.
169
+ * @param {Record<string, any>} result @returns {Record<string, any>}
170
+ */
171
+ export function recordedVerdict(result) {
172
+ const listed = openFindings(result.findings);
173
+ if (result.approved === true && !listed.some((f) => f.severity === 'critical')) return { ...result, approved: true, open: [] };
174
+ return { ...result, approved: false, open: listed };
175
+ }
176
+
161
177
  /**
162
178
  * @typedef {object} WorkerOpts
163
179
  * @property {string} runId
@@ -351,9 +367,10 @@ export async function createWorker(opts, deps = {}) {
351
367
  /**
352
368
  * Sign and write the result + done marker, then the signed ledger row. A ledger failure does not
353
369
  * undo the result: the done marker is what the queue reads.
354
- * @param {string} id @param {Record<string, any>} result
370
+ * @param {string} id @param {Record<string, any>} outcome - recorded through {@link recordedVerdict}.
355
371
  */
356
- async function complete(id, result) {
372
+ async function complete(id, outcome) {
373
+ const result = recordedVerdict(outcome);
357
374
  const signed = writeResult({ repoRoot, runId, ticket: id, result, key });
358
375
  try {
359
376
  await ledger({
@@ -363,6 +380,11 @@ export async function createWorker(opts, deps = {}) {
363
380
  file: result.file ?? null,
364
381
  content_hash: result.content_hash ?? null,
365
382
  status: result.status,
383
+ // B52: the gate reads the verdict from the ledger, never from the result file
384
+ approved: result.approved,
385
+ open: result.open,
386
+ // the gate tells `refused: other-run` (never reviewed) from other refusals by it
387
+ ...(typeof result.reason === 'string' ? { reason: result.reason } : {}),
366
388
  ...(typeof result.trigger === 'string' ? { trigger: result.trigger } : {}),
367
389
  ...(typeof result.stopped === 'string' ? { stopped: result.stopped } : {}),
368
390
  });