@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 +26 -0
- package/README.md +8 -0
- package/docs/reference/blocks.json +17 -1
- package/package.json +1 -1
- package/skill/SKILL.md +1 -1
- package/skill/references/adapters/claude-code.md +1 -1
- package/skill/references/adapters/solo.md +1 -1
- package/skill/references/code.md +4 -4
- package/skill/references/decisions.md +1 -1
- package/skill/references/review.md +2 -2
- package/skill/scripts/forge +1 -1
- package/src/cli/block.mjs +123 -18
- package/src/review/gate-check.mjs +395 -25
- package/src/review/triage.mjs +11 -4
- package/src/state/block.mjs +139 -2
- package/src/worker/loop.mjs +24 -2
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":
|
|
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
package/skill/SKILL.md
CHANGED
|
@@ -41,7 +41,7 @@ State lives in the plan file's header — `facts → planned → hardened → co
|
|
|
41
41
|
4. Per block, in dispatch order, never more than `caps.coders` alive: `forge block open <id> --run <r> --level L<n> --owned <paths…> --acceptance <file> --brief <file> --lines <n> [--kind code|docs|contract]` prints the pointer `BRIEF <path> lines=<n> sha=<sha8> <<<EOM>>>`. Send the pointer per the engine's adapter — `solo` → `references/adapters/solo.md`, `harness` → `references/adapters/claude-code.md`, `subprocess` → `references/adapters/subprocess.md` — and wait for `ACK <sha8> lines=<n>`.
|
|
42
42
|
5. The coder's first message is its **facts diff**, then its forecast (cases and lines, `templates/block-record.md`), then code, file by file: after each file `forge review-file <path> --block <id> --run <r>`, then `forge review-file --wait <ticket>` before the next file. Rounds ≥ 2 re-check only the fix hunks (`references/review.md`).
|
|
43
43
|
6. `===BLOCK <id> COMPLETE===` or `===BLOCK <id> FAILED: <reason>===` ends the coder's turn. Its report is a claim sheet: re-measure it (`references/code.md` §5).
|
|
44
|
-
7. `forge block close <id> --run <r> --report <coder report>` is the gate: a report without a `reviewed <path> ticket <id>` line per changed file is FAILED; a signed `review.approved` row per changed file, gates green, every clause covered, forecast vs actual, no `rule_break`. Red ⇒ `references/decisions.md` (S1 `next`, escalation). Green ⇒ one commit for the block in the project's house style; `forge block rebase <id> --run <r>` first when HEAD moved.
|
|
44
|
+
7. `forge block close <id> --run <r> --report <coder report>` is the gate: a report without a `reviewed <path> ticket <id>` line per changed file is FAILED; a signed `review.approved` row per changed file that no later review of the same content overrode (`not_approved`), no `unowned_change`, no `no_changes` (the files are the owned diff plus every owned file reviewed for the block), gates green, every clause covered, forecast vs actual, no `rule_break`. Red ⇒ `references/decisions.md` (S1 `next`, escalation). Green ⇒ one commit for the block in the project's house style; `forge block rebase <id> --run <r>` first when HEAD moved.
|
|
45
45
|
8. `forge run end --run <r>` when every block is `done`; `forge report --slug <slug>` prints cost per block, escalations, review rounds and the spend per run (`budget.usd`, `references/ledger.md` §3.1).
|
|
46
46
|
|
|
47
47
|
## §4 Hard rules (the headings of `references/rules-core.md`)
|
|
@@ -12,7 +12,7 @@ The harness's `Agent` tool runs the coder as a subagent; everything else — the
|
|
|
12
12
|
|
|
13
13
|
The tool return is the completion signal; the report must begin with `ACK <sha8>` and end with `===BLOCK <id> COMPLETE===` (+ test summary + `git diff --stat`) or `===BLOCK <id> FAILED: <reason>===`. A report without the sentinel is a failed block, never a partial success.
|
|
14
14
|
|
|
15
|
-
**Between completion and close come the review rounds.** The subagent ran `forge review-file <path> --block <id> --run <r>` per file and `--wait`ed on each ticket inside its turn, so most rounds happen before the sentinel; when the tool returns, first drain every `pending` ticket with `forge review-file --wait <ticket>` yourself — a pending ticket is a review in flight, not an open finding, and waiting on it costs no round. Then only an open `fix_now` finding, or a file the coder never submitted, starts a fix round (each one counts toward the file's cap): dispatch a new `Agent` call at the level the ladder says (`review.md` §5 — the coder is gone with its tool return, so the fix brief carries the open findings and the fix history) and it runs `review-file` again on the fixed file. Rounds are bounded: four rounds per file, then the L3 rung once, then `stopped: review_cap` for the human. Only when every changed file has a signed `review.approved` row do you run the re-measurement of `code.md` §5 and `forge block close <id> --run <r> --report <file>` (the subagent's final report, saved to a file) — the transcript grep reads `.code-forge/runs/<run>/<block>.log`; pass `--transcript <file>` when the harness saved the subagent's transcript elsewhere. Closing with a file still unreviewed is a human decision: `forge block waive <id> <finding> --run <r> --file <path> --reason "<why>"` per finding, or `forge block close … --no-require-reviews`, both audited as their own rows.
|
|
15
|
+
**Between completion and close come the review rounds.** The subagent ran `forge review-file <path> --block <id> --run <r>` per file and `--wait`ed on each ticket inside its turn, so most rounds happen before the sentinel; when the tool returns, first drain every `pending` ticket with `forge review-file --wait <ticket>` yourself — a pending ticket is a review in flight, not an open finding, and waiting on it costs no round. Then only an open `fix_now` finding, or a file the coder never submitted, starts a fix round (each one counts toward the file's cap): dispatch a new `Agent` call at the level the ladder says (`review.md` §5 — the coder is gone with its tool return, so the fix brief carries the open findings and the fix history) and it runs `review-file` again on the fixed file. Rounds are bounded: four rounds per file, then the L3 rung once, then `stopped: review_cap` for the human. Only when every changed file has a signed `review.approved` row do you run the re-measurement of `code.md` §5 and `forge block close <id> --run <r> --report <file>` (the subagent's final report, saved to a file) — the transcript grep reads `.code-forge/runs/<run>/<block>.log`; pass `--transcript <file>` when the harness saved the subagent's transcript elsewhere. Closing with a file still unreviewed is a human decision: `forge block waive <id> <finding> --run <r> --file <path> --reason "<why>"` per finding, or `forge block close … --no-require-reviews`, which the human runs at their own terminal (it asks for a yes, has no `--yes`, and is refused under an active autopilot grant), both audited as their own rows.
|
|
16
16
|
|
|
17
17
|
## §2.1 Plan approval
|
|
18
18
|
|
|
@@ -22,7 +22,7 @@ Solo adds spawn, watch, timers, scratchpads, locks and `kv` on top of the CLI; i
|
|
|
22
22
|
5. **Wake channels, event-driven, never fixed polls:** record your own pid once (`whoami` → `kv_set("forge:orchestrator_pid")`); coders that can call Solo carry the push contract (post-sentinel `kv_set("block:<id>", …)` + `timer_set(1s, body, delivery_process_id=<your pid>)`); for every group arm `timer_fire_when_idle_any([coders], max_wait≈20–25 min, delivery_process_id=<your pid>)` right after the launch gate (idle-any ignores already-idle processes, so arm while they are provably running); keep one long dead-man fallback per 30–40 min with an L0 triage first. On wake: sentinel ⇒ gate; no sentinel and active again ⇒ re-arm; no sentinel and idle ⇒ parked, intervene now.
|
|
23
23
|
6. Completion = `search_output(pattern="===BLOCK <id> COMPLETE===")`, last occurrence (the `kv` key is the truncation backup). Completion is not closure.
|
|
24
24
|
7. **Review rounds, with the coder still alive.** The coder ran `forge review-file <path> --block <id> --run <r>` per file and `--wait`ed on each ticket; read the ledger (`forge ledger tail --slug <slug>`) for what is still open — a `fix_now` finding, a `pending` ticket, a file never submitted. Each open finding is a fix round sent to the **warm coder** by `send_input` (the findings by id, `<<<EOM>>>`), which fixes and re-runs `review-file` on that file; round ≥ 2 re-checks only the fix hunks (`review.md` §5). **One ladder** (`decisions.md` §5): a stall or exhausted rounds *below L2* climbs one level — close this coder and respawn a fresh one at the higher level with the open findings and the fix history in its brief; **the respawn keeps the per-file round count**, the cap does not reset. L2 is the highest running level: a trigger at L2, or the four-round cap on a file, goes to the **L3 rung once** — a fresh L3 patch session, never this coder and never a coder at L3 — and if findings stay open after the `patch_check` round the file is `stopped: review_cap` for the human. No other path exists.
|
|
25
|
-
8. **Close** only when every changed file has a signed `review.approved` row: the re-measurement of `code.md` §5, then `forge block close <id> --run <r> --report <file>` (the coder's final report) — the transcript grep reads the Solo output. Closing with a file still unreviewed is a human decision, audited: `forge block waive <id> <finding> --run <r> --file <path> --reason "<why>"` per finding, or `forge block close … --no-require-reviews
|
|
25
|
+
8. **Close** only when every changed file has a signed `review.approved` row: the re-measurement of `code.md` §5, then `forge block close <id> --run <r> --report <file>` (the coder's final report) — the transcript grep reads the Solo output. Closing with a file still unreviewed is a human decision, audited: `forge block waive <id> <finding> --run <r> --file <path> --reason "<why>"` per finding, or `forge block close … --no-require-reviews`, which the human runs at their own terminal (it asks for a yes, has no `--yes`, and is refused under an active autopilot grant).
|
|
26
26
|
9. **Reap after the close**: `close_process` the coder and confirm `lock_status` shows no lease before the next group. Locks: a coder that can call Solo holds `lock_acquire` on its file area while editing; coders that cannot never overlap another block.
|
|
27
27
|
|
|
28
28
|
## §4 Latency overlaps (standing)
|
package/skill/references/code.md
CHANGED
|
@@ -30,8 +30,8 @@ The coder's report is a claim sheet. The gate reproduces each number in the meas
|
|
|
30
30
|
|
|
31
31
|
| claim | re-measured by |
|
|
32
32
|
|---|---|
|
|
33
|
-
| only owned paths touched | file set = tracked changes ∪ untracked,
|
|
34
|
-
| every file reviewed | the report names a `review-file` ticket id per changed file (`--report`, which the orchestrator always passes) and a **signed** `review.approved` row for the current content hash of every changed or new file, or an architect-ruled disposition to a named successor block |
|
|
33
|
+
| only owned paths touched | file set = tracked changes since the base ∪ untracked (not ignored), listed from the repository top level, outside the repository top-level `.code-forge/` (keep transcripts and reports there; a subdirectory workspace's own `.code-forge/` is source tree except this run's record mirror and the block's default transcript: a `--transcript` or `--report` path in the source tree is a change like any other); each file must be in `owned_files`, unchanged since `block open` recorded the tree (`tree_at_open`; a path unreadable at open or at close never counts as unchanged), owned by another block of the run that is still **open**, or a closed block's file still exactly at the content its gate approved — else `unowned_change <file>` until `forge block claim <id> <path> --run <r>` or the human puts it back (not lifted by `--no-require-reviews`). The gate cannot tell which coder wrote a file an open sibling owns: it passes it to that sibling's gate and prints `WARN block <id>: <file> changed and is owned by open block <other>` for each one — read those lines |
|
|
34
|
+
| every file reviewed | file set = owned files changed since the base ∪ owned files with any review row of the block (`review.result`/`round`/`approved`/`triage`/`cap`/`late_finding`), an owned entry with no glob covering the paths below it; every path is repo-root-relative (what the worker signs), and an owned entry also matches relative to a workspace in a subdirectory; an empty set refuses `no_changes` (only the owner's `--no-require-reviews`, confirmed at a terminal, lifts it), and an empty diff with review rows WARNs (work committed before `block open`, or in another worktree — open blocks on a base without the work, in the workspace the coder edits). The report names a `review-file` ticket id per changed file (`--report`, which the orchestrator always passes) and a **signed** `review.approved` row for the current content hash of every changed or new file that no later review of the same content overrode (a `review.result` with `approved: false`, a `review.round` with findings open, a `review.cap`): else `not_approved <file> <finding>` per open finding, cleared only by a fix and a fresh approval or a waiver — a critical only by the human's `block waive` — or an architect-ruled disposition to a named successor block |
|
|
35
35
|
| tests green | every gate command run once in the export, each exit status read |
|
|
36
36
|
| clauses covered | every clause names ≥ 1 test id; each test exists and passed in this run; S1 `scope` on the hunks outside the named tests says `false` |
|
|
37
37
|
| test count | the filtered run's delta equals the new tests' own count |
|
|
@@ -39,9 +39,9 @@ The coder's report is a claim sheet. The gate reproduces each number in the meas
|
|
|
39
39
|
| no rule break | the transcript grep for forbidden verbs and paths (`--transcript <file>`, else the run record's log); a missing transcript ⇒ a signed `gate.transcript_missing` row and a WARN, never silence |
|
|
40
40
|
| high tier proven | every changed high-tier file (`proof.md` §1) has a signed, proven red→green row with `red_kind: assertion` for this block whose `covers` names it (light tier: optional), else `unproven <file>`; only the human's `block waive <id> proof --file <path>` clears it |
|
|
41
41
|
| rows genuine | the MAC of every gate-relevant row verifies; the live worker matches the pinned pid |
|
|
42
|
-
| nothing left open | no unruled `late_findings`; no file at `review_cap` without a signed `review.waived` row |
|
|
42
|
+
| nothing left open | no unruled `late_findings` (a ruling clears only a warning or nit ruled `nit`; a critical late finding needs the human's waiver); no file at `review_cap` without a signed `review.waived` row |
|
|
43
43
|
|
|
44
|
-
`--no-require-reviews` is the human's way out, written as its own audit row
|
|
44
|
+
`--no-require-reviews` is the human's way out, written as its own audit row. The human runs it themselves at a terminal: it asks for a yes, has no `--yes`, exits 2 unless stdin and stdout are both terminals, lifts `no_changes` and is refused while an autopilot grant is active — so never run it for them; tell them the command when they say so in chat. A finding counts only under the gate's own configuration; a tautology (an expected value computed by the code under test) is a finding. Reproduce disclosures: a coder that reports its own rule break did the hard part.
|
|
45
45
|
|
|
46
46
|
## §6 Budget — the stop is the feature (R6)
|
|
47
47
|
|
|
@@ -50,6 +50,6 @@ A fresh L3 session receives the failure evidence — the last attempt's diff, th
|
|
|
50
50
|
|
|
51
51
|
## §7 What reaches the human
|
|
52
52
|
|
|
53
|
-
`next = stop`, `review.over_budget`, an orphan file, `worker_down`, `review_cap`, `redecompose`, `ask_human`, a security question, consensus unavailable, and one budget raise per run. Everything else is decided by S1, S2 or the rules above and recorded. A change to a rule, limit or check is never decided here: it always reaches the owner (`rules-core.md` R17).
|
|
53
|
+
`next = stop`, `review.over_budget`, an orphan file or `unowned_change`, a `not_approved` critical finding, `--no-require-reviews`, `worker_down`, `review_cap`, `redecompose`, `ask_human`, a security question, consensus unavailable, and one budget raise per run. Everything else is decided by S1, S2 or the rules above and recorded. A change to a rule, limit or check is never decided here: it always reaches the owner (`rules-core.md` R17).
|
|
54
54
|
|
|
55
55
|
**Autopilot:** while a grant is active, a warning or nit waiver, one extra round at `review_cap` and a coder level go first to the delegate (`forge autopilot ask`, then `forge autopilot waive|round|level --decision <id>` only when it acted, `autopilot.md` §2). Never delegated: `next = stop`, `ask_human`, a security question, a critical finding, a budget raise, `redecompose` — they wait for the owner (`autopilot.md` §5).
|
|
@@ -11,7 +11,7 @@ The topology is decided in one place (`planSessions`). Consensus (§6) runs only
|
|
|
11
11
|
1. Tools on the file first: lint, types, the file's own tests. Red ⇒ `tools_red` back to the coder, no model review, ledger `gate.red`.
|
|
12
12
|
2. S1 `risk` (path floors first; `confidence < check` ⇒ S2 picks the depth).
|
|
13
13
|
3. Planned depth: `risk < 1` ⇒ one L2 `quick` · `1 ≤ risk < 2`, or `risk ≤ review.single_reviewer_max_risk` ⇒ one L2 `full`, no judge · otherwise two blind L2 sessions (lens A correctness, lens B contracts and security) plus an L3 judge that reads the two reports — and the diff only when `review.judge_sees_diff` is true.
|
|
14
|
-
4. Triage: findings from a single L2 session go to S1 `defect` (`thresholds.findings.fix` fix now · mid-band batched to one L3 ruling per file · below `thresholds.findings.nit` a nit). Findings that come from a judge are final.
|
|
14
|
+
4. Triage: findings from a single L2 session go to S1 `defect` (`thresholds.findings.fix` fix now · mid-band batched to one L3 ruling per file · below `thresholds.findings.nit` a nit). A `critical` finding is always fix now — neither S1 nor the L3 ruling makes it a nit. Findings that come from a judge are final.
|
|
15
15
|
|
|
16
16
|
## §3 Isolation and the packet
|
|
17
17
|
|
|
@@ -31,7 +31,7 @@ Per file, per block:
|
|
|
31
31
|
3a. **Heavy rounds climb at once** (`review_warnings`, `decisions.md` §5 rule 3a): below L2, a round — round 1 included — that leaves ≥ `escalation.warning_threshold` (2) warnings or any `critical` open, `escalation.after_rounds_with_warnings` (1) times at the current level, climbs one level for the next fix. One round with a stronger model is faster and cheaper than three with a cheap one. The per-file cap (step 5) stays the outer limit.
|
|
32
32
|
4. **Ladder**: the running ceiling is `min(L2, escalation.stop_at)` — L3 is never a *running* level. A trigger — rule 1 (`retries`), rule 2 (`escalation.review_rounds_per_level` exhausted with an open `fix_now`), rule 2b (`review_stall`) or step 3a (`review_warnings`) — below the ceiling climbs one level, coded by a fresh session with the open findings and the fix history in its brief (an L1 block's rounds 3–4 run at L2). At the ceiling — an L2 block, or an L1 block that has climbed to L2 — it takes the **L3 rung**: a patch ≤ 80 lines, owned files only, reviewed as a `patch_check` round outside the cap. Under `escalation.l3_mode: code` (opt-in) the rung is instead one L3 coding turn. Either way the rung fires **once per block** and the same post-patch order follows it. After it, in order: `patch_check` clean ⇒ complete; open set not smaller than before the patch ⇒ `stopped: l3_patch_exhausted` (`review_stall`); round ≥ `review.max_rounds_per_file` ⇒ `stopped: review_cap`; otherwise the fixes continue at L2 with `rounds_at_level` reset and the patch in the brief. Any later trigger in that block stops with its named reason (`l3_patch_exhausted`, `review_cap`) and never patches again.
|
|
33
33
|
5. **Cap — four rounds per file** (`review.max_rounds_per_file`, default 4, range 2–6). Round 4 still open ⇒ the L3 rung if the block has not used it (same post-patch order as step 4), else `stopped: review_cap`. A stop means the block cannot close, the ledger lists the open findings, and the human decides — fix by hand, `forge block waive <id> <finding-id> --run <r> --file <path> --reason <text>` (a signed `review.waived` row, `by: human`), or re-decompose. The coder never sees a `waive` verb; it is on the forbidden list. **Autopilot:** under a grant, a warning or nit finding may be waived by the delegate (`forge autopilot waive … --decision <id>`, written `by: autopilot`) and a file at the cap may get one extra round (`forge autopilot round`); a critical or proof waiver is never delegated (`autopilot.md`).
|
|
34
|
-
6. **Block-close sweep**: `forge block close` batches the `late_findings` per file to one L3 judge ruling (`fix_now | nit`); a `fix_now` opens a round on that file at the block's current level and counts toward its cap.
|
|
34
|
+
6. **Block-close sweep**: `forge block close` batches the `late_findings` per file to one L3 judge ruling (`fix_now | nit`); a `fix_now` opens a round on that file at the block's current level and counts toward its cap. The gate counts a ruling only when it is `nit` on a warning or nit finding; a critical late finding stays open until the human waives it.
|
|
35
35
|
7. Ledger: one `review.round` row per round with `open_before`, `closed`, `new_in_hunks`, `late`, `open_after`; `forge report` prints rounds per file and rounds that stalled.
|
|
36
36
|
|
|
37
37
|
Rechecks are never degraded by the budget: a round-2+ packet is already the minimum.
|
package/skill/scripts/forge
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
# copied install with no package next to it); `test/skill/shim.test.mjs` asserts the pin
|
|
11
11
|
# equals package.json's version.
|
|
12
12
|
# The shim holds no logic of its own: every verb, flag and message is the CLI's.
|
|
13
|
-
PINNED_VERSION="0.
|
|
13
|
+
PINNED_VERSION="0.7.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)
|
|
17
|
-
*
|
|
18
|
-
*
|
|
16
|
+
* changed owned file (deletions included) that no later review of the same content made stale
|
|
17
|
+
* (B52: `not_approved <file> <finding>` per open finding of that later review, unless waived — a
|
|
18
|
+
* critical only by the human's `block waive`) — `--no-require-reviews` is the human's way out, is
|
|
19
|
+
* forbidden to coders (`mergeForbidden`), needs the human to confirm at a terminal (B52: stdin
|
|
20
|
+
* or stdout not a TTY ⇒ exit 2, an active autopilot grant ⇒ refused, there is no `--yes`; a block
|
|
21
|
+
* that is not open is refused first, exactly as without the flag), lifts `no_changes`, and leaves a signed
|
|
22
|
+
* `gate.reviews_waived {by: human}` row before the close (never a silent skip; a declined prompt
|
|
23
|
+
* prints `block <id> close: cancelled …`). B52: a changed or new file no block of the run
|
|
24
|
+
* accounts for is `unowned_change <file>` (`unownedChanges`, listed from the repository top
|
|
25
|
+
* level; not lifted by `--no-require-reviews`; only the repository top-level `.code-forge/` is
|
|
26
|
+
* exempt — plus, for a workspace in a subdirectory, this run's record mirror and this block's
|
|
27
|
+
* default transcript there — never a `--transcript` or `--report` path in the source tree; a
|
|
28
|
+
* closed block's file still exactly at the content its gate approved is accounted for); a file another OPEN block of the run owns
|
|
29
|
+
* is left to that block's gate with one `WARN` line per file (the close cannot tell which coder
|
|
30
|
+
* wrote it); an old ledger's `review.result` rows are judged by their signed result files
|
|
31
|
+
* (`resultFilesFor`). B52 (field run): the file set is the diff since base ∪ every owned file
|
|
32
|
+
* with review rows of the block (`reviewedFiles`; an owned entry with no glob owns the paths below
|
|
33
|
+
* it, `ownsAt`), so work committed before `block open` or done in another worktree is still
|
|
34
|
+
* checked (with a WARN naming the likely cause when the diff shows none of it); an empty set is
|
|
35
|
+
* `no_changes`; late findings ruled; `review_cap` findings waived; no
|
|
19
36
|
* `rule_break`, plus the transcript grep. The transcript is `--transcript`, else the block's
|
|
20
37
|
* `transcript` in the run record, else `.code-forge/runs/<run>/<block>.log`; none ⇒ a signed
|
|
21
38
|
* `gate.transcript_missing` row and a WARN line (never a silent pass, never a refusal). An id the
|
|
@@ -48,13 +65,13 @@ import path from 'node:path';
|
|
|
48
65
|
import { parse as parseYAML } from 'yaml';
|
|
49
66
|
import { approvalCheckText, expiryChecks } from '../autopilot/limits.mjs';
|
|
50
67
|
import { appendRow, readAllRows } from '../ledger/write.mjs';
|
|
51
|
-
import { attemptBlock, claimPath, closeBlock, openBlock, rebaseBlock, stopBlock } from '../state/block.mjs';
|
|
68
|
+
import { attemptBlock, claimPath, closeBlock, openBlock, rebaseBlock, repoTop, stopBlock } from '../state/block.mjs';
|
|
52
69
|
import { intFlag, parseFlags } from '../state/cli-args.mjs';
|
|
53
70
|
import { StateError } from '../state/paths.mjs';
|
|
54
|
-
import { findOverlap } from '../state/registry.mjs';
|
|
55
71
|
import { readRun, writeSigned } from '../state/run.mjs';
|
|
56
72
|
import { loadKey, verifyRow } from '../state/signer.mjs';
|
|
57
|
-
import { blockFileSet, reviewGateCheck, waiveFinding } from '../review/gate-check.mjs';
|
|
73
|
+
import { blockFileSet, changedFiles, ownsAt, resultFilesFor, reviewedFiles, reviewGateCheck, unownedChanges, waiveFinding } from '../review/gate-check.mjs';
|
|
74
|
+
import { checkExpiry } from '../autopilot/grant.mjs';
|
|
58
75
|
import { writeSafe } from '../util/redact.mjs';
|
|
59
76
|
import { DEFAULT_CONFIG_FILENAME, loadProjectConfig } from '../config/load.mjs';
|
|
60
77
|
import { validateConfig } from '../config/validate.mjs';
|
|
@@ -73,8 +90,13 @@ const FLAG_VALUES = ['run', 'level', 'acceptance', 'brief', 'attempt', 'base', '
|
|
|
73
90
|
|
|
74
91
|
/**
|
|
75
92
|
* @param {string[]} args
|
|
76
|
-
* @param {{
|
|
77
|
-
*
|
|
93
|
+
* @param {{
|
|
94
|
+
* stdout?: {write: (s: string) => unknown}, stderr?: {write: (s: string) => unknown}, probe?: import('../state/run.mjs').StartTimeProbe, now?: () => Date,
|
|
95
|
+
* isTTY?: boolean, stdinIsTTY?: boolean, stdoutIsTTY?: boolean,
|
|
96
|
+
* ui?: {confirm: (o: {message: string, initialValue?: boolean}) => Promise<unknown>, isCancel: (v: unknown) => boolean},
|
|
97
|
+
* }} [deps] - `now` (B48): the clock of the autopilot expiry checks. `stdinIsTTY` / `stdoutIsTTY`
|
|
98
|
+
* (default `process.stdin.isTTY` / `process.stdout.isTTY`; `isTTY` sets both) and `ui` (default
|
|
99
|
+
* `@clack/prompts`): B52's confirmation of `--no-require-reviews`, which needs both to be TTYs.
|
|
78
100
|
* @returns {Promise<number>}
|
|
79
101
|
*/
|
|
80
102
|
export async function runBlock(args, deps = {}) {
|
|
@@ -167,13 +189,44 @@ export async function runBlock(args, deps = {}) {
|
|
|
167
189
|
if (sub === 'close') {
|
|
168
190
|
const entry = record.blocks?.[id];
|
|
169
191
|
if (!entry) throw new StateError('unknown_block', `block ${id} is not in run ${runId}`);
|
|
170
|
-
|
|
192
|
+
// B52: a block that is not open is refused here, with or without --no-require-reviews, by
|
|
193
|
+
// the same check and message as `closeBlock` — and the owner is never asked about it
|
|
194
|
+
if (entry.status !== 'open') throw new StateError('no-block', `block ${id} is not open in run ${runId}`);
|
|
171
195
|
const reviewsWaived = flags['no-require-reviews'] === true;
|
|
172
|
-
// the
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
196
|
+
// B52: the human's way out is confirmed by the human, at a terminal, before anything is written
|
|
197
|
+
if (reviewsWaived) {
|
|
198
|
+
const asked = await confirmNoReviews(runId, id, deps);
|
|
199
|
+
if (asked === 'cancelled') {
|
|
200
|
+
err(`block ${id} close: cancelled — the review checks stay on\n`);
|
|
201
|
+
return 1;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
const rows = (await readAllRows(record.project)).filter((row) => row.run === runId);
|
|
205
|
+
// B52: ONE path base and ONE listing, memoised as a promise and shared by every check. The
|
|
206
|
+
// base is the repository top level: the worker signs every review row with a repo-root-
|
|
207
|
+
// relative `file` and keeps its queue and signed result files under `<top>/.code-forge/`
|
|
208
|
+
// (`repoRootOf`), and `tree_at_open` is stamped there too. An owned entry matches a path
|
|
209
|
+
// as given or, for a workspace in a subdirectory, relative to the workspace (`ownsAt`).
|
|
210
|
+
// A failure here is the usual `git-failed` refusal.
|
|
211
|
+
/** @type {Promise<{top: string, wsPrefix: string, owns: (owned: ReadonlyArray<string>, file: string) => boolean, changed: string[], fileSet: Array<{file: string, content_hash: string}>, reviewed: string[]}> | undefined} */
|
|
212
|
+
let treeOnce;
|
|
213
|
+
const tree = () =>
|
|
214
|
+
(treeOnce ??= (async () => {
|
|
215
|
+
const top = await repoTop(record.workspace);
|
|
216
|
+
// both sides as realpaths: git prints the top resolved (/private/var on macOS) while the
|
|
217
|
+
// run record may keep a symlinked spelling (/var)
|
|
218
|
+
const real = async (/** @type {string} */ p) => realpath(p).catch(() => path.resolve(p));
|
|
219
|
+
const wsPrefix = path.relative(await real(top), await real(record.workspace)).split(path.sep).join('/');
|
|
220
|
+
const owns = ownsAt(wsPrefix);
|
|
221
|
+
const isMine = (/** @type {string} */ f) => owns(entry.owned_files, f);
|
|
222
|
+
const changed = await changedFiles({ repoRoot: top, base: entry.base_sha });
|
|
223
|
+
// the file set is the diff since base ∪ every owned file with review rows of this block —
|
|
224
|
+
// work the diff cannot see (committed before `block open`, done in another worktree) is
|
|
225
|
+
// still checked, at its content in the workspace
|
|
226
|
+
const reviewed = reviewedFiles(rows, id).filter(isMine);
|
|
227
|
+
const fileSet = await blockFileSet({ repoRoot: top, base: entry.base_sha, owned: entry.owned_files, matchOwned: isMine, changed: [...new Set([...changed, ...reviewed])].sort() });
|
|
228
|
+
return { top, wsPrefix, owns, changed, fileSet, reviewed };
|
|
229
|
+
})());
|
|
177
230
|
const reviews = reviewGateCheck(async () => {
|
|
178
231
|
const transcript = await findTranscript(record, id, typeof flags.transcript === 'string' ? flags.transcript : null);
|
|
179
232
|
if (transcript === null) {
|
|
@@ -184,14 +237,37 @@ export async function runBlock(args, deps = {}) {
|
|
|
184
237
|
if (reviewsWaived) await writeSigned(runId, writeRow, { event: 'gate.reviews_waived', block: id, by: 'human' });
|
|
185
238
|
// B20: the high paths come first — a base the workspace does not know refuses the close here
|
|
186
239
|
const highPaths = await highPathsAt(record.workspace, entry.base_sha);
|
|
187
|
-
const fileSet = await
|
|
240
|
+
const { top, wsPrefix, owns, changed, fileSet, reviewed } = await tree();
|
|
241
|
+
const isMine = (/** @type {string} */ f) => owns(entry.owned_files, f);
|
|
242
|
+
const key = await loadKey(runId);
|
|
243
|
+
// B52: an empty diff with review rows is never silent — name the likely cause
|
|
244
|
+
if (reviewed.length > 0 && !changed.some(isMine)) {
|
|
245
|
+
err(`WARN block ${id}: no owned file differs from the block base in the workspace, but ${reviewed.length} owned file(s) have review rows — the work was likely committed before block open or done in another worktree; they are checked at their content in the workspace\n`);
|
|
246
|
+
}
|
|
247
|
+
// B52: only code-forge's own state is exempt — `<top>/.code-forge/`, where the worker keeps
|
|
248
|
+
// its queue and signed results — never a `--transcript` or `--report` path in the source
|
|
249
|
+
// tree. For a workspace in a subdirectory, `<workspace>/.code-forge/` is source tree like any
|
|
250
|
+
// other path, except the two files code-forge itself keeps there at fixed names: this run's
|
|
251
|
+
// record mirror and this block's default transcript (`.code-forge/runs/<run>.json`,
|
|
252
|
+
// `.code-forge/runs/<run>/<id>.log`)
|
|
253
|
+
const wsRel = (/** @type {string} */ p) => path.posix.join(wsPrefix, p);
|
|
254
|
+
const ownState = new Set([wsRel(`.code-forge/runs/${runId}.json`), wsRel(`.code-forge/runs/${runId}/${id}.log`)]);
|
|
255
|
+
const scope = await unownedChanges({ top, changed: changed.filter((f) => !ownState.has(f)), id, blocks: record.blocks, rows, key, isMine, owns, stateDir: '.code-forge' });
|
|
256
|
+
if (scope === null) err(`WARN block ${id}: opened before code-forge recorded the tree at block open; unowned changes were not checked\n`);
|
|
257
|
+
for (const { file, block: other } of scope?.siblings ?? []) err(`WARN block ${id}: ${file} changed and is owned by open block ${other}; not checked here (its own gate reviews it)\n`);
|
|
188
258
|
return {
|
|
189
259
|
block: id,
|
|
190
260
|
runId,
|
|
191
261
|
rows,
|
|
192
|
-
key
|
|
262
|
+
key,
|
|
193
263
|
transcript,
|
|
264
|
+
// B52: a change no block of the run owns is refused — `--no-require-reviews` does not lift it
|
|
265
|
+
unowned: scope?.unowned ?? [],
|
|
266
|
+
// B52: an old ledger's review.result rows are judged by their signed result files
|
|
267
|
+
resultFiles: await resultFilesFor({ repoRoot: top, runId, block: id, rows }),
|
|
194
268
|
files: reviewsWaived ? [] : fileSet,
|
|
269
|
+
// B52: nothing changed and nothing reviewed is never a pass (the owner's way out is --no-require-reviews)
|
|
270
|
+
noChanges: !reviewsWaived && fileSet.length === 0,
|
|
195
271
|
// B20: high-tier files need a proven red→green row; `--no-require-reviews` does not lift it
|
|
196
272
|
proofFiles: fileSet,
|
|
197
273
|
highPaths,
|
|
@@ -206,8 +282,8 @@ export async function runBlock(args, deps = {}) {
|
|
|
206
282
|
} catch {
|
|
207
283
|
return { ok: false, reason: 'coder report FAILED: --report cannot be read' };
|
|
208
284
|
}
|
|
209
|
-
const
|
|
210
|
-
const missing = missingReviewTickets(text,
|
|
285
|
+
const { fileSet } = await tree();
|
|
286
|
+
const missing = missingReviewTickets(text, fileSet.map((f) => f.file));
|
|
211
287
|
return missing.length === 0 ? { ok: true } : { ok: false, reason: `coder report FAILED: no review-file ticket id for ${missing.join(', ')}` };
|
|
212
288
|
};
|
|
213
289
|
const result = await closeBlock({ runId, id, rows, writeRow, livePid: intFlag(flags['worker-pid'], 'worker-pid'), probe, extraChecks: [reviews, report] });
|
|
@@ -222,6 +298,35 @@ export async function runBlock(args, deps = {}) {
|
|
|
222
298
|
return 2;
|
|
223
299
|
}
|
|
224
300
|
|
|
301
|
+
/** B52: the refusal when `--no-require-reviews` has no terminal to ask the human at. */
|
|
302
|
+
export const NO_REVIEWS_NO_TTY = '--no-require-reviews needs the human at a terminal to confirm; the block stays open (there is no --yes)';
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* B52: `--no-require-reviews` lifts every per-file review check, so it is the human's alone — the
|
|
306
|
+
* same rule as `autopilot approve`: a flag can be passed by any process (the orchestrator, a
|
|
307
|
+
* script, the delegate's tooling), so the human confirms at a terminal and there is NO `--yes`.
|
|
308
|
+
* No terminal — stdin AND stdout must both be TTYs (a piped stdout means nobody may see the
|
|
309
|
+
* question) — ⇒ `usage` (exit 2); an active autopilot grant on the run ⇒ `autopilot-active`
|
|
310
|
+
* (exit 1; the owner stops the grant first) — nothing is written either way. `deps.stdinIsTTY` /
|
|
311
|
+
* `deps.stdoutIsTTY` inject each side; `deps.isTTY` injects both at once.
|
|
312
|
+
* @param {string} runId @param {string} id
|
|
313
|
+
* @param {{isTTY?: boolean, stdinIsTTY?: boolean, stdoutIsTTY?: boolean, ui?: {confirm: (o: {message: string, initialValue?: boolean}) => Promise<unknown>, isCancel: (v: unknown) => boolean}, now?: () => Date}} deps
|
|
314
|
+
* @returns {Promise<'confirmed' | 'cancelled'>}
|
|
315
|
+
* @throws {StateError} `usage`, `autopilot-active`
|
|
316
|
+
*/
|
|
317
|
+
async function confirmNoReviews(runId, id, deps) {
|
|
318
|
+
const stdinTTY = deps.stdinIsTTY ?? deps.isTTY ?? process.stdin.isTTY === true;
|
|
319
|
+
const stdoutTTY = deps.stdoutIsTTY ?? deps.isTTY ?? process.stdout.isTTY === true;
|
|
320
|
+
if (!(stdinTTY && stdoutTTY)) throw new StateError('usage', NO_REVIEWS_NO_TTY);
|
|
321
|
+
const grant = await checkExpiry(runId, (deps.now ?? (() => new Date()))());
|
|
322
|
+
if (grant.state === 'active') {
|
|
323
|
+
throw new StateError('autopilot-active', `--no-require-reviews is refused while an autopilot grant is active on run ${runId} (stop it first: code-forge autopilot stop --run ${runId})`);
|
|
324
|
+
}
|
|
325
|
+
const ui = deps.ui ?? /** @type {any} */ (await import('@clack/prompts'));
|
|
326
|
+
const go = await ui.confirm({ message: `Close block ${id} of run ${runId} WITHOUT the per-file review checks? Only the owner may say yes.`, initialValue: false });
|
|
327
|
+
return ui.isCancel(go) || go !== true ? 'cancelled' : 'confirmed';
|
|
328
|
+
}
|
|
329
|
+
|
|
225
330
|
/**
|
|
226
331
|
* B47: the level `autopilot level` chose for the block — the latest `autopilot.level` row of this
|
|
227
332
|
* run for it whose MAC verifies — or null when there is none.
|
|
@@ -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
|
|
13
|
-
*
|
|
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 `
|
|
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 (!
|
|
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 (
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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((
|
|
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
|
|
158
|
-
if (typeof
|
|
159
|
-
if (worst === '' || rank[
|
|
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
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
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
|
/**
|
package/src/review/triage.mjs
CHANGED
|
@@ -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
|
-
|
|
133
|
-
|
|
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',
|
package/src/state/block.mjs
CHANGED
|
@@ -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
|
-
|
|
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 {
|
package/src/worker/loop.mjs
CHANGED
|
@@ -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>}
|
|
370
|
+
* @param {string} id @param {Record<string, any>} outcome - recorded through {@link recordedVerdict}.
|
|
355
371
|
*/
|
|
356
|
-
async function complete(id,
|
|
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
|
});
|