@codedology/code-forge 0.7.7 → 0.9.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 +97 -0
- package/README.md +68 -2
- package/docs/README.md +2 -1
- package/docs/reference/blocks.json +390 -1
- package/docs/reference/config.md +26 -8
- package/package.json +1 -1
- package/schema/code-forge.schema.json +81 -4
- package/skill/SKILL.md +6 -6
- package/skill/references/code.md +45 -7
- package/skill/references/ledger.md +8 -4
- package/skill/references/proof.md +44 -3
- package/skill/references/review.md +25 -5
- package/skill/references/security.md +1 -1
- package/skill/scripts/forge +1 -1
- package/skill/templates/coder-brief.md +19 -2
- package/src/autopilot/binnacle.mjs +21 -3
- package/src/cli/block.mjs +427 -41
- package/src/cli/context.mjs +175 -0
- package/src/cli/gates.mjs +125 -15
- package/src/cli/ledger.mjs +46 -2
- package/src/cli/proof.mjs +62 -16
- package/src/cli/report.mjs +7 -4
- package/src/cli/review-file.mjs +324 -18
- package/src/cli/status.mjs +212 -0
- package/src/config/validate.mjs +45 -11
- package/src/context/pack.mjs +319 -0
- package/src/doctor/index.mjs +5 -4
- package/src/doctor/local.mjs +91 -3
- package/src/engines/sentinel.mjs +27 -6
- package/src/gates/detect.mjs +21 -8
- package/src/gates/expand.mjs +202 -0
- package/src/gates/owned-count.mjs +66 -0
- package/src/gates/run.mjs +49 -12
- package/src/install/wizard/answers.mjs +34 -5
- package/src/install/wizard/flags.mjs +8 -1
- package/src/install/wizard/project-file.mjs +2 -0
- package/src/install/wizard/run.mjs +6 -0
- package/src/install/wizard/steps.mjs +24 -4
- package/src/install/wizard/summary.mjs +13 -5
- package/src/ledger/export.mjs +1 -1
- package/src/ledger/outside-review.mjs +164 -0
- package/src/ledger/report.mjs +17 -5
- package/src/ledger/write.mjs +7 -1
- package/src/proof/export.mjs +93 -10
- package/src/proof/lock.mjs +28 -5
- package/src/proof/worktree.mjs +482 -0
- package/src/review/answers.mjs +445 -0
- package/src/review/deletion.mjs +106 -0
- package/src/review/engine.mjs +36 -2
- package/src/review/fixloop.mjs +25 -15
- package/src/review/gate-check.mjs +384 -83
- package/src/review/open-findings.mjs +59 -0
- package/src/review/reopen.mjs +258 -0
- package/src/review/stop-exits.mjs +15 -0
- package/src/state/block-end.mjs +290 -0
- package/src/state/block.mjs +103 -39
- package/src/state/brief-status.mjs +131 -0
- package/src/state/config-snapshot.mjs +51 -0
- package/src/state/registry.mjs +35 -4
- package/src/state/run.mjs +10 -1
- package/src/util/breadcrumbs.mjs +1 -0
- package/src/util/forbidden.mjs +46 -1
- package/src/util/gate-token.mjs +8 -0
- package/src/util/tmp.mjs +16 -4
- package/src/worker/engine.mjs +230 -24
- package/src/worker/loop.mjs +29 -8
- package/src/worker/queue.mjs +296 -15
- package/src/worker/review-state.mjs +346 -51
- package/src/worker/ticket.mjs +46 -6
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
## §1 The trigger is a CLI call, and the file set is complete
|
|
4
4
|
|
|
5
|
-
`forge review-file <path> --block <id> --run <r>` enqueues a ticket and returns `{ticket, status: queued}` in under a second; `forge review-file --wait <ticket> [--max 90s]` polls it. The worker computes the diff itself against the base sha `forge block open` recorded: tracked changes plus untracked files, filtered by `owned_files` for the review and unfiltered for the scope check. A coder that never calls `review-file` gains nothing: the block gate refuses any changed or new file without a signed `review.approved` row for its final content hash. The reviewer is always a new OS process (R1, R10) — identical under Solo, a harness subagent, a detached CLI process, or a human typing.
|
|
5
|
+
`forge review-file <path> --block <id> --run <r>` enqueues a ticket and returns `{ticket, status: queued}` in under a second; `forge review-file --wait <ticket> [--max 90s]` polls it. The same content answers the cached result; `review-file <path> --block <id> --fresh` (B75) drops that cached result and queues the ticket again only when it never counted as a review (`stale`, `unavailable`, or `refused` with `other-run`, `context-unreadable` or `worktree-*`). Use it after such a transient answer to get a real review; a coder may run it, and with no cached result it is a plain `review-file`. A cached `review-state-tampered` is refused `fresh-tampered` (only the owner's `block close --no-require-reviews` gets past it); any other cached result is refused `fresh-needs-reopen` (the human's exits are `block reopen` and `block waive`). A refused marker is read in place and never moved; only a re-runnable one is claimed (an atomic rename, then a byte compare) and dropped. The worker computes the diff itself against the base sha `forge block open` recorded: tracked changes plus untracked files, filtered by `owned_files` for the review and unfiltered for the scope check. A coder that never calls `review-file` gains nothing: the block gate refuses any changed or new file without a signed `review.approved` row for its final content hash. The reviewer is always a new OS process (R1, R10) — identical under Solo, a harness subagent, a detached CLI process, or a human typing.
|
|
6
|
+
|
|
7
|
+
Under `proof.isolation: worktree` (`proof.md` §3) the coder calls `review-file` inside the block's tree. The ticket still goes to the main tree's queue, where the worker runs. The content hash, the diff against the block's base and the peers' diffs are read in the block's tree, which the worker takes from the run record, and the ticket and the answer carry `tree: <path>`. The tree is part of the ticket id, and a ticket whose tree is not the one in the run record, or a ticket with no tree for a block that has one, is answered `review.unavailable` with `reason: worktree-mismatch`. A block the run record gives no worktree is hashed in the main tree, wherever `review-file` is called, and its ticket names no tree. The packet's facts slot names the tree and its branch. A change made only in the main tree is never seen. A recorded tree that cannot be used, or an unreadable run record, refuses `review-file` itself (exit 1, nothing queued); after the ticket was queued the worker answers `review.unavailable` with the same reason. The reasons: `worktree-missing` (gone), `worktree-not-dir` (a file), `worktree-malformed` (a bad record), `worktree-branch-mismatch` (off its branch), `worktree-foreign` (a checkout of another repository), `worktree-workspace-missing` (no workspace subdirectory in the tree), `git-failed` (git could not run, with its reason), `run-record-unreadable` (the record cannot be parsed or names no workspace), plus `worktree-mismatch` (worker only) and `bad-path` (`review-file` only). See `proof.md` §3 for each cause.
|
|
6
8
|
|
|
7
9
|
**The owner's two ways out of the per-file review.** (1) `forge block close <id> --no-require-reviews`, typed by the owner at a terminal (`code.md` §5). (2) `review.external: {required: true, name: <reviewer>}` in `.code-forge.yml`, set before `run start`, when an outside reviewer reviews each block (B58). With `required: true`, `run start` refuses a missing or blank `name`, and writes a signed `review.external` row with the reviewer's name. `run reload` refuses to add, remove or change the key. With the key, `block close` does not check the review rows (a `not_approved` file included) or the report's ticket ids, needs no terminal and works under an autopilot grant. When the block closes, it writes a signed `gate.reviews_waived {by: config, reviewer}` row just before `block.close`; a refused close writes no such row. Unowned changes, `unproven`, `no_changes`, the transcript grep and the registry's orphans still refuse. No block may own `.code-forge.yml` while the key is on: `block open` and `block claim` refuse it. `block open`, `block claim` and `block close` read the run's config snapshot to find the key, so a snapshot that does not match its hash refuses them with `config-snapshot`, with or without the key. Never set or remove the key yourself: it is the owner's decision.
|
|
8
10
|
|
|
@@ -17,11 +19,13 @@ The topology is decided in one place (`planSessions`). Consensus (§6) runs only
|
|
|
17
19
|
|
|
18
20
|
## §3 Isolation and the packet
|
|
19
21
|
|
|
20
|
-
Every reviewer, recheck, judge and S2 session runs with cwd = a fresh empty temp dir under the run's temp root and the packet **on stdin** — a no-tools session never opens a path and the packet never enters argv. Packet order is fixed so provider prompt caching applies: lens, project rules digest (≤ 60 lines), facts-sheet excerpt for the symbols in the diff, context, diff. Context: a file at or under `review.context.whole_file_max_lines` travels whole; above it each hunk carries ± `review.context.hunk_context_lines` lines; over `review.budgets.full_in` the context shrinks to ± `review.context.min_context_lines`, then the digest is trimmed, never the diff; a diff alone over budget ⇒ `split_required`, with `tokens_in` and `budget` in the result, its ledger `review.result` row and the `review-file --wait` answer (`stop: split_required`) — except a Markdown file (`.md`, `.markdown`), which is reviewed **section by section**: the diff is cut at its headings (`#`..`######`, not inside fenced code), consecutive sections share a packet while it fits, every section packet runs the file's whole plan with its own stub guard, the file is approved only when every section is, and findings are merged with ids `S<section>.<id>`; a recheck over budget is split the same way, each open finding listed in its own section's packet. A section that alone is over budget keeps the file `split_required` (with that `section`), and so does a deleted Markdown file or one with no heading (fail-safe: one section); nothing is ever truncated. Moved code (B56): code moved between two changed files of the same block is named in a `## moved code` section before the diff (`- base lines a-b removed here were added in <file> lines c-d in this block.` / `- lines c-d were moved here from <file> (its base lines a-b) in this block.`), in round 1, rechecks, patch checks and the judge's packet. Detection is deterministic line matching against the block's other changed owned files diffed against the same base: lines trimmed with whitespace collapsed, lines with fewer than 4 letters or digits ignored, a moved stretch needs ≥ 3 such lines or ≥ 120 such characters; at most 12 ranges are listed; a Markdown section packet lists only the moves under its own headings (none ⇒ no section); the peers are listed and read once per review (one `git ls-tree <base>`, one `git diff <base>` over the peers that exist in the base — a peer deleted in the block, staged or not, is all removed lines — one `git check-ignore` and one `git diff --no-index` per new peer; `a/`/`b/` prefixes forced, `core.quotePath=false`; a peer name with a control, bidi or separator character, a quote or a backslash is skipped; a peer whose diff is over 2 MiB is skipped, never buffered past that); a removed block cut right above a heading belongs to the section holding its removed hunk; a move inside one file is not detected. It is extra text counted in the packet budget: every hunk stays, `reviewed_hunks` is unchanged, and a removal listed there is not a defect by itself — only stale references or imports are. The closed-book rule is in every lens: judge only what is in the packet; a file you cannot see goes in `needs_file`, and the worker attaches it (read-only, trimmed) for **one** more round.
|
|
22
|
+
Every reviewer, recheck, judge and S2 session runs with cwd = a fresh empty temp dir under the run's temp root and the packet **on stdin** — a no-tools session never opens a path and the packet never enters argv. Packet order is fixed so provider prompt caching applies: lens, project rules digest (≤ 60 lines), facts-sheet excerpt for the symbols in the diff, context, diff. With a `context` key in the config (B63a, `code.md` §3) the project rules digest is the rules files' text and then, under a `### lessons` subheading, the lessons file, read from the main tree, never from a block worktree, each time a packet is built. `context` is read from the live config; `run reload` accepts a change to it. Rules and lessons share the one 60-line cap and are trimmed together, from the end. Without the key the slot is `(none)`, as before. A rules or lessons file missing, outside the repository or unreadable when the ticket is reviewed (or a bad `context` value) makes the ticket `review.unavailable` with `reason: context-unreadable`: no session runs and its packet directory is removed. The worker does not retry it: the ticket is done with that signed result. `review-file` on the same content is a fresh attempt (`retry: true`) each time it is run, with no cap (no session ran, so the one re-run of a failed packet is not used up); once the file is back, that attempt is reviewed normally. Context: a file at or under `review.context.whole_file_max_lines` travels whole; above it each hunk carries ± `review.context.hunk_context_lines` lines; over `review.budgets.full_in` the context shrinks to ± `review.context.min_context_lines`, then the digest is trimmed, never the diff; a diff alone over budget ⇒ `split_required`, with `tokens_in` and `budget` in the result, its ledger `review.result` row and the `review-file --wait` answer (`stop: split_required`) — except a Markdown file (`.md`, `.markdown`), which is reviewed **section by section**: the diff is cut at its headings (`#`..`######`, not inside fenced code), consecutive sections share a packet while it fits, every section packet runs the file's whole plan with its own stub guard, the file is approved only when every section is, and findings are merged with ids `S<section>.<id>`; a recheck over budget is split the same way, each open finding listed in its own section's packet. A section that alone is over budget keeps the file `split_required` (with that `section`), and so does a deleted Markdown file or one with no heading (fail-safe: one section); nothing is ever truncated. Moved code (B56): code moved between two changed files of the same block is named in a `## moved code` section before the diff (`- base lines a-b removed here were added in <file> lines c-d in this block.` / `- lines c-d were moved here from <file> (its base lines a-b) in this block.`), in round 1, rechecks, patch checks and the judge's packet. Detection is deterministic line matching against the block's other changed owned files diffed against the same base: lines trimmed with whitespace collapsed, lines with fewer than 4 letters or digits ignored, a moved stretch needs ≥ 3 such lines or ≥ 120 such characters; at most 12 ranges are listed; a Markdown section packet lists only the moves under its own headings (none ⇒ no section); the peers are listed and read once per review (one `git ls-tree <base>`, one `git diff <base>` over the peers that exist in the base — a peer deleted in the block, staged or not, is all removed lines — one `git check-ignore` and one `git diff --no-index` per new peer; `a/`/`b/` prefixes forced, `core.quotePath=false`; a peer name with a control, bidi or separator character, a quote or a backslash is skipped; a peer whose diff is over 2 MiB is skipped, never buffered past that); a removed block cut right above a heading belongs to the section holding its removed hunk; a move inside one file is not detected. It is extra text counted in the packet budget: every hunk stays, `reviewed_hunks` is unchanged, and a removal listed there is not a defect by itself — only stale references or imports are. The closed-book rule is in every lens: judge only what is in the packet; a file you cannot see goes in `needs_file`, and the worker attaches it (read-only, trimmed) for **one** more round.
|
|
23
|
+
|
|
24
|
+
Deleted files (B74): a deleted owned file is reviewed at the content hash `deleted`; its round-1 packets (each reviewer's and the judge's; a file that stays deleted has no later round, since its content cannot change) have a `## deleted file` section before the diff saying the file is deleted and listing the tree paths that still name its basename — one `git grep -l -z --untracked -F -e <basename> -- .` with the reviewed working tree (the block's worktree or the main tree, the one the diff is read from) as cwd, at most 20 paths sorted, never the deleted path itself or a secret-like path; a path with a control character is never printed, and it and the paths past 20 are counted as `(<n> more not listed)`. No match ⇒ `no references found`; a failed grep, a basename under 3 characters or a deleted path with a control character (printed JSON-escaped) ⇒ `references not checked`. A file is "deleted" only when its diff is not empty and the path is absent from the reviewed tree (a binary file that still exists never gets the note); a note reader that fails gives `references not checked` and the review goes on. No exemption: the deletion still needs a signed approval (or the waiver/answers exits) to close, and an approval at `deleted` stops counting once the file comes back.
|
|
21
25
|
|
|
22
26
|
## §4 The stub guard — what counts as a review (R11)
|
|
23
27
|
|
|
24
|
-
A result is a review only if the process exited 0, the JSON validates against the finding schema, `reviewed_hunks` equals the packet's hunk headers exactly and in order, and the output has at least `review.min_tokens_out` tokens (a clean pass — `passed: true`, no findings, a non-blank summary — needs only 40, whatever the diff size). Anything else is ledger `review.unavailable` (`exit`, `schema`, `hunks_mismatch`, `too_short`, `timeout`, `budget` — the run reached `budget.usd`, `ledger.md` §3.1) and **is not approval**; `review-file` on the same content re-runs the same packet once, then the file stays unreviewed and the block cannot close. Every `schema` miss leaves a signed `review.schema_invalid` row with a structural diagnosis of the answer — its size and kind (`json`, `not-json`, `empty`), its top-level keys and their types, and the schema errors (path, keyword, message), or the parse error and its byte offset — never the answer's text. At a packet's **second** `schema` miss (same run, file, content hash, packet text, level and role — the first try plus that re-run), the worker tries the packet ONCE on `review.second_levels.<level>` when the owner configured one, and writes a signed `review.schema_fallback {lens, role, level, packet_hash, from_provider, from_model, to, to_provider, to_model, attempts, refused?}` row first (no row written ⇒ no try; any earlier row for the packet at that level ⇒ no try). Its first model that is not the failed one — and, for a closed-book role, not Codex unless `review.allow_open_book_codex` — runs (`refused: same-model` or `refused: closed-book` when there is none). The session's tokens are both sessions'. Its answer goes through the same stub guard; a valid one is a normal answer, a failed one leaves the file unavailable. Never in consensus mode; `budget.usd` refuses it like any session. Without a second level only the diagnosis row is new. A session that hangs is killed at `review.session_timeout_s` (default 300), process group and all, and is spawned once more on the same packet and level; only a second timeout is `unavailable: timeout`. Each timed-out attempt leaves a `review.session_timeout` ledger note with the last 4 KB of its stderr (redacted, never the packet) and only the byte count and last event types of its stdout.
|
|
28
|
+
A result is a review only if the process exited 0, the JSON validates against the finding schema, `reviewed_hunks` equals the packet's hunk headers exactly and in order, and the output has at least `review.min_tokens_out` tokens (a clean pass — `passed: true`, no findings, a non-blank summary — needs only 40, whatever the diff size). Anything else is ledger `review.unavailable` (`exit`, `schema`, `hunks_mismatch`, `too_short`, `timeout`, `budget` — the run reached `budget.usd`, `ledger.md` §3.1; `context-unreadable`, before any session, is retried differently: see §3) and **is not approval**; `review-file` on the same content re-runs the same packet once, then the file stays unreviewed and the block cannot close. Every `schema` miss leaves a signed `review.schema_invalid` row with a structural diagnosis of the answer — its size and kind (`json`, `not-json`, `empty`), its top-level keys and their types, and the schema errors (path, keyword, message), or the parse error and its byte offset — never the answer's text. At a packet's **second** `schema` miss (same run, file, content hash, packet text, level and role — the first try plus that re-run), the worker tries the packet ONCE on `review.second_levels.<level>` when the owner configured one, and writes a signed `review.schema_fallback {lens, role, level, packet_hash, from_provider, from_model, to, to_provider, to_model, attempts, refused?}` row first (no row written ⇒ no try; any earlier row for the packet at that level ⇒ no try). Its first model that is not the failed one — and, for a closed-book role, not Codex unless `review.allow_open_book_codex` — runs (`refused: same-model` or `refused: closed-book` when there is none). The session's tokens are both sessions'. Its answer goes through the same stub guard; a valid one is a normal answer, a failed one leaves the file unavailable. Never in consensus mode; `budget.usd` refuses it like any session. Without a second level only the diagnosis row is new. A session that hangs is killed at `review.session_timeout_s` (default 300), process group and all, and is spawned once more on the same packet and level; only a second timeout is `unavailable: timeout`. Each timed-out attempt leaves a `review.session_timeout` ledger note with the last 4 KB of its stderr (redacted, never the packet) and only the byte count and last event types of its stdout.
|
|
25
29
|
|
|
26
30
|
## §5 The convergence rule — bounded fix rounds (R15)
|
|
27
31
|
|
|
@@ -31,10 +35,26 @@ Per file, per block:
|
|
|
31
35
|
2. **Round n ≥ 2 reviews only the fix hunks**: the diff between the content hash reviewed in round n−1 and the current content, ± `review.context.hunk_context_lines`, plus the open findings by id (`review.recheck_scope: fix_hunks`, the default; `file` re-sends the whole packet for a consumer that wants it). The `recheck` lens answers `resolved[]` per finding — S1 `resolved` at or above `thresholds.findings.resolved` closes it, else the fresh L2 recheck decides — and may raise a new finding **only inside a fix hunk**; a finding outside becomes a `late_finding`, ruled at block close (`review.late_findings: sweep`; `block` makes it block the round).
|
|
32
36
|
3. **Strictly shrinking**: the open `fix_now` set after round n is smaller than after round n−1, counting closures minus new in-hunk findings. Equal or larger ⇒ `review_stall` (rule 2b), a trigger at once.
|
|
33
37
|
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.
|
|
34
|
-
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;
|
|
35
|
-
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
|
|
38
|
+
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; the patch closed no finding that was open before it (ids compared, not counts) ⇒ `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. A patch that closed at least one old finding is progress, not a stall, even when new findings in its fix hunks keep the open set from shrinking (B73). Any later trigger in that block stops with its named reason (`l3_patch_exhausted`, `review_cap`) and never patches again.
|
|
39
|
+
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 stopped file (`review_cap`, `l3_patch_exhausted`, `split_required`, …) keeps the block from closing; the ledger lists its open findings. The stop names its ways out: a `stopped` answer from `review-file` carries `exits: ["block reopen", "block waive", "review-file --answers", "re-decompose"]`, and the close's `review_cap` detail and `unreviewed` detail for a stopped file end with `exits: block reopen, block waive, review-file --answers, re-decompose` (B73). That printed list is the code's list, not an order; follow Exit 1–4 below. **The ways out of a stop, in this order** (take the first that fits):
|
|
40
|
+
- **Exit 1 — fix, then `block reopen` (B72).** The file is fixed by hand, then the human restarts its review: `forge block reopen <id> --file <path> --run <r> --reason <text>`. It also restarts a `lost` state (step 8) on its current content. It is human-only like `block waive`: on the coder forbidden list, run by the orchestrator only after the human says so. A stopped file must differ from the content its stop reviewed. Every stop records that content as `stop_content_hash` in the signed state; a state from before B72 has none, and then only the `review.cap` row of that stop (after the file's latest reopen, same round and reason) names it — otherwise the content is unknown and the reopen is refused. The verb first removes the done marker of the current content's ticket, so the next `review-file` runs instead of returning the cached stop. Then it writes one signed `review.reopened {block, file, from: stopped|lost, stop_reason, at_seq, from_hash, to_hash, reason, by: human}` row (`at_seq` is the file's latest `review.state` anchor seq; `from_hash` the content the stop reviewed, null for `lost`; `to_hash` the current content). There is at most one row per `at_seq`: a retry on the same content finds its row and writes no second one, and other content at the same `at_seq` is refused. The worker starts a new state for a row whose MAC verifies, of this run, `by: human`, whose `at_seq` is the latest anchor seq: round 1, a full review (no fix hunks), the level from the run record, up to `review.max_rounds_per_file` rounds again. It saves at `at_seq + 1`, so a row works once. For a stop the worker checks again that the content changed; on the stop's own content the stop repeats. The block's one L3 rung stays spent when it was used, so a reopened file that reaches the cap stops at `review_cap`. The gate needs nothing new: an approval written after the `review.cap` row clears the cap. Refusals (exit 1, no row): `reopen-same-content` (a stopped file still at the content its stop reviewed, or a stop whose content is unknown: change the file, or waive its findings instead), `reopen-tampered` (an integrity failure stays a security event), `reopen-not-stopped` (no state, or an open or complete one), `reopen-block-not-open` (the block is stopped or closed), `reopen-not-owned`, `unknown_block` (the block lookup code `block waive` uses; it and `reopen-block-not-open` are checked before anything else is read), `reopen-config` (the run's config snapshot or `tmp.root` cannot be read), `reopen-already-recorded` (a row for this `at_seq` already names other content: run `review-file`, or the content changed since that reopen), `reopen-write-failed` (the row was not written). A missing `--file` or `--reason`, or a `--file` that is not a plain repo-root-relative path (no `..`, `./`, `//` or `/./`), is `usage` (exit 2).
|
|
41
|
+
- **Exit 2 — `review-file --answers` (B71).** A finding fixed in ANOTHER file of the block (a missing test added in the test file, say) is answered with `forge review-file <answering file> --block <id> --answers <id,id,…> --of <source> --run <r>` (1 to 10 ids, none twice; `--of` names the source because finding ids are numbered per file). A coder may run it.
|
|
42
|
+
- **Refusals.** The checks run when the ticket is queued and again in the worker, whose check is the one that counts. The block must own both files (`answers-not-owned`), they must be two files (`answers-same-file`), and every id must be open on the source's CURRENT hash (`answers-not-open`). Open means named by the latest signed refusal row whose `content_hash` is the current hash, or by ANY signed `review.cap` row whose `content_hash` is the current hash, not waived or answered yet, and one the close could count as answered: never the proof finding (a proof finding is cleared by its proof, not by a reviewer) and never an id read at an unknown severity (a cap id no signed refusal at that hash records, or a value outside critical/warning/nit). A cap id from an older hash (or a cap row with no `content_hash`) is never open for an answer: the close still requires it, and the human waive exit clears it. A missing `--of`, no ids, more than 10 ids, an id given twice or a malformed id is `usage` (exit 2). The other refusals exit 1 and queue nothing. In the worker, an answering file that is gone or cannot be read is `answers-file-missing`.
|
|
43
|
+
- **The review.** The ticket is its own kind (`kind: answers`); its id also covers the source, the source hash and the sorted ids, so the same request is the same ticket. The worker sends ONE fresh L2 reviewer session (lens `answers`) a packet with the findings, the source lines around each, and the answering file's diff against the block base (its full content when the base has no such file). Each file is read ONCE: those bytes are hashed and the packet is built from them, so the row's `by_hash` is the hash of what the reviewer saw. The packet file is removed after the session; a packet that cannot be built is `unavailable` (`packet-unreadable`), one that cannot be written `unavailable` (`packet-write-failed`). The reviewer answers `resolved` or `not_resolved` per id. A missing, repeated or broken verdict is `not_resolved`. A failed session (no result, not `ok`, a non-zero exit, or a spawn that throws: `session-error`) is `unavailable` (retry it; it is not a verdict) and writes no row. An answering file changed since the ticket is `stale` (`answering-file-changed`); a source changed since the ticket is `stale` (`source-changed`). All verdicts are built first, then each resolved id gets one signed `review.answered {block, file, content_hash, finding, by_file, by_hash, ticket}` row. If a write fails the ticket is `unavailable` (`ledger-write-failed`); the same request again retries the same ticket, the ids it already answered stay open for it, and it writes only the missing rows, so it ends with one row per id. `review-file --wait` prints `{answers: [{id, verdict, reason}]}`.
|
|
44
|
+
- **Never an approval.** The answering file still needs its own `review.approved`, and the source's fix-loop state is unchanged.
|
|
45
|
+
- **Exit 3 — `block waive`.** The human waives each open finding: `forge block waive <id> <finding-id> --run <r> --file <path> --reason <text>` (a signed `review.waived` row, `by: human`). 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`).
|
|
46
|
+
- **Exit 4 — re-decompose.** The human decides, and the architect plans the work again as new blocks (`decisions.md` §6, `redecompose`). Each new block reviews its files from round 1.
|
|
47
|
+
- **A fully waived or answered file closes with a `gate.review_waived_all` row.** A file with no approval for its current hash counts as reviewed — `reviewed_by_waiver`, never approved — when a signed `review.round` exists for that hash (a repeated stop or an `unavailable` result never counts), the latest refusal row for the hash is signed, carries `content_hash` equal to that hash (a refusal row with no `content_hash` still refuses) and names at least one finding, and every finding it names and every open id of the file's `review.cap` rows is waived or answered. A critical waiver counts only `by: human`; an autopilot waiver counts only when `autopilotWaiverProblem` finds nothing, and only for a finding a signed refusal row at that hash records as warning or nit (or records with no severity at all). A cap id no refusal row records a severity for, or a cap row whose `open` is not a list of ids, is the human's alone. A signed, still-current answer clears its finding there, and in the late-finding and cap checks only at the severity the rows RECORD for that id (a late finding's own severity; a cap id's from the signed refusals at the current hash) — a missing or unknown one is never cleared by an answer. An answer counts only while BOTH the source and the answering file have the row's hashes (a change to either makes it stale), and it may clear a critical. Otherwise the file stays `unreviewed`. The close writes one signed `gate.review_waived_all {block, file, content_hash, findings, by}` row per such file just before `block.close`: `findings` are the sorted waived ids, `by: human` when every clearing waiver is the human's, else `autopilot`; `answered` lists the answered ids (only when there is one); with every id answered, `findings` is `[]` and `by` is null. When an id is both waived and answered, a human waiver is reported as the waiver; otherwise the answer is listed under `answered` instead of an autopilot waiver. The close line prints only the parts that apply: `block <id>: <file> reviewed_by_waiver (<ids>; by <human|autopilot>)`, `reviewed_by_waiver (W1; by human; answered C1)`, or `reviewed_by_waiver (answered C1, C2)` (ids sorted and joined by `, `). A refused close writes no such row, and a close where no file needs it writes none.
|
|
48
|
+
- **Not exits.** `block attempt` never resets review state: it only raises the attempt counter (a coder may run it), and a stopped file stays stopped, with no session. New content without a reopen row still gets the cached stop, with no session. `review-file --fresh` (§1) re-runs only a result that never counted as a review (`stale`, `unavailable`, or `refused` with `other-run`, `context-unreadable` or `worktree-*`); on a stop it refuses `fresh-needs-reopen`. A `tampered` state (step 8) is not a stop and gets no exits: only the owner's `block close --no-require-reviews` gets past it.
|
|
36
49
|
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.
|
|
37
50
|
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.
|
|
51
|
+
8. **A state file that goes missing is rebuilt from the ledger.** The worker keeps each file's round, level, open and late findings and stop in a signed state file. Every save first writes a signed `review.state` anchor row, and the anchor carries the state, so a missing file is rebuilt from it (Restore, below). Only an anchor with no state leaves the file `lost` (Lost, below).
|
|
52
|
+
- **Where it lives.** `<code-forge home>/runs/<run>/review-state/<block>/<sha24>.json` (default `~/.code-forge`; dir 0700, file 0600). It is not under the run's temp root, so a temp sweep or a removed run temp root does not touch it. Every save goes there.
|
|
53
|
+
- **Upgrade from the old place.** A state file from an older version may still be at the old temp-root path (`<run temp root>/review-state/<block>/`). It is read once. If it matches its anchor, it is moved to the new path (status `ok`, no `review.state_restored` row). If it does not, it is `tampered` and stays where it is.
|
|
54
|
+
- **Doctor row.** Full `forge doctor` (not `--quick`) prints one WARN `review state` row per active run with state files missing. A file counts as missing only when it is at neither the new path nor the old one (a file still at the old path is moved on its next load if it matches its anchor, and refused as `tampered` otherwise). The row counts the files the ledger can rebuild and the `lost` ones, and lists at most 10. When the run's `tmp.root` is not an absolute path, the row says the old temp location could not be checked and counts nothing. When an anchor of the run does not verify, the row says the review state was not checked.
|
|
55
|
+
- **Restore.** The anchor carries the slim signed state (`state_body`: the state without the reviewed text, plus that text's sha256). Over 64 KB it carries `state_omitted: "too_large"` instead. When the state file is gone (deleted, or a home directory restored without it), the next ticket rebuilds it from the latest anchor's `state_body`. It saves it as a new seq and writes one signed `review.state_restored {block, file, seq, from_seq}` row. The round counters, the open and late findings and a stop stay as they were; nothing restarts at round 1. The reviewed text itself is kept as a git blob in the reviewed tree's object store, and `state_body` names it (`reviewed_content_blob`). A restore reads that blob back and uses it only when its sha256 equals the reviewed text's sha, so the next round still reviews only what changed since the last review, even when the file changed meanwhile. When the blob is gone (git gc pruned it), does not match, its id is malformed, no content tree is known, or the anchor is older and has none, the current file is used if it still matches the sha; otherwise the next round reviews the full file instead of the fix hunks. A blob that cannot be written for any reason (for example the tree is not a git repository) leaves `reviewed_content_blob: null` and never fails the round.
|
|
56
|
+
- **Lost.** A missing file whose anchor has no `state_body` (an older run, or `state_omitted`) is `lost`. The ticket is refused with `review-state-lost` and no session runs. The refusal message names `forge block reopen`: the human's reopen (step 5, exit 1) restarts the file at round 1 on its current content.
|
|
57
|
+
- **Tampered.** A present file that does not match its anchor (changed bytes, an older signed copy, or no anchor at all) is `tampered` (`review-state-tampered`) and is never overwritten. A missing file is `tampered` too, not restored, when its latest anchor's `state_body` fails its signature or its run/block/file/seq check, or its `state.file` is not the file. Any `review.state` anchor of the block that fails verification makes the file `tampered`.
|
|
38
58
|
|
|
39
59
|
Rechecks are never degraded by the budget: a round-2+ packet is already the minimum.
|
|
40
60
|
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
## §2 The forbidden list — one list, rendered per CLI at spawn, grepped from every transcript
|
|
8
8
|
|
|
9
|
-
`gh pr ready|merge|close` and base retargets · `git push --force*` · `git reset --hard` · `git checkout --` · `git restore` · `git clean` · `git stash` · `git branch -D` · `git rm` · `rm -rf` outside `.code-forge/` · any argv containing a `production.markers` token or a `production.names` name · any read or write of `~/.code-forge/runs/` · `forge worker`, `run start`, `run reload` and `block
|
|
9
|
+
`gh pr ready|merge|close` and base retargets · `git push --force*` · `git reset --hard` · `git checkout --` · `git restore` · `git clean` · `git stash` · `git branch -D` · `git rm` · `rm -rf` outside `.code-forge/` · any argv containing a `production.markers` token or a `production.names` name · any read or write of `~/.code-forge/runs/` · `forge worker`, `run start`, `run reload`, `block waive` and `block reopen` from a coder (also via `npx`) · `--remove-worktree` from a coder (B62a) · any write under `.code-forge/reviews/` or `.code-forge/queue/*.done`.
|
|
10
10
|
|
|
11
11
|
Rendered into every coder spawn as the CLI's own deny rules (path rules widen a directory to `<dir>/**`; a read-write path denies read, edit and write); carried verbatim in the brief; refused by the package's own process spawner; and at block close the transcript (captured stdout, the harness log, or the Solo output) is grepped for the forbidden verbs **and paths** — a hit is a `rule_break` row that fails the gate. Where a CLI cannot load a rules file, `doctor` says so and the transcript grep remains the detector.
|
|
12
12
|
|
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.9.0"
|
|
14
14
|
set -eu
|
|
15
15
|
|
|
16
16
|
self="$0"
|
|
@@ -15,10 +15,23 @@
|
|
|
15
15
|
|
|
16
16
|
## The file loop
|
|
17
17
|
|
|
18
|
-
After you finish each file in `owned_files` (tests written and green) run `forge review-file <path> --block <id> --run <r>`; before the next file run `forge review-file --wait <ticket> [--max 90s]` and act on the packet. A `worker_down` answer ⇒ print `===BLOCK <id> FAILED: worker down===` (never approval).
|
|
18
|
+
After you finish each file in `owned_files` (tests written and green) run `forge review-file <path> --block <id> --run <r>`; before the next file run `forge review-file --wait <ticket> [--max 90s]` and act on the packet. A finding whose fix belongs in another owned file (a missing test, say) is fixed there and sent with `forge review-file <that file> --block <id> --run <r> --answers <ids> --of <source>`. A `worker_down` answer ⇒ print `===BLOCK <id> FAILED: worker down===` (never approval).
|
|
19
19
|
|
|
20
20
|
## Final report — FAILED unless it lists the ticket ids
|
|
21
21
|
|
|
22
|
+
Run the block's test command (`test_command`) and print its result on a line of its own, exactly:
|
|
23
|
+
|
|
24
|
+
`TESTS <command> exit=<n>`
|
|
25
|
+
|
|
26
|
+
`<command>` is the command you ran (not empty), `<n>` its exit code as a decimal 0–255 with no leading zeros (`exit=0`, never `exit=00`). Print it just before the sentinel; the last `TESTS` line after your previous sentinel and before this one is the one that counts, and a line inside prose does not count. When the run has `context.require_coder_tests`, `forge block close <id> --run <r> --report <file>` refuses a report without it or with an exit that is not 0 — the full list of refusals is in `references/code.md` §3 (the close check). For example:
|
|
27
|
+
|
|
28
|
+
```
|
|
29
|
+
TESTS npm test exit=0
|
|
30
|
+
===BLOCK B7 COMPLETE===
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
The `TESTS` line stays ABOVE the sentinel; only the test summary comes after it.
|
|
34
|
+
|
|
22
35
|
End with `===BLOCK <id> COMPLETE===`, the test summary and `git diff --stat`, then one line per changed file:
|
|
23
36
|
|
|
24
37
|
`reviewed <path> ticket <ticket-id>`
|
|
@@ -29,7 +42,11 @@ Or `===BLOCK <id> FAILED: <reason>===`.
|
|
|
29
42
|
|
|
30
43
|
## Fix-round brief (round ≥ 2, or a respawn after a climb)
|
|
31
44
|
|
|
32
|
-
The open findings by id, the fix history, the files they sit in. Fix only those hunks, then run `forge review-file <path> --block <id> --run <r>` and `--wait` on **every file the round changed** — a fix the reviewer never saw is not a fix. The final report follows the same rule: one `reviewed <path> ticket <ticket-id>` line per file changed in the round, or the round counts as FAILED.
|
|
45
|
+
The open findings by id, the fix history, the files they sit in. Fix only those hunks, then run `forge review-file <path> --block <id> --run <r>` and `--wait` on **every file the round changed** — a fix the reviewer never saw is not a fix. A fix made in another owned file is sent with `--answers <ids> --of <source>` (The file loop). The final report follows the same rule: one `reviewed <path> ticket <ticket-id>` line per file changed in the round, or the round counts as FAILED. Re-run the block's test command after the fixes and print a fresh `TESTS <command> exit=<n>` line just before the round's sentinel: a TESTS line from an earlier report does not count.
|
|
46
|
+
|
|
47
|
+
## Context pack
|
|
48
|
+
|
|
49
|
+
<!-- the block's context pack, pasted as text: the block header (run, block, level, owned and test files, worktree), the project's build, test, lint and format commands, the rules files and the lessons file. `forge context pack --run <r> --block <id>` writes it; paste its contents, never its run-root path (`references/code.md` §3). Without a `context` key, leave this section out. -->
|
|
33
50
|
|
|
34
51
|
## Project rules
|
|
35
52
|
|
|
@@ -104,6 +104,18 @@ const isDelegateSession = (row) => row.event === 'session' && row.role === 'dele
|
|
|
104
104
|
/** @param {Row} row */
|
|
105
105
|
const inLog = (row) => (typeof row.event === 'string' && (row.event.startsWith('autopilot.') || EXTRA_EVENTS.includes(row.event))) || (row.event === 'review.waived' && row.by === 'autopilot') || isDelegateSession(row);
|
|
106
106
|
|
|
107
|
+
/**
|
|
108
|
+
* B60: what a `gate.done` row says about its tests — the related tests and their count, the full
|
|
109
|
+
* suite and why, or nothing for a gate run without the `{test_files}` item.
|
|
110
|
+
* @param {Row} row @returns {string}
|
|
111
|
+
*/
|
|
112
|
+
function gateScope(row) {
|
|
113
|
+
if (row.scope === 'related') return Number.isInteger(row.test_count) ? ` · related tests (${row.test_count})` : ' · related tests';
|
|
114
|
+
if (row.scope !== 'full') return '';
|
|
115
|
+
const why = row.reason === 'threshold' ? 'the block owns more files than the threshold' : row.reason === 'no_tests' ? 'no test file known' : null;
|
|
116
|
+
return why ? ` · full suite (${why})` : ' · full suite';
|
|
117
|
+
}
|
|
118
|
+
|
|
107
119
|
/**
|
|
108
120
|
* Event label and fixed-format detail of one row (never the packet, question or answer text).
|
|
109
121
|
* @param {Row} row @returns {{event: string, detail: string, link: string | null}}
|
|
@@ -159,7 +171,7 @@ function describe(row) {
|
|
|
159
171
|
case 'gate.red':
|
|
160
172
|
return { event: 'Gate red', detail: `${row.gate ?? 'gate'} · exit ${row.code ?? 'unknown'}`, link: null };
|
|
161
173
|
case 'gate.done':
|
|
162
|
-
return { event: 'Gates finished', detail: `${row.stack ?? 'stack'} · ${row.all_ok === true ? 'all green' : 'not all green'}`, link: null };
|
|
174
|
+
return { event: 'Gates finished', detail: `${row.stack ?? 'stack'} · ${row.all_ok === true ? 'all green' : 'not all green'}${gateScope(row)}`, link: null };
|
|
163
175
|
default:
|
|
164
176
|
return { event: String(row.event), detail: '', link: null };
|
|
165
177
|
}
|
|
@@ -329,7 +341,13 @@ export function buildBinnacle({ runId, rows, record, now, scrubCtx = scrubCtxOf(
|
|
|
329
341
|
const approved = new Set(all.filter((r) => r.event === 'review.approved').map((r) => `${u(r.block, 'unknown block')}\0${u(r.file, 'unknown file')}`)).size;
|
|
330
342
|
const results = all.filter((r) => r.event === 'review.result').length;
|
|
331
343
|
const prs = [...new Set(all.map((r) => first(r.pr_url, r.pr)).filter(str))];
|
|
332
|
-
|
|
344
|
+
// B60: a gate run on the block's related tests only (`scope: related`) is not the full suite; when
|
|
345
|
+
// related runs came after the last full one, the status says so, so an old full run does not look current
|
|
346
|
+
const gateRuns = all.filter((r) => r.event === 'gate.done');
|
|
347
|
+
const fullSuite = [...gateRuns].reverse().find((r) => r.scope !== 'related');
|
|
348
|
+
const fullSuiteText = !fullSuite
|
|
349
|
+
? gateRuns.length > 0 ? 'not recorded · related tests only' : 'not recorded'
|
|
350
|
+
: `${fullSuite.all_ok === true ? 'green' : 'red'}${gateRuns.at(-1) !== fullSuite ? ` · no full suite since ${u(fullSuite.ts, 'unknown time')}` : ''}`;
|
|
333
351
|
const readFiles = [...new Set([...all.filter((r) => (r.event === 'review.waived' && r.by === 'autopilot') || r.event === 'autopilot.extra_round').map((r) => r.file), ...toOwner.map((r) => r.subject?.file)].filter(str))];
|
|
334
352
|
const baseSha = ids.map((id) => blocksRec[id]?.base_sha).find(str);
|
|
335
353
|
const approx = mine.filter((x) => x.approx).length;
|
|
@@ -342,7 +360,7 @@ export function buildBinnacle({ runId, rows, record, now, scrubCtx = scrubCtxOf(
|
|
|
342
360
|
const status = [
|
|
343
361
|
{ item: 'Result', value: ids.length === 0 ? 'no blocks yet' : `${closedIds.length} of ${ids.length} block${ids.length === 1 ? '' : 's'} closed` },
|
|
344
362
|
{ item: 'Stack/PRs', value: prs.length === 0 ? 'none recorded' : prs.join(', ') },
|
|
345
|
-
{ item: 'Full suite', value:
|
|
363
|
+
{ item: 'Full suite', value: fullSuiteText },
|
|
346
364
|
{ item: 'Reviews', value: `${results} result${results === 1 ? '' : 's'} · ${approved} file${approved === 1 ? '' : 's'} approved · ${waived} waived by autopilot` },
|
|
347
365
|
{ item: 'Files to read', value: readFiles.length === 0 ? 'none' : `${readFiles.slice(0, 5).join(', ')}${readFiles.length > 5 ? ` and ${readFiles.length - 5} more` : ''}` },
|
|
348
366
|
{ item: 'Base', value: short(baseSha) ?? 'unknown' },
|