@codedology/code-forge 0.7.5 → 0.7.7

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 CHANGED
@@ -6,6 +6,33 @@ waits on a second real consumer (plan §10.4, Q17).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.7.7] — 2026-10-11
10
+
11
+ ### Added
12
+
13
+ - Brief-only mode (B59, issue #13 item 2): run start --mode brief writes a signed run.mode {mode: brief, plan: none}
14
+ row; the mode is fixed for the run. In a brief run, block open without --level takes the lane from the rules
15
+ (fallbackLane) over the owned entries, --lines and proof.tiers.high.paths of the run config snapshot, and writes a
16
+ signed decision row {question: lane, source: rules, facts} before dispatch; an explicit --level wins. report prints
17
+ the mode of each run in its text output (not in --json or --export). --mode plan or no flag keeps the old behaviour.
18
+
19
+ ## [0.7.6] — 2026-10-11
20
+
21
+ ### Added
22
+
23
+ - review.external {required, name} (B58, issue #11): an owner-approved outside reviewer replaces the local per-file
24
+ review. Read only from the run-start config snapshot (run reload refuses to change it; run start refuses required:
25
+ true without a name) and signed as a review.external row; block close then skips the per-file review check (also
26
+ not_approved) and writes a signed gate.reviews_waived {by: config, reviewer} row just before block.close, only when
27
+ the block closes, with no TTY and under autopilot. Unowned, proof, no_changes, transcript, orphans and closeBlock
28
+ checks still refuse. block open and block claim refuse an owned entry that covers .code-forge.yml. Without the key
29
+ nothing changes.
30
+
31
+ ### Changed
32
+
33
+ - block open, block claim and block close now read the run's config snapshot; a snapshot that does not match its hash
34
+ refuses them with config-snapshot, with or without review.external (B58).
35
+
9
36
  ## [0.7.5] — 2026-10-08
10
37
 
11
38
  ### Fixed
package/README.md CHANGED
@@ -12,6 +12,21 @@ for cross-model, parallel, evidence-gated feature delivery — on Claude Code, C
12
12
 
13
13
  Next release:
14
14
 
15
+ - **Work from a brief, with no plan.** `run start --mode brief` marks the run as brief-only.
16
+ Then `block open` without `--level` picks the lane from the rules: the owned files,
17
+ `--lines` and `proof.tiers.high.paths`. A signed `decision` row records it with
18
+ `source: rules`. `--level` still wins, and `report` shows the mode of each run.
19
+
20
+ In 0.7.6:
21
+
22
+ - **An outside reviewer can replace the local review.** Set `review.external: {required: true,
23
+ name: <reviewer>}` in `.code-forge.yml`. `block close` then skips the per-file review check
24
+ and writes a signed `gate.reviews_waived` row with `by: config`. It needs no TTY and works
25
+ under autopilot. All other close checks still run. The key is fixed at `run start`, and a
26
+ coder can never own `.code-forge.yml`.
27
+
28
+ In 0.7.5:
29
+
15
30
  - **An owned directory owns the files inside it, everywhere.** A block that owns `src/feature`
16
31
  now owns `src/feature/a.swift` for `block open`, `block claim`, `block rebase`, the scope gate,
17
32
  proof and the review worker, not only for `block close`. `block open` refuses a second block
@@ -4,7 +4,7 @@
4
4
  "plans/code-forge-plan-v1.3.md §10.4",
5
5
  "plans/NIGHT-LOG.md"
6
6
  ],
7
- "block_count": 60,
7
+ "block_count": 62,
8
8
  "baseline_ids": ["B0", "B1", "B2", "B3", "B4", "B5", "B6", "B7", "B8"],
9
9
  "baseline_note": "Wave 0-1 blocks, landed before v1.3; depends_on may name them, but they have no entry under blocks.",
10
10
  "blocks": {
@@ -823,6 +823,33 @@
823
823
  "B56"
824
824
  ],
825
825
  "note": "root-added 2026-10-08; found while fixing GitHub issue #2 (B52); also amends src/state/{registry,block}.mjs, src/review/gate-check.mjs, src/cli/{block,proof,plan}.mjs, src/proof/{export,tiers}.mjs, src/worker/engine.mjs, src/session/plan-check.mjs, tests, docs/how-it-works.md, skill/references/code.md"
826
+ },
827
+ "B58": {
828
+ "title": "Outside reviewer (issue #11, #13 item 1): config key review.external {required, name}, read only from the run-start config snapshot (immutable, run reload refuses it), signed review.external row at run start; block close skips the per-file review check and writes a signed gate.reviews_waived {by: config, reviewer} row just before block.close, only when the block closes; unowned, proof, no_changes, transcript and closeBlock checks still refuse; block open and block claim refuse an owned entry that covers .code-forge.yml; run start refuses required: true without a name",
829
+ "wave": 13,
830
+ "level": "L2",
831
+ "status": "landed",
832
+ "owned": [
833
+ "test/review/external.test.mjs"
834
+ ],
835
+ "depends_on": [
836
+ "B57"
837
+ ],
838
+ "note": "root-added 2026-10-11; GitHub issue #13 adoption plan (plans/adoption-13/plan.md); also amends src/state/{config-snapshot,run,block}.mjs, src/cli/block.mjs, schema/code-forge.schema.json, docs/reference/config.md, docs/{how-it-works,concepts}.md, skill/references/{review,decisions,code}.md, test/config/schema-coverage.test.mjs"
839
+ },
840
+ "B59": {
841
+ "title": "Brief-only mode (issue #13 item 2): run start --mode brief writes a signed run.mode {mode: brief, plan: none} row and stores the mode (fixed for the run); in a brief run block open without --level takes the lane from fallbackLane over facts built from the owned entries, --lines and proof.tiers.high.paths of the run config snapshot (src/decide/lane-facts.mjs) and writes a signed decision row {question: lane, source: rules, facts} before dispatch; an explicit --level wins; outside brief mode nothing changes; report prints the run mode per run in its text output (not a 14th section); no task verb",
842
+ "wave": 13,
843
+ "level": "L1",
844
+ "status": "landed",
845
+ "owned": [
846
+ "src/decide/lane-facts.mjs",
847
+ "test/state/brief-mode.test.mjs"
848
+ ],
849
+ "depends_on": [
850
+ "B58"
851
+ ],
852
+ "note": "root-added 2026-10-11; GitHub issue #13 adoption plan (plans/adoption-13/plan.md); also amends src/state/{run,block,config-snapshot}.mjs, src/cli/{run,block,report}.mjs, src/ledger/report.mjs, src/review/engine.mjs (shared migration/policy path checks), docs/{how-it-works,concepts}.md, skill/SKILL.md, skill/references/plan.md"
826
853
  }
827
854
  }
828
855
  }
