@codedology/code-forge 0.3.1 → 0.4.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 +60 -0
- package/README.md +79 -21
- package/bin/code-forge.mjs +6 -1
- package/docs/README.md +5 -4
- package/docs/reference/blocks.json +153 -1
- package/docs/reference/config.md +8 -1
- package/package.json +4 -2
- package/schema/code-forge.schema.json +15 -4
- package/skill/SKILL.md +7 -7
- package/skill/references/adapters/claude-code.md +1 -1
- package/skill/references/adapters/solo.md +1 -1
- package/skill/references/adapters/subprocess.md +1 -1
- package/skill/references/code.md +4 -3
- package/skill/references/continuity.md +4 -0
- package/skill/references/decisions.md +5 -2
- package/skill/references/ledger.md +8 -2
- package/skill/references/plan.md +3 -3
- package/skill/references/review.md +8 -5
- package/skill/references/security.md +3 -1
- package/skill/scripts/forge +1 -1
- package/skill/templates/coder-brief.md +36 -0
- package/skill/templates/plan.md +2 -0
- package/src/cli/block.mjs +51 -8
- package/src/cli/jev.mjs +114 -17
- package/src/cli/ledger.mjs +99 -8
- package/src/cli/logs.mjs +193 -58
- package/src/cli/plan.mjs +27 -11
- package/src/cli/report.mjs +15 -1
- package/src/cli/resolve.mjs +16 -1
- package/src/cli/review-file.mjs +10 -5
- package/src/cli/run.mjs +109 -8
- package/src/config/closed-book.mjs +75 -0
- package/src/config/validate.mjs +128 -7
- package/src/decide/escalation.mjs +112 -11
- package/src/decide/jev-client.mjs +1 -1
- package/src/decide/questions.mjs +39 -3
- package/src/doctor/probes.mjs +48 -6
- package/src/engines/builders/claude.mjs +4 -1
- package/src/engines/builders/codex.mjs +26 -11
- package/src/engines/builders/grok.mjs +3 -3
- package/src/engines/efforts.mjs +109 -0
- package/src/engines/sentinel.mjs +34 -0
- package/src/keys/onepassword.mjs +3 -0
- package/src/ledger/report.mjs +66 -29
- package/src/ledger/spend.mjs +171 -0
- package/src/review/engine.mjs +65 -23
- package/src/review/fixloop.mjs +29 -6
- package/src/review/session-retry.mjs +69 -0
- package/src/review/triage.mjs +26 -1
- package/src/review/validate-review.mjs +21 -6
- package/src/session/author.mjs +6 -2
- package/src/session/plan-check.mjs +71 -6
- package/src/session/plan-sections.mjs +98 -0
- package/src/session/spawn.mjs +217 -8
- package/src/state/block.mjs +13 -4
- package/src/state/config-snapshot.mjs +150 -0
- package/src/state/run.mjs +86 -0
- package/src/util/breadcrumbs.mjs +145 -0
- package/src/util/error-hint.mjs +3 -2
- package/src/util/error-kind.mjs +2 -2
- package/src/util/error-log.mjs +109 -18
- package/src/util/forbidden.mjs +3 -1
- package/src/util/known-fixes.json +1 -0
- package/src/util/known-fixes.mjs +153 -0
- package/src/worker/engine.mjs +13 -8
- package/src/worker/loop.mjs +135 -39
- package/src/worker/queue.mjs +68 -7
|
@@ -22,7 +22,7 @@ Solo adds spawn, watch, timers, scratchpads, locks and `kv` on top of the CLI; i
|
|
|
22
22
|
5. **Wake channels, event-driven, never fixed polls:** record your own pid once (`whoami` → `kv_set("forge:orchestrator_pid")`); coders that can call Solo carry the push contract (post-sentinel `kv_set("block:<id>", …)` + `timer_set(1s, body, delivery_process_id=<your pid>)`); for every group arm `timer_fire_when_idle_any([coders], max_wait≈20–25 min, delivery_process_id=<your pid>)` right after the launch gate (idle-any ignores already-idle processes, so arm while they are provably running); keep one long dead-man fallback per 30–40 min with an L0 triage first. On wake: sentinel ⇒ gate; no sentinel and active again ⇒ re-arm; no sentinel and idle ⇒ parked, intervene now.
|
|
23
23
|
6. Completion = `search_output(pattern="===BLOCK <id> COMPLETE===")`, last occurrence (the `kv` key is the truncation backup). Completion is not closure.
|
|
24
24
|
7. **Review rounds, with the coder still alive.** The coder ran `forge review-file <path> --block <id> --run <r>` per file and `--wait`ed on each ticket; read the ledger (`forge ledger tail --slug <slug>`) for what is still open — a `fix_now` finding, a `pending` ticket, a file never submitted. Each open finding is a fix round sent to the **warm coder** by `send_input` (the findings by id, `<<<EOM>>>`), which fixes and re-runs `review-file` on that file; round ≥ 2 re-checks only the fix hunks (`review.md` §5). **One ladder** (`decisions.md` §5): a stall or exhausted rounds *below L2* climbs one level — close this coder and respawn a fresh one at the higher level with the open findings and the fix history in its brief; **the respawn keeps the per-file round count**, the cap does not reset. L2 is the highest running level: a trigger at L2, or the four-round cap on a file, goes to the **L3 rung once** — a fresh L3 patch session, never this coder and never a coder at L3 — and if findings stay open after the `patch_check` round the file is `stopped: review_cap` for the human. No other path exists.
|
|
25
|
-
8. **Close** only when every changed file has a signed `review.approved` row: the re-measurement of `code.md` §5, then `forge block close <id> --run <r>` — the transcript grep reads the Solo output. Closing with a file still unreviewed is a human decision, audited: `forge block waive <id> <finding> --run <r> --file <path> --reason "<why>"` per finding, or `forge block close … --no-require-reviews`.
|
|
25
|
+
8. **Close** only when every changed file has a signed `review.approved` row: the re-measurement of `code.md` §5, then `forge block close <id> --run <r> --report <file>` (the coder's final report) — the transcript grep reads the Solo output. Closing with a file still unreviewed is a human decision, audited: `forge block waive <id> <finding> --run <r> --file <path> --reason "<why>"` per finding, or `forge block close … --no-require-reviews`.
|
|
26
26
|
9. **Reap after the close**: `close_process` the coder and confirm `lock_status` shows no lease before the next group. Locks: a coder that can call Solo holds `lock_acquire` on its file area while editing; coders that cannot never overlap another block.
|
|
27
27
|
|
|
28
28
|
## §4 Latency overlaps (standing)
|
|
@@ -8,7 +8,7 @@ Never selected automatically (`degraded.md` §1). Coders are detached CLI proces
|
|
|
8
8
|
|
|
9
9
|
## §2 Completion and evidence
|
|
10
10
|
|
|
11
|
-
The coder's stdout is captured; completion is the **last** `===BLOCK <id> COMPLETE===` or `===BLOCK <id> FAILED: <reason>===` line in it (a quoted sentinel inside a sentence never matches). Process exit without a sentinel is a failed block. Then the re-measurement of `code.md` §5 and `forge block close <id> --run <r> --transcript <captured stdout>` — the transcript grep runs on the capture. A coder that hangs is killed at `--timeout` together with its process group; the pid entry is cleared.
|
|
11
|
+
The coder's stdout is captured; completion is the **last** `===BLOCK <id> COMPLETE===` or `===BLOCK <id> FAILED: <reason>===` line in it (a quoted sentinel inside a sentence never matches). Process exit without a sentinel is a failed block. Then the re-measurement of `code.md` §5 and `forge block close <id> --run <r> --transcript <captured stdout> --report <captured stdout>` — the transcript grep runs on the capture. A coder that hangs is killed at `--timeout` together with its process group; the pid entry is cleared.
|
|
12
12
|
|
|
13
13
|
## §3 What the coder's sandbox looks like per provider
|
|
14
14
|
|
package/skill/references/code.md
CHANGED
|
@@ -10,12 +10,13 @@ The coder then forecasts **cases** (tests) and **lines**. Compute the floor your
|
|
|
10
10
|
|
|
11
11
|
## §3 Dispatch — what the brief contains
|
|
12
12
|
|
|
13
|
-
`forge block open <id> --run <r> --level L<n> --owned <paths…> --acceptance <file> --brief <file> [--attempt <n>] [--base <sha>] --lines <n>` records the base sha, the owned files (overlap with another active block ⇒ refused), the level, the clauses with their test ids and the forecast, writes the ledger `dispatch` row and prints the pointer `BRIEF <path> lines=<n> sha=<sha8> <<<EOM>>>` (≤ 200 B). Call it before every spawn, in every engine. The brief file carries, in this order:
|
|
13
|
+
`forge block open <id> --run <r> --level L<n> --owned <paths…> --acceptance <file> --brief <file> [--attempt <n>] [--base <sha>] [--kind code|docs|contract] --lines <n>` records the base sha, the owned files (overlap with another active block ⇒ refused), the block kind (declared, else detected — `decisions.md` §5), the level (a docs/contract block below `levels.coder_floor_docs`, default L1, is raised to it and the CLI prints the floor), the clauses with their test ids and the forecast, writes the ledger `dispatch` row and prints the pointer `BRIEF <path> lines=<n> sha=<sha8> <<<EOM>>>` (≤ 200 B). Call it before every spawn, in every engine. A docs block that owns a glob (`docs/**`) must declare `--kind docs`: a glob is judged by its literal ending, so undeclared it counts as `code`. The brief file carries, in this order:
|
|
14
14
|
|
|
15
15
|
1. `id`, `title`, the level, `owned_files` ("edit ONLY these"), the acceptance clauses with test ids, `test_command`.
|
|
16
16
|
2. The reply contract: `ACK <sha8> lines=<n>` before any tool call; the facts diff; the forecast.
|
|
17
17
|
3. The file loop: *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
|
-
4. The project's own rules digest (`CLAUDE.md`/`AGENTS.md`, ≤ 60 lines), the forbidden list verbatim (`security.md` §2), and the sentinel contract: `===BLOCK <id> COMPLETE===` followed by the test summary
|
|
18
|
+
4. The project's own rules digest (`CLAUDE.md`/`AGENTS.md`, ≤ 60 lines), the forbidden list verbatim (`security.md` §2), and the sentinel contract: `===BLOCK <id> COMPLETE===` followed by the test summary, `git diff --stat` and one `reviewed <path> ticket <ticket-id>` line per changed file, or `===BLOCK <id> FAILED: <reason>===`.
|
|
19
|
+
5. **FAILED unless** (`templates/coder-brief.md`): a final report — first attempt or fix round — that does not list the `review-file` ticket id of every changed file counts as FAILED whatever its sentinel says. Pass the report to the gate with `forge block close <id> --run <r> --report <file>`; it refuses `coder report FAILED: no review-file ticket id for <files>`.
|
|
19
20
|
|
|
20
21
|
The brief never mentions `block waive`, keys, or `~/.code-forge/runs/`. The coder process gets no Jev key (the System 1 model's key, `keys.jev`) and no signer key.
|
|
21
22
|
|
|
@@ -30,7 +31,7 @@ The coder's report is a claim sheet. The gate reproduces each number in the meas
|
|
|
30
31
|
| claim | re-measured by |
|
|
31
32
|
|---|---|
|
|
32
33
|
| only owned paths touched | file set = tracked changes ∪ untracked, minus other active blocks' owned sets ⊆ `owned_files`; anything else is an orphan that stops every gate until `forge block claim <id> <path> --run <r>` or the human deletes it |
|
|
33
|
-
| every file reviewed | a **signed** `review.approved` row for the current content hash of every changed or new file, or an architect-ruled disposition to a named successor block |
|
|
34
|
+
| every file reviewed | the report names a `review-file` ticket id per changed file (`--report`, which the orchestrator always passes) and a **signed** `review.approved` row for the current content hash of every changed or new file, or an architect-ruled disposition to a named successor block |
|
|
34
35
|
| tests green | every gate command run once in the export, each exit status read |
|
|
35
36
|
| clauses covered | every clause names ≥ 1 test id; each test exists and passed in this run; S1 `scope` on the hunks outside the named tests says `false` |
|
|
36
37
|
| test count | the filtered run's delta equals the new tests' own count |
|
|
@@ -29,3 +29,7 @@ Symptoms: a PR flips to conflicting, or its diff suddenly shows the whole stack;
|
|
|
29
29
|
- **Duplicate-append is the common conflict** in append-only files: read every hunk anyway — one in four is a genuinely different block.
|
|
30
30
|
- **Prove with a tree comparison:** `git diff <pre> <post>` empty when the merge reconciles history only; `git merge-base --is-ancestor`; the line count back to block size. Merged ≠ in the default branch: check both.
|
|
31
31
|
- **Re-run classifiers and forecasts after any base change**: a size or hot-file figure taken across a stale base is not a number.
|
|
32
|
+
|
|
33
|
+
## §4 A config change mid-run
|
|
34
|
+
|
|
35
|
+
Edit `.code-forge.yml`, then `forge run reload --run <id>`. It validates the file (an invalid one is refused, nothing changes), prints the changed key paths — never values — and writes a signed `run.reload` row with the old and new snapshot hashes; no change prints `no config change` and writes nothing. Open blocks stay open at their attempt numbers; review tickets already queued finish on the config they were enqueued with, new tickets use the new one, and the worker picks it up without a restart. `project.slug`, `engine`, `tmp.root`, `keys`, `system1.key` and `version` are fixed for the run: a change to one is refused by name — put it back, or end the run and start a new one. Never `block stop` + `run end` + `run start` + `block open --attempt n+1` for a config change.
|
|
@@ -6,7 +6,7 @@ Deterministic tools answer facts (gates, diffs, counts). **System 1 (S1)** answe
|
|
|
6
6
|
|
|
7
7
|
| id | type | fires | answers |
|
|
8
8
|
|---|---|---|---|
|
|
9
|
-
| `lane` | choice | plan, per block; code, on re-decomposition | `L0` trivial · `L1` a plain feature following an existing pattern · `L2` money, dates, tenancy, migrations, concurrency, unknown failure · `split` too large or two concerns. **L3 is not a lane** |
|
|
9
|
+
| `lane` | choice | plan, per block (`--block <id>`: `plan check` reads it); code, on re-decomposition | `L0` trivial · `L1` a plain feature following an existing pattern · `L2` money, dates, tenancy, migrations, concurrency, unknown failure · `split` too large or two concerns. **L3 is not a lane** |
|
|
10
10
|
| `risk` | score 0–3 | plan, per block; code, per finished file | `0` cosmetic · `1` wrong behaviour, easy to notice and revert · `2` silent wrong data for some users · `3` silent wrong money or a cross-tenant leak |
|
|
11
11
|
| `security_sensitive` | yes/no | plan, per block; code, per file unless path floors already forced `high` | touches authentication, authorization, tenancy, secrets, money movement or webhook verification |
|
|
12
12
|
| `next` | choice | code, after each attempt's tool gate | `complete` all green, tests added or `no_new_tests_reason` given · `retry` a contained failure the same coder fixes in one more attempt · `escalate` repeated or spreading failures, or a risky area · `stop` the evidence contradicts the brief — the human decides |
|
|
@@ -33,10 +33,13 @@ No key ⇒ a banner at run start; 401 ⇒ stop; 422 ⇒ stop with the request id
|
|
|
33
33
|
1. **Attempts:** attempts at the current level equal `escalation.retries_per_level` and not green ⇒ +1 level, whatever S1 said (`retries`).
|
|
34
34
|
2. **Review rounds:** `escalation.review_rounds_per_level` exhausted with an open `fix_now` ⇒ +1 level (`review_rounds`).
|
|
35
35
|
3. **Review stall:** a re-check round whose open `fix_now` set did not strictly shrink ⇒ +1 level at once (`review_stall`, `review.md` §5).
|
|
36
|
-
|
|
36
|
+
3a. **Heavy rounds:** below L2, `escalation.after_rounds_with_warnings` (default 1) rounds at the current level whose open `fix_now` set holds ≥ `escalation.warning_threshold` (default 2) warnings or any `critical` ⇒ +1 level at once (`review_warnings`). A cheap coder that fails a round with several warnings climbs; it never spends three rounds at the same level. At L2 it does not fire — the L3 rung keeps its own triggers (rules 2, 3, 6). `after_rounds_with_warnings: 0` turns it off.
|
|
37
|
+
4. **Security floor, at dispatch:** `security_sensitive` ⇒ first-attempt level `min(max(lane, L1) + 1, L2)` (`security`). Then the **docs floor**: a `docs` or `contract` block (below) is never dispatched under `levels.coder_floor_docs` (default L1) — `forge block open` raises the level and the `dispatch` row carries `requested_level` and `trigger: docs_floor`. L0 never codes docs or contracts.
|
|
37
38
|
5. **S2 ruling:** S1 `next = escalate` is never acted on directly — it goes to S2 as a check (`s2_ruling`); S1 `next = stop` ⇒ the human.
|
|
38
39
|
6. **Review cap:** `review.max_rounds_per_file` reached on a file with an open `fix_now` ⇒ the L3 rung for that file, once per block, always as a patch whatever `escalation.l3_mode` says (`review_cap`); checked last, only when no rule above returned an action this attempt.
|
|
39
40
|
|
|
41
|
+
**Block kind.** `forge block open --kind code|docs|contract` declares it; without the flag it is detected from the owned paths: every path ends in `.md .mdx .markdown .rst .adoc` ⇒ `docs` (`.txt` is not docs: `requirements.txt`); every path is docs or a contract (`*.schema.json|yaml|yml`, `.proto`, `.graphql`, `.gql`, `.avsc`, `*.openapi.json|yaml|yml`, or a `.json/.yaml/.yml` under a `schema/`, `schemas/`, `contract/` or `contracts/` directory) and one is a contract ⇒ `contract`; anything else ⇒ `code`. A glob is judged by its literal ending, so a block owning `docs/**` declares its kind. The kind is recorded in the run record and drives the docs floor (rule 4) and the review topology (`review.md` §2).
|
|
42
|
+
|
|
40
43
|
**The ceiling constraint (not a ranked rule).** *Never skip a level* constrains every rule above: a "+1 level" is always the next level, and **the highest running level is L2**. When rule 1, 2 or 3 fires at L2 there is no coding level above, and the **L3 rung** takes over (§6): a patch session, never a coder at L3 — the block continues at L2 afterwards. `escalation.stop_at` = L3 (fixed) names that rung, not a level a coder runs at; `l3_mode: code` is the only way a coder runs at L3, and it is not the default. The rung is one per block from either source (a trigger at L2 or the review cap, rule 6): a second hit from either always `stop`s (`review_cap` for the cap; the ceiling reason for the trigger), so `stop_at` means something in every mode. A fresh coder spawned by a level climb inherits the file's round count: the cap counts rounds per file, not per coder.
|
|
41
44
|
|
|
42
45
|
**Own-family adjudication (R9).** When the orchestrator and the coder share a model family, every ruling the orchestrator makes about that coder's work is a judgement inside the family: it is written as a ledger row named as such, and it is asymmetric — the orchestrator may **accept** a finding alone; it may **not dismiss, defer or waive** one. Dismissals, cap raises and scope changes go to the architect (an L3 session) or the human.
|
|
@@ -8,11 +8,17 @@ Events you will read most: `run.start`, `run.stop {reason}`, `run.end`, `dispatc
|
|
|
8
8
|
|
|
9
9
|
## §2 `forge report --slug <slug> [--json] [--export <dir>]`
|
|
10
10
|
|
|
11
|
-
Terminal output in this version. Sections: cost per completed
|
|
11
|
+
Terminal output in this version. Sections: cost per block — completed and still open, with `completed` and the count of unknown-price sessions (per level and lane, split review / coder / S1+S2 / facts / proof time) · lane distribution · escalations per block with `trigger` · S1 calls per block · S2 rate and overrule rate per question · the review budget table with `missed_after_degrade` per degrade step · review depth and context-mode distribution, findings by category and severity · fix rounds per file, rounds that stalled, files stopped at `review_cap` · `review.unavailable` by reason · proof time per block · forecast vs actual lines and cases with the measured lines-per-case · blocks stopped at L3 · `overspend` and `l3_fallback` counts · runs stopped for `no_engine`. `--export <dir>` writes one JSON file per section — the durable dataset a future UI reads. After the sections the terminal output prints the spend per run (open blocks included) and the total.
|
|
12
12
|
|
|
13
13
|
## §3 Live views
|
|
14
14
|
|
|
15
|
-
`forge ledger tail --slug <slug> [--n <count>]` prints the last rows — the orchestrator's live view when no Solo process list exists. `forge run status --run <id>` prints the run record: active blocks, orphans, whether the worker is pinned
|
|
15
|
+
`forge ledger tail --slug <slug> [--n <count> | -n <count>]` prints the last rows — the orchestrator's live view when no Solo process list exists. `forge run status --run <id>` prints the run record: active blocks, orphans, whether the worker is pinned, and `spent_usd` / `budget_usd` / `unknown_usd_sessions`.
|
|
16
|
+
|
|
17
|
+
## §3.1 Spend and `budget.usd`
|
|
18
|
+
|
|
19
|
+
Every `session` row carries `usd` (tokens × the static price table) or `usd: null, usd_unknown: true` when the provider/level has no price — never a guess; a `session.background` coder's price is always unknown. Spend code-forge never saw (a cloud or hand-run coder) goes in with `forge ledger add coder --run <r> --block <b> --usd <n> [--note "..."]`, a signed `session` row with `manual: true`. With `budget.usd` set, every session spawn first sums the run's spend: at 80 % one `budget.warning` row and one stderr line per run; at 100 % the session is refused (`budget.usd 20.00 reached (spent 20.13); raise budget.usd or end the run`), a `budget.refused` row is written, nothing is spawned, and a review reports `review.unavailable {reason: budget}`. A budget whose spend cannot be read refuses too (fail closed).
|
|
20
|
+
|
|
21
|
+
Known limits, accepted: unknown-price sessions and `session.background` coders add **0** to the spent total — record their real cost with `forge ledger add coder --usd <n>`; `forge run status` shows `unknown_usd_sessions` and prints `N sessions have no known price; the budget does not count them` when N > 0. Two sessions spawned at the same moment can each write a `budget.warning` row (a rare duplicate; the warning still means the same thing).
|
|
16
22
|
|
|
17
23
|
## §4 Calibration — `forge ledger calibration --slug <slug> [--question <id>]`
|
|
18
24
|
|
package/skill/references/plan.md
CHANGED
|
@@ -13,7 +13,7 @@ Run `forge facts --brief <brief> [--sources <path>...] --out <plans_dir>/<slug>.
|
|
|
13
13
|
|
|
14
14
|
## §1 The author job
|
|
15
15
|
|
|
16
|
-
`forge author --job plan --brief <brief> --facts <sheet> --out <plan>` runs a fresh L3 closed-book session (empty cwd, the packet on stdin) and returns `{draft, questions[], cost}`. It refuses without `--facts` (exit 2, `facts sheet required: run forge facts first`) and refuses a sheet older than the brief. The draft follows `templates/plan.md`; its §0 **is** the facts sheet, verbatim, and it must contain the section "Acceptance clauses the facts sheet cannot back" — `none` when empty, never absent.
|
|
16
|
+
`forge author --job plan --brief <brief> --facts <sheet> --out <plan>` runs a fresh L3 closed-book session (empty cwd, the packet on stdin) and returns `{draft, questions[], cost}`. It refuses without `--facts` (exit 2, `facts sheet required: run forge facts first`) and refuses a sheet older than the brief. The draft follows `templates/plan.md`; its §0 **is** the facts sheet, verbatim, and it must contain the section "Acceptance clauses the facts sheet cannot back" — `none` when empty, never absent. The author's packet carries the exact required headings from the same list `plan check` reads (`src/session/plan-sections.mjs`).
|
|
17
17
|
|
|
18
18
|
## §2 Decomposition — what every block carries
|
|
19
19
|
|
|
@@ -22,7 +22,7 @@ Run `forge facts --brief <brief> [--sources <path>...] --out <plans_dir>/<slug>.
|
|
|
22
22
|
| `id`, `title` | one deliverable per block |
|
|
23
23
|
| `owned_files` | exact paths or disjoint globs; a glob never contains `[ ] ( ) ! + @` (an exact path may); pairwise disjoint across blocks that can run together |
|
|
24
24
|
| `depends_on` | acyclic; a block imports only modules of blocks it depends on — a batch-mate's module is a `next = stop` |
|
|
25
|
-
| `level` | the S1 `lane` answer (`L0` trivial · `L1` a plain feature following a pattern · `L2` money, dates, tenancy, migrations, concurrency · `split` too large or two concerns); **L3 is not a lane** |
|
|
25
|
+
| `level` | the S1 `lane` answer recorded for the block — `forge jev ask lane --block <id> --plan <plan> --state <file>`, or `--rules` (the deterministic fallback) when Jev is unavailable; never a block category (`L0` trivial · `L1` a plain feature following a pattern · `L2` money, dates, tenancy, migrations, concurrency · `split` too large or two concerns); **L3 is not a lane** |
|
|
26
26
|
| `acceptance` | clauses that each name ≥ 1 test id; a clause citing a flag, path, version or API cites a VERIFIED fact id or sits in the unbackable list with a tolerance naming this block |
|
|
27
27
|
| `cases`, `lines` | forecasts: `cases = 2 × counted clauses`, `lines = 26 × cases` with a floor of 1 200 for a greenfield block and 500 for an amendment; above `budget.block_cases` or `budget.block_lines` the block is split in the plan, never "split-ready" |
|
|
28
28
|
| `test_command` | a quoted glob or an explicit file list, never a bare directory |
|
|
@@ -36,7 +36,7 @@ Every clause of the parent's acceptance maps to exactly one block; an unmapped c
|
|
|
36
36
|
|
|
37
37
|
## §4 The deterministic exit — `forge plan check <plan> [--facts <sheet>]`
|
|
38
38
|
|
|
39
|
-
Prints every failing row by name and exits 1 on any of: an acceptance clause with a claim token that resolves to no VERIFIED fact and no tolerance row (`unbackable clause without tolerance: <block> <clause>`); a missing caller map or a block absent from it; a block without `cases` and `lines`; a forecast above `budget.block_cases` / `budget.block_lines` without a named split; overlapping `owned_files` in one wave; a cyclic `depends_on` or an import of a batch-mate; a glob with `[ ] ( ) ! + @`; more than `caps.coders` concurrent blocks in a slot; `questions[]` still open; the facts sheet's sha absent from the draft. Green ⇒ set `status: hardened` after the harden job (`harden.md`), never before.
|
|
39
|
+
Prints every failing row by name and exits 1 on any of: an acceptance clause with a claim token that resolves to no VERIFIED fact and no tolerance row (`unbackable clause without tolerance: <block> <clause>`); a missing caller map or a block absent from it; a block without `cases` and `lines`; a forecast above `budget.block_cases` / `budget.block_lines` without a named split; overlapping `owned_files` in one wave; a cyclic `depends_on` or an import of a batch-mate; a glob with `[ ] ( ) ! + @`; more than `caps.coders` concurrent blocks in a slot; `questions[]` still open; the facts sheet's sha absent from the draft; a block whose level has no recorded lane, or differs from it (`block <id>: level L2 differs from the recorded lane L1 (jev)`). A required section under a near-miss heading at its template position (§0.x, §3) passes with a `WARN` naming the exact heading. stdout ends with the lane recorded per block (`lanes: B1 L1 (jev) · B2 L0 (rules)`). Green ⇒ set `status: hardened` after the harden job (`harden.md`), never before.
|
|
40
40
|
|
|
41
41
|
## §5 Cost
|
|
42
42
|
|
|
@@ -4,11 +4,13 @@
|
|
|
4
4
|
|
|
5
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.
|
|
6
6
|
|
|
7
|
-
## §2 Depth by risk (adaptive mode
|
|
7
|
+
## §2 Depth by risk (adaptive mode)
|
|
8
|
+
|
|
9
|
+
The topology is decided in one place (`planSessions`). Consensus (§6) runs only when `review.multimodel` is on **and** the file's risk is above `review.single_reviewer_max_risk` (default 1) **and** the block is not a `docs`/`contract` block (`decisions.md` §5) unless `review.multimodel_for_docs` is true (default false). Everything else is adaptive, below. Why: a consensus review of a four-file docs block cost $16.70 — review cost dominates, and a low-risk file gains nothing from a second provider.
|
|
8
10
|
|
|
9
11
|
1. Tools on the file first: lint, types, the file's own tests. Red ⇒ `tools_red` back to the coder, no model review, ledger `gate.red`.
|
|
10
12
|
2. S1 `risk` (path floors first; `confidence < check` ⇒ S2 picks the depth).
|
|
11
|
-
3. Planned depth: `risk < 1` ⇒ one L2 `quick` · `1 ≤ risk < 2` ⇒ one L2 `full
|
|
13
|
+
3. Planned depth: `risk < 1` ⇒ one L2 `quick` · `1 ≤ risk < 2`, or `risk ≤ review.single_reviewer_max_risk` ⇒ one L2 `full`, no judge · otherwise two blind L2 sessions (lens A correctness, lens B contracts and security) plus an L3 judge that reads the two reports — and the diff only when `review.judge_sees_diff` is true.
|
|
12
14
|
4. Triage: findings from a single L2 session go to S1 `defect` (`thresholds.findings.fix` fix now · mid-band batched to one L3 ruling per file · below `thresholds.findings.nit` a nit). Findings that come from a judge are final.
|
|
13
15
|
|
|
14
16
|
## §3 Isolation and the packet
|
|
@@ -17,7 +19,7 @@ Every reviewer, recheck, judge and S2 session runs with cwd = a fresh empty temp
|
|
|
17
19
|
|
|
18
20
|
## §4 The stub guard — what counts as a review (R11)
|
|
19
21
|
|
|
20
|
-
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. Anything else is ledger `review.unavailable` (`exit`, `schema`, `hunks_mismatch`, `too_short`, `timeout`) and **is not approval**; the worker retries once on the level's fallback, then the file stays unreviewed and the block cannot close. A session that hangs is killed at `review.session_timeout_s
|
|
22
|
+
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. Anything else is ledger `review.unavailable` (`exit`, `schema`, `hunks_mismatch`, `too_short`, `timeout`, `budget` — the run reached `budget.usd`, `ledger.md` §3.1) and **is not approval**; the worker retries once on the level's fallback, then the file stays unreviewed and the block cannot close. A session that hangs is killed at `review.session_timeout_s` (default 300), process group and all, and is spawned once more on the same packet and level; only a second timeout is `unavailable: timeout`. Each timed-out attempt leaves a `review.session_timeout` ledger note with the last 4 KB of its stderr (redacted, never the packet) and only the byte count and last event types of its stdout.
|
|
21
23
|
|
|
22
24
|
## §5 The convergence rule — bounded fix rounds (R15)
|
|
23
25
|
|
|
@@ -26,14 +28,15 @@ Per file, per block:
|
|
|
26
28
|
1. Round 1 is the tiered review of §2 on the full packet.
|
|
27
29
|
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).
|
|
28
30
|
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.
|
|
29
|
-
|
|
31
|
+
3a. **Heavy rounds climb at once** (`review_warnings`, `decisions.md` §5 rule 3a): below L2, a round — round 1 included — that leaves ≥ `escalation.warning_threshold` (2) warnings or any `critical` open, `escalation.after_rounds_with_warnings` (1) times at the current level, climbs one level for the next fix. One round with a stronger model is faster and cheaper than three with a cheap one. The per-file cap (step 5) stays the outer limit.
|
|
32
|
+
4. **Ladder**: the running ceiling is `min(L2, escalation.stop_at)` — L3 is never a *running* level. A trigger — rule 1 (`retries`), rule 2 (`escalation.review_rounds_per_level` exhausted with an open `fix_now`), rule 2b (`review_stall`) or step 3a (`review_warnings`) — below the ceiling climbs one level, coded by a fresh session with the open findings and the fix history in its brief (an L1 block's rounds 3–4 run at L2). At the ceiling — an L2 block, or an L1 block that has climbed to L2 — it takes the **L3 rung**: a patch ≤ 80 lines, owned files only, reviewed as a `patch_check` round outside the cap. Under `escalation.l3_mode: code` (opt-in) the rung is instead one L3 coding turn. Either way the rung fires **once per block** and the same post-patch order follows it. After it, in order: `patch_check` clean ⇒ complete; open set not smaller than before the patch ⇒ `stopped: l3_patch_exhausted` (`review_stall`); round ≥ `review.max_rounds_per_file` ⇒ `stopped: review_cap`; otherwise the fixes continue at L2 with `rounds_at_level` reset and the patch in the brief. Any later trigger in that block stops with its named reason (`l3_patch_exhausted`, `review_cap`) and never patches again.
|
|
30
33
|
5. **Cap — four rounds per file** (`review.max_rounds_per_file`, default 4, range 2–6). Round 4 still open ⇒ the L3 rung if the block has not used it (same post-patch order as step 4), else `stopped: review_cap`. A stop means the block cannot close, the ledger lists the open findings, and the human decides — fix by hand, `forge block waive <id> <finding-id> --run <r> --file <path> --reason <text>` (a signed `review.waived` row, `by: human`), or re-decompose. The coder never sees a `waive` verb; it is on the forbidden list.
|
|
31
34
|
6. **Block-close sweep**: `forge block close` batches the `late_findings` per file to one L3 judge ruling (`fix_now | nit`); a `fix_now` opens a round on that file at the block's current level and counts toward its cap.
|
|
32
35
|
7. Ledger: one `review.round` row per round with `open_before`, `closed`, `new_in_hunks`, `late`, `open_after`; `forge report` prints rounds per file and rounds that stalled.
|
|
33
36
|
|
|
34
37
|
Rechecks are never degraded by the budget: a round-2+ packet is already the minimum.
|
|
35
38
|
|
|
36
|
-
## §6 Consensus mode (`review.multimodel` on)
|
|
39
|
+
## §6 Consensus mode (`review.multimodel` on, risk above `review.single_reviewer_max_risk`, not a docs/contract block unless `review.multimodel_for_docs`)
|
|
37
40
|
|
|
38
41
|
Two L2 reviewers, one per effective provider (`resolve(L2)` and `review.second_levels`), each a fresh blind session with the `full` lens; then a fresh L3 judge **from the third provider** reads the two reports and returns the findings plus `agreement: both|A-only|B-only`. The judge's `fix_now` list is final. Second provider unavailable at run start ⇒ `consensus unavailable: <reason>`; ask the human once per run whether to continue adaptive, stop when non-interactive. Rechecks run on the provider that raised the finding; §5 applies unchanged.
|
|
39
42
|
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
## §2 The forbidden list — one list, rendered per CLI at spawn, grepped from every transcript
|
|
8
8
|
|
|
9
|
-
`gh pr ready|merge|close` and base retargets · `git push --force*` · `git reset --hard` · `git checkout --` · `git restore` · `git clean` · `git stash` · `git branch -D` · `git rm` · `rm -rf` outside `.code-forge/` · any argv containing a `production.markers` token or a `production.names` name · any read or write of `~/.code-forge/runs/` · `forge worker`, `run start` and `block waive` from a coder (also via `npx`) · any write under `.code-forge/reviews/` or `.code-forge/queue/*.done`.
|
|
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 waive` from a coder (also via `npx`) · 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
|
|
|
@@ -24,6 +24,8 @@ Rendered into every coder spawn as the CLI's own deny rules (path rules widen a
|
|
|
24
24
|
|
|
25
25
|
Reviewer, recheck, judge, S2, author and facts sessions run in print mode with the provider's own isolation flags, cwd = an empty temp dir under the run's temp root, and the packet on stdin; `doctor` proves that no project document is visible (it asks the reviewer builder to echo the first line of the project's rules file and expects nothing) and that the coder builder cannot read a known file outside the workspace (`claude: path deny rules not honoured` when it can). The facts delegate gets read-only shell tools with every write verb denied.
|
|
26
26
|
|
|
27
|
+
Codex has no mode without a shell: `-s read-only` still lets it read any absolute path. So openai is refused for reviewer, judge, S2 and author. `validate` warns, `resolve` prints `closed_book: "refused"`, the spawner refuses the session (an openai fallback is skipped), and `doctor` FAILs the isolation row with `codex cannot run closed-book yet: it always has a shell; use anthropic or xai for reviewer, judge, S2 and plan author`. Coders and the facts delegate still run on Codex. The opt-in `review.allow_open_book_codex: true` (default off) lets Codex run those roles with its read-only argv; `validate` then warns `codex reviewer is not closed-book: it can read files on this machine (review.allow_open_book_codex)`, `resolve` prints `closed_book: "open-book (allowed)"`, and `doctor` runs the isolation probe and reports WARN.
|
|
28
|
+
|
|
27
29
|
## §5 What the package itself never does
|
|
28
30
|
|
|
29
31
|
No telemetry (`telemetry: off`, reserved). No `postinstall`. No secret in argv — a keychain write goes through stdin. Temp dirs live under one per-run root and are swept by the next `run start`; the reaper kills only processes this package registered whose start time still matches — never a foreign pid. Every child `git` runs with inherited `GIT_*` variables stripped, so a gate invoked from a hook still sees the right repository.
|
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.4.0"
|
|
14
14
|
set -eu
|
|
15
15
|
|
|
16
16
|
self="$0"
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# Block `<id>` — `<title>` (coder brief)
|
|
2
|
+
|
|
3
|
+
| field | value |
|
|
4
|
+
|---|---|
|
|
5
|
+
| level | `L<n>` (the lane recorded by `forge jev ask lane --block <id>`) |
|
|
6
|
+
| owned_files | `<paths…>` — edit ONLY these |
|
|
7
|
+
| acceptance | `<clause (test ids)>` · … |
|
|
8
|
+
| test_command | `<quoted glob or explicit file list>` |
|
|
9
|
+
|
|
10
|
+
## Reply contract
|
|
11
|
+
|
|
12
|
+
1. `ACK <sha8> lines=<n>` before any tool call.
|
|
13
|
+
2. Your facts diff: every contradiction between this brief and the code, in one message.
|
|
14
|
+
3. Your forecast: cases and lines.
|
|
15
|
+
|
|
16
|
+
## The file loop
|
|
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).
|
|
19
|
+
|
|
20
|
+
## Final report — FAILED unless it lists the ticket ids
|
|
21
|
+
|
|
22
|
+
End with `===BLOCK <id> COMPLETE===`, the test summary and `git diff --stat`, then one line per changed file:
|
|
23
|
+
|
|
24
|
+
`reviewed <path> ticket <ticket-id>`
|
|
25
|
+
|
|
26
|
+
**FAILED unless:** the report lists the `review-file` ticket id of every changed file. A report with no ticket ids, or one that misses a changed file, counts as FAILED whatever its sentinel says: the orchestrator treats it as `===BLOCK <id> FAILED: no review-file ticket ids===` and `forge block close <id> --run <r> --report <file>` refuses it (`coder report FAILED: no review-file ticket id for <files>`). "All findings addressed" is not a report.
|
|
27
|
+
|
|
28
|
+
Or `===BLOCK <id> FAILED: <reason>===`.
|
|
29
|
+
|
|
30
|
+
## Fix-round brief (round ≥ 2, or a respawn after a climb)
|
|
31
|
+
|
|
32
|
+
The open findings by id, the fix history, the files they sit in. Fix only those hunks, then run `forge review-file <path> --block <id> --run <r>` and `--wait` on **every file the round changed** — a fix the reviewer never saw is not a fix. The final report follows the same rule: one `reviewed <path> ticket <ticket-id>` line per file changed in the round, or the round counts as FAILED.
|
|
33
|
+
|
|
34
|
+
## Project rules
|
|
35
|
+
|
|
36
|
+
<!-- the project's rules digest (≤ 60 lines) and the forbidden list verbatim (`references/security.md` §2); what a brief never carries is in `references/code.md` §3 -->
|
package/skill/templates/plan.md
CHANGED
|
@@ -29,6 +29,8 @@ Chosen approach and why · rejected alternatives (one line each) · the project
|
|
|
29
29
|
|---|---|---|---|---|---|---|---|---|---|
|
|
30
30
|
| B1 | … | L1 | — | `src/x.mjs`, `test/x.test.mjs` | 12 | 1 200 | (1) … (`test/x.test.mjs`, F3) · (2) … | `node --test 'test/x/**/*.test.mjs'` | … |
|
|
31
31
|
|
|
32
|
+
`level` is the lane recorded for the block by `forge jev ask lane --block <id>` (or `--rules`), never a category; `forge plan check` refuses any other.
|
|
33
|
+
|
|
32
34
|
Split rule: `cases = 2 × clauses`, `lines = 26 × cases` (floor 1 200 greenfield, 500 amendment); above `budget.block_cases` or `budget.block_lines` the block is split here. A glob in `owned_files` never contains `[ ] ( ) ! + @`.
|
|
33
35
|
|
|
34
36
|
## §3 Caller map
|
package/src/cli/block.mjs
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* `block open` before every spawn in every engine; it prints the brief pointer (≤ 200 bytes).
|
|
4
4
|
*
|
|
5
5
|
* block open <id> --run <r> --level L<n> --owned <paths…> --acceptance <file>
|
|
6
|
-
* [--brief <file>] [--attempt <n>] [--base <sha>] [--lines <forecast>]
|
|
6
|
+
* [--brief <file>] [--attempt <n>] [--base <sha>] [--lines <forecast>] [--kind code|docs|contract]
|
|
7
7
|
* block attempt <id> --run <r>
|
|
8
8
|
* block rebase <id> --run <r> [--head <ref>]
|
|
9
9
|
* block claim <id> <path> --run <r>
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* block stop <id> --run <r> --reason <text>
|
|
12
12
|
* block waive <id> <finding-id> --run <r> --file <path> --reason <text> (human-only, §4.9)
|
|
13
13
|
*
|
|
14
|
-
* `block close [--transcript <file>] [--no-require-reviews]` also runs B12b's review rows
|
|
14
|
+
* `block close [--transcript <file>] [--report <file>] [--no-require-reviews]` also runs B12b's review rows
|
|
15
15
|
* (`review/gate-check.mjs`): a signed `review.approved` row for the current content hash of every
|
|
16
16
|
* changed owned file (deletions included) — `--no-require-reviews` is the human's way out, is
|
|
17
17
|
* forbidden to coders (`mergeForbidden`), and leaves a signed `gate.reviews_waived {by: human}`
|
|
@@ -27,6 +27,12 @@
|
|
|
27
27
|
* but does not load or validate refuses the close (a typo must never drop a high path), and a
|
|
28
28
|
* base that is missing or not a commit of the workspace refuses it too (`git-failed`).
|
|
29
29
|
*
|
|
30
|
+
* B36: `--report <file>` is the coder's final report. The check is enforced when `--report` is
|
|
31
|
+
* given (the orchestrator always passes it, `skill/references/code.md` §3): a changed owned file
|
|
32
|
+
* whose `review-file` ticket id the report does not name refuses the close with `coder report
|
|
33
|
+
* FAILED: no review-file ticket id for <files>` (skipped under `--no-require-reviews`, the human's
|
|
34
|
+
* way out). It runs after the review rows and reuses their file set (listed once).
|
|
35
|
+
*
|
|
30
36
|
* `--acceptance` is a YAML/JSON list of `{clause, tests: [test ids…]}`. `block close` runs B8's
|
|
31
37
|
* rows of the gate (worker pin, MACs, orphans); the gate rows owned by later blocks join it
|
|
32
38
|
* through `closeBlock`'s `extraChecks`.
|
|
@@ -49,14 +55,15 @@ import { validateConfig } from '../config/validate.mjs';
|
|
|
49
55
|
import { migrateConfig } from '../config/migrate.mjs';
|
|
50
56
|
import { exec } from '../util/exec.mjs';
|
|
51
57
|
import { gitChildEnv } from '../worker/ticket.mjs';
|
|
58
|
+
import { missingReviewTickets } from '../engines/sentinel.mjs';
|
|
52
59
|
|
|
53
60
|
const USAGE =
|
|
54
|
-
'usage: code-forge block open <id> --run <r> --level L<n> --owned <paths…> --acceptance <file> [--brief <file>] [--attempt <n>] [--base <sha>] [--lines <n>]\n' +
|
|
55
|
-
' code-forge block attempt|rebase
|
|
61
|
+
'usage: code-forge block open <id> --run <r> --level L<n> --owned <paths…> --acceptance <file> [--brief <file>] [--attempt <n>] [--base <sha>] [--lines <n>] [--kind code|docs|contract]\n' +
|
|
62
|
+
' code-forge block attempt|rebase <id> --run <r> · block close <id> --run <r> [--transcript <file>] [--report <file>] [--no-require-reviews] · block claim <id> <path> --run <r> · block stop <id> --run <r> --reason <text>\n' +
|
|
56
63
|
' code-forge block waive <id> <finding-id> --run <r> --file <path> --reason <text>\n';
|
|
57
64
|
|
|
58
65
|
const SUBCOMMANDS = ['open', 'attempt', 'rebase', 'claim', 'close', 'stop', 'waive'];
|
|
59
|
-
const FLAG_VALUES = ['run', 'level', 'acceptance', 'brief', 'attempt', 'base', 'lines', 'head', 'worker-pid', 'reason', 'file', 'transcript'];
|
|
66
|
+
const FLAG_VALUES = ['run', 'level', 'acceptance', 'brief', 'attempt', 'base', 'lines', 'head', 'worker-pid', 'reason', 'file', 'transcript', 'kind', 'report'];
|
|
60
67
|
|
|
61
68
|
/**
|
|
62
69
|
* @param {string[]} args
|
|
@@ -106,9 +113,12 @@ export async function runBlock(args, deps = {}) {
|
|
|
106
113
|
base: typeof flags.base === 'string' ? flags.base : undefined,
|
|
107
114
|
lines: intFlag(flags.lines, 'lines') ?? null,
|
|
108
115
|
brief,
|
|
116
|
+
...(typeof flags.kind === 'string' ? { kind: flags.kind } : {}),
|
|
117
|
+
cfg: await openConfig(record.workspace),
|
|
109
118
|
writeRow,
|
|
110
119
|
});
|
|
111
|
-
|
|
120
|
+
const raised = block.level !== flags.level ? ` (${block.kind} floor, asked ${flags.level})` : '';
|
|
121
|
+
out(`block ${id} open · ${block.level}${raised} · ${block.kind} · attempt ${block.attempt} · base ${block.base_sha.slice(0, 12)}\n`);
|
|
112
122
|
out(`${pointer ?? 'brief: none given (--brief <file>)'}\n`);
|
|
113
123
|
return 0;
|
|
114
124
|
}
|
|
@@ -141,6 +151,11 @@ export async function runBlock(args, deps = {}) {
|
|
|
141
151
|
if (!entry) throw new StateError('unknown_block', `block ${id} is not in run ${runId}`);
|
|
142
152
|
const rows = (await readAllRows(record.project)).filter((row) => row.run === runId);
|
|
143
153
|
const reviewsWaived = flags['no-require-reviews'] === true;
|
|
154
|
+
// the block's file set is listed once and shared by the review and report checks; a failure
|
|
155
|
+
// there is the usual `git-failed` refusal
|
|
156
|
+
/** @type {ReturnType<typeof blockFileSet> | undefined} */
|
|
157
|
+
let fileSetOnce;
|
|
158
|
+
const blockFiles = () => (fileSetOnce ??= blockFileSet({ repoRoot: record.workspace, base: entry.base_sha, owned: entry.owned_files, matchOwned: (f) => findOverlap(entry.owned_files, [f]) !== null }));
|
|
144
159
|
const reviews = reviewGateCheck(async () => {
|
|
145
160
|
const transcript = await findTranscript(record, id, typeof flags.transcript === 'string' ? flags.transcript : null);
|
|
146
161
|
if (transcript === null) {
|
|
@@ -151,7 +166,7 @@ export async function runBlock(args, deps = {}) {
|
|
|
151
166
|
if (reviewsWaived) await writeSigned(runId, writeRow, { event: 'gate.reviews_waived', block: id, by: 'human' });
|
|
152
167
|
// B20: the high paths come first — a base the workspace does not know refuses the close here
|
|
153
168
|
const highPaths = await highPathsAt(record.workspace, entry.base_sha);
|
|
154
|
-
const fileSet = await
|
|
169
|
+
const fileSet = await blockFiles();
|
|
155
170
|
return {
|
|
156
171
|
block: id,
|
|
157
172
|
runId,
|
|
@@ -164,7 +179,20 @@ export async function runBlock(args, deps = {}) {
|
|
|
164
179
|
highPaths,
|
|
165
180
|
};
|
|
166
181
|
});
|
|
167
|
-
|
|
182
|
+
// B36: the coder's report names a review-file ticket for every changed file, or it is FAILED
|
|
183
|
+
const report = async () => {
|
|
184
|
+
if (typeof flags.report !== 'string' || reviewsWaived) return { ok: true };
|
|
185
|
+
let text;
|
|
186
|
+
try {
|
|
187
|
+
text = await readFile(flags.report, 'utf8');
|
|
188
|
+
} catch {
|
|
189
|
+
return { ok: false, reason: 'coder report FAILED: --report cannot be read' };
|
|
190
|
+
}
|
|
191
|
+
const files = await blockFiles();
|
|
192
|
+
const missing = missingReviewTickets(text, files.map((f) => f.file));
|
|
193
|
+
return missing.length === 0 ? { ok: true } : { ok: false, reason: `coder report FAILED: no review-file ticket id for ${missing.join(', ')}` };
|
|
194
|
+
};
|
|
195
|
+
const result = await closeBlock({ runId, id, rows, writeRow, livePid: intFlag(flags['worker-pid'], 'worker-pid'), probe, extraChecks: [reviews, report] });
|
|
168
196
|
(result.ok ? out : err)(`block ${id} ${result.status}${result.event ? ` (${result.event})` : ''}${result.reason ? `: ${result.reason}` : ''}\n`);
|
|
169
197
|
return result.ok ? 0 : 1;
|
|
170
198
|
}
|
|
@@ -176,6 +204,21 @@ export async function runBlock(args, deps = {}) {
|
|
|
176
204
|
return 2;
|
|
177
205
|
}
|
|
178
206
|
|
|
207
|
+
/**
|
|
208
|
+
* The project config for `block open`'s coder floor (B34): the loaded `.code-forge.yml`, or
|
|
209
|
+
* undefined when there is none or it does not load — the floor then takes its default (L1), the
|
|
210
|
+
* safe side.
|
|
211
|
+
* @param {string} workspace @returns {Promise<Record<string, any> | undefined>}
|
|
212
|
+
*/
|
|
213
|
+
async function openConfig(workspace) {
|
|
214
|
+
try {
|
|
215
|
+
const loaded = await loadProjectConfig(workspace);
|
|
216
|
+
return loaded.ok && loaded.config ? loaded.config : undefined;
|
|
217
|
+
} catch {
|
|
218
|
+
return undefined;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
179
222
|
/**
|
|
180
223
|
* The block's coder transcript: `--transcript`, else the run record's `blocks.<id>.transcript`
|
|
181
224
|
* (workspace-relative), else `.code-forge/runs/<run>/<id>.log`; null when none exists.
|
package/src/cli/jev.mjs
CHANGED
|
@@ -5,6 +5,19 @@
|
|
|
5
5
|
* `src/decide/**` directly rather than shelling out to this verb).
|
|
6
6
|
*
|
|
7
7
|
* jev ask <id> --state <file.json> [--cwd <dir>] [--slug <slug>] [--key-ref <ref>]
|
|
8
|
+
* [--block <id>] [--plan <file>] [--rules]
|
|
9
|
+
*
|
|
10
|
+
* B36: `--block` records the block the answer is for (and `--plan` the plan file's base name) on
|
|
11
|
+
* the decision row — `plan check` refuses a block whose level is not the lane recorded this way.
|
|
12
|
+
* `--rules` answers `lane`, `risk` or `security_sensitive` with the deterministic fallback rules
|
|
13
|
+
* (`decide/fallback-rules.mjs`, for a run without Jev): the state file is then an object carrying
|
|
14
|
+
* all six fallback facts (`filesChanged`, `linesAdded` numbers; `touchesMigration`,
|
|
15
|
+
* `touchesPolicyOrMiddleware`, `pathFloorHit` booleans; `keywordsFound` a string list) — a missing
|
|
16
|
+
* or mistyped one is a usage error naming it, with nothing recorded. `system1.disable` still
|
|
17
|
+
* applies. No key is resolved, no request is sent, and the row says `source: 'rules'`.
|
|
18
|
+
* The ledger slug is `--slug`, else `project.slug`, else the directory-name slug `run` and
|
|
19
|
+
* `author` use (`slugFor`; B36 — it used to fall back to `code-forge`). An unknown flag is a usage
|
|
20
|
+
* error.
|
|
8
21
|
*
|
|
9
22
|
* Resolves the Jev key through B2's chain (`resolveKey`, earlier batch — the seam rule allows a
|
|
10
23
|
* Wave 1 module to import a block in an earlier batch statically), asks Jev, runs the answer
|
|
@@ -23,24 +36,74 @@ import path from 'node:path';
|
|
|
23
36
|
import { buildQuestionPayload, isKnownQuestion, isQuestionDisabled } from '../decide/questions.mjs';
|
|
24
37
|
import { askJev as realAskJev } from '../decide/jev-client.mjs';
|
|
25
38
|
import { decide } from '../decide/thresholds.mjs';
|
|
39
|
+
import { resolveDispatchQuestionsByRules } from '../decide/fallback-rules.mjs';
|
|
26
40
|
import { createDefaultKeyStore, resolveKey } from '../keys/store.mjs';
|
|
27
41
|
import { appendRow as realAppendRow } from '../ledger/write.mjs';
|
|
28
42
|
import { writeSafe } from '../util/redact.mjs';
|
|
43
|
+
import { slugFor } from './run.mjs';
|
|
44
|
+
|
|
45
|
+
const USAGE = 'usage: code-forge jev ask <question-id> --state <file> [--cwd <dir>] [--slug <slug>] [--key-ref <ref>] [--block <id>] [--plan <file>] [--rules]\n';
|
|
46
|
+
|
|
47
|
+
/** The block id shape the run record uses (`state/block.mjs`). */
|
|
48
|
+
const BLOCK_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,31}$/;
|
|
49
|
+
|
|
50
|
+
/** Flags that take a value, and the one boolean flag. */
|
|
51
|
+
const VALUE_FLAGS = Object.freeze(['--state', '--cwd', '--slug', '--key-ref', '--block', '--plan']);
|
|
52
|
+
const BOOLEAN_FLAGS = Object.freeze(['--rules']);
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Strict flag walk over everything after `ask <id>`: a value flag takes the next token (never one
|
|
56
|
+
* that starts with `--`, so a bare `--slug --cwd x` never reads `--cwd` as the slug), a boolean
|
|
57
|
+
* flag takes none, and an unknown token, a repeated flag or a missing value is a usage error.
|
|
58
|
+
* @param {string[]} rest
|
|
59
|
+
* @returns {{ok: true, values: Record<string, string>, booleans: Set<string>} | {ok: false}}
|
|
60
|
+
*/
|
|
61
|
+
function parseAskFlags(rest) {
|
|
62
|
+
/** @type {Record<string, string>} */
|
|
63
|
+
const values = {};
|
|
64
|
+
const booleans = new Set();
|
|
65
|
+
for (let i = 0; i < rest.length; i += 1) {
|
|
66
|
+
const arg = rest[i];
|
|
67
|
+
if (BOOLEAN_FLAGS.includes(arg)) {
|
|
68
|
+
if (booleans.has(arg)) return { ok: false };
|
|
69
|
+
booleans.add(arg);
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (!VALUE_FLAGS.includes(arg) || Object.hasOwn(values, arg)) return { ok: false };
|
|
73
|
+
const next = rest[i + 1];
|
|
74
|
+
if (next === undefined || next.startsWith('--')) return { ok: false };
|
|
75
|
+
values[arg] = next;
|
|
76
|
+
i += 1;
|
|
77
|
+
}
|
|
78
|
+
return { ok: true, values, booleans };
|
|
79
|
+
}
|
|
29
80
|
|
|
30
|
-
|
|
81
|
+
/** The six fallback facts `--rules` needs, each with its type (`decide/fallback-rules.mjs`). */
|
|
82
|
+
const RULE_FACTS = Object.freeze({
|
|
83
|
+
filesChanged: 'number',
|
|
84
|
+
linesAdded: 'number',
|
|
85
|
+
touchesMigration: 'boolean',
|
|
86
|
+
touchesPolicyOrMiddleware: 'boolean',
|
|
87
|
+
pathFloorHit: 'boolean',
|
|
88
|
+
keywordsFound: 'string[]',
|
|
89
|
+
});
|
|
31
90
|
|
|
32
91
|
/**
|
|
33
|
-
* @param {
|
|
34
|
-
* @returns {
|
|
35
|
-
*
|
|
36
|
-
* must never silently read `--cwd` as `--slug`'s value.
|
|
92
|
+
* @param {unknown} state
|
|
93
|
+
* @returns {string[]} the fallback facts that are missing or of the wrong type (all six when the
|
|
94
|
+
* state is not a plain object).
|
|
37
95
|
*/
|
|
38
|
-
function
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
96
|
+
function badRuleFacts(state) {
|
|
97
|
+
if (state === null || typeof state !== 'object' || Array.isArray(state)) return Object.keys(RULE_FACTS);
|
|
98
|
+
const facts = /** @type {Record<string, unknown>} */ (state);
|
|
99
|
+
return Object.entries(RULE_FACTS)
|
|
100
|
+
.filter(([name, type]) => {
|
|
101
|
+
const v = facts[name];
|
|
102
|
+
if (type === 'number') return typeof v !== 'number' || !Number.isFinite(v) || v < 0;
|
|
103
|
+
if (type === 'boolean') return typeof v !== 'boolean';
|
|
104
|
+
return !Array.isArray(v) || !v.every((k) => typeof k === 'string');
|
|
105
|
+
})
|
|
106
|
+
.map(([name]) => name);
|
|
44
107
|
}
|
|
45
108
|
|
|
46
109
|
/**
|
|
@@ -82,14 +145,31 @@ export async function runJev(args, deps = {}) {
|
|
|
82
145
|
return 2;
|
|
83
146
|
}
|
|
84
147
|
|
|
85
|
-
const
|
|
86
|
-
|
|
87
|
-
const slugFlag = readOptionalFlag(args, '--slug');
|
|
88
|
-
const keyRefFlag = readOptionalFlag(args, '--key-ref');
|
|
89
|
-
if (!stateFlag.ok || !cwdFlag.ok || !slugFlag.ok || !keyRefFlag.ok || !stateFlag.value) {
|
|
148
|
+
const parsed = parseAskFlags(args.slice(2));
|
|
149
|
+
if (!parsed.ok || parsed.values['--state'] === undefined) {
|
|
90
150
|
err(USAGE);
|
|
91
151
|
return 2;
|
|
92
152
|
}
|
|
153
|
+
const stateFlag = { value: parsed.values['--state'] };
|
|
154
|
+
const cwdFlag = { value: parsed.values['--cwd'] };
|
|
155
|
+
const slugFlag = { value: parsed.values['--slug'] };
|
|
156
|
+
const keyRefFlag = { value: parsed.values['--key-ref'] };
|
|
157
|
+
const blockFlag = { value: parsed.values['--block'] };
|
|
158
|
+
const planFlag = { value: parsed.values['--plan'] };
|
|
159
|
+
if (blockFlag.value !== undefined && !BLOCK_ID.test(blockFlag.value)) {
|
|
160
|
+
err('jev ask: --block must be a block id (letters, digits, . _ -; at most 32 characters)\n');
|
|
161
|
+
return 2;
|
|
162
|
+
}
|
|
163
|
+
const rules = parsed.booleans.has('--rules');
|
|
164
|
+
/** @type {Record<string, unknown>} */
|
|
165
|
+
const scope = {
|
|
166
|
+
...(blockFlag.value !== undefined ? { block: blockFlag.value } : {}),
|
|
167
|
+
...(planFlag.value !== undefined ? { plan: path.basename(planFlag.value) } : {}),
|
|
168
|
+
};
|
|
169
|
+
if (rules && !['lane', 'risk', 'security_sensitive'].includes(id)) {
|
|
170
|
+
err(`jev ask: no rule answers "${id}" (--rules covers lane, risk, security_sensitive)\n`);
|
|
171
|
+
return 2;
|
|
172
|
+
}
|
|
93
173
|
const cwd = cwdFlag.value ?? process.cwd();
|
|
94
174
|
|
|
95
175
|
let state;
|
|
@@ -110,11 +190,28 @@ export async function runJev(args, deps = {}) {
|
|
|
110
190
|
return 1;
|
|
111
191
|
}
|
|
112
192
|
|
|
193
|
+
// the same ledger as the rest of the tool: --slug, else project.slug, else the directory name
|
|
194
|
+
const slug = slugFlag.value ?? slugFor(cfg, cwd);
|
|
195
|
+
|
|
113
196
|
if (isQuestionDisabled(id, cfg)) {
|
|
114
197
|
err(`jev ask: question "${id}" is disabled by system1.disable\n`);
|
|
115
198
|
return 1;
|
|
116
199
|
}
|
|
117
200
|
|
|
201
|
+
if (rules) {
|
|
202
|
+
const bad = badRuleFacts(state);
|
|
203
|
+
if (bad.length > 0) {
|
|
204
|
+
err(`jev ask: --rules needs the fallback facts in the --state object; missing or wrong type: ${bad.join(', ')}\n`);
|
|
205
|
+
return 2;
|
|
206
|
+
}
|
|
207
|
+
const byRules = resolveDispatchQuestionsByRules(state);
|
|
208
|
+
const value = byRules[/** @type {'lane'|'risk'|'security_sensitive'} */ (id)].value;
|
|
209
|
+
const decisionId = makeId();
|
|
210
|
+
await write({ event: 'decision', decision_id: decisionId, question: id, answer: value, source: 'rules', ...scope }, { slug });
|
|
211
|
+
out(`${JSON.stringify({ decision_id: decisionId, question: id, answer: value, source: 'rules', ...scope }, null, 2)}\n`);
|
|
212
|
+
return 0;
|
|
213
|
+
}
|
|
214
|
+
|
|
118
215
|
const keyStore = store ?? (await createDefaultKeyStore(env));
|
|
119
216
|
const keyRef = keyRefFlag.value ?? cfg?.keys?.jev;
|
|
120
217
|
let resolved;
|
|
@@ -146,7 +243,6 @@ export async function runJev(args, deps = {}) {
|
|
|
146
243
|
return 1;
|
|
147
244
|
}
|
|
148
245
|
const decision = decide(id, answer, cfg);
|
|
149
|
-
const slug = slugFlag.value ?? cfg?.project?.slug ?? 'code-forge';
|
|
150
246
|
const decisionId = makeId();
|
|
151
247
|
|
|
152
248
|
await write(
|
|
@@ -159,6 +255,7 @@ export async function runJev(args, deps = {}) {
|
|
|
159
255
|
confidence: decision.confidence,
|
|
160
256
|
margin: decision.margin,
|
|
161
257
|
source: 'jev',
|
|
258
|
+
...scope,
|
|
162
259
|
tokens_in: result.usage?.input_tokens,
|
|
163
260
|
tokens_out: result.usage?.output_tokens,
|
|
164
261
|
},
|