@codedology/code-forge 0.6.1 → 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 +41 -0
- package/README.md +9 -1
- package/docs/reference/blocks.json +17 -1
- package/docs/reference/config.md +1 -1
- package/package.json +1 -1
- package/schema/code-forge.schema.json +1 -1
- package/skill/SKILL.md +2 -2
- 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/ledger.md +1 -1
- package/skill/references/plan.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,44 @@ 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
|
+
|
|
35
|
+
## [0.6.2] — 2026-10-07
|
|
36
|
+
|
|
37
|
+
### Changed
|
|
38
|
+
|
|
39
|
+
- Docs and skill: the project root rule in `docs/how-it-works.md`, `docs/concepts.md` (new "Project root" row),
|
|
40
|
+
`skill/references/ledger.md` and the `project.slug` note in `docs/reference/config.md`; `skill/SKILL.md` and
|
|
41
|
+
`skill/references/plan.md` say a flag is checked only with `grep -rn -- <flag> <source folder>`.
|
|
42
|
+
|
|
43
|
+
### Fixed
|
|
44
|
+
|
|
45
|
+
- README: the 0.6.1 notes are labelled as released (they said "Next release").
|
|
46
|
+
|
|
9
47
|
## [0.6.1] — 2026-10-07
|
|
10
48
|
|
|
11
49
|
### Added
|
|
@@ -27,6 +65,9 @@ waits on a second real consumer (plan §10.4, Q17).
|
|
|
27
65
|
a pipe, a list or `rg`; grep options come from a fixed allow-list. No Claude allow rules are added.
|
|
28
66
|
- A command claim written without its CLI (`run reload`) gets the project's own bin only when the brief names that bin
|
|
29
67
|
and the first word is not a well-known CLI.
|
|
68
|
+
- Two CI tests no longer fail on a slow Node 22 runner: the worker-restart test waits for at least one review session
|
|
69
|
+
(the worker runs up to `review.parallel_tickets` at once), and the README test waits for the worker to exit after
|
|
70
|
+
`run end`.
|
|
30
71
|
|
|
31
72
|
## [0.6.0] — 2026-10-06
|
|
32
73
|
|
package/README.md
CHANGED
|
@@ -10,7 +10,15 @@ 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
|
|
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
|
+
|
|
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
|
|
16
24
|
change, relax or remove a limit, threshold, budget, gate or review requirement. When a limit
|
|
@@ -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/docs/reference/config.md
CHANGED
|
@@ -13,7 +13,7 @@ Every other key defaults as shown, or is entirely absent from a minimal file. `a
|
|
|
13
13
|
| `version` | const `1` | yes | — | |
|
|
14
14
|
| `project` | object | | — | |
|
|
15
15
|
| `project.name` | string | | — | |
|
|
16
|
-
| `project.slug` | string | | — |
|
|
16
|
+
| `project.slug` | string | | — | B50: the ledger name. Default: the project root's folder name (the nearest .code-forge.yml up to the git top level, else the git top level), so every verb run from a subfolder writes to the same ledger. |
|
|
17
17
|
| `project.plans_dir` | string | | — | |
|
|
18
18
|
| `project.languages` | object | | — | |
|
|
19
19
|
| `project.languages.chat` | string | | — | |
|
package/package.json
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
"additionalProperties": false,
|
|
15
15
|
"properties": {
|
|
16
16
|
"name": { "type": "string" },
|
|
17
|
-
"slug": { "type": "string" },
|
|
17
|
+
"slug": { "type": "string", "description": "B50: the ledger name. Default: the project root's folder name (the nearest .code-forge.yml up to the git top level, else the git top level), so every verb run from a subfolder writes to the same ledger." },
|
|
18
18
|
"plans_dir": { "type": "string" },
|
|
19
19
|
"languages": {
|
|
20
20
|
"type": "object",
|
package/skill/SKILL.md
CHANGED
|
@@ -34,14 +34,14 @@ State lives in the plan file's header — `facts → planned → hardened → co
|
|
|
34
34
|
|
|
35
35
|
## §3 The loop
|
|
36
36
|
|
|
37
|
-
1. `forge facts --brief <brief> --out <plans_dir>/<slug>.facts.md` — an L0 delegate checks every claim with
|
|
37
|
+
1. `forge facts --brief <brief> --out <plans_dir>/<slug>.facts.md` — an L0 delegate checks every claim with one read-only command (a flag only with `grep -rn -- <flag> <source folder>`, never `--help`). You read the sheet; you never read the sources yourself (`references/plan.md` §0).
|
|
38
38
|
2. `forge author --job plan --brief <brief> --facts <sheet> --out <plan>` — refuses without `--facts` (exit 2) and refuses a sheet older than the brief. The draft's §0 is the sheet, verbatim.
|
|
39
39
|
3. Harden: `forge author --job harden --brief <brief> --facts <sheet> --draft <plan> --answers <file>` until `questions[]` is empty. The human answers; you relay, never answer for them (`references/harden.md`). Before the exit gate, record each block's lane: `forge jev ask lane --block <id> --plan <plan> --state <file>` (`--rules` without Jev); the plan's level must equal it. Exit gate: `forge plan check <plan>` green, with a lane for every block on its `lanes:` line (`references/plan.md`).
|
|
40
40
|
Then show the plan to the owner for approval in the harness's plan mode, never as chat text (Claude Code: EnterPlanMode, write the plan, ExitPlanMode); a harness without a plan mode shows it and waits for an explicit yes. No block is dispatched before that approval (`references/plan.md` §4.1).
|
|
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).
|
|
@@ -30,4 +30,4 @@ Per question id and `source`: reliability buckets × outcome, the count of `≥
|
|
|
30
30
|
|
|
31
31
|
## §5 Durability
|
|
32
32
|
|
|
33
|
-
Rotated at 10 MB; rows are signed only where the gate relies on them; `report --export` snapshots are what anything downstream reads. The ledger is per project slug, so a run from a sub-directory still lands in the same file.
|
|
33
|
+
Rotated at 10 MB; rows are signed only where the gate relies on them; `report --export` snapshots are what anything downstream reads. The ledger is per project slug, and every verb takes the config and the slug from the **project root** (the nearest regular `.code-forge.yml` up to the git top level, else the git top level; outside git never above HOME, and a `.git` at HOME does not count), so a run from a sub-directory still lands in the same file.
|
package/skill/references/plan.md
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
Run `forge facts --brief <brief> [--sources <path>...] --out <plans_dir>/<slug>.facts.md` before anything else. What it does:
|
|
6
6
|
|
|
7
7
|
1. Deterministic: the CLI extracts every *claim token* from the brief and the sources it names — CLI flags, `<cli> <verb>` commands, paths, versions, endpoints, package names, environment variable names — and writes a claim list. A `<cli> <verb>` written without its CLI (`run reload`) whose first word is not one of the project's CLIs, not a well-known CLI (`git gh npm npx node pnpm yarn bun deno ls cat grep which find test make docker curl claude codex python python3 pip go cargo op`), and that the brief never uses as a CLI of its own (`` `gh` `` or `` `gh --repo x` `` in a code span) — decided from the brief and `package.json` alone, never PATH — becomes `<project CLI> <verb>` (`code-forge run reload`) when the brief itself (not a source) names one of the project's CLIs (its `package.json` `bin` names) as a whole word — `.code-forge.yml` or `code-forge.config` do not count; `plan check` then counts a clause citing `run reload` as backed by that VERIFIED fact, and only when the extra word is one of the project's own CLIs.
|
|
8
|
-
2. One L0 session whose cwd is a **read-only snapshot of the repository at HEAD plus the `--sources` files**, built under the run's temp root and removed at session end — so `./path` claims are checkable, while **uncommitted files are not visible unless named as sources** (the snapshot is HEAD, not the working tree) — and whose packet is the claim list and the rule "run the cheapest read-only check, quote the command and the output line, tag VERIFIED / NOT-FOUND / UNVERIFIABLE, never infer". Two lines of defence, exactly as built. **First, at spawn:** the session gets the shell tool only, with the forbidden list plus every write verb rendered as deny rules and the provider's restricted mode (file tools confined to the working directory). Nothing is pre-approved: the delegate only runs what the provider already treats as read-only. A flag claim is therefore checked in the project's **source text**, never by running a program: `grep -rn -- <flag> <path>` over a top-level source folder of the snapshot such as `src` (never `.` or the root); it is VERIFIED only when the claim is that flag and the excerpt has a `file:line:text` hit from a source file — not `.md`/`.markdown`/`.txt`, not under `docs/ plans/ test/ tests/ fixtures/ node_modules/`, not the brief, and under the grep's path — holding the flag as a whole token (`--force-push` does not prove `--force`); a NOT-FOUND counts only over one whole top-level source folder that exists as a directory in the snapshot (not hidden, not `dist build coverage out` or the folders above). The grep's path must be an existing source directory of the snapshot (a nested one such as `src/cli` is fine for VERIFIED; a single file never is), and no other command — a plain grep included — ever proves or disproves a flag. Every check is ONE command: no pipe and no list (`| ; && || &`). There is no pre-execution gate on individual commands beyond the deny rules: the delegate runs what it runs. **Second, after the fact:** the CLI checks every `command` the delegate reports against an allow-list, fail closed, and refuses the whole sheet on the first violation. Allowed forms (never `rg`, which searches recursively by default): `ls cat head tail wc grep stat file uname sw_vers jq which` with in-cwd arguments (`grep` options only `-n -c -i -w -F` before `--`, recursive only as the whole command `grep -rn -- <--long-flag> <path>` with one relative subfolder
|
|
8
|
+
2. One L0 session whose cwd is a **read-only snapshot of the repository at HEAD plus the `--sources` files**, built under the run's temp root and removed at session end — so `./path` claims are checkable, while **uncommitted files are not visible unless named as sources** (the snapshot is HEAD, not the working tree) — and whose packet is the claim list and the rule "run the cheapest read-only check, quote the command and the output line, tag VERIFIED / NOT-FOUND / UNVERIFIABLE, never infer". Two lines of defence, exactly as built. **First, at spawn:** the session gets the shell tool only, with the forbidden list plus every write verb rendered as deny rules and the provider's restricted mode (file tools confined to the working directory). Nothing is pre-approved: the delegate only runs what the provider already treats as read-only. A flag claim is therefore checked in the project's **source text**, never by running a program: `grep -rn -- <flag> <path>` over a top-level source folder of the snapshot such as `src` (never `.` or the root); it is VERIFIED only when the claim is that flag and the excerpt has a `file:line:text` hit from a source file — not `.md`/`.markdown`/`.txt`, not under `docs/ plans/ test/ tests/ fixtures/ node_modules/`, not the brief, and under the grep's path — holding the flag as a whole token (`--force-push` does not prove `--force`); a NOT-FOUND counts only over one whole top-level source folder that exists as a directory in the snapshot (not hidden, not `dist build coverage out` or the folders above). The grep's path must be an existing source directory of the snapshot (a nested one such as `src/cli` is fine for VERIFIED; a single file never is), and no other command — a plain grep included — ever proves or disproves a flag. Every check is ONE command: no pipe and no list (`| ; && || &`). There is no pre-execution gate on individual commands beyond the deny rules: the delegate runs what it runs. **Second, after the fact:** the CLI checks every `command` the delegate reports against an allow-list, fail closed, and refuses the whole sheet on the first violation. Allowed forms (never `rg`, which searches recursively by default): `ls cat head tail wc grep stat file uname sw_vers jq which` with in-cwd arguments (`grep` options only `-n -c -i -w -F` before `--`, recursive only as the whole command `grep -rn -- <--long-flag> <path>` with one relative subfolder, never `.` or a secret path; only a source folder proves a flag), `find` with only these predicates — `-name -iname -path -ipath -type -maxdepth -mindepth -print -print0 -newer -size -empty -mtime -mmin -not ! -a -o -and -or` — every other predicate refused, `git log|show|status|rev-parse|ls-files|cat-file`, `npm view|ls`, `test -e|-f|-d|-n <one operand>`, and `curl` as a HEAD request of exactly one http(s) URL with `-s -S -I -L -f --max-time <N>` only. No program ever runs with `--help`, `-h` or `--version` (BSD `rm foo --help` deletes `foo`): a version claim is checked with `npm view`, `which` or `ls`; an env claim is only `printenv NAME >/dev/null` with its excerpt blanked. Refused anywhere: backticks, `$`, redirection, unquoted globs, `~`, `..`, absolute paths outside `/usr`, `/opt/homebrew`, `/bin`, and secret-looking paths. **`npm view` and `curl -I` reach the network** — they are what makes a package version or an endpoint checkable; nothing else the delegate may run does.
|
|
9
9
|
**Path claims:** `./path` claims resolve against the HEAD snapshot, so a committed file is VERIFIED or NOT-FOUND by a plain `ls`/`test -e`; a file that exists only uncommitted in the working tree reads NOT-FOUND unless you passed it with `--sources`. `~/…` claims stay UNVERIFIABLE (`~` is refused), and absolute paths are allowed only under `/usr`, `/opt/homebrew` and `/bin`; a home-directory path that matters to a block is checked by you with one read-only command and recorded in the unbackable list with that command as the tolerance.
|
|
10
10
|
3. Deterministic again: the answer is validated against the facts schema, rendered with `templates/facts-sheet.md`, and refused when a VERIFIED tag has no output excerpt. Every claim, command and excerpt passes through the redactor. The header records the brief's sha and mtime.
|
|
11
11
|
|
|
@@ -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.
|