@@ -117,6 +117,9 @@ Every other key defaults as shown, or is entirely absent from a minimal file. `a
117
117
  | `review.provider_concurrency.openai` | integer | | `2` | min 1 |
118
118
  | `review.provider_concurrency.xai` | integer | | `2` | min 1 |
119
119
  | `review.parallel_tickets` | integer | | `3` | min 1; max 16; B42: how many review tickets the worker runs at once (oldest first); 1 = one at a time. Tickets for the same file never overlap. |
120
+ | `review.external` | object | | — | |
121
+ | `review.external.required` | boolean | | `false` | B58: true ⇒ block close skips the local per-file review checks (and the --report ticket check), with no terminal and under autopilot, and writes a signed gate.reviews_waived {by: config} row. Unowned changes, proof, no_changes and the transcript grep still refuse. No block may own .code-forge.yml. |
122
+ | `review.external.name` | string | | — | minLength 1; B58: the outside reviewer, written in the review.external and gate.reviews_waived rows. Required when required is true: run start refuses a missing or blank name. |
120
123
  | `engine` | enum: `auto`, `solo`, `harness`, `subprocess` | | `"auto"` | |
121
124
  | `harnesses` | array<string> | | — | |
122
125
  | `harness` | object | | — | |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@codedology/code-forge",
3
- "version": "0.7.5",
3
+ "version": "0.7.7",
4
4
  "description": "Plan/harden/code/review orchestration CLI and Agent Skill for cross-model, parallel, evidence-gated feature delivery.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -153,7 +153,16 @@
153
153
  "xai": { "type": "integer", "minimum": 1, "default": 2 }
154
154
  }
155
155
  },
156
- "parallel_tickets": { "type": "integer", "minimum": 1, "maximum": 16, "default": 3, "description": "B42: how many review tickets the worker runs at once (oldest first); 1 = one at a time. Tickets for the same file never overlap." }
156
+ "parallel_tickets": { "type": "integer", "minimum": 1, "maximum": 16, "default": 3, "description": "B42: how many review tickets the worker runs at once (oldest first); 1 = one at a time. Tickets for the same file never overlap." },
157
+ "external": {
158
+ "type": "object",
159
+ "additionalProperties": false,
160
+ "description": "B58: an outside reviewer reviews each block. Set it before run start; run reload refuses to add, remove or change it.",
161
+ "properties": {
162
+ "required": { "type": "boolean", "default": false, "description": "B58: true ⇒ block close skips the local per-file review checks (and the --report ticket check), with no terminal and under autopilot, and writes a signed gate.reviews_waived {by: config} row. Unowned changes, proof, no_changes and the transcript grep still refuse. No block may own .code-forge.yml." },
163
+ "name": { "type": "string", "minLength": 1, "pattern": "\\S", "description": "B58: the outside reviewer, written in the review.external and gate.reviews_waived rows. Required when required is true: run start refuses a missing or blank name." }
164
+ }
165
+ }
157
166
  }
158
167
  },
159
168
  "engine": { "type": "string", "enum": ["auto", "solo", "harness", "subprocess"], "default": "auto" },
package/skill/SKILL.md CHANGED
@@ -11,7 +11,7 @@ You are the **orchestrator**: the session that invoked this skill. You dispatch,
11
11
 
12
12
  1. `forge validate` — exit 0, or stop and show the refusal (it names the key, never the value).
13
13
  2. `forge doctor --quick` — config, links, key resolution. On the first run of a day run `forge doctor` in full and read its `engine` row and its `signer: same-user boundary only` row (`references/security.md`).
14
- 3. `forge run start` — prints `run <id> started · engine <e> · worker pid <n>`. The engine is `solo`, `harness` or `subprocess` (`engine: auto` picks; `subprocess` only by explicit config). No engine ⇒ the four-line stop text in `references/degraded.md` §1 is printed and you stop. Keep `<id>`: every later verb takes `--run <id>`.
14
+ 3. `forge run start` — prints `run <id> started · engine <e> · worker pid <n>`. The engine is `solo`, `harness` or `subprocess` (`engine: auto` picks; `subprocess` only by explicit config). No engine ⇒ the four-line stop text in `references/degraded.md` §1 is printed and you stop. Keep `<id>`: every later verb takes `--run <id>`. A small change with briefs and no plan starts with `forge run start --mode brief` instead (`references/plan.md` §6); the mode is fixed for the run.
15
15
  4. When the engine is not `solo`, print the banner of `references/degraded.md` §2 once.
16
16
 
17
17
  ## §1 Invocation and state
@@ -10,7 +10,7 @@ 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>] [--kind code|docs|contract] --lines <n>` records the base sha, the owned files (overlap with another active block ⇒ refused; an owned entry with no glob character — `*`, `?`, `{` — owns that path and every path below it, so `src/feature` owns `src/feature/a.swift` but not `src/featureX/a.swift`, and `src/feature` overlaps `src/feature/x.swift` or `src` in another block — unless the entry is a regular file in the workspace at `block open` (or `block claim`): then it owns only itself, recorded as `owned_kinds`, and every later check reads that answer; a glob owns what it matches, and a brace entry `src/{a,b}` is a glob, so `src/a` owns nothing below it; no trailing `/`), 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:
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; an owned entry with no glob character — `*`, `?`, `{` — owns that path and every path below it, so `src/feature` owns `src/feature/a.swift` but not `src/featureX/a.swift`, and `src/feature` overlaps `src/feature/x.swift` or `src` in another block — unless the entry is a regular file in the workspace at `block open` (or `block claim`): then it owns only itself, recorded as `owned_kinds`, and every later check reads that answer; a glob owns what it matches, and a brace entry `src/{a,b}` is a glob, so `src/a` owns nothing below it; no trailing `/`), 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. `--level` may be left out only in a brief-mode run (`plan.md` §6: the rules pick the lane from the owned entries, `--lines` and `proof.tiers.high.paths`, and a signed `decision` row with `source: rules` records it) or when autopilot recorded a level; otherwise its absence is a usage error. 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.
@@ -41,7 +41,7 @@ The coder's report is a claim sheet. The gate reproduces each number in the meas
41
41
  | rows genuine | the MAC of every gate-relevant row verifies; the live worker matches the pinned pid |
