@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 +27 -0
- package/README.md +15 -0
- package/docs/reference/blocks.json +28 -1
- package/docs/reference/config.md +3 -0
- package/package.json +1 -1
- package/schema/code-forge.schema.json +10 -1
- package/skill/SKILL.md +1 -1
- package/skill/references/code.md +2 -2
- package/skill/references/decisions.md +1 -1
- package/skill/references/ledger.md +1 -1
- package/skill/references/plan.md +12 -0
- package/skill/references/review.md +2 -0
- package/skill/scripts/forge +1 -1
- package/src/cli/block.mjs +66 -8
- package/src/cli/report.mjs +16 -2
- package/src/cli/run.mjs +16 -6
- package/src/decide/lane-facts.mjs +41 -0
- package/src/ledger/report.mjs +15 -1
- package/src/review/engine.mjs +3 -2
- package/src/state/block.mjs +64 -6
- package/src/state/config-snapshot.mjs +36 -0
- package/src/state/run.mjs +26 -4
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":
|
|
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
|
}
|
package/docs/reference/config.md
CHANGED
|
@@ -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
|
@@ -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
|
package/skill/references/code.md
CHANGED
|
@@ -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
|
|
package/skill/references/plan.md
CHANGED
|
@@ -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.
|
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.7.
|
|
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
|
-
//
|
|
135
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
package/src/cli/report.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/src/ledger/report.mjs
CHANGED
|
@@ -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
|
package/src/review/engine.mjs
CHANGED
|
@@ -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:
|
|
156
|
-
touchesPolicyOrMiddleware:
|
|
156
|
+
touchesMigration: isMigrationPath(file),
|
|
157
|
+
touchesPolicyOrMiddleware: isPolicyPath(file),
|
|
157
158
|
});
|
|
158
159
|
}
|
|
159
160
|
|
package/src/state/block.mjs
CHANGED
|
@@ -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
|
|
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: [
|
|
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)
|
|
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)]})`);
|