@codedology/code-forge 0.8.0 → 0.10.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 +114 -0
- package/README.md +46 -7
- package/docs/reference/blocks.json +618 -1
- package/docs/reference/config.md +36 -0
- package/package.json +1 -1
- package/schema/code-forge.schema.json +89 -0
- package/skill/references/adapters/claude-code.md +1 -1
- package/skill/references/adapters/solo.md +2 -2
- package/skill/references/adapters/subprocess.md +12 -2
- package/skill/references/code.md +21 -7
- package/skill/references/ledger.md +2 -2
- package/skill/references/proof.md +77 -2
- package/skill/references/review.md +29 -4
- package/skill/references/security.md +5 -5
- package/skill/scripts/forge +1 -1
- package/skill/templates/coder-brief.md +4 -2
- package/src/autopilot/binnacle.mjs +9 -3
- package/src/cli/block.mjs +300 -33
- package/src/cli/jev.mjs +22 -3
- package/src/cli/keys.mjs +156 -21
- package/src/cli/proof.mjs +35 -7
- package/src/cli/review-file.mjs +264 -13
- package/src/cli/spawn.mjs +44 -3
- package/src/config/validate.mjs +320 -5
- package/src/doctor/index.mjs +7 -5
- package/src/doctor/local.mjs +116 -8
- package/src/doctor/probes.mjs +38 -0
- package/src/engines/builders/codex.mjs +44 -4
- package/src/engines/sandbox-dirs.mjs +257 -0
- package/src/keys/store.mjs +128 -21
- package/src/ledger/write.mjs +7 -1
- package/src/proof/junit.mjs +530 -0
- package/src/proof/red-green.mjs +57 -9
- package/src/proof/red-parse.mjs +2 -0
- package/src/proof/runners.mjs +472 -0
- package/src/proof/xcresult.mjs +285 -0
- package/src/review/answers.mjs +445 -0
- package/src/review/deletion.mjs +106 -0
- package/src/review/engine.mjs +117 -31
- package/src/review/fixloop.mjs +70 -28
- package/src/review/gate-check.mjs +378 -80
- package/src/review/lenses/A.md +1 -0
- package/src/review/lenses/B.md +1 -0
- package/src/review/lenses/full.md +1 -0
- package/src/review/lenses/quick.md +1 -0
- package/src/review/lenses/recheck.md +1 -0
- package/src/review/reopen.mjs +256 -0
- package/src/review/route.mjs +232 -0
- package/src/review/schema-fallback.mjs +77 -22
- package/src/review/session-retry.mjs +44 -9
- package/src/review/stop-exits.mjs +15 -0
- package/src/review/test-quality.mjs +201 -0
- package/src/review/verb-forms.mjs +39 -0
- package/src/session/spawn.mjs +39 -2
- package/src/state/block.mjs +13 -3
- package/src/state/config-snapshot.mjs +39 -0
- package/src/state/run.mjs +4 -0
- package/src/util/forbidden.mjs +11 -1
- package/src/util/tmp.mjs +16 -4
- package/src/worker/engine.mjs +75 -9
- package/src/worker/loop.mjs +82 -15
- package/src/worker/queue.mjs +265 -10
- package/src/worker/review-state.mjs +346 -51
- package/src/worker/ticket.mjs +9 -1
|
@@ -9,7 +9,7 @@ Tier per file = `high` when `risk ≥ 2`, or a `proof.tiers.high.paths` match, o
|
|
|
9
9
|
| light | red→green recommended for every new or changed test; **optional at the gate** | the coder or orchestrator runs the runner per test (§2) |
|
|
10
10
|
| high | red→green **required at the gate** for every changed high-tier file, plus the deeper **review** depth of `review.md` §2 (two lenses and a judge) | as above; `block close` refuses `unproven <file>` |
|
|
11
11
|
|
|
12
|
-
**`forge block close` enforces the high tier:** tier inputs are `proof.tiers.high.paths` (the union of the config at the block's base and the current one), the recorded `risk`, and `security_sensitive`; a row naming the file whose MAC fails makes it high. Each changed high-tier file needs a signed `proof` row of this run and block with `step: red-green`, `proven: true`, `red_kind: assertion` whose `covers` list names the file (`revert` covers the sources it put back; `assertion-deletion` covers only its test), else `unproven <file>`; only the human's `forge block waive <id> proof --file <path>` clears it.
|
|
12
|
+
**`forge block close` enforces the high tier:** tier inputs are `proof.tiers.high.paths` (the union of the config at the block's base and the current one), the recorded `risk`, and `security_sensitive`; a row naming the file whose MAC fails makes it high. Each changed high-tier file needs a signed `proof` row of this run and block with `step: red-green`, `proven: true`, `red_kind: assertion` whose `covers` list names the file (`revert` covers the sources it put back; `assertion-deletion` covers only its test), else `unproven <file>`; only the human's `forge block waive <id> proof --file <path>` clears it (it asks for a yes at a terminal: the orchestrator prints the exact command and the human runs it in their own terminal; there is no `--yes`, `review.md` §5).
|
|
13
13
|
|
|
14
14
|
**Mutation testing is not part of this product.** The question whether tool-made mutants on changed lines (Infection, Stryker) should be a user-facing proof tier was decided: **cut**. There is no `proof mutate`, no nightly mutation run, no `min_msi` floor, and no mutation-tool key in `.code-forge.yml`; a consumer that runs a mutation tool does so outside code-forge and never beside a coder's test run in the same tree. What replaces it as proof is the rule itself: one exact test per acceptance clause plus one negative or boundary where the clause has a failure mode — assertions that say **which** element and **how many**, never merely whether something exists — and the isolated per-file review with the convergence rule, which on this package's own build found the defects green suites had missed.
|
|
15
15
|
|
|
@@ -21,6 +21,75 @@ Tier per file = `high` when `risk ≥ 2`, or a `proof.tiers.high.paths` match, o
|
|
|
21
21
|
|
|
22
22
|
The runner works in the export (§3) or under the lock with a journal: it refuses when a journal already exists (an earlier run was interrupted) → journals every source file the test covers (bytes, sha256, mode — or `existed: false`) → writes the **base** version of each (`git show <base>:<path>`, never `stash` or `checkout --`); **a file the block added has no base version, so it is removed for the red run** → runs the filtered tests → **parses** the output: an assertion failure ⇒ `RED`; a fatal, compile or import error ⇒ `RED_INVALID`, which proves nothing (a removed module that the test imports is the usual cause — such a test needs an assertion that fails against the base, not an import that crashes) → restores every journaled file byte for byte, **verifies each sha256**, deletes the journal (in a `finally`, so an ordinary error restores too) → when the red step was `RED`, runs again ⇒ `GREEN`. A characterization test (`proof: characterization`) is proven by assertion deletion. The ledger `proof` row records `mechanism` and `red_kind`; one `RED` + `GREEN` pair per proven test. SIGTERM mid-run leaves the journal; `forge proof restore <block> --run <r>` restores the exact hashes and refuses when no journal exists.
|
|
23
23
|
|
|
24
|
+
**How a test report is read (B86).** A `node:test` file is still classified from its console text. A runner that writes a JUnit XML report (PHPUnit, Pest, pytest, `swift test --xunit-output`) is classified from that report instead, because console text changes with the environment and is cut at 10 MiB. The reader fails closed: a report that is missing, empty, over 8 MiB, not UTF-8 or malformed, that has a `<!DOCTYPE` or `<!ENTITY` (no entity is ever expanded) or a character that is not an XML character, or that has an element other than `testsuites`, `testsuite`, `testcase`, `failure`, `error`, `skipped`, `system-out`, `system-err`, `properties`, `property` is unreadable. Each test's id is `classname.name` (or `name`). A `<failure>` is an assertion only when its `type` names one, or when it has no `type` and its `message` names one. The names: `AssertionError`, `ERR_ASSERTION`, `ExpectationFailedException`, `AssertionFailedError`, `Failed asserting that`, `XCTAssert…`, `XCTFail`, `Expectation failed` (Swift Testing), and pytest's `assert …`. Any other `<failure>` and every `<error>` is an error. One exception: `swift test --xunit-output` writes every failed XCTest as `<failure message="failure"></failure>` (Swift 6.3; older and Linux builds write `message="failed"`), with no type and no cause. Such a bare failure (no `type`, message exactly `failure` or `failed`, an empty body; a body of only whitespace, as text or CDATA, counts as empty) counts as an assertion only when the console text has XCTest's assertion line for that same test (`<file>:<line>: error: -[Module.Suite test] : XCTAssert… failed` on macOS, `Suite.test : XCTAssert… failed` on Linux, or XCTFail's `: failed - <message>`) and shows no Swift crash (`Fatal error`, `Precondition failed`, `Assertion failed`, `Unexpectedly found nil`, `signal code`, `Illegal instruction`, `Segmentation fault`); a thrown error (`: failed: caught error`), a missing line or a crash leaves it an error. This table applies only to a runner that writes a report; a `node:test` file is still classified from its console text, as above. The RED step, first row that applies:
|
|
25
|
+
|
|
26
|
+
| when | verdict | `red_kind` |
|
|
27
|
+
|---|---|---|
|
|
28
|
+
| exit 0, no signal | `NOT_RED` | `none` |
|
|
29
|
+
| a timeout or a signal | `RED_INVALID` | `fatal` |
|
|
30
|
+
| the console text shows an import, compile or fatal error | `RED_INVALID` | `import` / `compile` / `fatal` |
|
|
31
|
+
| no report, or a report is unreadable | `RED_INVALID` | `error` (no readable test report) |
|
|
32
|
+
| no test ran (zero tests, or only skipped ones) | `RED_INVALID` | `error` (the filter ran no test) |
|
|
33
|
+
| any test errored, or failed without an assertion | `RED_INVALID` | `error` |
|
|
34
|
+
| at least one test failed on an assertion | `RED` | `assertion` |
|
|
35
|
+
| the run failed, but the report has no failure | `RED_INVALID` | `error` |
|
|
36
|
+
|
|
37
|
+
GREEN needs exit 0, a readable report, at least one test that ran, no failed or errored test, and every test that failed at RED present and passed; anything else is `NOT_GREEN`. The result keeps names only: `failed` is the count, and `failed_tests` lists at most 20 test ids of at most 200 characters each, never a failure message.
|
|
38
|
+
|
|
39
|
+
**Runners (B87).** A test file that is not a `node:test` file is proven through a `proof.runners` entry. Without one, `proof red-green` refuses it (exit 1): `only node:test files (.mjs, .cjs, .js) have a filtered test command in this version — add a proof.runners entry for it (see proof.md §2)`.
|
|
40
|
+
|
|
41
|
+
```yaml
|
|
42
|
+
proof:
|
|
43
|
+
runners:
|
|
44
|
+
- match: ["tests/**/*Test.php"]
|
|
45
|
+
adapter: junit
|
|
46
|
+
argv: [php, artisan, test, "{test_file}", --filter, "{test_filter}", --log-junit, "{report}"]
|
|
47
|
+
env: {DB_CONNECTION: sqlite, DB_DATABASE: ":memory:"}
|
|
48
|
+
- match: ["tests/**/test_*.py"]
|
|
49
|
+
adapter: junit
|
|
50
|
+
argv: [pytest, "{test_id}", --junitxml, "{report}"]
|
|
51
|
+
timeout_s: 900
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
- **Keys.** `match`: a non-empty list of globs over repo-relative paths. `adapter`: `junit` (any runner that writes a JUnit XML report), `swift` (`swift test`: the `junit` rules plus the Swift rules below) or `xcodebuild` (`xcodebuild test`, read from its result bundle; it needs an `xcode` object, Xcode below). `argv`: the command as an argv array, never a shell string. `env` (optional): variables added to the runner's environment, which never inherits a `GIT_*` variable. A value is never recorded: the signed row and the JSON output both hold only the names (`env_keys`). When the runner's own output echoes a value of 8 or more characters, code-forge masks it in every line it prints, as for gate env (below). `timeout_s` (optional): 60 to 7200 seconds per step, default 600.
|
|
55
|
+
- **Tokens.** An argv item that is exactly one of these is replaced: `{test_file}` the repo-relative test path; `{test_id}` `<file>::<case>`, or `<file>` with no case (a pytest node id); `{test_filter}` the case, regex-escaped (for `swift`, anchored as `(?:^|[./])<case>(?![^/])`, and with no case the file's base name, regex-escaped and anchored as `\.<name>/`); `{report}` the path where the runner must write its JUnit report. The `xcodebuild` adapter has its own tokens instead (Xcode, below). A token is a whole item, never part of one (`--log-junit={report}` is refused; give `--log-junit`, `"{report}"`), and never `argv[0]`. `validate` refuses: an empty `proof.runners: []` (leave the key out for no runner); an entry key it does not know (a typo such as `timeout`); an unknown adapter; a missing or empty `match`; a missing or empty `argv`, an `argv` given as one shell string, or an argv item that is not a string; a NUL character in an argv item or an env value; a token inside a longer item, a token as `argv[0]`, and a token-shaped item the adapter does not list (with an unknown adapter, one no adapter lists); a `junit` or `swift` argv without `{report}`, or without a test token (`{test_file}`, `{test_id}` or `{test_filter}`: a proof must name its test); with an unknown adapter, an argv without a report item of any adapter (`{report}` or `{result_bundle}`) or without a test token of any adapter (`{test_file}`, `{test_id}`, `{test_filter}` or `{only_testing}`), so a misspelt adapter still shows every argv error; the `xcodebuild` checks below; a `timeout_s` out of range.
|
|
56
|
+
- **Selection.** The runner list is read from the run's config snapshot, never from the live `.code-forge.yml`: `run start` freezes it, `run reload` refuses to change it, and while it is set no block may own `.code-forge.yml`. A coder cannot change the command that proves its own work. The first entry whose `match` covers the `--test` file runs it. No entry matches and the file is `.mjs`, `.cjs` or `.js`: the built-in `node:test` command, as before. A run started without a snapshot uses no runner.
|
|
57
|
+
- **Fail closed.** A `junit` runner whose argv has `{test_filter}` needs a case: `--test <file>` with no `::case` is a usage error (exit 2, no row): give `--test <file>::<case>`. A `swift` runner accepts a missing case: it then filters by the file's suite (Swift, below). An empty case (`<file>::`), and a test path or a case that starts with `-`, are refused (exit 2, no row). A snapshot list that is not a list, is empty, or has an entry that fails the checks above refuses the proof (`runner-invalid`, exit 1, no row): the entry is never run, never skipped, and the file never falls through to another entry or to `node:test`. A matched entry that has no runner hook is a usage error instead (exit 2, no row, no precheck).
|
|
58
|
+
- **The run.** Each step (RED, GREEN) gets a fresh report dir under the run's temp root, outside the work dir; the runner runs in the work dir (the export, or the tree under the lock) and the dir is deleted after its report is read. RED is classified from the report (the table above), GREEN by the GREEN rule above; a GREEN step that throws is `NOT_GREEN`, and the row is still written. A changed file that a runner's `match` covers is a test, never a source: it is not reverted. The journal, restore and signal rules are the same as for `node:test`.
|
|
59
|
+
- **Swift (B88).** The `swift` adapter runs `swift test` through SwiftPM:
|
|
60
|
+
|
|
61
|
+
```yaml
|
|
62
|
+
proof:
|
|
63
|
+
runners:
|
|
64
|
+
- match: ["Tests/**/*.swift"]
|
|
65
|
+
adapter: swift
|
|
66
|
+
argv: [swift, test, --parallel, --filter, "{test_filter}", --xunit-output, "{report}"]
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
`swift test --filter` searches its regex in each test's `Module.Suite/test` id, unanchored. `{test_filter}` is the case, regex-escaped and anchored: it starts the id or follows a `.` or `/`, and it ends the id or is followed by a `/` (`--test Tests/KitTests/ClampSwiftTests.swift::ClampSwiftTests/capsHigh()` gives `(?:^|[./])ClampSwiftTests/capsHigh\(\)(?![^/])`), so it never selects a near-name suite (`MoreClampSwiftTests/capsHigh()`) or a longer test name (`testCapsHigh` never selects `testCapsHighToo`); give the case as `Suite/test`, because a bare test name (`::capsHigh()`) selects that test in every suite. The end is not `$`: SwiftPM 6.3.3 matches a Swift Testing test against an id with more after the test name, so `$` selects no Swift Testing test. A `swift` runner accepts a missing case: `{test_filter}` is then the file's base name, regex-escaped and anchored with a `.` before it and a `/` after it (`ClampXCTests.swift` gives `\.ClampXCTests/`), so it selects that suite only and never a near-name suite such as `BarClampXCTests` or `ClampXCTestsHelpers`; name each XCTest class or Swift Testing suite after its file. A run that selects no test (a suite not named after its file) exits 0 and writes both reports with zero tests (SwiftPM warns "No matching test cases were run"): RED is `NOT_RED` (exit 1, no proof; like every verdict, it still writes its `red-green` row, with `green: null` and `proven: false`); a GREEN run that selects no test is `NOT_GREEN`. Keep `--parallel`: without it SwiftPM 6.3 writes no XCTest report at `{report}`, and a step that wrote the Swift Testing report but no XCTest report is `RED_INVALID error` (the reason names `--parallel`) and never `GREEN`. The adapter reads every `.xml` file in the step's report dir: the XCTest report at `{report}` and the Swift Testing report SwiftPM writes next to it as `<report stem>-swift-testing.xml` (`report-swift-testing.xml`); a failure in either counts. A missing or unreadable report proves nothing: at RED it is `RED_INVALID error`, with no `failed` count and no `failed_tests`; at GREEN it is `NOT_GREEN`, also when every test that failed at RED passed in the other report (GREEN needs every report file RED had). A test that crashes, such as one that hits `fatalError`, leaves the Swift Testing report cut off: `RED_INVALID error`. A failed run that wrote no report at all and whose console shows a `swiftc` error (`<file>.swift:<line>:<col>: error:`, `error: emit-module command failed`, `error: compile command failed`) is `RED_INVALID compile`; an XCTest assertion line (`<file>.swift:<line>: error: -[…]`, no column), a crash (`<file>.swift:<line>: Fatal error: …`) and SwiftPM's closing `error: fatalError` are not compile errors, and no report without a compile line stays `RED_INVALID error`. **A test of a new API needs the human waiver:** a test that calls a function, type or method the block added does not compile at base, so `revert` gives `RED_INVALID compile`, never `RED`. For a high-tier file the only way past the gate is the human's `forge block waive <id> proof --file <path>` (D3), run by the human in their own terminal; prove the behaviour with a test of an API that already exists at base where you can. For the block gate, a SwiftPM `gates.test` is `test: {argv: [swift, test, --parallel]}`.
|
|
70
|
+
- **PHP (B89).** PHPUnit and Pest are proven through the `junit` adapter with the Laravel runner above, `[php, artisan, test, "{test_file}", --filter, "{test_filter}", --log-junit, "{report}"]`: `php artisan test` passes the file, `--filter` and `--log-junit` through to PHPUnit and to Pest (verified live, §4), so a Laravel app needs no `vendor/bin/phpunit` or `vendor/bin/pest` runner. Give the case as the test's own name: the PHPUnit method (`--test tests/Unit/ClampTest.php::test_caps_high`) or the Pest description (`--test tests/Unit/ClampTest.php::caps high`). `{test_filter}` is the case, regex-escaped. PHPUnit and Pest read `--filter` as a regex and search it in the test name unanchored, so a longer name that contains the case runs too, and must then pass at GREEN. Because the case is escaped, a Pest description with `( ) [ ] . + ? /` is safe: it selects that test and not a near name (checked on Pest 4.7.8 with `clamp (high) [edge] a.b + c? x/y`). A description with `#` and digits or with `@` selects no test (PHPUnit reads those as a data-set name). **A PHP run that selects no test is `RED_INVALID error`, where a Swift one is `NOT_RED`,** because of the exit code: PHPUnit and Pest print "No tests found.", exit 1 and write an empty report (`<testsuites/>`; seen live on Pest 4.7.8 through artisan and on PHPUnit 12.5.33 run directly in the Pest app, in both console forms). The RED classifier (`classifyReport`) checks exit 0 first: exit 0 is `NOT_RED`, which is what `swift test` gives, since it exits 0 when no test matches. A non-zero exit goes on: the console holds no import, compile or fatal line, the report is readable, and no test in it ran, so the classifier stops at its "the filter ran no test" row: `RED_INVALID error`. Neither is a proof. The report's ids are `Tests.Unit.ClampTest.test_caps_high` (PHPUnit) and `Tests.Unit.ClampTest.caps high` (Pest); in a filtered run the root suite is named after the test class (in a full-config run after the `phpunit.xml` path), and the reader does not depend on that name. A failed assertion is `<failure type="PHPUnit\Framework\ExpectationFailedException">`: `RED assertion`. **A test of a class the block added is `RED_INVALID`:** at base the class is gone, the test errors (`<error type="Error">`, `Class "App\Support\Greeting" not found`, runner exit 2), and the kind is `import` in both of Laravel's console forms: its plain text, and the one-line JSON summary Laravel 13 prints under an AI agent (`CLAUDECODE` or another agent variable set), where the class name is JSON-escaped (`Class \"App\\Support\\Greeting\" not found`). It proves nothing; for a high-tier file the way past the gate is the human waiver, as for a new Swift API.
|
|
71
|
+
- **Xcode (B102).** An app that only builds with Xcode (an `.xcodeproj` or `.xcworkspace`, iOS targets) is proven through the `xcodebuild` adapter, which runs `xcodebuild test` and reads its result bundle:
|
|
72
|
+
|
|
73
|
+
```yaml
|
|
74
|
+
proof:
|
|
75
|
+
runners:
|
|
76
|
+
- match: ["AppTests/**/*.swift"]
|
|
77
|
+
adapter: xcodebuild
|
|
78
|
+
xcode:
|
|
79
|
+
workspace: App.xcworkspace # or project: App.xcodeproj; neither = a Swift package in the work dir
|
|
80
|
+
scheme: App
|
|
81
|
+
destination: "platform=iOS Simulator,name=iPhone 17"
|
|
82
|
+
test_target: AppTests
|
|
83
|
+
argv: [xcodebuild, test, -workspace, "{workspace}", -scheme, "{scheme}", -destination, "{destination}", "{only_testing}", -resultBundlePath, "{result_bundle}"]
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
The `xcode` object needs `scheme`, `destination` and `test_target` (one target name, no `/`), and at most one of `workspace` and `project`; each value is a non-empty string that does not start with `-`. Only an `xcodebuild` entry may have an `xcode` object, and an `xcodebuild` entry must have one. Its tokens are `{workspace}`, `{project}`, `{scheme}` and `{destination}` (the values from the snapshot's `xcode` object), `{only_testing}` (one item, `-only-testing:<test_target>/<Suite>[/<test>]`, the Suite from the case or the file's base name) and `{result_bundle}` (a fresh `<step dir>/r.xcresult` path under the run's temp root, outside the work dir, that does not exist yet). `validate` refuses an `xcodebuild` argv without `{scheme}`, `{destination}`, `{only_testing}` or `{result_bundle}`, a `{workspace}` or `{project}` token whose key is not set, a `workspace` or `project` key with no matching token, and `{report}` or a test token (those belong to `junit` and `swift`). The schema says the same where JSON Schema can: no value starting with `-`, no `/` in `test_target`, not both `workspace` and `project`, `xcode` required for `xcodebuild` and refused for any other adapter. The case is `<Suite>` or `<Suite>/<test>`: `--test Tests/KitTests/ClampXCTests.swift::ClampXCTests/testCapsHigh`, or with no case the file's suite (`-only-testing:KitTests/ClampXCTests`). What Xcode 26.6 was seen to do with `-only-testing`: a near-name suite (`ClampXCTestsExtra`, `MoreClampSwiftTests`) was not selected; **a Swift Testing case needs its `()`:** `::ClampSwiftTests/capsHigh()`; without them nothing is selected and `xcodebuild` exits 0; an XCTest case works with or without `()`. **Exit 0 with no test run is `RED_INVALID error`** (the filter ran no test). This differs from `junit` and `swift`, where any exit 0 is `NOT_RED`: `xcodebuild test` exits 0 when its filter selects nothing, so an empty run says nothing about the test and is never read as "the test passed". A clean exit with no bundle or an unreadable one is `RED_INVALID error` too. At GREEN, an empty run, a missing or unreadable bundle, or a test outside the suite is `NOT_GREEN`. With a case, the scope is still the whole `<test_target>/<Suite>/`: another test of the same suite in the result is not refused, and when it failed with an assertion too it is one more RED failure that GREEN must see pass. A nested Swift Testing suite cannot be named as a case: `Outer/Inner/test()` is refused (`usage`, nothing run); give the outer `<Suite>` to run all of it.
|
|
87
|
+
|
|
88
|
+
**The precheck** runs before the export is built and before any file is reverted, and only reads: `xcode-select -p` must point at a full Xcode, else exit 1 `xcode-missing` (Command Line Tools only, or no developer directory). A simulator destination (`platform=iOS Simulator`, or the watchOS, tvOS or visionOS simulator) must name, by `name` or `id` (and by `OS` when given; `OS=latest` matches any), a device that `xcrun simctl list devices available -j` lists, else exit 1 `destination-unavailable`, with the names that do exist (at most 10). A simulator destination with neither `name` nor `id` is `destination-unavailable` too, before `simctl` runs: the message says it names no simulator. `platform=macOS` needs no simulator check. Either refusal writes no row and no journal and leaves the tree unchanged. code-forge never boots, creates or deletes a simulator. Both commands get the runner's env (no `GIT_*`) and a 30 s timeout.
|
|
89
|
+
|
|
90
|
+
**The result** is read with `xcrun xcresulttool get test-results tests --path <bundle>` (argv only, 60 s timeout, output capped at 8 MiB). A test's id is `<test_target>/<nodeIdentifier>`: `KitTests/ClampXCTests/testCapsHigh()` (XCTest) or `KitTests/ClampSwiftTests/capsHigh()` (Swift Testing). A failure message is an assertion only when it is `XCTAssert… failed` (not `… failed: threw error`), XCTFail's `failed` or `failed - <message>`, or Swift Testing's `Expectation failed: …`. A thrown error (`failed: caught error: …`, `Caught error: …`), `Issue recorded: …` or a crash is an error, so the test proves nothing (`RED_INVALID error`). No bundle, a bundle that is not a directory, a failed `xcresulttool`, JSON that does not parse, two tests with the same id, and a node type, place or result the reader does not know: no readable result, `RED_INVALID error` at RED and `NOT_GREEN` at GREEN. Today that makes these unreadable: a parameterized Swift Testing test (`Arguments` nodes), repetitions (`Repetition`, `Test Case Run`), a run over several destinations or test plan configurations (`Device`, `Test Plan Configuration`), a `Runtime Warning`, and the result `Expected Failure`. Prove such a test through a plain case, one destination and no repetitions. **Only the named suite counts:** a test outside `<test_target>/<Suite>/` is `RED_INVALID error` at RED and `NOT_GREEN` at GREEN (the filter ran other tests). This holds for a run that exits 0 too: a passing run with any test outside the suite, or with only such tests, is `RED_INVALID error`, never `NOT_RED`, since the test under proof did not run cleanly in scope. **A build failure is `RED_INVALID compile`:** `** BUILD FAILED **` or `** TEST BUILD FAILED **` in the output, `xcodebuild test`'s `Testing cancelled because the build failed.` (Xcode 26.6 then ends with `** TEST FAILED **`), or no test results and a `swiftc` error. A test of a new function is such a case, and needs the human waiver, as for Swift.
|
|
91
|
+
- **The row.** The signed `proof` row and the JSON output add `runner: <adapter>`, `env_keys` (the entry's env variable names, `[]` without an `env`) and `failed_tests` (names only) when a runner was used; an invalid RED (a missing or unreadable report) has no `failed` count and an empty `failed_tests`. `failed_tests` is a bounded list of test names (at most 20, each cut at 200 characters, control characters removed), never a failure message. The full list of failed ids that GREEN checks (`failed_ids`) stays in memory and is never stored. A `node:test` row is unchanged.
|
|
92
|
+
|
|
24
93
|
## §3 The measurement export (R13) — `proof.isolation: export`, the default
|
|
25
94
|
|
|
26
95
|
Parallel blocks share one working tree, so nothing is measured there. `forge proof export <block> --run <r>` builds `.code-forge/export/<block>/`: (a) `git archive <base_sha>` extracted; (b) every path the base's `.gitattributes` marks `export-ignore` restored from `git show` — the hazard is real: repositories mark `/.github` and `CHANGELOG.md` that way; (c) every path in `proof.export.copy_untracked` that exists in the main tree added (`.env`, `.env.testing` by default; never anything under `.code-forge/`); (d) the block's owned files at their **current** content — new files included, and an owned file that existed at the base but is deleted in the tree is **removed from the export** too (listed under `removed` in the export's result), so a deletion is measured as a deletion; (e) the dependency dirs in `proof.export.link_dirs` symlinked read-only — except a **Composer vendor dir** (one holding `composer/autoload_real.php`), which is **copied** (listed under `copied` in the export's result): PHP resolves `__DIR__` through symlinks, so a linked `vendor` makes Composer's autoloader load `App\…` and `Tests\…` from the main tree, where another block's half-written file would leak into the measurement. Gates, red→green and the test-count delta run there; `--remove` deletes it at block close. A gate command that fails inside the export with a missing-file error is reported as `export.incomplete: <path>` with the sentence *add it to `proof.export.copy_untracked` or set `proof.isolation: lock`* — never as a red test.
|
|
@@ -65,11 +134,17 @@ Test-count delta (blocks without a worktree) = the count on the block's filtered
|
|
|
65
134
|
|
|
66
135
|
The export is proven on the package's own example repository (a Node library with a `node:test` suite, `/.github` and `CHANGELOG.md` restored from `export-ignore`).
|
|
67
136
|
|
|
68
|
-
**PHP (Pest and PHPUnit, Laravel) under export: VERIFIED** on 2026-10-11 with PHP 8.4.25, Composer 2.10.2 and Laravel Framework 13.35.0 — a fresh `laravel/laravel` skeleton once on PHPUnit 12.5.38 (as shipped) and once on Pest 4.7.8 (`pestphp/pest` + `pestphp/pest-plugin-laravel` added). Re-run it with `CODE_FORGE_LIVE_PHP=1 node --import ./test/helpers/isolate.mjs --test test/proof/laravel-live.test.mjs` (needs `php`, `composer` and the network; skipped without the flag, so `npm test` never needs them; POSIX only — it was run on macOS, not on Windows). Each check builds the skeleton, commits it
|
|
137
|
+
**PHP (Pest and PHPUnit, Laravel) under export: VERIFIED** on 2026-10-11 with PHP 8.4.25, Composer 2.10.2 and Laravel Framework 13.35.0 — a fresh `laravel/laravel` skeleton once on PHPUnit 12.5.38 (as shipped) and once on Pest 4.7.8 (`pestphp/pest` + `pestphp/pest-plugin-laravel` added). Re-run it with `CODE_FORGE_LIVE_PHP=1 node --import ./test/helpers/isolate.mjs --test test/proof/laravel-live.test.mjs` (needs `php`, `composer` and the network; skipped without the flag, so `npm test` never needs them; POSIX only — it was run on macOS, not on Windows). Each check builds the skeleton, commits it with a broken `app/Support/Clamp.php`, adds the block's owned files (the fixed `Clamp` and its unit test, a new `app/` class and its unit test, a feature test that writes to the database), breaks a file the block does not own in the main tree (the main tree goes red), builds the export with the default lists and runs the detected gate `php artisan test --compact` there: exit 0, 6 tests passed (the skeleton's 2 and the block's 4). What a Laravel export needs:
|
|
69
138
|
- `vendor` in `proof.export.link_dirs` (the default): it is **copied**, not linked, as a Composer vendor dir (§3 (e)). Run `composer install` in the main tree first; the export never installs anything. The copy has a cost on every export: about 5 s and 69 MB for the Laravel skeleton where the file system cannot clone; code-forge asks for a copy-on-write clone (`COPYFILE_FICLONE`), which is near-free where it is supported (APFS, Btrfs, XFS). An absolute symlink inside `vendor` that points into the main tree (a Composer path repository: `vendor/acme/pkg -> <main>/packages/pkg`) is pointed at the same path in the export; relative links and links outside the main tree are kept.
|
|
70
139
|
- `.env` (and `.env.testing` when the project has one) in `proof.export.copy_untracked` (the default): Laravel needs its `APP_KEY`.
|
|
71
140
|
- A database the export can own: SQLite in memory. The skeleton's `phpunit.xml` already sets `DB_CONNECTION=sqlite` and `DB_DATABASE=:memory:`; when yours does not, set them as gate env: `test: {argv: [php, artisan, test, --compact], env: {DB_CONNECTION: sqlite, DB_DATABASE: ":memory:"}}`. A suite that needs a running database server is not isolated by the export — use `proof.isolation: lock`.
|
|
72
141
|
|
|
73
142
|
Detection: `composer.json` + `vendor/bin/pest` is `php-pest`; `composer.json` + `vendor/bin/phpunit` without Pest is `php-phpunit`. With an `artisan` file either runs `php artisan test --compact`; without one, `vendor/bin/pest` or `vendor/bin/phpunit` (a plain PHP project; detected, not run live).
|
|
74
143
|
|
|
144
|
+
**PHP (PHPUnit and Pest, Laravel) red→green under export: VERIFIED** on 2026-10-13 with PHP 8.4.25, Composer 2.10.2 and Laravel Framework 13.35.0, on PHPUnit 12.5.39 and on Pest 4.7.8 (PHPUnit 12.5.33 under it), macOS on arm64, through the same live check (`CODE_FORGE_LIVE_PHP=1`, after its gate part). The runner is `php artisan test` (`[php, artisan, test, "{test_file}", --filter, "{test_filter}", --log-junit, "{report}"]`, §2 PHP): it passes `--log-junit` through to PHPUnit and to Pest, and both write the report. `proof red-green` on the Clamp test's failing case (`::test_caps_high`, Pest `::caps high`) gives RED `assertion`, then GREEN, exit 0 and a signed row with `runner: junit`, exactly that test in `failed_tests`, `env_keys` `DB_CONNECTION` and `DB_DATABASE`, covering `app/Support/Clamp.php` and `app/Support/Greeting.php`; a test of the class the block added gives `RED_INVALID` `import` (`proof red-green` exits 1; the runner itself exits 2), from a plain terminal and under an AI agent (both seen live). The main tree is unchanged. A full-config run (`php artisan test --log-junit <report>`, no `--filter`) writes nested suites: a root suite named after the `phpunit.xml` path, then `Unit` and `Feature`, then the classes; the reader gives the same ids whatever the root suite is named. A step takes about 0.3 s; a whole proof, export included, about 4 to 5 s. No `<warning>`, no element and no `type` the reader did not know appeared. The captured reports are the PHP fixtures in `test/fixtures/junit/`.
|
|
145
|
+
|
|
146
|
+
**Swift (`swift test`, XCTest and Swift Testing) red→green under export: VERIFIED** on 2026-10-13 with Apple Swift 6.3.3 (swiftlang-6.3.3.1.3), Xcode 26.6, macOS 26 on arm64. Re-run it with `CODE_FORGE_LIVE_SWIFT=1 node --import ./test/helpers/isolate.mjs --test test/proof/swift-live.test.mjs` (needs `swift` on PATH, no network; skipped with the reason without the flag, so `npm test` never needs Swift; XCTest is skipped with the reason when only the Command Line Tools are selected). It runs `swift package init --type library --name Kit` in a temp dir, commits a broken `clamp`, fixes it in the block and adds an XCTest class, a Swift Testing suite and two near-name suites that always fail (`ClampXCTestsExtra`, `MoreClampSwiftTests`); `proof red-green` with the runner of §2 gives RED `assertion`, then GREEN, exit 0 and a signed row covering `Sources/Kit/Kit.swift` for the XCTest file (no case), the Swift Testing file (no case) and one case of each (`::ClampXCTests/testCapsHigh`, `::ClampSwiftTests/capsHigh()`), each with exactly that test in `failed_tests`: neither anchored filter selects a near-name suite, also not one with a test of the same name (`MoreClampXCTests/testCapsHigh`, `MoreClampSwiftTests/capsHigh()`, which always fail); SwiftPM builds in the export (`.code-forge/export/A/.build`), never in the package; without XCTest no XCTest file is written; a test that crashes at base (`fatalError`) gives `RED_INVALID error`; a file whose suite is not named after it selects no test and gives `NOT_RED`; a test of a new function gives `RED_INVALID compile` (exit 1), which needs the human waiver (§2 Swift). A step takes 11 to 16 s when the export builds from scratch and about 1.2 s once it is built (each proof's export starts without `.build`). The captured reports are the Swift fixtures in `test/fixtures/junit/`.
|
|
147
|
+
|
|
148
|
+
**Xcode (`xcodebuild test`, XCTest and Swift Testing on an iOS simulator) red→green under export: VERIFIED** on 2026-10-13 with Xcode 26.6 (17F113) and the iPhone 17 simulator (iOS 26.5), macOS 26 on arm64. Re-run it with `CODE_FORGE_LIVE_XCODE=1 node --import ./test/helpers/isolate.mjs --test test/proof/xcodebuild-live.test.mjs` (needs a full Xcode and an available iPhone simulator, no network; skipped with the reason without the flag, when `xcode-select -p` points at the Command Line Tools, or when `xcrun simctl list devices available -j` lists no iPhone on an iOS runtime, so `npm test` never needs Xcode). It uses an already booted iPhone simulator, else the first available one, and never boots, creates, erases or deletes one itself (`xcodebuild` may boot the one it is given). It runs `swift package init --type library --name Kit` in a temp dir with `platforms: [.iOS(.v17), .macOS(.v14)]`, commits a broken `clamp`, fixes it in the block and adds an XCTest class, a Swift Testing suite and two near-name suites that always fail (`ClampXCTestsExtra`, `MoreClampSwiftTests`); the runner is `[xcodebuild, test, -scheme, "{scheme}", -destination, "{destination}", "{only_testing}", -derivedDataPath, <dir>, -resultBundlePath, "{result_bundle}"]` with `xcode: {scheme: Kit, destination: "platform=iOS Simulator,name=<simulator>,OS=<iOS version>", test_target: KitTests}`, the destination filled in from the chosen simulator's name and iOS version (on the verified run `platform=iOS Simulator,name=iPhone 17,OS=26.5`) (a Swift package needs no `workspace` or `project`; `-derivedDataPath` keeps the build out of the home folder). `proof red-green` gives RED `assertion`, then GREEN, exit 0 and a signed row with `runner: xcodebuild` covering `Sources/Kit/Kit.swift` for the XCTest file (no case), the Swift Testing file (no case) and one case of each (`::ClampXCTests/testCapsHigh`, `::ClampSwiftTests/capsHigh()`), each with exactly that test in `failed_tests` (`KitTests/ClampXCTests/testCapsHigh()`, `KitTests/ClampSwiftTests/capsHigh()`): `-only-testing` selects no near-name suite; `xcodebuild` exits 65 at RED and 0 at GREEN, and writes a result bundle at both; a test of a new function gives `RED_INVALID compile` (exit 1; `xcodebuild` exits 65 with `Testing cancelled because the build failed.` and a bundle with no test), which needs the human waiver (§2 Swift); a runner whose destination names a simulator that does not exist gives exit 1 `destination-unavailable` before the export is built: no row, no `xcodebuild` run, the package tree unchanged (same hash). Every step's `xcresulttool` JSON has the node shape of the B102 fixture of the same run and reads to the same tests (Swift Testing lists a suite's tests in a different order from run to run; the reader does not depend on it), so the B102 fixtures in `test/fixtures/xcresult/` are kept as captured. A RED step takes about 23 to 30 s (it rebuilds after the revert), a GREEN step about 4 to 7 s, the compile step about 3 s, the precheck refusal about 0.2 s; the whole check about 130 s.
|
|
149
|
+
|
|
75
150
|
A Vitest stack is **UNVERIFIED** until a real second consumer runs it; `proof.isolation: lock` is the documented fallback until then. `doctor` says which gate commands are on PATH.
|
|
@@ -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
|
+
A ticket whose session breaks is not a review. A broken result is a `schema` miss or a timed-out attempt (B91): a timeout counts as a broken result, like a schema miss. Misses and timeouts of the same packet, level and role are counted together, so one miss plus one timeout is two. A packet's second broken result is tried once on the ticket's second level, in the same ticket, only when a second level exists (`review.second_levels.<level>`, or for a routed ticket the rule's `second`) and never in consensus mode (§4).
|
|
6
8
|
|
|
7
9
|
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.
|
|
8
10
|
|
|
@@ -21,9 +23,15 @@ The topology is decided in one place (`planSessions`). Consensus (§6) runs only
|
|
|
21
23
|
|
|
22
24
|
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
25
|
|
|
26
|
+
Big files (B90): to send a big file to a long-context reviewer, set `review.routes` once — do not edit `.code-forge.yml` per file and restore it after. A rule is `{min_lines, level?, model, provider?, effort?, fallback?, session_timeout_s?, second?}`; `min_lines` and `model` are required. A rule's level decides which sessions it changes: an L2 rule (`level: L2`, the default) changes only the reviewers' model, and in consensus mode only slot A — slot B stays `review.second_levels.L2`; an L3 rule (`level: L3`) changes only the judge's model. What is counted: the file's lines at the reviewed state (the bytes in the review packet) — its newline bytes, plus one for a last line with no newline — once per ticket. A deleted file is never routed. Rules compete only within their level: among the rules of one level, the one with the largest `min_lines` at or under the count wins, and one ticket can take one L2 rule and one L3 rule; two rules with the same `level` and `min_lines` are refused. Left out, an optional key gets: `provider` — the top-level `provider` (the rule replaces the level entry, so the level's own `provider` is not inherited), and validation checks the rule with that same provider; `effort` — none (the CLI's default); `fallback` — none; `session_timeout_s` — the rule does not count in the timeout; `second` — `review.second_levels.<level>`. The timeout is one value for every session of the ticket: the largest `session_timeout_s` among the matched rules that set one (a matched rule without one is left out of that max, it does not count as `review.session_timeout_s`), else `review.session_timeout_s`. The rule's `second` is the second-level try's model (§4) for its level, in place of `review.second_levels.<level>` — except an L2 rule in consensus mode, whose `second` is not used. One signed `review.route {level, min_lines, lines, provider, model, session_timeout_s, second_model}` row per routed level is written before the ticket's first session: `provider` is the one the routed level runs on (the inherited one when the rule names none), and `second_model` is the rule's `second` model when that second is used, else null — always null for an L2 rule in consensus mode, because slot B is `review.second_levels.L2`, not the rule's. A retry of the same ticket never writes its rows twice. A row that cannot be written ⇒ `review.unavailable` with `reason: route-row`, and a routes list in the snapshot that cannot be used (or a routed level that cannot be resolved) ⇒ `reason: route-invalid` — no session runs. No rule matches (or no `review.routes`) ⇒ the ticket runs exactly as before. `run reload` accepts a change to it; validation refuses two rules with the same `level` and `min_lines`, and, with `review.multimodel`, an L2 rule whose provider (its own, else the inherited top-level one) equals the second reviewer's (slot B's, or the rule's own `second.provider`) (`review-routes-invalid`).
|
|
27
|
+
|
|
28
|
+
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.
|
|
29
|
+
|
|
30
|
+
Test quality (B92): a test file that reads a source file and asserts on its text gets a `## test-quality` section before the diff, in each reviewer's round-1 packet and the judge's. Rechecks get the lens rule but no `## test-quality` section. The section names the file and the flagged line numbers — at most 10, the rest counted as `(<n> more not listed)` — never the file's text. Detection is a text match with no model call: a test file (a test path, a `Tests/` dir, or a name ending in `Tests.<ext>` or `Test.<ext>`; never a `.md` or `.txt` file or a file under a `fixtures`, `fixture` or `__fixtures__` dir) with a line holding a read call and a line holding a contains or match call (`includes(`, `contains(`, `toContain(`, `toMatch(`, `assert.match(`, `assertIn(`, `strings.Contains(`). The read calls that count are `readFileSync`, `readFile`, `open(`, `read_text`, `String(contentsOf`, `contentsOfFile`, `File.read`, `os.ReadFile`, `fs::read_to_string`, `readText(`, `readString(` and `ReadAllText(`, matched as text. The call counts when its span — its own line, plus up to 3 more lines while the read call's own `(` is still open (a formatter split) — holds a quoted source path (`.swift`, `.ts`, `.tsx`, `.js`, `.mjs`, `.cjs`, `.py`, `.go`, `.rs`, `.kt`, `.java`, `.rb`, `.php`, `.cs`) or a name bound on an earlier line to one; the call's own line is the one listed. A name is bound only when its value IS the source-path literal: the whole right-hand side, or the literal passed straight to `join`, `path.join`, `resolve` or `path.resolve` (`spawnSync('node', ['bin/cli.mjs'])` binds nothing); the binding may carry modifiers and a type (`private val X`, `const val X`, `private const val X`, `private static let X`, `private static final String X`, `private const string X`, `const string X`, `export const X`). A fixture read never counts: a read whose span holds a string literal with a `fixtures`, `fixture` or `__fixtures__` path segment (`'test/fixtures/a.mjs'`, `join(dir, 'fixtures', 'a.mjs')`), or names a name bound on a line holding such a literal (`const FIXTURES = join(__dirname, 'fixtures')`). The section is a hint, not a finding: it writes no row and changes no gate. The lens rule tells the reviewer to check those lines and raise such a test as a `warning` ("test reads source text; prove behaviour instead") unless the read file is a fixture. It is extra text counted in the packet budget, like the moved-code section; a file with no flag gets no section. Over budget it takes the path any extra takes: the context shrinks, then the digest is trimmed; when the diff plus the extras alone are over budget, the file is `split_required` (with `tokens_in` and `budget`, no session; a Markdown file is split by section instead).
|
|
31
|
+
|
|
24
32
|
## §4 The stub guard — what counts as a review (R11)
|
|
25
33
|
|
|
26
|
-
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**
|
|
34
|
+
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. A broken result is a `schema` miss or a timed-out attempt (B91). At a packet's **second** broken result (same run, file, content hash, packet text, level and role — counted from the signed `review.schema_invalid` rows and the signed `review.session_timeout` notes of the level's own models, plus the result in hand; misses and timeouts count together, so one miss plus one timeout is a second broken result), the worker tries the packet ONCE on the ticket's second level when there is one — `review.second_levels.<level>`, or for a routed ticket the rule's `second` (B90; an L2 rule's `second` never applies in consensus mode) — and writes a signed `review.schema_fallback {lens, role, level, packet_hash, from_provider, from_model, to, to_provider, to_model, attempts, trigger, refused?}` row first (no row written ⇒ no try; any earlier row for the packet at that level ⇒ no try). `trigger` is `schema` or `timeout`, the kind of the second broken result; a row without it (written before B91) is a `schema` one. 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 nothing is tried and no `review.schema_fallback` row is written: a `schema` miss adds only its diagnosis row, and a timeout runs as before B91, its note now carrying `packet_hash` and signed. 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. A second timeout is the packet's second broken result: with a second level it is tried there in the same ticket (`trigger: timeout`), without one it is `unavailable: timeout`. With a second level, a timeout after a `schema` miss on the same packet goes there at once, with no same-model retry; without one, that timeout is retried once on the same model, then the file is `unavailable: timeout`. The second level's own misses and timeouts (`second_level: true`) never count. Notes without `packet_hash` (written before B91), unsigned notes and `second_level: true` notes are read but never count. Each timed-out attempt leaves a signed `review.session_timeout` ledger note with the packet's `packet_hash`, the last 4 KB of its stderr (redacted, never the packet) and only the byte count and last event types of its stdout.
|
|
27
35
|
|
|
28
36
|
## §5 The convergence rule — bounded fix rounds (R15)
|
|
29
37
|
|
|
@@ -33,10 +41,27 @@ Per file, per block:
|
|
|
33
41
|
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).
|
|
34
42
|
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.
|
|
35
43
|
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.
|
|
36
|
-
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;
|
|
37
|
-
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
|
|
44
|
+
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.
|
|
45
|
+
5. **Cap — four rounds per file** (`review.max_rounds_per_file`, default 4, range 2–6). The per-file cap still counts per file: the orchestrator sends one fix message per block (`code.md` §4), and one block round is one round for each file it touched. 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):
|
|
46
|
+
- **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, and run by the human in their own terminal (see **The human runs `block waive` and `block reopen`** below). 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 `/./`, and no control character), is `usage` (exit 2).
|
|
47
|
+
- **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.
|
|
48
|
+
- **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`.
|
|
49
|
+
- **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}]}`.
|
|
50
|
+
- **Never an approval.** The answering file still needs its own `review.approved`, and the source's fix-loop state is unchanged.
|
|
51
|
+
- **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. The human runs it in their own terminal (next paragraph). **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`).
|
|
52
|
+
- **The human runs `block waive` and `block reopen` (B80).** Both verbs ask for a yes at a terminal. The orchestrator prints the exact command, with the human's reason, and never answers the prompt; the human types the command in their own terminal. The rule: a coder or the orchestrator never runs either verb. With stdin or stdout not a terminal (a pipe, or a process started with no terminal) the waive exits 2 with `block waive needs the human at a terminal to confirm; nothing was recorded (there is no --yes)` and the reopen with `block reopen needs the human at a terminal to confirm; nothing was recorded (there is no --yes)`; neither writes a row or removes a done marker. At a terminal the verb first refuses what it would refuse anyway, before it asks: a malformed `--file` or finding id, or one with a control character (exit 2), an unknown block (`unknown_block`), for a reopen a block that is not open (`reopen-block-not-open`) or a file it does not own (`reopen-not-owned`), and for a waive a file the block does not own (`waive-not-owned`) or a finding id that no review row of the block names for that file (`waive-unknown-finding`; the `proof` finding is exempt) — exit 1, nothing asked, nothing written. A "no" or a cancel prints `block <id> <verb>: cancelled — nothing was recorded` (exit 1). A "yes" writes the same signed row as before. There is no `--yes`: it is an unknown flag (exit 2). Under an active autopilot grant both verbs still prompt the same way (only `block close --no-require-reviews` is refused); the delegate's waivers go through `forge autopilot waive` (`by: autopilot`), which needs no terminal. The prompt checks only that stdin and stdout are terminals; a pseudo-terminal passes it, so it does not prove a human (`security.md` §3).
|
|
53
|
+
- **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.
|
|
54
|
+
- **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.
|
|
55
|
+
- **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.
|
|
38
56
|
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.
|
|
39
57
|
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.
|
|
58
|
+
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).
|
|
59
|
+
- **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.
|
|
60
|
+
- **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.
|
|
61
|
+
- **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.
|
|
62
|
+
- **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.
|
|
63
|
+
- **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.
|
|
64
|
+
- **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`.
|
|
40
65
|
|
|
41
66
|
Rechecks are never degraded by the budget: a round-2+ packet is already the minimum.
|
|
42
67
|
|
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
## §1 Keys never reach a coder
|
|
4
4
|
|
|
5
|
-
`keys.<name>` in `.code-forge.yml` holds a reference only (`user`, `op://…`, `env:NAME`, `keychain:<name>`), never a value. Resolution runs the chain — environment → OS keychain → 1Password `op read` (cached 8 h) → ask at setup — and runs **only inside the worker or the orchestrator's own CLI calls, never in a coder process**. `forge keys list | set <name> [--op <ref>] | test <name> [--ref <ref>] | remove <name>`; `list` never prints a value. Every output path is redacted: a message names the key path, never the value. Coder sessions receive neither the S1 key nor the signer key in their environment (subprocess env allow-list; Solo agent env likewise); reviewer sessions keep only the provider login variables they need to authenticate.
|
|
5
|
+
`keys.<name>` in `.code-forge.yml` holds a reference only (`user`, `op://…`, `env:NAME`, `keychain:<name>`), never a value. Resolution runs the chain — environment → OS keychain → 1Password `op read` (cached `keys.cache_hours`, default 8 h, max 24 h; `keys set --op <ref> --cache-hours <n>` sets the stored expiry for that one key, but the next CLI/worker/doctor read still caps it at `keys.cache_hours`, 8 h when unset) → ask at setup — and runs **only inside the worker or the orchestrator's own CLI calls, never in a coder process**. `forge keys list | set <name> [--op <ref> [--cache-hours <1-24>]] | test <name> [--ref <ref>] | remove <name>`; `list` never prints a value; under 1 h left it adds a stderr `WARN <name> expires in <m> min; re-link: code-forge keys set <name> --op <ref>` line, and `doctor` a `WARN` keys row. When an expired cached key cannot be refreshed, `jev ask` prints `jev ask: the cached jev key expired at <iso> and 1Password did not answer; re-link: code-forge keys set jev --op <ref>`: **relay that line to the human and stop**; never run `keys set --op` yourself in an agent session, because 1Password asks the human to approve it. Every output path is redacted: a message names the key path, never the value. Coder sessions receive neither the S1 key nor the signer key in their environment (subprocess env allow-list; Solo agent env likewise); reviewer sessions keep only the provider login variables they need to authenticate.
|
|
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
|
|
|
@@ -14,11 +14,11 @@ Rendered into every coder spawn as the CLI's own deny rules (path rules widen a
|
|
|
14
14
|
|
|
15
15
|
**Mechanism.** A per-run HMAC key (32 random bytes) lives in `~/.code-forge/runs/<run>.key`, mode 0600, outside the workspace; `forge run start` generates it, the worker and `forge block close` read it, and it is never placed in a coder's environment or workspace. Gate-relevant rows carry `mac`; a verification failure is a `ledger.tamper` row and the block is `stopped`. Key rotation = a new run.
|
|
16
16
|
|
|
17
|
-
**What it defends against.** A coder that never called `review-file` and appends or edits rows or result files with its ordinary tools — no valid MAC, refused. A coder that edits a review result after the worker wrote it — the MAC no longer matches. A stale approval reused after the file changed — rows are keyed by content hash. A coder that starts its own worker to sign rows — the pinned pid and start time in the run record no longer match, `worker.replaced`, block stopped. A coder that waives its own finding — `block waive` is forbidden and the row requires `by: human` signed by the worker's key.
|
|
17
|
+
**What it defends against.** A coder that never called `review-file` and appends or edits rows or result files with its ordinary tools — no valid MAC, refused. A coder that edits a review result after the worker wrote it — the MAC no longer matches. A stale approval reused after the file changed — rows are keyed by content hash. A coder that starts its own worker to sign rows — the pinned pid and start time in the run record no longer match, `worker.replaced`, block stopped. A coder that waives its own finding — `block waive` is forbidden and the row requires `by: human` signed by the worker's key. An agent that runs `block waive` or `block reopen` where stdin or stdout is not a terminal (a pipe, or a process started with no terminal) — both ask for a yes at a terminal, so they exit 2 with `block <verb> needs the human at a terminal to confirm; nothing was recorded (there is no --yes)` and record nothing (B80). The orchestrator prints the exact command and never answers the prompt; the human types the command in their own terminal. There is no `--yes`. Under an active autopilot grant `block waive` and `block reopen` still prompt the same way; only `block close --no-require-reviews` is refused under a grant. Autopilot's own waive path, `forge autopilot waive` (written `by: autopilot`), needs no terminal.
|
|
18
18
|
|
|
19
|
-
**What it does not defend against, and the product does not claim.** A coder running as the same OS user with a shell can read the key file and mint a MAC, or edit the run record. Within one OS user there is no boundary that a determined bypass-permissions process cannot cross; the deny rules make it a deliberate, named rule break rather than an accident, and the transcript grep makes the attempt visible (`rule_break: key_path`). `doctor` prints `signer: same-user boundary only` on every full run so nobody reads "signed" as "unforgeable".
|
|
19
|
+
**What it does not defend against, and the product does not claim.** A coder running as the same OS user with a shell can read the key file and mint a MAC, or edit the run record. **The honest limit of the `block waive` / `block reopen` prompt:** the prompt checks only that stdin and stdout are terminals. It does not prove a human: a process that runs the verb in a pseudo-terminal (`script`, `expect`, a pty library, or an agent shell that is itself a pty, as Solo processes may be) passes the check and can answer yes, and a same-user process can read the run key and sign a row. The signer is a same-user boundary only. Within one OS user there is no boundary that a determined bypass-permissions process cannot cross; the deny rules make it a deliberate, named rule break rather than an accident, and the transcript grep makes the attempt visible (`rule_break: key_path`). `doctor` prints `signer: same-user boundary only` on every full run so nobody reads "signed" as "unforgeable".
|
|
20
20
|
|
|
21
|
-
**The real boundary is the consumer's choice:** run coders as a second OS user, or in a container that cannot reach `~/.code-forge/runs/` (a Codex `workspace-write` sandbox already blocks writes there; reads remain open). An in-memory key over a socket was considered and rejected: a same-user process can still replace the worker or edit the pin.
|
|
21
|
+
**The real boundary is the consumer's choice:** run coders as a second OS user, or in a container that cannot reach `~/.code-forge/runs/` (a Codex `workspace-write` sandbox already blocks writes there; reads remain open). The Codex coder's `coder.sandbox` profile (`adapters/subprocess.md` §3) sets how far that holds: `workspace` (the default; no key gives today's argv) opens only the project tree for writes; `toolchain` also opens each checked `add_dirs` entry. An entry is refused (`sandbox-dir-refused`) when it is `/` or the home directory, or is, holds or sits inside `~/.code-forge`, `~/.codex`, `~/.ssh`, `~/.gnupg`, `~/.aws`, `~/.config`, `~/Library/Keychains`, the run temp root or the session `CODEX_HOME`, so `~/Library` is refused too; `~/` expands against HOME, each entry becomes an absolute realpath, and a symlink is judged by its target; the message names the key path, never the value. `full` (`-s danger-full-access`) turns the Codex sandbox off: it opens every path the OS user can write, `~/.code-forge/runs/` included, with network as the CLI allows, so only the same-user rules above hold, like the Claude coder; the argv keeps the `$TMPDIR` and `/tmp` exclusion flags, which Codex ignores there. Under `workspace` or `full`, `add_dirs` is unused and `validate` warns `sandbox-dirs-unused`. The key has no effect on the Claude and Grok coders. `doctor` prints no `coder sandbox` row without the key, one INFO row with it, a WARN row for `full`, and a no-effect INFO row only when a Claude or Grok coder is configured. The profile is authority: fixed at `run start`, refused by `run reload`, and a differing live value stops `forge spawn --role coder --run <r>` with `sandbox-changed`; without `--run` the live config is read. An in-memory key over a socket was considered and rejected: a same-user process can still replace the worker or edit the pin.
|
|
22
22
|
|
|
23
23
|
## §4 Isolation of closed-book sessions
|
|
24
24
|
|
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.10.0"
|
|
14
14
|
set -eu
|
|
15
15
|
|
|
16
16
|
self="$0"
|
|
@@ -15,7 +15,9 @@
|
|
|
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
|
+
|
|
20
|
+
Proof is behaviour, not text: never read a source file in a test to assert on its text. Call the code and check what it does; a test may read a fixture. A test file that reads a source file and asserts on its text gets a `## test-quality` section in its review packet. The section is a hint, not a finding: the reviewer checks those lines and, when the test really reads source text to assert on it, raises a `warning` (unless the file read is a fixture).
|
|
19
21
|
|
|
20
22
|
## Final report — FAILED unless it lists the ticket ids
|
|
21
23
|
|
|
@@ -42,7 +44,7 @@ Or `===BLOCK <id> FAILED: <reason>===`.
|
|
|
42
44
|
|
|
43
45
|
## Fix-round brief (round ≥ 2, or a respawn after a climb)
|
|
44
46
|
|
|
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. 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.
|
|
47
|
+
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
48
|
|
|
47
49
|
## Context pack
|
|
48
50
|
|
|
@@ -159,7 +159,9 @@ function describe(row) {
|
|
|
159
159
|
case 'review.session_timeout':
|
|
160
160
|
return { event: 'Review session timed out', detail: `${row.lens ?? row.role ?? 'session'} · attempt ${u(row.attempt)} · ${row.retried === true ? 'retried' : 'gave up'}`, link: null };
|
|
161
161
|
case 'review.schema_fallback':
|
|
162
|
-
|
|
162
|
+
// B91: `trigger: timeout` ⇒ the second broken result was a timed-out attempt; no trigger
|
|
163
|
+
// (rows before B91) or `schema` ⇒ two schema misses
|
|
164
|
+
return { event: row.trigger === 'timeout' ? 'Review broken twice (timeout)' : 'Review answer off-schema twice', detail: `${row.lens ?? row.role ?? 'session'} · ${u(row.from_model, 'unknown model')} → ${u(row.to_model, 'unknown model')} · ${typeof row.refused === 'string' ? `refused (${u(row.refused)})` : 'retried once'}`, link: null };
|
|
163
165
|
case 'worker.down':
|
|
164
166
|
return { event: 'Worker down', detail: 'the pinned worker is not running', link: null };
|
|
165
167
|
case 'worker.replaced':
|
|
@@ -209,12 +211,16 @@ const SCHEMA_FALLBACK_FIX = Object.freeze({
|
|
|
209
211
|
});
|
|
210
212
|
|
|
211
213
|
/**
|
|
212
|
-
* The incident of a `review.schema_fallback` row (B55): retried once, or refused and why.
|
|
214
|
+
* The incident of a `review.schema_fallback` row (B55): retried once, or refused and why. B91:
|
|
215
|
+
* a row with `trigger: timeout` says the second broken result was a timeout.
|
|
213
216
|
* @param {Row} r @param {string | null} time
|
|
214
217
|
* @returns {{time: string | null, what: string, effect: string, fix: string}}
|
|
215
218
|
*/
|
|
216
219
|
function schemaFallbackIncident(r, time) {
|
|
217
|
-
const what =
|
|
220
|
+
const what =
|
|
221
|
+
r.trigger === 'timeout'
|
|
222
|
+
? `A ${r.lens ?? r.role ?? 'review'} session broke twice on the same packet, the second time by timing out (${u(r.from_model, 'unknown model')})`
|
|
223
|
+
: `A ${r.lens ?? r.role ?? 'review'} answer failed the schema twice on the same packet (${u(r.from_model, 'unknown model')})`;
|
|
218
224
|
if (typeof r.refused !== 'string') return { time, what, effect: `It was retried once on the second level (${u(r.to_model, 'unknown model')}).`, fix: "Check the file's next review result." };
|
|
219
225
|
const fix = Object.hasOwn(SCHEMA_FALLBACK_FIX, r.refused) ? SCHEMA_FALLBACK_FIX[/** @type {keyof typeof SCHEMA_FALLBACK_FIX} */ (r.refused)] : 'Review the file by hand.';
|
|
220
226
|
return { time, what, effect: `The second-level try was refused (${u(r.refused)}); the review stays unavailable.`, fix };
|