42
42
  | nothing left open | no unruled `late_findings` (a ruling clears only a warning or nit ruled `nit`; a critical late finding needs the human's waiver); no file at `review_cap` without a signed `review.waived` row |
43
43
 
44
- `--no-require-reviews` is the human's way out, written as its own audit row. The human runs it themselves at a terminal: it asks for a yes, has no `--yes`, exits 2 unless stdin and stdout are both terminals, lifts `no_changes` and is refused while an autopilot grant is active — so never run it for them; tell them the command when they say so in chat. A finding counts only under the gate's own configuration; a tautology (an expected value computed by the code under test) is a finding. Reproduce disclosures: a coder that reports its own rule break did the hard part.
44
+ `--no-require-reviews` is the human's way out, written as its own audit row. The human runs it themselves at a terminal: it asks for a yes, has no `--yes`, exits 2 unless stdin and stdout are both terminals, lifts `no_changes` and is refused while an autopilot grant is active — so never run it for them; tell them the command when they say so in chat. The owner's other way out is `review.external` in the config before `run start` (`review.md` §1): the gate then skips the review rows and the report's ticket ids and writes `gate.reviews_waived {by: config}`; every other check, `no_changes` included, still applies. A finding counts only under the gate's own configuration; a tautology (an expected value computed by the code under test) is a finding. Reproduce disclosures: a coder that reports its own rule break did the hard part.
45
45
 
46
46
  ## §6 Budget — the stop is the feature (R6)
47
47
 
@@ -50,6 +50,6 @@ A fresh L3 session receives the failure evidence — the last attempt's diff, th
50
50
 
51
51
  ## §7 What reaches the human
52
52
 
53
- `next = stop`, `review.over_budget`, an orphan file or `unowned_change`, a `not_approved` critical finding, `--no-require-reviews`, `worker_down`, `review_cap`, `redecompose`, `ask_human`, a security question, consensus unavailable, and one budget raise per run. Everything else is decided by S1, S2 or the rules above and recorded. A change to a rule, limit or check is never decided here: it always reaches the owner (`rules-core.md` R17).
53
+ `next = stop`, `review.over_budget`, an orphan file or `unowned_change`, a `not_approved` critical finding, `--no-require-reviews`, `worker_down`, `review_cap`, `redecompose`, `ask_human`, a security question, consensus unavailable, and one budget raise per run. The owner has two ways out of the per-file review gate: `--no-require-reviews` at a terminal, or `review.external.required: true` with the outside reviewer's `name`, set in `.code-forge.yml` before `run start` (`review.md` §1). You never choose either one. Everything else is decided by S1, S2 or the rules above and recorded. A change to a rule, limit or check is never decided here: it always reaches the owner (`rules-core.md` R17).
54
54
 
55
55
  **Autopilot:** while a grant is active, a warning or nit waiver, one extra round at `review_cap` and a coder level go first to the delegate (`forge autopilot ask`, then `forge autopilot waive|round|level --decision <id>` only when it acted, `autopilot.md` §2). Never delegated: `next = stop`, `ask_human`, a security question, a critical finding, a budget raise, `redecompose` — they wait for the owner (`autopilot.md` §5).
@@ -4,7 +4,7 @@
4
4
 
5
5
  Every row has the run, the block, the event and a timestamp; the ones the block gate relies on carry `mac` (`security.md` §3). Useful fields: `level`, `provider`, `model`, `effort`, `fallback_step` (every spawn), `role ∈ {coder, reviewer, judge, s2, author, facts}`, `trigger` (escalations), `source ∈ {jev, rules, s2, s2-fallback, shadow}` (decisions), `depth_unconstrained` / `depth_chosen` / `degrade_step` and `context_mode ∈ {whole, hunks, minimal, recheck}` (reviews), `round` and `kind ∈ {full, recheck, patch_check, sweep}` (rounds), `isolation ∈ {export, lock}` (proof), `l3_patch`, `l3_fallback`, `overspend`. **Tokens are facts, dollars are estimates**: `tokens_source: reported` when the CLI's JSON carries usage, else `estimated`. File paths in rows are relative to the repository root.
6
6
 
7
- Events you will read most: `run.start`, `run.stop {reason}`, `run.end`, `dispatch`, `gate.red`, `review.plan`, `review.round`, `review.done`, `review.approved`, `review.unavailable {reason}`, `review.late_finding`, `review.waived {by: human}`, `review.budget`, `forecast.cases_exceeded`, `proof`, `outcome`, `block.rebase`, `block.orphan`, `ledger.tamper`, `worker.replaced`, `facts.built`, `tmp.swept`.
7
+ Events you will read most: `run.start`, `run.mode {mode: brief, plan: none}` (a brief-mode run, `plan.md` §6), `review.external {required, reviewer}` (the owner's outside reviewer, `review.md`), `run.stop {reason}`, `run.end`, `dispatch`, `gate.red`, `review.plan`, `review.round`, `review.done`, `review.approved`, `review.unavailable {reason}`, `review.late_finding`, `review.waived {by: human}`, `gate.reviews_waived {by: human | config}` (`by: config` from `review.external`), `decision {question: lane, source: rules}` (a brief-mode `block open` without `--level`), `review.budget`, `forecast.cases_exceeded`, `proof`, `outcome`, `block.rebase`, `block.orphan`, `ledger.tamper`, `worker.replaced`, `facts.built`, `tmp.swept`.
8
8
 
9
9
  ## §2 `forge report --slug <slug> [--json] [--export <dir>]`
10
10
 
@@ -52,3 +52,15 @@ Never treat a chat summary of the plan as the approval step, and never start `fo
52
52
  ## §5 Cost
53
53
 
54
54
  One L0 session per plan for the facts (typically a claim list of 40–80 items) and one L3 session per author round; `forge report --slug <slug>` shows both as `facts $` and coder/review splits.
55
+
56
+ ## §6 Brief mode — briefs, no plan
57
+
58
+ For a small change whose blocks are clear from their briefs (a fix, a doc pass, one or two blocks with no ordering to argue about), skip §0–§4: `forge run start --mode brief`. The run record stores `mode: brief` and the ledger gets one signed `run.mode {mode: brief, plan: none}` row right after `run.start`; the mode is fixed for the run (`run reload` never changes it) and `forge report` lists each run as `brief` or `plan`. Plan mode (`--mode plan`, or no flag) is the default and is unchanged. There is no separate verb for a task: each block is still `forge block open` with its brief, owned files and acceptance clauses, and is closed by the same gate.
59
+
60
+ In a brief-mode run `forge block open <id> --run <r> --owned <paths…> --acceptance <file> --brief <file> --lines <n>` may leave out `--level`: the lane then comes from the deterministic rules (the `--rules` fallback of `jev ask lane`), over these facts only —
61
+
62
+ - an owned entry under `proof.tiers.high.paths` of the run's config snapshot (or a directory entry above one), a migration path, or a policy/middleware path ⇒ `L2`;
63
+ - else more than 4 owned entries or more than 200 estimated changed lines (`--lines`) ⇒ `L1`;
64
+ - else `L0` (a docs or contract block is still raised to `levels.coder_floor_docs`).
65
+
66
+ `block open` writes one signed `decision {question: lane, answer: <lane>, source: rules}` row naming the block, then the `dispatch` row, and prints `(rules lane)` after the level. An explicit `--level` still wins and writes no decision row. Give `--lines` whenever you can: without it the size rule sees only the number of owned entries. Outside brief mode a missing `--level` with no autopilot level stays a usage error, and `--lines` stays the forecast it always was.
@@ -4,6 +4,8 @@
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
+ **The owner's two ways out of the per-file review.** (1) `forge block close <id> --no-require-reviews`, typed by the owner at a terminal (`code.md` §5). (2) `review.external: {required: true, name: <reviewer>}` in `.code-forge.yml`, set before `run start`, when an outside reviewer reviews each block (B58). With `required: true`, `run start` refuses a missing or blank `name`, and writes a signed `review.external` row with the reviewer's name. `run reload` refuses to add, remove or change the key. With the key, `block close` does not check the review rows (a `not_approved` file included) or the report's ticket ids, needs no terminal and works under an autopilot grant. When the block closes, it writes a signed `gate.reviews_waived {by: config, reviewer}` row just before `block.close`; a refused close writes no such row. Unowned changes, `unproven`, `no_changes`, the transcript grep and the registry's orphans still refuse. No block may own `.code-forge.yml` while the key is on: `block open` and `block claim` refuse it. `block open`, `block claim` and `block close` read the run's config snapshot to find the key, so a snapshot that does not match its hash refuses them with `config-snapshot`, with or without the key. Never set or remove the key yourself: it is the owner's decision.
8
+
7
9
  ## §2 Depth by risk (adaptive mode)
8
10
 
9
11
  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.
@@ -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.7.5"
13
+ PINNED_VERSION="0.7.7"
14
14
  set -eu
15
15
 
16
16
  self="$0"
package/src/cli/block.mjs CHANGED
@@ -50,11 +50,28 @@
50
50
  * FAILED: no review-file ticket id for <files>` (skipped under `--no-require-reviews`, the human's
51
51
  * way out). It runs after the review rows and reuses their file set (listed once).
52
52
  *
53
+ * B58 (issue #11): `review.external.required: true` in the run's config snapshot (the owner set it
54
+ * before `run start`; `run reload` refuses to change it) is the owner's non-interactive way out
55
+ * when an outside reviewer reviews the block: the per-file review checks and the `--report` ticket
56
+ * check are skipped, with no TTY and under an active autopilot grant, and a signed
57
+ * `gate.reviews_waived {by: config, reviewer: <name>}` row is written just before `block.close`,
58
+ * only when the block closes. Everything
59
+ * else still refuses — `unowned_change`, `unproven`, `no_changes`, the transcript grep and
60
+ * `closeBlock`'s own rows. With `--no-require-reviews` the human path runs exactly as without the key.
61
+ *
53
62
  * B47: `block open` without `--level` takes the level `autopilot level` chose for the block (the
54
63
  * latest `autopilot.level` row of the run for it whose MAC verifies, `autopilotLevel`); with
55
64
  * neither it is a usage error (`no --level given and no autopilot level recorded for <id>`). An
56
65
  * explicit `--level` always wins; a repeated or valueless `--level` is a usage error as before.
57
66
  *
67
+ * B59: in a brief-mode run (`run start --mode brief`), `block open` without `--level` takes the
68
+ * lane from the §3.5 rules (`fallbackLane`) over facts built from the owned entries, `--lines`
69
+ * (estimated changed lines) and `proof.tiers.high.paths` of the run's config snapshot
70
+ * (`laneFacts`), and writes one signed `decision {question: lane, source: rules}` row naming the
71
+ * block, then the `dispatch` row (`openBlock`; neither when the open is refused). An explicit
72
+ * `--level` still wins, with no decision row. Outside brief mode `--lines` stays the forecast it
73
+ * always was, and no `--level` without an autopilot level is the usage error above.
74
+ *
58
75
  * `--acceptance` is a YAML/JSON list of `{clause, tests: [test ids…]}`. `block close` runs B8's
59
76
  * rows of the gate (worker pin, MACs, orphans); the gate rows owned by later blocks join it
60
77
  * through `closeBlock`'s `extraChecks`.
@@ -64,9 +81,12 @@ import { readFile, realpath } from 'node:fs/promises';
64
81
  import path from 'node:path';
65
82
  import { parse as parseYAML } from 'yaml';
66
83
  import { approvalCheckText, expiryChecks } from '../autopilot/limits.mjs';
84
+ import { fallbackLane } from '../decide/fallback-rules.mjs';
85
+ import { laneFacts } from '../decide/lane-facts.mjs';
67
86
  import { appendRow, readAllRows } from '../ledger/write.mjs';
68
87
  import { attemptBlock, claimPath, closeBlock, openBlock, rebaseBlock, repoTop, stopBlock } from '../state/block.mjs';
69
88
  import { intFlag, parseFlags } from '../state/cli-args.mjs';
89
+ import { configInForce, externalReviewer } from '../state/config-snapshot.mjs';
70
90
  import { StateError } from '../state/paths.mjs';
71
91
  import { readRun, writeSigned } from '../state/run.mjs';
72
92
  import { loadKey, verifyRow } from '../state/signer.mjs';
@@ -131,8 +151,25 @@ export async function runBlock(args, deps = {}) {
131
151
  }
132
152
 
133
153
  if (sub === 'open') {
134
- // B47: no --level ⇒ the level autopilot chose for the block (a signed autopilot.level row)
135
- const level = typeof flags.level === 'string' ? flags.level : await autopilotLevel(runId, id, record);
154
+ // an explicit --level wins; B59: else in a brief-mode run the rules lane; B47: else the level
155
+ // autopilot chose for the block (a signed autopilot.level row)
156
+ /** @type {{lane: string, facts: ReturnType<typeof laneFacts>} | null} */
157
+ let rules = null;
158
+ /** @type {string | null} */
159
+ let level;
160
+ // B59: only the rules lane parses --lines this early; every other open parses it where it
161
+ // always was (after the level check and --attempt), so its errors keep their pre-B59 order
162
+ /** @type {number | null | undefined} */
163
+ let lines;
164
+ if (typeof flags.level === 'string') {
165
+ level = flags.level;
166
+ } else if (record.mode === 'brief') {
167
+ lines = intFlag(flags.lines, 'lines') ?? null;
168
+ rules = rulesLane(record, /** @type {string[]} */ (flags.owned), lines);
169
+ level = rules.lane;
170
+ } else {
171
+ level = await autopilotLevel(runId, id, record);
172
+ }
136
173
  if (level === null) throw new StateError('usage', `no --level given and no autopilot level recorded for ${id}`);
137
174
  const acceptance = parseYAML(await readFile(/** @type {string} */ (flags.acceptance), 'utf8'), { prettyErrors: false });
138
175
  let brief;
@@ -150,14 +187,15 @@ export async function runBlock(args, deps = {}) {
150
187
  acceptance,
151
188
  attempt: intFlag(flags.attempt, 'attempt'),
152
189
  base: typeof flags.base === 'string' ? flags.base : undefined,
153
- lines: intFlag(flags.lines, 'lines') ?? null,
190
+ lines: lines === undefined ? (intFlag(flags.lines, 'lines') ?? null) : lines,
154
191
  brief,
192
+ ...(rules ? { decision: { question: 'lane', answer: rules.lane, source: 'rules', facts: rules.facts } } : {}),
155
193
  ...(typeof flags.kind === 'string' ? { kind: flags.kind } : {}),
156
194
  cfg: await openConfig(record.workspace),
157
195
  writeRow,
158
196
  });
159
197
  const raised = block.level !== level ? ` (${block.kind} floor, asked ${level})` : '';
160
- const chosen = typeof flags.level === 'string' ? '' : ' (autopilot level)';
198
+ const chosen = typeof flags.level === 'string' ? '' : rules ? ' (rules lane)' : ' (autopilot level)';
161
199
  out(`block ${id} open · ${block.level}${raised}${chosen} · ${block.kind} · attempt ${block.attempt} · base ${block.base_sha.slice(0, 12)}\n`);
162
200
  out(`${pointer ?? 'brief: none given (--brief <file>)'}\n`);
163
201
  return 0;
@@ -201,6 +239,9 @@ export async function runBlock(args, deps = {}) {
201
239
  return 1;
202
240
  }
203
241
  }
242
+ // B58: the owner's other way out — `review.external.required` in the run's config snapshot
243
+ // (set before `run start`, immutable): no TTY, allowed under autopilot, the human flag wins
244
+ const external = reviewsWaived ? null : externalReviewer(record);
204
245
  const rows = (await readAllRows(record.project)).filter((row) => row.run === runId);
205
246
  // B52: ONE path base and ONE listing, memoised as a promise and shared by every check. The
206
247
  // base is the repository top level: the worker signs every review row with a repo-root-
@@ -265,8 +306,9 @@ export async function runBlock(args, deps = {}) {
265
306
  unowned: scope?.unowned ?? [],
266
307
  // B52: an old ledger's review.result rows are judged by their signed result files
267
308
  resultFiles: await resultFilesFor({ repoRoot: top, runId, block: id, rows }),
268
- files: reviewsWaived ? [] : fileSet,
269
- // B52: nothing changed and nothing reviewed is never a pass (the owner's way out is --no-require-reviews)
309
+ files: reviewsWaived || external !== null ? [] : fileSet,
310
+ // B52: nothing changed and nothing reviewed is never a pass (the owner's way out is
311
+ // --no-require-reviews); B58: `review.external` does not lift it
270
312
  noChanges: !reviewsWaived && fileSet.length === 0,
271
313
  // B20: high-tier files need a proven red→green row; `--no-require-reviews` does not lift it
272
314
  proofFiles: fileSet,
@@ -275,7 +317,7 @@ export async function runBlock(args, deps = {}) {
275
317
  });
276
318
  // B36: the coder's report names a review-file ticket for every changed file, or it is FAILED
277
319
  const report = async () => {
278
- if (typeof flags.report !== 'string' || reviewsWaived) return { ok: true };
320
+ if (typeof flags.report !== 'string' || reviewsWaived || external !== null) return { ok: true };
279
321
  let text;
280
322
  try {
281
323
  text = await readFile(flags.report, 'utf8');
@@ -286,7 +328,10 @@ export async function runBlock(args, deps = {}) {
286
328
  const missing = missingReviewTickets(text, fileSet.map((f) => f.file));
287
329
  return missing.length === 0 ? { ok: true } : { ok: false, reason: `coder report FAILED: no review-file ticket id for ${missing.join(', ')}` };
288
330
  };
289
- const result = await closeBlock({ runId, id, rows, writeRow, livePid: intFlag(flags['worker-pid'], 'worker-pid'), probe, extraChecks: [reviews, report] });
331
+ // B58: the config waiver row is written by `closeBlock` under the run lock, just before
332
+ // `block.close` and only when every other check passed — never by a refused close
333
+ const closeRows = external === null ? [] : [{ event: 'gate.reviews_waived', block: id, by: 'config', reviewer: external }];
334
+ const result = await closeBlock({ runId, id, rows, writeRow, livePid: intFlag(flags['worker-pid'], 'worker-pid'), probe, extraChecks: [reviews, report], closeRows });
290
335
  (result.ok ? out : err)(`block ${id} ${result.status}${result.event ? ` (${result.event})` : ''}${result.reason ? `: ${result.reason}` : ''}\n`);
291
336
  return result.ok ? 0 : 1;
292
337
  }
@@ -327,6 +372,19 @@ async function confirmNoReviews(runId, id, deps) {
327
372
  return ui.isCancel(go) || go !== true ? 'cancelled' : 'confirmed';
328
373
  }
329
374
 
375
+ /**
376
+ * B59: the rules lane for a brief-mode block — `fallbackLane` over the facts of its owned entries,
377
+ * the `--lines` estimate and the snapshot's `proof.tiers.high.paths` (none for a record from
378
+ * before snapshots).
379
+ * @param {Record<string, any>} record @param {string[]} owned @param {number | null} lines
380
+ * @returns {{lane: string, facts: ReturnType<typeof laneFacts>}}
381
+ */
382
+ function rulesLane(record, owned, lines) {
383
+ const high = configInForce(record)?.proof?.tiers?.high?.paths;
384
+ const facts = laneFacts({ owned, lines, highPaths: high });
385
+ return { lane: fallbackLane(facts), facts };
386
+ }
387
+
330
388
  /**
331
389
  * B47: the level `autopilot level` chose for the block — the latest `autopilot.level` row of this
332
390
  * run for it whose MAC verifies — or null when there is none.
@@ -1,10 +1,12 @@
1
1
  /**
2
2
  * `code-forge report [--slug <slug>] [--json] [--export <dir>]` (plan §6.2, R4). Terminal output
3
- * only, plus an optional `--export <dir>` that writes the same 13 sections as JSON files.
3
+ * only, plus an optional `--export <dir>` that writes the same 13 sections as JSON files. The
4
+ * terminal output ends with the spend per run (B33) and the mode of each run (B59: brief or plan);
5
+ * both are text only, never in `--json` or `--export`, so the 13 pinned sections stay as they are.
4
6
  */
5
7
 
6
8
  import { writeSafe } from '../util/redact.mjs';
7
- import { buildReport, buildSpend } from '../ledger/report.mjs';
9
+ import { buildReport, buildRunModes, buildSpend } from '../ledger/report.mjs';
8
10
  import { exportReportSections } from '../ledger/export.mjs';
9
11
  import { readAllRows } from '../ledger/write.mjs';
10
12
 
@@ -61,6 +63,7 @@ export async function runReport(args, deps = {}) {
61
63
  out(`== ${name} ==\n${JSON.stringify(data, null, 2)}\n`);
62
64
  }
63
65
  out(spendLines(buildSpend(rows)));
66
+ out(modeLines(buildRunModes(rows)));
64
67
  return 0;
65
68
  } catch (err_) {
66
69
  err(`report: ${err_?.message ?? String(err_)}\n`);
@@ -81,6 +84,17 @@ export function spendLines({ runs, total }) {
81
84
  return `${lines.join('\n')}\n`;
82
85
  }
83
86
 
87
+ /**
88
+ * B59: the mode of each run (`brief` or `plan`), after the spend — text only, never in `--json`
89
+ * or `--export`. Empty (no header) when the ledger has no run.
90
+ * @param {ReturnType<typeof buildRunModes>} modes
91
+ * @returns {string}
92
+ */
93
+ export function modeLines(modes) {
94
+ if (modes.length === 0) return '';
95
+ return `${['run mode:', ...modes.map((m) => ` run ${m.run}: ${m.mode}`)].join('\n')}\n`;
96
+ }
97
+
84
98
  /** @param {string[]} args @returns {Promise<number>} */
85
99
  export default async function report(args) {
86
100
  return runReport(args);
package/src/cli/run.mjs CHANGED
@@ -2,7 +2,7 @@
2
2
  * `code-forge run start|status|end` (plan §4.9). The orchestrator runs these; a coder never does
3
3
  * (`run start` is on the forbidden list, ★ `code-forge-worker-from-coder`).
4
4
  *
5
- * run start [--cwd <dir>] [--run <id>] [--engine <e>] [--worker-pid <pid>]
5
+ * run start [--cwd <dir>] [--run <id>] [--engine <e>] [--worker-pid <pid>] [--mode plan|brief]
6
6
  * run start --reattach --run <id> [--worker-pid <pid>]
7
7
  * run status --run <id> (B33: includes spent_usd / budget_usd / unknown_usd_sessions)
8
8
  * run reload --run <id>
@@ -14,6 +14,12 @@
14
14
  * queue announces (`repinWorker`). Called as `runRun(args, deps)` without `deps.startWorker` (the
15
15
  * state tests), no worker is launched and none is pinned unless `--worker-pid` is given.
16
16
  *
17
+ * B59 `--mode brief`: a run with briefs and no plan. The mode is stored in the run record and on a
18
+ * signed `run.mode {mode: brief, plan: none}` row, and it is fixed for the run (`run reload` never
19
+ * touches it); `block open` without `--level` then takes the lane from the rules. `--mode plan`
20
+ * (the default) changes nothing; any other value, or `--mode` on any other verb or with
21
+ * `--reattach`, is a usage error (exit 2).
22
+ *
17
23
  * B35 `run reload`: re-read and validate the workspace's `.code-forge.yml` and replace the run's
18
24
  * config snapshot (`reloadRun`) — no `block stop` / `run end` / `run start` cycle. An invalid
19
25
  * config or a change to a key that is fixed for the run is refused with nothing changed. It prints
@@ -29,7 +35,7 @@ import { budgetUsdOf, runSpend } from '../ledger/spend.mjs';
29
35
  import { appendRow, readAllRows } from '../ledger/write.mjs';
30
36
  import { intFlag, parseFlags } from '../state/cli-args.mjs';
31
37
  import { StateError } from '../state/paths.mjs';
32
- import { endRun, processStartTime, readRun, reattachWorker, startRun, stopPinnedWorker } from '../state/run.mjs';
38
+ import { endRun, processStartTime, readRun, reattachWorker, RUN_MODES, startRun, stopPinnedWorker } from '../state/run.mjs';
33
39
  import { writeSafe } from '../util/redact.mjs';
34
40
  import { sweepRoots, tmpBase } from '../util/tmp.mjs';
35
41
  import { launchWorker, repinWorker } from '../worker/loop.mjs';
@@ -101,9 +107,12 @@ export async function runRun(args, deps = {}) {
101
107
  return 2;
102
108
  }
103
109
  try {
104
- const { flags } = parseFlags(rest, { values: ['run', 'cwd', 'engine', 'worker-pid'], booleans: ['reattach'] });
110
+ const { flags } = parseFlags(rest, { values: ['run', 'cwd', 'engine', 'worker-pid', 'mode'], booleans: ['reattach'] });
105
111
  const runId = typeof flags.run === 'string' ? flags.run : undefined;
106
112
  const workerPid = intFlag(flags['worker-pid'], 'worker-pid');
113
+ const mode = typeof flags.mode === 'string' ? flags.mode : undefined;
114
+ if (mode !== undefined && (sub !== 'start' || flags.reattach)) throw new StateError('usage', '--mode is set once, by a new run start (the mode is fixed for the run)');
115
+ if (mode !== undefined && !RUN_MODES.includes(mode)) throw new StateError('usage', `--mode must be one of ${RUN_MODES.join(', ')}`);
107
116
 
108
117
  if (sub === 'start' && flags.reattach) {
109
118
  if (!runId) throw new StateError('usage', 'run start --reattach needs --run');
@@ -129,7 +138,7 @@ export async function runRun(args, deps = {}) {
129
138
  const engine = typeof flags.engine === 'string' ? flags.engine : undefined;
130
139
  const tmpRoot = typeof loaded.config?.tmp?.root === 'string' ? loaded.config.tmp.root : undefined;
131
140
  await sweepRoots({ root: tmpBase(tmpRoot) }); // dead runs' temp roots and orphans (§9.6, V12)
132
- let record = await startRun({ workspace, project, config: loaded.config, engine, runId, workerPid, writeRow: ledgerWriter(project), probe });
141
+ let record = await startRun({ workspace, project, config: loaded.config, engine, mode, runId, workerPid, writeRow: ledgerWriter(project), probe });
133
142
  if (!record.worker && startWorker) {
134
143
  const launched = await startWorker({ runId: record.run_id, workspace });
135
144
  const startedAt = await (probe ?? processStartTime)(launched.pid); // what the stop below re-checks against
@@ -141,7 +150,8 @@ export async function runRun(args, deps = {}) {
141
150
  throw thrown;
142
151
  }
143
152
  }
144
- out(`run ${record.run_id} started · engine ${record.engine} · worker ${record.worker ? `pid ${record.worker.pid}` : 'not pinned'}\n`);
153
+ const brief = record.mode === 'brief' ? ' · mode brief (no plan)' : '';
154
+ out(`run ${record.run_id} started · engine ${record.engine}${brief} · worker ${record.worker ? `pid ${record.worker.pid}` : 'not pinned'}\n`);
145
155
  if (!record.worker) err('run start: no worker pinned — start the worker, then `run start --reattach --run <id> --worker-pid <pid>`\n');
146
156
  return 0;
147
157
  }
@@ -211,7 +221,7 @@ async function reloadVerb(runId, { out, err }) {
211
221
  }
212
222
 
213
223
  const USAGE =
214
- 'usage: code-forge run start [--cwd <dir>] [--run <id>] [--engine <e>] [--worker-pid <pid>] | start --reattach --run <id> [--worker-pid <pid>] | status --run <id> | reload --run <id> | end --run <id>\n';
224
+ 'usage: code-forge run start [--cwd <dir>] [--run <id>] [--engine <e>] [--worker-pid <pid>] [--mode plan|brief] | start --reattach --run <id> [--worker-pid <pid>] | status --run <id> | reload --run <id> | end --run <id>\n';
215
225
 
216
226
  /** @param {string[]} args @returns {Promise<number>} */
217
227
  export default async function run(args) {
@@ -0,0 +1,41 @@
1
+ /**
2
+ * B59: the facts `fallbackLane` (./fallback-rules.mjs) needs, built from what a brief-mode
3
+ * `block open` knows before any code exists: the block's owned entries, the estimated changed
4
+ * lines (`--lines`) and `proof.tiers.high.paths` from the run's config snapshot. Pure — no disk,
5
+ * no git; the caller reads the snapshot.
6
+ *
7
+ * filesChanged the number of distinct owned entries (a directory entry counts once)
8
+ * linesAdded `--lines`, 0 when not given
9
+ * touchesMigration an owned entry names a migration (`isMigrationPath`)
10
+ * touchesPolicyOrMiddleware an owned entry names a policy or middleware (`isPolicyPath`)
11
+ * pathFloorHit a high path owns an owned entry, or an owned entry owns a high path
12
+ * (a directory entry above a high path counts: its files are in scope)
13
+ *
14
+ * The migration and policy checks are case-insensitive SUBSTRING matches anywhere in the path
15
+ * (`src/migrationHelper.ts` is a migration path), the same patterns the §3.5 review rules use
16
+ * (`rulesRisk` in src/review/engine.mjs calls these two functions).
17
+ */
18
+
19
+ import { ownsFile } from '../state/registry.mjs';
20
+
21
+ /** A path the §3.5 rules treat as a migration. @param {string} file @returns {boolean} */
22
+ export const isMigrationPath = (file) => /migration/i.test(file);
23
+
24
+ /** A path the §3.5 rules treat as a policy or middleware. @param {string} file @returns {boolean} */
25
+ export const isPolicyPath = (file) => /polic(y|ies)|middleware/i.test(file);
26
+
27
+ /**
28
+ * @param {{owned: ReadonlyArray<string>, lines?: number | null, highPaths?: unknown}} input
29
+ * `highPaths` that is not an array (absent, null) means no high paths.
30
+ * @returns {Required<Omit<import('./fallback-rules.mjs').FallbackFacts, 'keywordsFound'>>}
31
+ */
32
+ export function laneFacts({ owned, lines = null, highPaths = [] }) {
33
+ const high = (Array.isArray(highPaths) ? highPaths : []).filter((p) => typeof p === 'string' && p.length > 0);
34
+ return {
35
+ filesChanged: new Set(owned).size,
36
+ linesAdded: lines ?? 0,
37
+ touchesMigration: owned.some(isMigrationPath),
38
+ touchesPolicyOrMiddleware: owned.some(isPolicyPath),
39
+ pathFloorHit: owned.some((entry) => ownsFile(high, entry) || high.some((h) => ownsFile([entry], h))),
40
+ };
41
+ }
@@ -12,7 +12,7 @@
12
12
  * coder/reviewer/judge/s2/author/facts spend (plus `ledger add coder` manual rows and
13
13
  * `session.background` rows, whose price is unknown); its dollars come from `./spend.mjs`
14
14
  * (`usd`, else `cost_usd`, else ESTIMATED from its tokens × `./prices.mjs`); `review.done` {block,file,context_mode,findings_by_severity,round}; `decision`
15
- * {block,question,source}; `outcome` {block,missed_after_degrade}; `escalation` {block,trigger};
15
+ * {block,question,source}; `run.start` {run}; `run.mode` {run,mode,plan} (B59, `buildRunModes`); `outcome` {block,missed_after_degrade}; `escalation` {block,trigger};
16
16
  * `review.unavailable` {block,reason}; `proof` {block,duration_ms}; `run.stop` {reason}.
17
17
  */
18
18
 
@@ -60,6 +60,20 @@ export function buildSpend(rows) {
60
60
  return { runs: spendByRun(acted), total: sumSpend(acted) };
61
61
  }
62
62
 
63
+ /**
64
+ * B59: the mode of every run with a `run.start` row, in ledger order — `brief` when the run has a
65
+ * `run.mode {mode: brief}` row, else `plan` (a run with no `run.mode` row is a plan run). Like the
66
+ * spend, it is terminal text only (`src/cli/report.mjs`): it is not in `--json` or `--export`, so
67
+ * the pinned `SECTION_NAMES` sections stay exactly as they are.
68
+ * @param {Array<Record<string, any>>} rows
69
+ * @returns {Array<{run: string, mode: 'brief' | 'plan'}>}
70
+ */
71
+ export function buildRunModes(rows) {
72
+ const brief = new Set(rows.filter((r) => r.event === 'run.mode' && r.mode === 'brief' && typeof r.run === 'string').map((r) => r.run));
73
+ const runs = [...new Set(rows.filter((r) => r.event === 'run.start' && typeof r.run === 'string').map((r) => r.run))];
74
+ return runs.map((run) => ({ run, mode: brief.has(run) ? 'brief' : 'plan' }));
75
+ }
76
+
63
77
  /**
64
78
  * Cost per block — completed AND still open (B33: a running total while the block is open):
65
79
  * review $ / coder $ / S1+S2 $ / facts $ / proof ms, `completed` (any `block.close` row) and
@@ -51,6 +51,7 @@ import path from 'node:path';
51
51
  import { resolveLevel } from '../config/known-ids.mjs';
52
52
  import { blockKind, isDocsKind } from '../decide/escalation.mjs';
53
53
  import { fallbackRisk } from '../decide/fallback-rules.mjs';
54
+ import { isMigrationPath, isPolicyPath } from '../decide/lane-facts.mjs';
54
55
  import { tierFor } from '../proof/tiers.mjs';
55
56
  import { assembleJudgePacket, assemblePacket, attachFiles, budgetFor, diffOnlyTokens, readFileDiff } from './packet.mjs';
56
57
  import { movedText, movesFor, peerDiffReader } from './moved.mjs';
@@ -152,8 +153,8 @@ export function rulesRisk({ file, plusCount, cfg }) {
152
153
  pathFloorHit,
153
154
  filesChanged: 1,
154
155
  linesAdded: plusCount,
155
- touchesMigration: /migration/i.test(file),
156
- touchesPolicyOrMiddleware: /polic(y|ies)|middleware/i.test(file),
156
+ touchesMigration: isMigrationPath(file),
157
+ touchesPolicyOrMiddleware: isPolicyPath(file),
157
158
  });
158
159
  }
159
160
 
@@ -11,12 +11,14 @@
11
11
  * B12) — this module never imports them (C17).
12
12
  */
13
13
 
14
- import { createHash } from 'node:crypto';
14
+ import { createHash, randomUUID } from 'node:crypto';
15
15
  import { createReadStream, existsSync, lstatSync, readlinkSync, realpathSync } from 'node:fs';
16
16
  import os from 'node:os';
17
17
  import path from 'node:path';
18
18
  import { BLOCK_KINDS, blockKind, dispatchLevel } from '../decide/escalation.mjs';
19
19
  import { exec } from '../util/exec.mjs';
20
+ import { DEFAULT_CONFIG_FILENAME } from '../config/load.mjs';
21
+ import { externalReviewer } from './config-snapshot.mjs';
20
22
  import { ownedKinds, pathKind } from './owned-kinds.mjs';
21
23
  import { isAncestorArgv, revParseArgv } from '../util/git.mjs';
22
24
  import { StateError } from './paths.mjs';
@@ -254,6 +256,39 @@ function activeBlocks(record) {
254
256
  return Object.fromEntries(Object.entries(record.blocks).filter(([, b]) => b.status === 'open'));
255
257
  }
256
258
 
259
+ /**
260
+ * B58: with `review.external.required` set in the run's snapshot, the project's `.code-forge.yml`
261
+ * is the owner's switch past the local per-file review, so no block may own it. These are the
262
+ * spellings an owned entry may match it by: relative to the workspace and, for a workspace in a
263
+ * subdirectory, relative to the repository top level (the close gate matches both, `ownsAt`).
264
+ * Empty when the key is not set.
265
+ * @param {Record<string, any>} record @returns {Promise<string[]>}
266
+ * @throws {StateError} `config-snapshot`, `git-failed`
267
+ */
268
+ async function guardedConfigPaths(record) {
269
+ if (externalReviewer(record) === null) return [];
270
+ const top = await repoTop(record.workspace);
271
+ // the repo-top spelling `<prefix>/.code-forge.yml` may also match a different file of the same
272
+ // name under the workspace (`<ws>/<prefix>/.code-forge.yml`): that over-match only refuses more,
273
+ // never less, and is the one `ownsAt` gives the close gate
274
+ const prefix = path.relative(top, realpathSync(record.workspace)).split(path.sep).join('/');
275
+ return prefix === '' ? [DEFAULT_CONFIG_FILENAME] : [DEFAULT_CONFIG_FILENAME, `${prefix}/${DEFAULT_CONFIG_FILENAME}`];
276
+ }
277
+
278
+ /**
279
+ * B58: refuse an owned entry that owns one of `guarded` (B57's rule, `ownsFile`: the exact path,
280
+ * a directory above it, or a glob that matches it).
281
+ * @param {ReadonlyArray<string>} owned @param {import('./registry.mjs').OwnedKinds} kinds @param {ReadonlyArray<string>} guarded
282
+ * @throws {StateError} `config-owned`
283
+ */
284
+ function refuseConfigOwner(owned, kinds, guarded) {
285
+ for (const entry of owned) {
286
+ if (guarded.some((file) => ownsFile([entry], file, kinds))) {
287
+ throw new StateError('config-owned', `owned entry ${JSON.stringify(entry)} covers the project's ${DEFAULT_CONFIG_FILENAME}; no block may own it while review.external.required is true`);
288
+ }
289
+ }
290
+ }
291
+
257
292
  /**
258
293
  * `block open` — refuses a second open of the same id and any owned set that overlaps another
259
294
  * ACTIVE block's; records base, owned files, level, attempt, acceptance, line forecast and the
@@ -262,10 +297,18 @@ function activeBlocks(record) {
262
297
  * and the dispatch row carry the floor level, and the row adds `requested_level` and
263
298
  * `trigger: 'docs_floor'`. B52: the entry also records `tree_at_open` ({@link treeAtOpen}), the
264
299
  * tree's changes at open, which `block close` reads to tell the coder's changes from earlier ones.
300
+ * B58: with `review.external.required` set, an owned entry that owns the project's config is
301
+ * refused (`config-owned`, {@link refuseConfigOwner}).
302
+ * B59: `decision` (the rules lane of a brief-mode block) is written as a signed `decision` row
303
+ * naming the block, with `decisionId` (default a fresh UUID) as its `decision_id`, just before the
304
+ * `dispatch` row — under the same lock, so a refused open writes neither. Its `answer` is the
305
+ * requested lane and must equal `level` (else `bad-decision`, nothing written); the docs floor may
306
+ * still raise the dispatched level, and the dispatch row then names the lane as `requested_level`.
265
307
  * @param {{
266
308
  * runId: string, id: string, level: string, owned: string[], acceptance: unknown,
267
309
  * attempt?: number, base?: string, lines?: number, brief?: {path: string, content: Buffer | string},
268
310
  * kind?: string, cfg?: Record<string, any>, writeRow: WriteRow, now?: Date,
311
+ * decision?: {question: 'lane', answer: string, source: 'rules', facts: Record<string, unknown>}, decisionId?: string,
269
312
  * }} opts
270
313
  * @returns {Promise<{block: Record<string, any>, pointer: string | null}>}
271
314
  */
@@ -276,6 +319,7 @@ export async function openBlock(opts) {
276
319
  if (!Number.isInteger(attempt) || attempt < 1) throw new StateError('bad-attempt', 'attempt must be an integer >= 1');
277
320
  if (lines !== null && !(Number.isInteger(lines) && lines > 0)) throw new StateError('bad-lines', 'lines forecast must be a positive integer');
278
321
  if (opts.kind !== undefined && !BLOCK_KINDS.includes(opts.kind)) throw new StateError('bad-kind', `kind must be one of ${BLOCK_KINDS.join(', ')}`);
322
+ if (opts.decision && opts.decision.answer !== level) throw new StateError('bad-decision', `the decision answer ${opts.decision.answer} is not the requested level ${level}`);
279
323
  const owned = assertOwned(opts.owned);
280
324
  const acceptance = assertAcceptance(opts.acceptance);
281
325
  const kind = blockKind({ owned, declared: opts.kind });
@@ -284,15 +328,19 @@ export async function openBlock(opts) {
284
328
 
285
329
  // B52: the tree is stamped BEFORE the run lock (hashing can be slow; other block commands must
286
330
  // not wait on it); under the lock the base is resolved again and must be the one stamped against
287
- const { workspace } = await readRun(runId);
331
+ const before = await readRun(runId);
332
+ const { workspace } = before;
288
333
  const stampedBase = await resolveCommit(opts.base ?? 'HEAD', workspace);
289
334
  const treeOpen = await treeAtOpen(workspace, stampedBase);
335
+ // B58: `review.external` cannot change mid-run, so the record read here decides
336
+ const guarded = await guardedConfigPaths(before);
290
337
 
291
338
  const block = await mutateRun(runId, null, writeRow, async (record) => {
292
339
  if (record.blocks[id]?.status === 'open') throw new StateError('already-open', `block ${id} is already open`);
293
340
  // B57: what each exact owned entry is on disk, under the lock (as `block claim`) — a regular
294
341
  // file owns only itself, here and in every later check
295
342
  const kinds = ownedKinds(record.workspace, owned);
343
+ refuseConfigOwner(owned, kinds, guarded);
296
344
  for (const [otherId, other] of Object.entries(activeBlocks(record))) {
297
345
  const pair = findOverlap(owned, other.owned_files, { kindsA: kinds, kindsB: other.owned_kinds });
298
346
  if (pair) throw new StateError('overlap', `block ${id} owns ${pair[0]}, which overlaps ${pair[1]} owned by open block ${otherId}`);
@@ -304,7 +352,10 @@ export async function openBlock(opts) {
304
352
  const floor = dispatched.trigger === 'docs_floor' ? { requested_level: level, trigger: 'docs_floor' } : {};
305
353
  return {
306
354
  result: entry,
307
- rows: [{ event: 'dispatch', block: id, level: dispatched.level, kind, ...floor, attempt, base_sha: baseSha, owned_files: entry.owned_files, lines_forecast: lines }],
355
+ rows: [
356
+ ...(opts.decision ? [{ ...opts.decision, event: 'decision', decision_id: opts.decisionId ?? randomUUID(), block: id }] : []),
357
+ { event: 'dispatch', block: id, level: dispatched.level, kind, ...floor, attempt, base_sha: baseSha, owned_files: entry.owned_files, lines_forecast: lines },
358
+ ],
308
359
  };
309
360
  });
310
361
  return { block, pointer };
@@ -347,13 +398,17 @@ export async function rebaseBlock({ runId, id, head = 'HEAD', writeRow }) {
347
398
  * glob, and when another open block owns it — or anything it would own: the claimed path becomes an
348
399
  * owned entry, so it is checked with `findOverlap` (B57: an entry owns the paths below it too,
349
400
  * unless it is a regular file now — its kind is recorded in `owned_kinds` as at `block open`).
401
+ * B58: with `review.external.required` set, a path that owns the project's config is refused
402
+ * (`config-owned`), as at `block open`.
350
403
  * @param {{runId: string, id: string, file: string, writeRow: WriteRow}} opts
351
404
  */
352
405
  export async function claimPath({ runId, id, file, writeRow }) {
353
406
  assertOwned([file]);
354
407
  if (!isExactEntry(file)) throw new StateError('bad-owned', `block claim takes one exact path, not a glob (${JSON.stringify(file)})`);
408
+ const guarded = await guardedConfigPaths(await readRun(runId));
355
409
  await mutateRun(runId, id, writeRow, (record, block) => {
356
410
  const kinds = { [file]: pathKind(record.workspace, file) };
411
+ refuseConfigOwner([file], kinds, guarded);
357
412
  for (const [otherId, other] of Object.entries(activeBlocks(record))) {
358
413
  const pair = otherId === id ? null : findOverlap([file], other.owned_files, { kindsA: kinds, kindsB: other.owned_kinds });
359
414
  if (pair) throw new StateError('overlap', `${file} overlaps ${pair[1]} owned by open block ${otherId}`);
@@ -396,15 +451,18 @@ export async function stopBlock({ runId, id, reason, writeRow, event = 'block.st
396
451
  * stopped); the block's signed `dispatch` row must exist and verify, and every other
397
452
  * gate-relevant row of the block must verify ⇒ else `ledger.tamper` (block stopped); then
398
453
  * `extraChecks`; finally, UNDER the run lock, orphans in the registry ⇒ refused (block stays
399
- * open), else closed.
454
+ * open), else closed. B58: `closeRows` are signed and written under that same lock just before
455
+ * `block.close`, and only when the block closes — a row that records why it may close is never
456
+ * left behind by a refused close.
400
457
  * @param {{
401
458
  * runId: string, id: string, rows: Record<string, any>[], writeRow: WriteRow,
402
459
  * livePid?: number, probe?: import('./run.mjs').StartTimeProbe,
403
460
  * extraChecks?: Array<() => Promise<{ok: boolean, reason?: string}>>,
461
+ * closeRows?: Record<string, any>[],
404
462
  * }} opts
405
463
  * @returns {Promise<{ok: boolean, status: 'closed' | 'stopped' | 'open', reason?: string, event?: string}>}
406
464
  */
407
- export async function closeBlock({ runId, id, rows, writeRow, livePid, probe, extraChecks = [] }) {
465
+ export async function closeBlock({ runId, id, rows, writeRow, livePid, probe, extraChecks = [], closeRows = [] }) {
408
466
  const record = await readRun(runId);
409
467
  if (record.blocks[id]?.status !== 'open') throw new StateError('no-block', `block ${id} is not open in run ${runId}`);
410
468
 
@@ -442,6 +500,6 @@ export async function closeBlock({ runId, id, rows, writeRow, livePid, probe, ex
442
500
  block.status = 'closed';
443
501
  /** @type {CloseResult} */
444
502
  const closed = { ok: true, status: 'closed' };
445
- return { result: closed, rows: [{ event: 'block.close', block: id, status: 'complete' }] };
503
+ return { result: closed, rows: [...closeRows, { event: 'block.close', block: id, status: 'complete' }] };
446
504
  });
447
505
  }
@@ -18,6 +18,7 @@
18
18
  */
19
19
 
20
20
  import { createHash } from 'node:crypto';
21
+ import { StateError } from './paths.mjs';
21
22
 
22
23
  /**
23
24
  * Keys that cannot change mid-run, each with why. A change to one is refused by `run reload`
@@ -30,6 +31,9 @@ export const IMMUTABLE_KEYS = Object.freeze({
30
31
  'tmp.root': "the worker's run temp root is created at run start",
31
32
  keys: 'the worker resolves keys once, at start',
32
33
  'system1.key': 'the worker resolves the Jev key once, at start',
34
+ // B58: the owner's switch past the local per-file review is set before the run starts or not at
35
+ // all, so nobody turns it on (or off) mid-run
36
+ 'review.external': 'the outside reviewer is fixed by the owner before the run starts',
33
37
  });
34
38
 
35
39
  /** @param {unknown} v @returns {boolean} what `JSON.stringify` drops from an object. */
@@ -148,3 +152,35 @@ export function snapshotFor(record, ticket) {
148
152
  if (!isMapping(config) || configHash(config) !== hash) throw new Error('config-snapshot');
149
153
  return { hash, config };
150
154
  }
155
+
156
+ /**
157
+ * The config snapshot in force for the run (no ticket pin), or null for a record from before
158
+ * snapshots.
159
+ * @param {Record<string, any>} record
160
+ * @returns {Record<string, any> | null}
161
+ * @throws {StateError} `config-snapshot` when the snapshot in force does not match its hash.
162
+ */
163
+ export function configInForce(record) {
164
+ try {
165
+ return snapshotFor(record, '')?.config ?? null;
166
+ } catch (thrown) {
167
+ if (!(thrown instanceof Error) || thrown.message !== 'config-snapshot') throw thrown;
168
+ throw new StateError('config-snapshot', `run ${record.run_id}: the config snapshot in force does not match its hash`);
169
+ }
170
+ }
171
+
172
+ /**
173
+ * B58: the outside reviewer named by the snapshot in force when `review.external.required` is
174
+ * true; null otherwise (key absent, `required` false, or a record from before snapshots). The key
175
+ * is immutable ({@link IMMUTABLE_KEYS}), so this is the value `run start` stored.
176
+ * @param {Record<string, any>} record
177
+ * @returns {string | null}
178
+ * @throws {StateError} `config-snapshot` when the snapshot in force does not match its hash.
179
+ */
180
+ export function externalReviewer(record) {
181
+ const external = configInForce(record)?.review?.external;
182
+ if (!isMapping(external) || external.required !== true) return null;
183
+ // required without a usable name is no switch at all (the schema refuses it; this is the floor)
184
+ const name = typeof external.name === 'string' ? external.name.trim() : '';
185
+ return name === '' ? null : name; // trimmed: no row ever carries padding
186
+ }
package/src/state/run.mjs CHANGED
@@ -16,7 +16,7 @@ import { mkdir, readFile, rename, rmdir, writeFile } from 'node:fs/promises';
16
16
  import path from 'node:path';
17
17
  import { exec } from '../util/exec.mjs';
18
18
  import { StateError, assertRunId, assertWorkspaceOutsideRuns, mirrorPath, newRunId, runRecordPath, runsDir } from './paths.mjs';
19
- import { changedKeyPaths, configHash, IMMUTABLE_KEYS, immutableChanges, initialConfigState } from './config-snapshot.mjs';
19
+ import { changedKeyPaths, configHash, externalReviewer, IMMUTABLE_KEYS, immutableChanges, initialConfigState } from './config-snapshot.mjs';
20
20
  import { generateKey, loadKey, signRow } from './signer.mjs';
21
21
 
22
22
  /** @typedef {(row: Record<string, any>) => Promise<unknown>} WriteRow */
@@ -132,11 +132,17 @@ async function pinWorker(workerPid, probe) {
132
132
  return { pid: workerPid, started_at: startedAt };
133
133
  }
134
134
 
135
+ /** B59: the run modes `run start --mode` takes; `plan` is the default and is never recorded. */
136
+ export const RUN_MODES = Object.freeze(['plan', 'brief']);
137
+
135
138
  /**
136
139
  * `run start`: record + key + worker pin + ledger `run.start`, all under the run lock. An
137
140
  * existing run id is refused before anything is written (its key and record stay untouched).
141
+ * B59: `mode: 'brief'` (a run with briefs and no plan) is stored in the record as `mode` and
142
+ * written as a signed `run.mode {mode: 'brief', plan: 'none'}` row right after `run.start`; `plan`
143
+ * or no mode leaves the record and the rows exactly as before. Nothing changes the mode later.
138
144
  * @param {{
139
- * workspace: string, project: string, config?: Record<string, any>, engine?: string,
145
+ * workspace: string, project: string, config?: Record<string, any>, engine?: string, mode?: string,
140
146
  * runId?: string, workerPid?: number, orchestratorPid?: number, soloPid?: string | null,
141
147
  * writeRow: WriteRow, probe?: StartTimeProbe, now?: Date,
142
148
  * }} opts
@@ -149,6 +155,14 @@ export async function startRun(opts) {
149
155
  throw new StateError('bad-project', 'project slug must match /^[a-z0-9][a-z0-9-]*$/ (config key project.slug)');
150
156
  }
151
157
  const workspace = assertWorkspaceOutsideRuns(opts.workspace);
158
+ if (opts.mode !== undefined && !RUN_MODES.includes(opts.mode)) throw new StateError('usage', `--mode must be one of ${RUN_MODES.join(', ')}`);
159
+ const brief = opts.mode === 'brief';
160
+ // B58: the schema lets `review.external` stand without a name while `required` is off; on, the
161
+ // reviewer must be named — refused before anything is written
162
+ const external = config.review?.external;
163
+ if (external?.required === true && (typeof external.name !== 'string' || external.name.trim() === '')) {
164
+ throw new StateError('external-no-name', 'review.external.required is true but review.external.name is missing or blank');
165
+ }
152
166
  const worker = await pinWorker(opts.workerPid, probe);
153
167
  const record = {
154
168
  version: 1,
@@ -166,6 +180,7 @@ export async function startRun(opts) {
166
180
  status: 'active',
167
181
  blocks: {},
168
182
  orphans: [],
183
+ ...(brief ? { mode: 'brief' } : {}),
169
184
  // B35: the config snapshot the worker reviews with (`run reload` replaces it). Only when the
170
185
  // caller passed the loaded config: a record without it keeps the worker on its boot config.
171
186
  ...(opts.config !== undefined ? { config: initialConfigState(config) } : {}),
@@ -179,6 +194,10 @@ export async function startRun(opts) {
179
194
  await generateKey(runId);
180
195
  await saveRun(record);
181
196
  await writeSigned(runId, writeRow, { event: 'run.start', engine: record.engine, worker_pinned: worker !== null });
197
+ if (brief) await writeSigned(runId, writeRow, { event: 'run.mode', mode: 'brief', plan: 'none' });
198
+ // B58: the owner's outside reviewer is on the ledger from the first row of the run on
199
+ const reviewer = externalReviewer(record);
200
+ if (reviewer !== null) await writeSigned(runId, writeRow, { event: 'review.external', required: true, reviewer });
182
201
  return record;
183
202
  });
184
203
  }
@@ -198,8 +217,8 @@ export async function startRun(opts) {
198
217
  * Fixed keys ({@link IMMUTABLE_KEYS}): every one that changed is refused at once
199
218
  * (`immutable-key`, nothing written). A record from before snapshots (no `config`) is checked
200
219
  * against what it does store: `engine` and the ledger slug (`effectiveSlug`, computed by the
201
- * caller the same way `run start` did); it then gets its first snapshot, `changed_keys: null`,
202
- * `old_hash: null`.
220
+ * caller the same way `run start` did), and B58's `review.external.required` must not turn true;
221
+ * it then gets its first snapshot, `changed_keys: null`, `old_hash: null`.
203
222
  *
204
223
  * No change (same hash) ⇒ nothing written, no row. Otherwise the record is saved FIRST (it is the
205
224
  * source of truth), then one signed `run.reload` row: the changed key PATHS (never values) and
@@ -238,6 +257,9 @@ export async function reloadRun({ runId, config, readPending = () => [], effecti
238
257
  frozen = [];
239
258
  if (effectiveSlug !== undefined && effectiveSlug !== current.project) frozen.push('project.slug');
240
259
  if (typeof current.engine === 'string' && (config.engine ?? 'auto') !== current.engine) frozen.push('engine');
260
+ // B58: a run started without the key has none to keep — turning it on now is a change; an
261
+ // entry that leaves it off (`required` false or absent, as the schema default) is not
262
+ if (config.review?.external?.required === true) frozen.push('review.external');
241
263
  }
242
264
  if (frozen.length > 0) {
243
265
  const why = frozen.map((key) => `${key} cannot change mid-run (${IMMUTABLE_KEYS[/** @type {keyof typeof IMMUTABLE_KEYS} */ (key)]})`);