@codedology/code-forge 0.7.6 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/README.md +57 -2
  3. package/docs/README.md +2 -1
  4. package/docs/reference/blocks.json +171 -1
  5. package/docs/reference/config.md +26 -8
  6. package/package.json +1 -1
  7. package/schema/code-forge.schema.json +81 -4
  8. package/skill/SKILL.md +6 -6
  9. package/skill/references/code.md +33 -4
  10. package/skill/references/ledger.md +8 -4
  11. package/skill/references/plan.md +12 -0
  12. package/skill/references/proof.md +44 -3
  13. package/skill/references/review.md +4 -2
  14. package/skill/references/security.md +1 -1
  15. package/skill/scripts/forge +1 -1
  16. package/skill/templates/coder-brief.md +18 -1
  17. package/src/autopilot/binnacle.mjs +21 -3
  18. package/src/cli/block.mjs +337 -37
  19. package/src/cli/context.mjs +175 -0
  20. package/src/cli/gates.mjs +125 -15
  21. package/src/cli/ledger.mjs +46 -2
  22. package/src/cli/proof.mjs +62 -16
  23. package/src/cli/report.mjs +21 -4
  24. package/src/cli/review-file.mjs +61 -6
  25. package/src/cli/run.mjs +16 -6
  26. package/src/cli/status.mjs +212 -0
  27. package/src/config/validate.mjs +45 -11
  28. package/src/context/pack.mjs +319 -0
  29. package/src/decide/lane-facts.mjs +41 -0
  30. package/src/doctor/local.mjs +2 -1
  31. package/src/engines/sentinel.mjs +27 -6
  32. package/src/gates/detect.mjs +21 -8
  33. package/src/gates/expand.mjs +202 -0
  34. package/src/gates/owned-count.mjs +66 -0
  35. package/src/gates/run.mjs +49 -12
  36. package/src/install/wizard/answers.mjs +34 -5
  37. package/src/install/wizard/flags.mjs +8 -1
  38. package/src/install/wizard/project-file.mjs +2 -0
  39. package/src/install/wizard/run.mjs +6 -0
  40. package/src/install/wizard/steps.mjs +24 -4
  41. package/src/install/wizard/summary.mjs +13 -5
  42. package/src/ledger/export.mjs +1 -1
  43. package/src/ledger/outside-review.mjs +164 -0
  44. package/src/ledger/report.mjs +32 -6
  45. package/src/proof/export.mjs +93 -10
  46. package/src/proof/lock.mjs +28 -5
  47. package/src/proof/worktree.mjs +482 -0
  48. package/src/review/engine.mjs +3 -2
  49. package/src/review/gate-check.mjs +9 -5
  50. package/src/review/open-findings.mjs +59 -0
  51. package/src/state/block-end.mjs +290 -0
  52. package/src/state/block.mjs +111 -37
  53. package/src/state/brief-status.mjs +131 -0
  54. package/src/state/config-snapshot.mjs +68 -8
  55. package/src/state/registry.mjs +35 -4
  56. package/src/state/run.mjs +21 -2
  57. package/src/util/breadcrumbs.mjs +1 -0
  58. package/src/util/forbidden.mjs +36 -1
  59. package/src/util/gate-token.mjs +8 -0
  60. package/src/worker/engine.mjs +157 -17
  61. package/src/worker/loop.mjs +26 -7
  62. package/src/worker/queue.mjs +36 -10
  63. package/src/worker/ticket.mjs +46 -6
