@codedology/code-forge 0.7.5 → 0.7.6
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 +17 -0
- package/README.md +8 -0
- package/docs/reference/blocks.json +14 -1
- package/docs/reference/config.md +3 -0
- package/package.json +1 -1
- package/schema/code-forge.schema.json +10 -1
- package/skill/references/code.md +1 -1
- package/skill/references/decisions.md +1 -1
- package/skill/references/review.md +2 -0
- package/skill/scripts/forge +1 -1
- package/src/cli/block.mjs +21 -4
- package/src/state/block.mjs +52 -4
- package/src/state/config-snapshot.mjs +27 -0
- package/src/state/run.mjs +15 -3
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,23 @@ waits on a second real consumer (plan §10.4, Q17).
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.7.6] — 2026-10-11
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- review.external {required, name} (B58, issue #11): an owner-approved outside reviewer replaces the local per-file
|
|
14
|
+
review. Read only from the run-start config snapshot (run reload refuses to change it; run start refuses required:
|
|
15
|
+
true without a name) and signed as a review.external row; block close then skips the per-file review check (also
|
|
16
|
+
not_approved) and writes a signed gate.reviews_waived {by: config, reviewer} row just before block.close, only when
|
|
17
|
+
the block closes, with no TTY and under autopilot. Unowned, proof, no_changes, transcript, orphans and closeBlock
|
|
18
|
+
checks still refuse. block open and block claim refuse an owned entry that covers .code-forge.yml. Without the key
|
|
19
|
+
nothing changes.
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
|
|
23
|
+
- block open, block claim and block close now read the run's config snapshot; a snapshot that does not match its hash
|
|
24
|
+
refuses them with config-snapshot, with or without review.external (B58).
|
|
25
|
+
|
|
9
26
|
## [0.7.5] — 2026-10-08
|
|
10
27
|
|
|
11
28
|
### Fixed
|
package/README.md
CHANGED
|
@@ -12,6 +12,14 @@ for cross-model, parallel, evidence-gated feature delivery — on Claude Code, C
|
|
|
12
12
|
|
|
13
13
|
Next release:
|
|
14
14
|
|
|
15
|
+
- **An outside reviewer can replace the local review.** Set `review.external: {required: true,
|
|
16
|
+
name: <reviewer>}` in `.code-forge.yml`. `block close` then skips the per-file review check
|
|
17
|
+
and writes a signed `gate.reviews_waived` row with `by: config`. It needs no TTY and works
|
|
18
|
+
under autopilot. All other close checks still run. The key is fixed at `run start`, and a
|
|
19
|
+
coder can never own `.code-forge.yml`.
|
|
20
|
+
|
|
21
|
+
In 0.7.5:
|
|
22
|
+
|
|
15
23
|
- **An owned directory owns the files inside it, everywhere.** A block that owns `src/feature`
|
|
16
24
|
now owns `src/feature/a.swift` for `block open`, `block claim`, `block rebase`, the scope gate,
|
|
17
25
|
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": 61,
|
|
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,19 @@
|
|
|
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"
|
|
826
839
|
}
|
|
827
840
|
}
|
|
828
841
|
}
|
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/references/code.md
CHANGED
|
@@ -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,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.6"
|
|
14
14
|
set -eu
|
|
15
15
|
|
|
16
16
|
self="$0"
|
package/src/cli/block.mjs
CHANGED
|
@@ -50,6 +50,15 @@
|
|
|
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
|
|
@@ -67,6 +76,7 @@ import { approvalCheckText, expiryChecks } from '../autopilot/limits.mjs';
|
|
|
67
76
|
import { appendRow, readAllRows } from '../ledger/write.mjs';
|
|
68
77
|
import { attemptBlock, claimPath, closeBlock, openBlock, rebaseBlock, repoTop, stopBlock } from '../state/block.mjs';
|
|
69
78
|
import { intFlag, parseFlags } from '../state/cli-args.mjs';
|
|
79
|
+
import { externalReviewer } from '../state/config-snapshot.mjs';
|
|
70
80
|
import { StateError } from '../state/paths.mjs';
|
|
71
81
|
import { readRun, writeSigned } from '../state/run.mjs';
|
|
72
82
|
import { loadKey, verifyRow } from '../state/signer.mjs';
|
|
@@ -201,6 +211,9 @@ export async function runBlock(args, deps = {}) {
|
|
|
201
211
|
return 1;
|
|
202
212
|
}
|
|
203
213
|
}
|
|
214
|
+
// B58: the owner's other way out — `review.external.required` in the run's config snapshot
|
|
215
|
+
// (set before `run start`, immutable): no TTY, allowed under autopilot, the human flag wins
|
|
216
|
+
const external = reviewsWaived ? null : externalReviewer(record);
|
|
204
217
|
const rows = (await readAllRows(record.project)).filter((row) => row.run === runId);
|
|
205
218
|
// B52: ONE path base and ONE listing, memoised as a promise and shared by every check. The
|
|
206
219
|
// base is the repository top level: the worker signs every review row with a repo-root-
|
|
@@ -265,8 +278,9 @@ export async function runBlock(args, deps = {}) {
|
|
|
265
278
|
unowned: scope?.unowned ?? [],
|
|
266
279
|
// B52: an old ledger's review.result rows are judged by their signed result files
|
|
267
280
|
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
|
|
281
|
+
files: reviewsWaived || external !== null ? [] : fileSet,
|
|
282
|
+
// B52: nothing changed and nothing reviewed is never a pass (the owner's way out is
|
|
283
|
+
// --no-require-reviews); B58: `review.external` does not lift it
|
|
270
284
|
noChanges: !reviewsWaived && fileSet.length === 0,
|
|
271
285
|
// B20: high-tier files need a proven red→green row; `--no-require-reviews` does not lift it
|
|
272
286
|
proofFiles: fileSet,
|
|
@@ -275,7 +289,7 @@ export async function runBlock(args, deps = {}) {
|
|
|
275
289
|
});
|
|
276
290
|
// B36: the coder's report names a review-file ticket for every changed file, or it is FAILED
|
|
277
291
|
const report = async () => {
|
|
278
|
-
if (typeof flags.report !== 'string' || reviewsWaived) return { ok: true };
|
|
292
|
+
if (typeof flags.report !== 'string' || reviewsWaived || external !== null) return { ok: true };
|
|
279
293
|
let text;
|
|
280
294
|
try {
|
|
281
295
|
text = await readFile(flags.report, 'utf8');
|
|
@@ -286,7 +300,10 @@ export async function runBlock(args, deps = {}) {
|
|
|
286
300
|
const missing = missingReviewTickets(text, fileSet.map((f) => f.file));
|
|
287
301
|
return missing.length === 0 ? { ok: true } : { ok: false, reason: `coder report FAILED: no review-file ticket id for ${missing.join(', ')}` };
|
|
288
302
|
};
|
|
289
|
-
|
|
303
|
+
// B58: the config waiver row is written by `closeBlock` under the run lock, just before
|
|
304
|
+
// `block.close` and only when every other check passed — never by a refused close
|
|
305
|
+
const closeRows = external === null ? [] : [{ event: 'gate.reviews_waived', block: id, by: 'config', reviewer: external }];
|
|
306
|
+
const result = await closeBlock({ runId, id, rows, writeRow, livePid: intFlag(flags['worker-pid'], 'worker-pid'), probe, extraChecks: [reviews, report], closeRows });
|
|
290
307
|
(result.ok ? out : err)(`block ${id} ${result.status}${result.event ? ` (${result.event})` : ''}${result.reason ? `: ${result.reason}` : ''}\n`);
|
|
291
308
|
return result.ok ? 0 : 1;
|
|
292
309
|
}
|
package/src/state/block.mjs
CHANGED
|
@@ -17,6 +17,8 @@ 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,6 +297,8 @@ 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}).
|
|
265
302
|
* @param {{
|
|
266
303
|
* runId: string, id: string, level: string, owned: string[], acceptance: unknown,
|
|
267
304
|
* attempt?: number, base?: string, lines?: number, brief?: {path: string, content: Buffer | string},
|
|
@@ -284,15 +321,19 @@ export async function openBlock(opts) {
|
|
|
284
321
|
|
|
285
322
|
// B52: the tree is stamped BEFORE the run lock (hashing can be slow; other block commands must
|
|
286
323
|
// not wait on it); under the lock the base is resolved again and must be the one stamped against
|
|
287
|
-
const
|
|
324
|
+
const before = await readRun(runId);
|
|
325
|
+
const { workspace } = before;
|
|
288
326
|
const stampedBase = await resolveCommit(opts.base ?? 'HEAD', workspace);
|
|
289
327
|
const treeOpen = await treeAtOpen(workspace, stampedBase);
|
|
328
|
+
// B58: `review.external` cannot change mid-run, so the record read here decides
|
|
329
|
+
const guarded = await guardedConfigPaths(before);
|
|
290
330
|
|
|
291
331
|
const block = await mutateRun(runId, null, writeRow, async (record) => {
|
|
292
332
|
if (record.blocks[id]?.status === 'open') throw new StateError('already-open', `block ${id} is already open`);
|
|
293
333
|
// B57: what each exact owned entry is on disk, under the lock (as `block claim`) — a regular
|
|
294
334
|
// file owns only itself, here and in every later check
|
|
295
335
|
const kinds = ownedKinds(record.workspace, owned);
|
|
336
|
+
refuseConfigOwner(owned, kinds, guarded);
|
|
296
337
|
for (const [otherId, other] of Object.entries(activeBlocks(record))) {
|
|
297
338
|
const pair = findOverlap(owned, other.owned_files, { kindsA: kinds, kindsB: other.owned_kinds });
|
|
298
339
|
if (pair) throw new StateError('overlap', `block ${id} owns ${pair[0]}, which overlaps ${pair[1]} owned by open block ${otherId}`);
|
|
@@ -347,13 +388,17 @@ export async function rebaseBlock({ runId, id, head = 'HEAD', writeRow }) {
|
|
|
347
388
|
* glob, and when another open block owns it — or anything it would own: the claimed path becomes an
|
|
348
389
|
* owned entry, so it is checked with `findOverlap` (B57: an entry owns the paths below it too,
|
|
349
390
|
* unless it is a regular file now — its kind is recorded in `owned_kinds` as at `block open`).
|
|
391
|
+
* B58: with `review.external.required` set, a path that owns the project's config is refused
|
|
392
|
+
* (`config-owned`), as at `block open`.
|
|
350
393
|
* @param {{runId: string, id: string, file: string, writeRow: WriteRow}} opts
|
|
351
394
|
*/
|
|
352
395
|
export async function claimPath({ runId, id, file, writeRow }) {
|
|
353
396
|
assertOwned([file]);
|
|
354
397
|
if (!isExactEntry(file)) throw new StateError('bad-owned', `block claim takes one exact path, not a glob (${JSON.stringify(file)})`);
|
|
398
|
+
const guarded = await guardedConfigPaths(await readRun(runId));
|
|
355
399
|
await mutateRun(runId, id, writeRow, (record, block) => {
|
|
356
400
|
const kinds = { [file]: pathKind(record.workspace, file) };
|
|
401
|
+
refuseConfigOwner([file], kinds, guarded);
|
|
357
402
|
for (const [otherId, other] of Object.entries(activeBlocks(record))) {
|
|
358
403
|
const pair = otherId === id ? null : findOverlap([file], other.owned_files, { kindsA: kinds, kindsB: other.owned_kinds });
|
|
359
404
|
if (pair) throw new StateError('overlap', `${file} overlaps ${pair[1]} owned by open block ${otherId}`);
|
|
@@ -396,15 +441,18 @@ export async function stopBlock({ runId, id, reason, writeRow, event = 'block.st
|
|
|
396
441
|
* stopped); the block's signed `dispatch` row must exist and verify, and every other
|
|
397
442
|
* gate-relevant row of the block must verify ⇒ else `ledger.tamper` (block stopped); then
|
|
398
443
|
* `extraChecks`; finally, UNDER the run lock, orphans in the registry ⇒ refused (block stays
|
|
399
|
-
* open), else closed.
|
|
444
|
+
* open), else closed. B58: `closeRows` are signed and written under that same lock just before
|
|
445
|
+
* `block.close`, and only when the block closes — a row that records why it may close is never
|
|
446
|
+
* left behind by a refused close.
|
|
400
447
|
* @param {{
|
|
401
448
|
* runId: string, id: string, rows: Record<string, any>[], writeRow: WriteRow,
|
|
402
449
|
* livePid?: number, probe?: import('./run.mjs').StartTimeProbe,
|
|
403
450
|
* extraChecks?: Array<() => Promise<{ok: boolean, reason?: string}>>,
|
|
451
|
+
* closeRows?: Record<string, any>[],
|
|
404
452
|
* }} opts
|
|
405
453
|
* @returns {Promise<{ok: boolean, status: 'closed' | 'stopped' | 'open', reason?: string, event?: string}>}
|
|
406
454
|
*/
|
|
407
|
-
export async function closeBlock({ runId, id, rows, writeRow, livePid, probe, extraChecks = [] }) {
|
|
455
|
+
export async function closeBlock({ runId, id, rows, writeRow, livePid, probe, extraChecks = [], closeRows = [] }) {
|
|
408
456
|
const record = await readRun(runId);
|
|
409
457
|
if (record.blocks[id]?.status !== 'open') throw new StateError('no-block', `block ${id} is not open in run ${runId}`);
|
|
410
458
|
|
|
@@ -442,6 +490,6 @@ export async function closeBlock({ runId, id, rows, writeRow, livePid, probe, ex
|
|
|
442
490
|
block.status = 'closed';
|
|
443
491
|
/** @type {CloseResult} */
|
|
444
492
|
const closed = { ok: true, status: 'closed' };
|
|
445
|
-
return { result: closed, rows: [{ event: 'block.close', block: id, status: 'complete' }] };
|
|
493
|
+
return { result: closed, rows: [...closeRows, { event: 'block.close', block: id, status: 'complete' }] };
|
|
446
494
|
});
|
|
447
495
|
}
|
|
@@ -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,26 @@ 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
|
+
* B58: the outside reviewer named by the snapshot in force when `review.external.required` is
|
|
158
|
+
* true; null otherwise (key absent, `required` false, or a record from before snapshots). The key
|
|
159
|
+
* is immutable ({@link IMMUTABLE_KEYS}), so this is the value `run start` stored.
|
|
160
|
+
* @param {Record<string, any>} record
|
|
161
|
+
* @returns {string | null}
|
|
162
|
+
* @throws {StateError} `config-snapshot` when the snapshot in force does not match its hash.
|
|
163
|
+
*/
|
|
164
|
+
export function externalReviewer(record) {
|
|
165
|
+
let snap;
|
|
166
|
+
try {
|
|
167
|
+
snap = snapshotFor(record, '');
|
|
168
|
+
} catch (thrown) {
|
|
169
|
+
if (!(thrown instanceof Error) || thrown.message !== 'config-snapshot') throw thrown;
|
|
170
|
+
throw new StateError('config-snapshot', `run ${record.run_id}: the config snapshot in force does not match its hash`);
|
|
171
|
+
}
|
|
172
|
+
const external = snap?.config.review?.external;
|
|
173
|
+
if (!isMapping(external) || external.required !== true) return null;
|
|
174
|
+
// required without a usable name is no switch at all (the schema refuses it; this is the floor)
|
|
175
|
+
const name = typeof external.name === 'string' ? external.name.trim() : '';
|
|
176
|
+
return name === '' ? null : name; // trimmed: no row ever carries padding
|
|
177
|
+
}
|
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 */
|
|
@@ -149,6 +149,12 @@ export async function startRun(opts) {
|
|
|
149
149
|
throw new StateError('bad-project', 'project slug must match /^[a-z0-9][a-z0-9-]*$/ (config key project.slug)');
|
|
150
150
|
}
|
|
151
151
|
const workspace = assertWorkspaceOutsideRuns(opts.workspace);
|
|
152
|
+
// B58: the schema lets `review.external` stand without a name while `required` is off; on, the
|
|
153
|
+
// reviewer must be named — refused before anything is written
|
|
154
|
+
const external = config.review?.external;
|
|
155
|
+
if (external?.required === true && (typeof external.name !== 'string' || external.name.trim() === '')) {
|
|
156
|
+
throw new StateError('external-no-name', 'review.external.required is true but review.external.name is missing or blank');
|
|
157
|
+
}
|
|
152
158
|
const worker = await pinWorker(opts.workerPid, probe);
|
|
153
159
|
const record = {
|
|
154
160
|
version: 1,
|
|
@@ -179,6 +185,9 @@ export async function startRun(opts) {
|
|
|
179
185
|
await generateKey(runId);
|
|
180
186
|
await saveRun(record);
|
|
181
187
|
await writeSigned(runId, writeRow, { event: 'run.start', engine: record.engine, worker_pinned: worker !== null });
|
|
188
|
+
// B58: the owner's outside reviewer is on the ledger from the first row of the run on
|
|
189
|
+
const reviewer = externalReviewer(record);
|
|
190
|
+
if (reviewer !== null) await writeSigned(runId, writeRow, { event: 'review.external', required: true, reviewer });
|
|
182
191
|
return record;
|
|
183
192
|
});
|
|
184
193
|
}
|
|
@@ -198,8 +207,8 @@ export async function startRun(opts) {
|
|
|
198
207
|
* Fixed keys ({@link IMMUTABLE_KEYS}): every one that changed is refused at once
|
|
199
208
|
* (`immutable-key`, nothing written). A record from before snapshots (no `config`) is checked
|
|
200
209
|
* 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`.
|
|
210
|
+
* caller the same way `run start` did), and B58's `review.external.required` must not turn true;
|
|
211
|
+
* it then gets its first snapshot, `changed_keys: null`, `old_hash: null`.
|
|
203
212
|
*
|
|
204
213
|
* No change (same hash) ⇒ nothing written, no row. Otherwise the record is saved FIRST (it is the
|
|
205
214
|
* source of truth), then one signed `run.reload` row: the changed key PATHS (never values) and
|
|
@@ -238,6 +247,9 @@ export async function reloadRun({ runId, config, readPending = () => [], effecti
|
|
|
238
247
|
frozen = [];
|
|
239
248
|
if (effectiveSlug !== undefined && effectiveSlug !== current.project) frozen.push('project.slug');
|
|
240
249
|
if (typeof current.engine === 'string' && (config.engine ?? 'auto') !== current.engine) frozen.push('engine');
|
|
250
|
+
// B58: a run started without the key has none to keep — turning it on now is a change; an
|
|
251
|
+
// entry that leaves it off (`required` false or absent, as the schema default) is not
|
|
252
|
+
if (config.review?.external?.required === true) frozen.push('review.external');
|
|
241
253
|
}
|
|
242
254
|
if (frozen.length > 0) {
|
|
243
255
|
const why = frozen.map((key) => `${key} cannot change mid-run (${IMMUTABLE_KEYS[/** @type {keyof typeof IMMUTABLE_KEYS} */ (key)]})`);
|