@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 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": 60,
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
  }
@@ -117,6 +117,9 @@ Every other key defaults as shown, or is entirely absent from a minimal file. `a
117
117
  | `review.provider_concurrency.openai` | integer | | `2` | min 1 |
118
118
  | `review.provider_concurrency.xai` | integer | | `2` | min 1 |
119
119
  | `review.parallel_tickets` | integer | | `3` | min 1; max 16; B42: how many review tickets the worker runs at once (oldest first); 1 = one at a time. Tickets for the same file never overlap. |
120
+ | `review.external` | object | | — | |
121
+ | `review.external.required` | boolean | | `false` | B58: true ⇒ block close skips the local per-file review checks (and the --report ticket check), with no terminal and under autopilot, and writes a signed gate.reviews_waived {by: config} row. Unowned changes, proof, no_changes and the transcript grep still refuse. No block may own .code-forge.yml. |
122
+ | `review.external.name` | string | | — | minLength 1; B58: the outside reviewer, written in the review.external and gate.reviews_waived rows. Required when required is true: run start refuses a missing or blank name. |
120
123
  | `engine` | enum: `auto`, `solo`, `harness`, `subprocess` | | `"auto"` | |
121
124
  | `harnesses` | array<string> | | — | |
122
125
  | `harness` | object | | — | |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@codedology/code-forge",
3
- "version": "0.7.5",
3
+ "version": "0.7.6",
4
4
  "description": "Plan/harden/code/review orchestration CLI and Agent Skill for cross-model, parallel, evidence-gated feature delivery.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -153,7 +153,16 @@
153
153
  "xai": { "type": "integer", "minimum": 1, "default": 2 }
154
154
  }
155
155
  },
156
- "parallel_tickets": { "type": "integer", "minimum": 1, "maximum": 16, "default": 3, "description": "B42: how many review tickets the worker runs at once (oldest first); 1 = one at a time. Tickets for the same file never overlap." }
156
+ "parallel_tickets": { "type": "integer", "minimum": 1, "maximum": 16, "default": 3, "description": "B42: how many review tickets the worker runs at once (oldest first); 1 = one at a time. Tickets for the same file never overlap." },
157
+ "external": {
158
+ "type": "object",
159
+ "additionalProperties": false,
160
+ "description": "B58: an outside reviewer reviews each block. Set it before run start; run reload refuses to add, remove or change it.",
161
+ "properties": {
162
+ "required": { "type": "boolean", "default": false, "description": "B58: true ⇒ block close skips the local per-file review checks (and the --report ticket check), with no terminal and under autopilot, and writes a signed gate.reviews_waived {by: config} row. Unowned changes, proof, no_changes and the transcript grep still refuse. No block may own .code-forge.yml." },
163
+ "name": { "type": "string", "minLength": 1, "pattern": "\\S", "description": "B58: the outside reviewer, written in the review.external and gate.reviews_waived rows. Required when required is true: run start refuses a missing or blank name." }
164
+ }
165
+ }
157
166
  }
158
167
  },
159
168
  "engine": { "type": "string", "enum": ["auto", "solo", "harness", "subprocess"], "default": "auto" },
@@ -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.
@@ -10,7 +10,7 @@
10
10
  # copied install with no package next to it); `test/skill/shim.test.mjs` asserts the pin
11
11
  # equals package.json's version.
12
12
  # The shim holds no logic of its own: every verb, flag and message is the CLI's.
13
- PINNED_VERSION="0.7.5"
13
+ PINNED_VERSION="0.7.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 --no-require-reviews)
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
- const result = await closeBlock({ runId, id, rows, writeRow, livePid: intFlag(flags['worker-pid'], 'worker-pid'), probe, extraChecks: [reviews, report] });
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
  }
@@ -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 { workspace } = await readRun(runId);
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); it then gets its first snapshot, `changed_keys: null`,
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)]})`);