@@ -0,0 +1,131 @@
1
+ /**
2
+ * B64a (issue #13 item 6): the short status of one run — the data `code-forge status` prints, as
3
+ * text or as one JSON line. A pure function: it reads the run record and the ledger rows it is
4
+ * handed and nothing else (no file, no lock, no row written); the CLI (`src/cli/status.mjs`) reads
5
+ * them and renders the result. The object it returns has exactly the keys of the `--json` run
6
+ * object, in order: run_id, status, mode, spent_usd, unknown_usd_sessions, budget_usd, blocks,
7
+ * more, closed, stopped.
8
+ *
9
+ * Per run: its status (null when the record has none — `run status` then prints no status either;
10
+ * the text shows `-`), its mode (`brief` for a `run start --mode brief` run, else `plan`), the
11
+ * spend (`runSpend`, the same numbers `run status` prints as spent_usd and unknown_usd_sessions;
12
+ * both null when the caller could not read the ledger, `rows: null`) and the budget the caller
13
+ * read, then one entry per block that is neither closed nor stopped — closed and stopped blocks are
14
+ * only counted. A block entry holds:
15
+ * - `state`: `open` on its first attempt, `attempt-<n>` after `block attempt` raised it to n; a
16
+ * block whose record status is anything other than open shows that status as its state;
17
+ * - `level`: the level it was dispatched at (the run record);
18
+ * - `model`: `<provider>/<model>` (or the model alone) of the block's latest coder `session` /
19
+ * `session.background` row of the run that names a model — the rows `src/session/spawn.mjs`
20
+ * writes carry `role`, `provider`, `model`; a `ledger add coder` row (`manual`, no model) is
21
+ * skipped — null when the ledger names none;
22
+ * - `gate`: the block's LAST gate run — `gate.done` says pass (`all_ok: true`) or fail, a
23
+ * `gate.red` with no `gate.done` after it is a fail; the name is the failing gate (the last
24
+ * `gate.red` of that run), null on a pass; `{result: 'none', name: null}` when no gate ran;
25
+ * - `findings`: the open review findings of the block, counted by the caller's `findingsOf`
26
+ * (the CLI counts what the block close gate would refuse, `src/review/gate-check.mjs`).
27
+ * Blocks are in block id order (numeric-aware: B2 before B10).
28
+ */
29
+
30
+ import { runSpend } from '../ledger/spend.mjs';
31
+
32
+ /**
33
+ * @typedef {{id: string, state: string, level: string | null, model: string | null, gate: {result: 'pass' | 'fail' | 'none', name: string | null}, findings: number}} BlockStatus
34
+ * @typedef {{run_id: string, status: string | null, mode: 'plan' | 'brief', spent_usd: number | null, unknown_usd_sessions: number | null, budget_usd: number | null, blocks: BlockStatus[], more: number, closed: number, stopped: number}} RunStatus
35
+ */
36
+
37
+ /** @param {string} a @param {string} b @returns {number} */
38
+ const byBlockId = (a, b) => a.localeCompare(b, 'en', { numeric: true }) || (a < b ? -1 : a > b ? 1 : 0);
39
+
40
+ /**
41
+ * The block's last gate run (see the module doc).
42
+ * @param {Array<Record<string, any>>} mine - the block's rows of the run, in ledger order.
43
+ * @returns {{result: 'pass' | 'fail' | 'none', name: string | null}}
44
+ */
45
+ export function lastGate(mine) {
46
+ /** @type {string | null} */
47
+ let red = null;
48
+ /** @type {{result: 'pass' | 'fail' | 'none', name: string | null}} */
49
+ let last = { result: 'none', name: null };
50
+ for (const r of mine) {
51
+ if (r.event === 'gate.red') {
52
+ red = typeof r.gate === 'string' ? r.gate : null;
53
+ last = { result: 'fail', name: red };
54
+ } else if (r.event === 'gate.done') {
55
+ last = r.all_ok === true ? { result: 'pass', name: null } : { result: 'fail', name: red };
56
+ red = null;
57
+ }
58
+ }
59
+ return last;
60
+ }
61
+
62
+ /**
63
+ * The engine/model of the block's latest coder session row, or null.
64
+ * @param {Array<Record<string, any>>} mine @returns {string | null}
65
+ */
66
+ export function coderModel(mine) {
67
+ for (let i = mine.length - 1; i >= 0; i -= 1) {
68
+ const r = mine[i];
69
+ if ((r.event === 'session' || r.event === 'session.background') && r.role === 'coder' && typeof r.model === 'string' && r.model.length > 0) {
70
+ return typeof r.provider === 'string' && r.provider.length > 0 ? `${r.provider}/${r.model}` : r.model;
71
+ }
72
+ }
73
+ return null;
74
+ }
75
+
76
+ /**
77
+ * @param {{
78
+ * record: Record<string, any>, rows: Array<Record<string, any>> | null, budget: number | null,
79
+ * findingsOf: (blockId: string) => number,
80
+ * }} opts - `rows`: the project's ledger rows (other runs are filtered out here), null when the
81
+ * ledger could not be read; `budget`: the workspace's `budget.usd` (null when unset), as
82
+ * `run status` reads it.
83
+ * @returns {RunStatus}
84
+ */
85
+ export function briefStatus({ record, rows, budget, findingsOf }) {
86
+ const runId = String(record.run_id);
87
+ /** @type {Map<string, Array<Record<string, any>>>} the run's rows, grouped by block once */
88
+ const byBlock = new Map();
89
+ for (const r of rows ?? []) {
90
+ if (r?.run !== runId || typeof r.block !== 'string') continue;
91
+ const list = byBlock.get(r.block);
92
+ if (list) list.push(r);
93
+ else byBlock.set(r.block, [r]);
94
+ }
95
+ const spend = rows == null ? null : runSpend(rows, runId);
96
+ const entries = record.blocks && typeof record.blocks === 'object' ? record.blocks : {};
97
+ const ids = Object.keys(entries).sort(byBlockId);
98
+ /** @type {BlockStatus[]} */
99
+ const blocks = [];
100
+ let closed = 0;
101
+ let stopped = 0;
102
+ for (const id of ids) {
103
+ const entry = entries[id];
104
+ const status = typeof entry?.status === 'string' ? entry.status : 'open';
105
+ if (status === 'closed') closed += 1;
106
+ else if (status === 'stopped') stopped += 1;
107
+ if (status === 'closed' || status === 'stopped') continue;
108
+ const mine = byBlock.get(id) ?? [];
109
+ const attempt = Number.isInteger(entry?.attempt) ? entry.attempt : 1;
110
+ blocks.push({
111
+ id,
112
+ state: status !== 'open' ? status : attempt > 1 ? `attempt-${attempt}` : 'open',
113
+ level: typeof entry?.level === 'string' ? entry.level : null,
114
+ model: coderModel(mine),
115
+ gate: lastGate(mine),
116
+ findings: findingsOf(id),
117
+ });
118
+ }
119
+ return {
120
+ run_id: runId,
121
+ status: typeof record.status === 'string' ? record.status : null,
122
+ mode: record.mode === 'brief' ? 'brief' : 'plan',
123
+ spent_usd: spend === null ? null : spend.usd,
124
+ unknown_usd_sessions: spend === null ? null : spend.unknown,
125
+ budget_usd: budget,
126
+ blocks,
127
+ more: 0,
128
+ closed,
129
+ stopped,
130
+ };
131
+ }
@@ -34,6 +34,16 @@ export const IMMUTABLE_KEYS = Object.freeze({
34
34
  // B58: the owner's switch past the local per-file review is set before the run starts or not at
35
35
  // all, so nobody turns it on (or off) mid-run
36
36
  'review.external': 'the outside reviewer is fixed by the owner before the run starts',
37
+ // B62a: every block of the run is isolated the same way — only two blocks that each have their
38
+ // own worktree may share owned paths — so neither key changes mid-run
39
+ 'proof.isolation': 'every block of the run is isolated the same way',
40
+ 'proof.worktree': 'the worktrees and branches of the open blocks were made with it',
41
+ // B63b: it tightens `block close` (the coder's green test run), so it is authority like
42
+ // `review.external`; the rest of `context` stays live
43
+ 'context.require_coder_tests': 'whether close needs the coder\'s green test run is fixed by the owner before the run starts',
44
+ // B64b: the block-end hook runs a command on the owner's machine at every block end, so only
45
+ // the owner sets it, before the run starts
46
+ 'hooks.on_block_end': 'it runs a command on the owner\'s machine at every block end; fixed by the owner before the run starts',
37
47
  });
38
48
 
39
49
  /** @param {unknown} v @returns {boolean} what `JSON.stringify` drops from an object. */
@@ -154,24 +164,74 @@ export function snapshotFor(record, ticket) {
154
164
  }
155
165
 
156
166
  /**
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.
167
+ * The config snapshot in force for the run (no ticket pin), or null for a record from before
168
+ * snapshots.
160
169
  * @param {Record<string, any>} record
161
- * @returns {string | null}
170
+ * @returns {Record<string, any> | null}
162
171
  * @throws {StateError} `config-snapshot` when the snapshot in force does not match its hash.
163
172
  */
164
- export function externalReviewer(record) {
165
- let snap;
173
+ export function configInForce(record) {
166
174
  try {
167
- snap = snapshotFor(record, '');
175
+ return snapshotFor(record, '')?.config ?? null;
168
176
  } catch (thrown) {
169
177
  if (!(thrown instanceof Error) || thrown.message !== 'config-snapshot') throw thrown;
170
178
  throw new StateError('config-snapshot', `run ${record.run_id}: the config snapshot in force does not match its hash`);
171
179
  }
172
- const external = snap?.config.review?.external;
180
+ }
181
+
182
+ /**
183
+ * B58: the outside reviewer named by the snapshot in force when `review.external.required` is
184
+ * true; null otherwise (key absent, `required` false, or a record from before snapshots). The key
185
+ * is immutable ({@link IMMUTABLE_KEYS}), so this is the value `run start` stored.
186
+ * @param {Record<string, any>} record
187
+ * @returns {string | null}
188
+ * @throws {StateError} `config-snapshot` when the snapshot in force does not match its hash.
189
+ */
190
+ export function externalReviewer(record) {
191
+ const external = configInForce(record)?.review?.external;
173
192
  if (!isMapping(external) || external.required !== true) return null;
174
193
  // required without a usable name is no switch at all (the schema refuses it; this is the floor)
175
194
  const name = typeof external.name === 'string' ? external.name.trim() : '';
176
195
  return name === '' ? null : name; // trimmed: no row ever carries padding
177
196
  }
197
+
198
+ /**
199
+ * B63b: whether `block close` needs the coder's `TESTS <command> exit=0` line — true only when the
200
+ * snapshot in force sets `context.require_coder_tests: true` (false for the key absent or false,
201
+ * or a record from before snapshots). The key is immutable ({@link IMMUTABLE_KEYS}), so this is
202
+ * the value `run start` stored; the live config is never read.
203
+ * @param {Record<string, any>} record
204
+ * @returns {boolean}
205
+ * @throws {StateError} `config-snapshot` when the snapshot in force does not match its hash.
206
+ */
207
+ export function requireCoderTests(record) {
208
+ const context = configInForce(record)?.context;
209
+ return isMapping(context) && context.require_coder_tests === true;
210
+ }
211
+
212
+ /** The default `hooks.on_block_end.timeout_s`. */
213
+ export const HOOK_TIMEOUT_DEFAULT_S = 60;
214
+ /** The schema's bounds of `hooks.on_block_end.timeout_s`. */
215
+ export const HOOK_TIMEOUT_MIN_S = 1;
216
+ export const HOOK_TIMEOUT_MAX_S = 600;
217
+
218
+ /**
219
+ * `hooks.on_block_end` of the run-start config snapshot (never the live config), or null when it
220
+ * is absent or malformed (the schema refuses a malformed one at `run start`).
221
+ * @param {Record<string, any>} record
222
+ * @returns {{argv: string[], timeoutS: number} | null}
223
+ */
224
+ export function blockEndHook(record) {
225
+ let hook;
226
+ try {
227
+ hook = configInForce(record)?.hooks?.on_block_end;
228
+ } catch {
229
+ return null;
230
+ }
231
+ if (hook === null || typeof hook !== 'object') return null;
232
+ const { argv, timeout_s: timeout } = hook;
233
+ if (!Array.isArray(argv) || argv.length === 0 || !argv.every((a) => typeof a === 'string' && a.length > 0)) return null;
234
+ const timeoutS = timeout === undefined ? HOOK_TIMEOUT_DEFAULT_S : timeout;
235
+ if (!Number.isInteger(timeoutS) || timeoutS < HOOK_TIMEOUT_MIN_S || timeoutS > HOOK_TIMEOUT_MAX_S) return null;
236
+ return { argv: [...argv], timeoutS };
237
+ }
@@ -34,6 +34,10 @@
34
34
  * vs `src/**` and vs `src/feature/*.swift` overlap, vs `src/*.mjs` and `src/featureX/*` do not);
35
35
  * a `file` entry against a glob overlaps only when the glob matches it (`src/README` vs
36
36
  * `src/**\/*.mjs` does not).
37
+ *
38
+ * B62a — between two OPEN BLOCKS the rule is `blocksOverlap`: two blocks that each have their own
39
+ * worktree (`proof.isolation: worktree`) may own the same path; any pair with a shared-tree block
40
+ * in it is checked with `findOverlap` exactly as above.
37
41
  */
38
42
 
39
43
  import { StateError } from './paths.mjs';
@@ -99,21 +103,24 @@ export function expandBraces(pattern) {
99
103
 
100
104
  /**
101
105
  * @param {unknown} owned
106
+ * @param {{label?: string, code?: string}} [as] - B60: `block open --tests` checks its test files by
107
+ * the same rules, named `test files` with the code `bad-tests`.
102
108
  * @returns {string[]}
103
109
  * An EXACT entry (no `*`, `?` or `{`) may contain `[ ] ( ) ! + @ }` — real routes such as
104
110
  * `pages/[id].vue`, `app/(group)/page.tsx`, `@types/x.d.ts` — and is matched literally by
105
111
  * string equality. A GLOB entry may not: there those characters would be character-class or
106
112
  * extglob syntax this matcher does not implement, so it is refused with that reason rather than
107
113
  * silently read one way by us and another way by git or a shell.
108
- * @throws {StateError} `bad-owned` — empty, absolute, `.`/`..`/empty segments (checked on every
114
+ * @throws {StateError} with code `as.code` (default `bad-owned`) and a message that starts with
115
+ * `as.label` (default `owned files`) — empty, absolute, `.`/`..`/empty segments (checked on every
109
116
  * brace alternative), backslashes, unbalanced braces, or unsupported glob syntax in a glob.
110
117
  */
111
- export function assertOwned(owned) {
112
- if (!Array.isArray(owned) || owned.length === 0) throw new StateError('bad-owned', 'owned files: at least one path is required');
118
+ export function assertOwned(owned, { label = 'owned files', code = 'bad-owned' } = {}) {
119
+ if (!Array.isArray(owned) || owned.length === 0) throw new StateError(code, `${label}: at least one path is required`);
113
120
  for (const entry of owned) {
114
121
  /** @param {string} why */
115
122
  const refuse = (why) => {
116
- throw new StateError('bad-owned', `owned files: invalid path ${JSON.stringify(entry)}: ${why}`);
123
+ throw new StateError(code, `${label}: invalid path ${JSON.stringify(entry)}: ${why}`);
117
124
  };
118
125
  if (typeof entry !== 'string' || entry.length === 0) refuse('must be a non-empty string');
119
126
  if (entry.includes('\\')) refuse('backslashes are not allowed (POSIX paths only)');
@@ -240,6 +247,30 @@ export function findOverlap(ownedA, ownedB, { kindsA, kindsB } = {}) {
240
247
  return null;
241
248
  }
242
249
 
250
+ /**
251
+ * One open block as the overlap rule sees it: its owned entries, their kinds (B57) and its own
252
+ * worktree, if any (B62a). `worktree` is the run record's `{path, branch, base_sha}` (or, for a
253
+ * block being opened, `{path}` of the tree it is about to get); `null` or absent means it works in
254
+ * the shared tree (export or lock isolation). Every caller passes this one shape.
255
+ * @typedef {{owned_files: ReadonlyArray<string>, owned_kinds?: OwnedKinds, worktree?: {path: string} | null}} OverlapBlock
256
+ */
257
+
258
+ /**
259
+ * B62a: the owned-files overlap rule between two open blocks. Two blocks that EACH have their own
260
+ * worktree, at different paths, may own the same path — each edits its own copy on its own branch,
261
+ * and nothing is merged by code-forge — so the pair never overlaps. Two blocks on the SAME tree
262
+ * path edit one tree, and a block without a worktree (export or lock isolation) edits the shared
263
+ * tree: both are checked exactly as before B62a ({@link findOverlap}, B57's kinds included).
264
+ * @param {OverlapBlock} a @param {OverlapBlock} b
265
+ * @returns {[string, string] | null} the first overlapping pair (a's entry, b's entry), or null.
266
+ */
267
+ export function blocksOverlap(a, b) {
268
+ const treeA = a.worktree ?? null;
269
+ const treeB = b.worktree ?? null;
270
+ if (treeA !== null && treeB !== null && treeA.path !== treeB.path) return null;
271
+ return findOverlap(a.owned_files, b.owned_files, { kindsA: a.owned_kinds, kindsB: b.owned_kinds });
272
+ }
273
+
243
274
  /**
244
275
  * Does `owned` own repo-relative `file`? A directory-like exact entry owns its path and every path
245
276
  * below it, a `file` one (per `kinds`) only itself (B57); a glob entry owns what it matches. A
package/src/state/run.mjs CHANGED
@@ -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,8 @@ 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';
152
160
  // B58: the schema lets `review.external` stand without a name while `required` is off; on, the
153
161
  // reviewer must be named — refused before anything is written
154
162
  const external = config.review?.external;
@@ -172,6 +180,7 @@ export async function startRun(opts) {
172
180
  status: 'active',
173
181
  blocks: {},
174
182
  orphans: [],
183
+ ...(brief ? { mode: 'brief' } : {}),
175
184
  // B35: the config snapshot the worker reviews with (`run reload` replaces it). Only when the
176
185
  // caller passed the loaded config: a record without it keeps the worker on its boot config.
177
186
  ...(opts.config !== undefined ? { config: initialConfigState(config) } : {}),
@@ -185,6 +194,7 @@ export async function startRun(opts) {
185
194
  await generateKey(runId);
186
195
  await saveRun(record);
187
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' });
188
198
  // B58: the owner's outside reviewer is on the ledger from the first row of the run on
189
199
  const reviewer = externalReviewer(record);
190
200
  if (reviewer !== null) await writeSigned(runId, writeRow, { event: 'review.external', required: true, reviewer });
@@ -207,7 +217,9 @@ export async function startRun(opts) {
207
217
  * Fixed keys ({@link IMMUTABLE_KEYS}): every one that changed is refused at once
208
218
  * (`immutable-key`, nothing written). A record from before snapshots (no `config`) is checked
209
219
  * against what it does store: `engine` and the ledger slug (`effectiveSlug`, computed by the
210
- * caller the same way `run start` did), and B58's `review.external.required` must not turn true;
220
+ * caller the same way `run start` did), B58's `review.external.required` must not turn true,
221
+ * B62a's `proof.isolation` must not turn to `worktree`, and B63b's `context.require_coder_tests`
222
+ * must not turn true;
211
223
  * it then gets its first snapshot, `changed_keys: null`, `old_hash: null`.
212
224
  *
213
225
  * No change (same hash) ⇒ nothing written, no row. Otherwise the record is saved FIRST (it is the
@@ -250,6 +262,13 @@ export async function reloadRun({ runId, config, readPending = () => [], effecti
250
262
  // B58: a run started without the key has none to keep — turning it on now is a change; an
251
263
  // entry that leaves it off (`required` false or absent, as the schema default) is not
252
264
  if (config.review?.external?.required === true) frozen.push('review.external');
265
+ // B62a: a run started without a snapshot was isolated by export (or lock); turning worktree
266
+ // isolation on now is a change
267
+ if (config.proof?.isolation === 'worktree') frozen.push('proof.isolation');
268
+ // B63b: likewise, a run started without a snapshot never required the coder's tests
269
+ if (config.context?.require_coder_tests === true) frozen.push('context.require_coder_tests');
270
+ // B64b: and never had a block-end hook
271
+ if (config.hooks?.on_block_end !== undefined) frozen.push('hooks.on_block_end');
253
272
  }
254
273
  if (frozen.length > 0) {
255
274
  const why = frozen.map((key) => `${key} cannot change mid-run (${IMMUTABLE_KEYS[/** @type {keyof typeof IMMUTABLE_KEYS} */ (key)]})`);
@@ -27,6 +27,7 @@ import path from 'node:path';
27
27
  export const SUBCOMMANDS = Object.freeze({
28
28
  autopilot: ['start', 'status', 'stop'],
29
29
  block: ['open', 'attempt', 'rebase', 'claim', 'close', 'stop', 'waive'],
30
+ context: ['pack'],
30
31
  gates: ['detect', 'run', 'secret-scan', 'safe-edit', 'scope', 'acceptance', 'transcript-grep'],
31
32
  jev: ['ask'],
32
33
  keys: ['list', 'set', 'test', 'remove'],
@@ -277,7 +277,9 @@ export const FORBIDDEN = Object.freeze(
277
277
  * always appends them, so every list built for a coder carries them.
278
278
  * - `code-forge block waive` — waiving a review finding is human-only;
279
279
  * - `code-forge autopilot` (any subcommand, B45) — the owner's grant to a delegate;
280
- * - `--no-require-reviews` — the human's way out of `block close`'s per-file review check.
280
+ * - `--no-require-reviews` — the human's way out of `block close`'s per-file review check;
281
+ * - `--remove-worktree` (B62a) — `block close` removing the block's worktree, the orchestrator's
282
+ * alone. `block close` itself is not on this list, so the flag is listed on its own.
281
283
  * @type {ReadonlyArray<ForbiddenEntry>}
282
284
  */
283
285
  export const CODER_ONLY_FORBIDDEN = Object.freeze(
@@ -306,6 +308,12 @@ export const CODER_ONLY_FORBIDDEN = Object.freeze(
306
308
  tokens: ['--no-require-reviews'],
307
309
  description: 'Close a block without the per-file review check (--no-require-reviews) — human-only',
308
310
  },
311
+ {
312
+ id: 'code-forge-remove-worktree',
313
+ kind: 'contains',
314
+ tokens: ['--remove-worktree'],
315
+ description: "Remove a block's worktree at close (block close --remove-worktree, B62a) — the orchestrator only",
316
+ },
309
317
  ].map(freezeEntry),
310
318
  );
311
319
 
@@ -342,6 +350,33 @@ export function mergeForbidden(extraTokens = []) {
342
350
  return [...FORBIDDEN, ...CODER_ONLY_FORBIDDEN, ...extras];
343
351
  }
344
352
 
353
+ /** B64b: the id of the entry {@link hookForbidden} builds. */
354
+ export const HOOK_FORBIDDEN_ID = 'code-forge-on-block-end-hook';
355
+
356
+ /**
357
+ * B64b: the owner's `hooks.on_block_end` command as a coder-forbidden `prefix` entry — the hook
358
+ * runs only from the orchestrator's block-end verbs, never for a coder. The tokens are TRIMMED:
359
+ * the command name of `argv[0]` (its basename, as every reading normalizes it), plus `argv[1]`
360
+ * when there is one that is not blank (clause 3, "if any"). `argv[1]` stays ONE token even with
361
+ * inner whitespace — a literal match, as ruled. No entry (`[]`, so a list built with it is
362
+ * unchanged) when there is no hook (`argv` absent, empty, or not all non-empty strings), or when
363
+ * `argv[0]` is blank, names a directory (`/`, `dir/`), or is `.` or `..`. Appended by the caller
364
+ * after the static entries; it never replaces or weakens one.
365
+ * @param {unknown} argv
366
+ * @returns {ForbiddenEntry[]}
367
+ */
368
+ export function hookForbidden(argv) {
369
+ if (!Array.isArray(argv) || argv.length === 0 || !argv.every((a) => typeof a === 'string' && a.length > 0)) return [];
370
+ const first = argv[0].trim();
371
+ // a command name that is blank, a directory (`/`, `dir/`), `.` or `..` matches nothing a coder runs
372
+ if (first.length === 0 || /[/\\]$/.test(first)) return [];
373
+ const name = commandName(first).trim();
374
+ if (name.length === 0 || name === '.' || name === '..') return [];
375
+ const arg = argv.length > 1 ? argv[1].trim() : '';
376
+ const tokens = arg.length > 0 ? [name, arg] : [name];
377
+ return [freezeEntry({ id: HOOK_FORBIDDEN_ID, kind: 'prefix', tokens, description: 'Run the configured on_block_end hook (hooks.on_block_end) — it runs only at a block end, from the orchestrator\'s verbs' })];
378
+ }
379
+
345
380
  // ── Normalization ────────────────────────────────────────────────────────────
346
381
 
347
382
  /** git global options that take the NEXT argv token as their value (when not written `--opt=value`). */
@@ -0,0 +1,8 @@
1
+ /**
2
+ * The one argv item a gate uses for the block's related tests. It lives in `util` because two
3
+ * layers need the same value: `gates/expand.mjs` expands it, and `config/validate.mjs` refuses it as
4
+ * a gate's command (`config` may not import `gates`).
5
+ */
6
+
7
+ /** A gate argv item that is EXACTLY this string expands to the block's test files. */
8
+ export const TEST_FILES_TOKEN = '{test_files}';