@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,290 @@
1
+ /**
2
+ * B64b (issue #13): the block-end signal — a done file and the owner's `hooks.on_block_end` hook.
3
+ *
4
+ * A block ends where `src/state/block.mjs` sets its status to `closed` or `stopped`: `closeBlock`
5
+ * closing it, `closeBlock` stopping it (a pinned worker event or `ledger.tamper`), and `stopBlock`
6
+ * (`block stop`). After each end — its ledger rows written and the run lock released — the CLI
7
+ * (`src/cli/block.mjs`):
8
+ *
9
+ * 1. writes `<runs>/<run>/blocks/<id>.done.json` ({@link doneFilePath}) atomically (a temp file in
10
+ * the same directory, then a rename; a second end overwrites it) with {@link doneFileContent}:
11
+ * the block, the run, its status, the end's event and reason, the HEAD commit of the block's
12
+ * tree, the block's own spend and its open findings, and the time. Never transcript text, diff
13
+ * text or secrets. A failed write changes nothing but one stderr warning line.
14
+ * 2. runs `hooks.on_block_end` (`blockEndHook` in `config-snapshot.mjs`, from the run-start
15
+ * config snapshot only — the key is immutable) once, outside the run lock: argv only (no
16
+ * shell), cwd the repository top, stdin closed, stdout and stderr discarded, the parent env plus
17
+ * `CODE_FORGE_RUN`, `CODE_FORGE_BLOCK`, `CODE_FORGE_BLOCK_STATUS` and `CODE_FORGE_DONE_FILE`;
18
+ * its process group is killed at `timeout_s`. One signed `hook.ran` row records it (under the run lock). A hook
19
+ * that fails, is missing, times out or crashes changes nothing but one stderr warning line.
20
+ */
21
+
22
+ import { spawn } from 'node:child_process';
23
+ import { mkdir, rename, rm, stat, writeFile } from 'node:fs/promises';
24
+ import path from 'node:path';
25
+ import { runSpend } from '../ledger/spend.mjs';
26
+ import { readAllRows } from '../ledger/write.mjs';
27
+ import { exec } from '../util/exec.mjs';
28
+ import { assertRunId, runsDir } from './paths.mjs';
29
+ import { withRunLock, writeSigned } from './run.mjs';
30
+
31
+ // B64b: the hook's config reader lives next to `externalReviewer` (read by `state/block.mjs` too)
32
+ export { HOOK_TIMEOUT_DEFAULT_S, HOOK_TIMEOUT_MAX_S, HOOK_TIMEOUT_MIN_S, blockEndHook } from './config-snapshot.mjs';
33
+
34
+ /**
35
+ * The block's done file: `<runs>/<run>/blocks/<id>.done.json` (the run's own directory under the
36
+ * runs dir, next to the run record — never in the source tree).
37
+ * @param {string} runId @param {string} id
38
+ * @returns {string} an absolute path
39
+ */
40
+ export function doneFilePath(runId, id) {
41
+ return path.resolve(runsDir(), assertRunId(runId), 'blocks', `${safeId(id)}.done.json`);
42
+ }
43
+
44
+ /** @param {string} id @returns {string} the id, refused when it could leave the blocks dir. */
45
+ function safeId(id) {
46
+ if (typeof id !== 'string' || id.length === 0 || id.includes('/') || id.includes('\\') || id === '.' || id === '..' || id.includes('\0')) {
47
+ throw new TypeError(`block id ${JSON.stringify(id)} cannot name a done file`);
48
+ }
49
+ return id;
50
+ }
51
+
52
+ /**
53
+ * The done file's content, keys in this order. Pure.
54
+ * @param {{
55
+ * block: string, runId: string, status: 'closed' | 'stopped', event: string, reason?: string | null,
56
+ * commit: string | null, spend: {usd: number, unknown: number} | null, findings: number | null, at: Date,
57
+ * }} facts
58
+ * @returns {{block: string, run_id: string, status: string, event: string, reason: string | null, commit: string | null,
59
+ * spent_usd: number | null, unknown_usd_sessions: number | null, findings: number | null, at: string}}
60
+ */
61
+ export function doneFileContent({ block, runId, status, event, reason = null, commit, spend, findings, at }) {
62
+ return {
63
+ block,
64
+ run_id: runId,
65
+ status,
66
+ event,
67
+ reason: typeof reason === 'string' ? reason : null,
68
+ commit: typeof commit === 'string' && commit.length > 0 ? commit : null,
69
+ // a non-finite spend or a non-integer count is unknown (null) — the keys are never dropped
70
+ spent_usd: spend && Number.isFinite(spend.usd) ? spend.usd : null,
71
+ unknown_usd_sessions: spend && Number.isInteger(spend.unknown) ? spend.unknown : null,
72
+ findings: Number.isInteger(findings) ? findings : null,
73
+ at: at.toISOString(),
74
+ };
75
+ }
76
+
77
+ /** @param {string} cwd @returns {Promise<string | null>} HEAD of `cwd`'s repository, or null. */
78
+ async function headCommit(cwd) {
79
+ try {
80
+ const env = {};
81
+ for (const [name, value] of Object.entries(process.env)) if (!name.startsWith('GIT_')) env[name] = value;
82
+ const res = await exec(['git', 'rev-parse', '--verify', 'HEAD'], {
83
+ cwd,
84
+ env: { ...env, GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: process.platform === 'win32' ? 'NUL' : '/dev/null', GIT_TERMINAL_PROMPT: '0' },
85
+ timeoutMs: 10000,
86
+ });
87
+ const sha = res.stdout.trim();
88
+ return res.result === 'ok' && /^[0-9a-f]{40,64}$/.test(sha) ? sha : null;
89
+ } catch {
90
+ return null;
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Write the block's done file after an end (its ledger rows are already written). Never throws: a
96
+ * failure prints `warning: done file not written: <reason>` on stderr and returns null.
97
+ * @param {{
98
+ * record: Record<string, any>, id: string, status: 'closed' | 'stopped', event: string, reason?: string | null,
99
+ * openFindings: (rows: Record<string, any>[]) => Promise<number>, err: (s: string) => unknown, now?: Date,
100
+ * }} opts - `record` is the run record read before the end (its `workspace` and the block's
101
+ * `worktree` do not change at an end); `openFindings` counts the block's open findings exactly
102
+ * as `code-forge status` does.
103
+ * @returns {Promise<string | null>} the done file's absolute path, or null when it was not written.
104
+ */
105
+ export async function writeDoneFile({ record, id, status, event, reason = null, openFindings, err, now = new Date() }) {
106
+ const runId = String(record?.run_id);
107
+ try {
108
+ const file = doneFilePath(runId, id);
109
+ /** @type {Record<string, any>[] | null} */
110
+ let rows = null;
111
+ try {
112
+ rows = await readAllRows(record.project);
113
+ } catch {
114
+ rows = null;
115
+ }
116
+ const spend = rows === null ? null : runSpend(rows.filter((r) => r?.block === id), runId);
117
+ // like the spend: unknown (null) when the rows cannot be read or the count fails — never a 0
118
+ /** @type {number | null} */
119
+ let findings = null;
120
+ if (rows !== null) {
121
+ try {
122
+ const n = await openFindings(rows);
123
+ findings = Number.isInteger(n) && n >= 0 ? n : null;
124
+ } catch {
125
+ findings = null;
126
+ }
127
+ }
128
+ const tree = record.blocks?.[id]?.worktree?.path;
129
+ const commit = await headCommit(typeof tree === 'string' && tree.length > 0 ? tree : record.workspace);
130
+ const content = doneFileContent({ block: id, runId, status, event, reason, commit, spend, findings, at: now });
131
+ const dir = path.dirname(file);
132
+ await mkdir(dir, { recursive: true, mode: 0o700 });
133
+ const tmp = path.join(dir, `.${path.basename(file)}.${process.pid}.${Date.now()}.tmp`);
134
+ try {
135
+ await writeFile(tmp, `${JSON.stringify(content)}\n`, { mode: 0o600 });
136
+ await rename(tmp, file);
137
+ } catch (thrown) {
138
+ await rm(tmp, { force: true }).catch(() => {});
139
+ throw thrown;
140
+ }
141
+ return file;
142
+ } catch (thrown) {
143
+ err(`warning: done file not written: ${reasonOf(thrown)}\n`);
144
+ return null;
145
+ }
146
+ }
147
+
148
+ /** @param {unknown} thrown @returns {string} a one-line reason (an error code or message). */
149
+ function reasonOf(thrown) {
150
+ const any = /** @type {any} */ (thrown);
151
+ const text = typeof any?.code === 'string' ? any.code : typeof any?.message === 'string' ? any.message : String(thrown);
152
+ return text.split('\n')[0];
153
+ }
154
+
155
+ /**
156
+ * @typedef {{exit: number | null, signal: string | null, timedOut: boolean, notFound: boolean, error: string | null}} SpawnResult
157
+ */
158
+
159
+ /** @param {unknown} thrown @returns {string} the error's code (`EACCES`, …), else `UNKNOWN` — never its message. */
160
+ function errorCode(thrown) {
161
+ const code = /** @type {any} */ (thrown)?.code;
162
+ return typeof code === 'string' && /^[A-Z0-9_]{1,32}$/.test(code) ? code : 'UNKNOWN';
163
+ }
164
+
165
+ /**
166
+ * The warning's tail for a hook that did not exit 0 (ruling round 2 (b)), or null for a clean exit:
167
+ * `exit N`, `timed out`, `not found`, `signal <NAME>` or `failed to start (<code>)`.
168
+ * @param {SpawnResult} ran @returns {string | null}
169
+ */
170
+ export function hookWarning(ran) {
171
+ if (ran.timedOut) return 'timed out';
172
+ if (ran.notFound) return 'not found';
173
+ if (ran.error !== null) return `failed to start (${ran.error})`;
174
+ if (typeof ran.exit === 'number') return ran.exit === 0 ? null : `exit ${ran.exit}`;
175
+ if (typeof ran.signal === 'string') return `signal ${ran.signal}`;
176
+ return 'failed to start (UNKNOWN)';
177
+ }
178
+
179
+ /**
180
+ * Spawn the hook: no shell, stdin closed, stdout and stderr discarded, its own process group,
181
+ * killed (the whole group) at the timeout.
182
+ * @param {string[]} argv @param {{cwd: string, env: NodeJS.ProcessEnv, timeoutMs: number}} opts
183
+ * `notFound` is an ENOENT for `argv[0]` itself; every other spawn error (a missing cwd included —
184
+ * Node names `argv[0]` in that ENOENT too, so the cwd is checked first) is `error`, its code.
185
+ * @returns {Promise<SpawnResult>}
186
+ */
187
+ async function spawnHook(argv, { cwd, env, timeoutMs }) {
188
+ try {
189
+ if (!(await stat(cwd)).isDirectory()) return { exit: null, signal: null, timedOut: false, notFound: false, error: 'ENOTDIR' };
190
+ } catch (thrown) {
191
+ return { exit: null, signal: null, timedOut: false, notFound: false, error: errorCode(thrown) };
192
+ }
193
+ return new Promise((resolve) => {
194
+ let timedOut = false;
195
+ let settled = false;
196
+ /** @type {NodeJS.Timeout | undefined} */
197
+ let timer;
198
+ const done = (/** @type {SpawnResult} */ r) => {
199
+ if (settled) return;
200
+ settled = true;
201
+ clearTimeout(timer);
202
+ resolve(r);
203
+ };
204
+ const failed = (/** @type {unknown} */ thrown) => {
205
+ const any = /** @type {any} */ (thrown);
206
+ const notFound = any?.code === 'ENOENT' && (any?.path === undefined || any.path === argv[0]);
207
+ done({ exit: null, signal: null, timedOut: false, notFound, error: notFound ? null : errorCode(thrown) });
208
+ };
209
+ let child;
210
+ try {
211
+ child = spawn(argv[0], argv.slice(1), { cwd, env, shell: false, detached: true, stdio: ['ignore', 'ignore', 'ignore'] });
212
+ } catch (thrown) {
213
+ failed(thrown);
214
+ return;
215
+ }
216
+ const pid = child.pid;
217
+ child.on('error', failed);
218
+ child.on('close', (code, signal) => done({ exit: code, signal: signal ?? null, timedOut, notFound: false, error: null }));
219
+ timer = setTimeout(() => {
220
+ timedOut = true;
221
+ try {
222
+ if (typeof pid === 'number' && pid > 1) process.kill(-pid, 'SIGKILL');
223
+ else child.kill('SIGKILL');
224
+ } catch {
225
+ try {
226
+ child.kill('SIGKILL');
227
+ } catch {
228
+ // already gone
229
+ }
230
+ }
231
+ }, timeoutMs);
232
+ });
233
+ }
234
+
235
+ /**
236
+ * Run `hooks.on_block_end` once for an end, outside the run lock, then write one signed `hook.ran`
237
+ * row under it (also when the hook failed, timed out or was not found). Never throws, never prints
238
+ * on stdout; a hook that did not exit 0 prints one `warning: on_block_end hook <tail>` line on
239
+ * stderr ({@link hookWarning}). `doneFile` is the empty string when the done file was not written.
240
+ * @param {{
241
+ * hook: {argv: string[], timeoutS: number}, runId: string, id: string, status: string, doneFile: string,
242
+ * repoTop: string, writeRow: (row: Record<string, any>) => Promise<unknown>, err: (s: string) => unknown,
243
+ * }} opts
244
+ * @returns {Promise<Record<string, any> | null>} the row written (unsigned fields), or null when none was.
245
+ */
246
+ export async function runBlockEndHook({ hook, runId, id, status, doneFile, repoTop, writeRow, err }) {
247
+ const env = {
248
+ ...process.env,
249
+ CODE_FORGE_RUN: runId,
250
+ CODE_FORGE_BLOCK: id,
251
+ CODE_FORGE_BLOCK_STATUS: status,
252
+ CODE_FORGE_DONE_FILE: doneFile,
253
+ };
254
+ const started = process.hrtime.bigint();
255
+ /** @type {SpawnResult} */
256
+ let ran;
257
+ // "failed to start" covers the spawn only; a later failure gets neutral wording below
258
+ try {
259
+ ran = await spawnHook(hook.argv, { cwd: repoTop, env, timeoutMs: hook.timeoutS * 1000 });
260
+ } catch (thrown) {
261
+ ran = { exit: null, signal: null, timedOut: false, notFound: false, error: errorCode(thrown) };
262
+ }
263
+ try {
264
+ const durationMs = Math.round(Number(process.hrtime.bigint() - started) / 1e6);
265
+ const warning = hookWarning(ran);
266
+ if (warning !== null) err(`warning: on_block_end hook ${warning}\n`);
267
+ const row = {
268
+ event: 'hook.ran',
269
+ block: id,
270
+ hook: 'on_block_end',
271
+ argv0: path.basename(hook.argv[0]),
272
+ exit: ran.exit,
273
+ signal: ran.signal,
274
+ timed_out: ran.timedOut,
275
+ duration_ms: durationMs,
276
+ };
277
+ try {
278
+ // clause 2: the hook.ran row is appended UNDER the run lock (writeSigned alone takes no lock)
279
+ await withRunLock(runId, () => writeSigned(runId, writeRow, row));
280
+ } catch (thrown) {
281
+ // an error code only, never raw message text
282
+ err(`warning: on_block_end hook row not written (${errorCode(thrown)})\n`);
283
+ return null;
284
+ }
285
+ return row;
286
+ } catch (thrown) {
287
+ err(`warning: on_block_end hook not recorded (${errorCode(thrown)})\n`);
288
+ return null;
289
+ }
290
+ }
@@ -11,18 +11,18 @@
11
11
  * B12) — this module never imports them (C17).
12
12
  */
13
13
 
14
- import { createHash } from 'node:crypto';
14
+ import { createHash, randomUUID } from 'node:crypto';
15
15
  import { createReadStream, existsSync, lstatSync, readlinkSync, realpathSync } from 'node:fs';
16
16
  import os from 'node:os';
17
17
  import path from 'node:path';
18
18
  import { BLOCK_KINDS, blockKind, dispatchLevel } from '../decide/escalation.mjs';
19
19
  import { exec } from '../util/exec.mjs';
20
20
  import { DEFAULT_CONFIG_FILENAME } from '../config/load.mjs';
21
- import { externalReviewer } from './config-snapshot.mjs';
21
+ import { blockEndHook, externalReviewer } from './config-snapshot.mjs';
22
22
  import { ownedKinds, pathKind } from './owned-kinds.mjs';
23
23
  import { isAncestorArgv, revParseArgv } from '../util/git.mjs';
24
24
  import { StateError } from './paths.mjs';
25
- import { assertOwned, findOverlap, isExactEntry, ownsFile, scopeGate } from './registry.mjs';
25
+ import { assertOwned, blocksOverlap, isExactEntry, ownsFile, scopeGate } from './registry.mjs';
26
26
  import { checkWorkerPin, readRun, saveRun, withRunLock, writeSigned } from './run.mjs';
27
27
  import { verifyRow, loadKey } from './signer.mjs';
28
28
 
@@ -261,34 +261,42 @@ function activeBlocks(record) {
261
261
  * is the owner's switch past the local per-file review, so no block may own it. These are the
262
262
  * spellings an owned entry may match it by: relative to the workspace and, for a workspace in a
263
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[]>}
264
+ * Empty when the key is not set. B64b: `hooks.on_block_end` in the snapshot guards it the same way
265
+ * — the file names a command the orchestrator runs at every block end — and `why` names the key
266
+ * that guards it (`review.external.required` first, so its message is unchanged).
267
+ * @param {Record<string, any>} record @returns {Promise<{paths: string[], why: string}>}
266
268
  * @throws {StateError} `config-snapshot`, `git-failed`
267
269
  */
268
270
  async function guardedConfigPaths(record) {
269
- if (externalReviewer(record) === null) return [];
271
+ const why = externalReviewer(record) !== null ? 'review.external.required is true' : blockEndHook(record) !== null ? 'hooks.on_block_end is set' : null;
272
+ if (why === null) return { paths: [], why: '' };
270
273
  const top = await repoTop(record.workspace);
271
274
  // the repo-top spelling `<prefix>/.code-forge.yml` may also match a different file of the same
272
275
  // name under the workspace (`<ws>/<prefix>/.code-forge.yml`): that over-match only refuses more,
273
276
  // never less, and is the one `ownsAt` gives the close gate
274
277
  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}`];
278
+ return { paths: prefix === '' ? [DEFAULT_CONFIG_FILENAME] : [DEFAULT_CONFIG_FILENAME, `${prefix}/${DEFAULT_CONFIG_FILENAME}`], why };
276
279
  }
277
280
 
278
281
  /**
279
282
  * B58: refuse an owned entry that owns one of `guarded` (B57's rule, `ownsFile`: the exact path,
280
283
  * 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
284
+ * @param {ReadonlyArray<string>} owned @param {import('./registry.mjs').OwnedKinds} kinds @param {{paths: ReadonlyArray<string>, why: string}} guarded
282
285
  * @throws {StateError} `config-owned`
283
286
  */
284
287
  function refuseConfigOwner(owned, kinds, guarded) {
285
288
  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`);
289
+ if (guarded.paths.some((file) => ownsFile([entry], file, kinds))) {
290
+ throw new StateError('config-owned', `owned entry ${JSON.stringify(entry)} covers the project's ${DEFAULT_CONFIG_FILENAME}; no block may own it while ${guarded.why}`);
288
291
  }
289
292
  }
290
293
  }
291
294
 
295
+ /**
296
+ * @typedef {{path: string, branch: string, base_sha: string}} TreeRecord - a block entry's `worktree` (B62a).
297
+ * @typedef {{worktree: TreeRecord, created: boolean, rollback: (() => Promise<string | null>) | null}} OpenTree - `rollback` resolves to a sentence naming leftovers, or null.
298
+ */
299
+
292
300
  /**
293
301
  * `block open` — refuses a second open of the same id and any owned set that overlaps another
294
302
  * ACTIVE block's; records base, owned files, level, attempt, acceptance, line forecast and the
@@ -299,12 +307,34 @@ function refuseConfigOwner(owned, kinds, guarded) {
299
307
  * tree's changes at open, which `block close` reads to tell the coder's changes from earlier ones.
300
308
  * B58: with `review.external.required` set, an owned entry that owns the project's config is
301
309
  * refused (`config-owned`, {@link refuseConfigOwner}).
310
+ * B59: `decision` (the rules lane of a brief-mode block) is written as a signed `decision` row
311
+ * naming the block, with `decisionId` (default a fresh UUID) as its `decision_id`, just before the
312
+ * `dispatch` row — under the same lock, so a refused open writes neither. Its `answer` is the
313
+ * requested lane and must equal `level` (else `bad-decision`, nothing written); the docs floor may
314
+ * still raise the dispatched level, and the dispatch row then names the lane as `requested_level`.
315
+ * B60: `tests` (`block open --tests <paths…>`, the same path rules as owned entries, else
316
+ * `bad-tests`) is recorded as the entry's `test_files` and on the dispatch row; a gate argv item
317
+ * `{test_files}` expands to them (`gates/expand.mjs`). Left out, neither carries the key.
318
+ * B62a: `worktree` (given by `block open` under `proof.isolation: worktree`, `proof/worktree.mjs`;
319
+ * this module never imports it) plans the block's own worktree, or the reuse of the one an earlier
320
+ * entry of the same id recorded (`previous`); its `make()` makes or reuses it. Both are called
321
+ * under the lock before anything is recorded: the plan before the overlap check (which lets this
322
+ * block share owned paths with another open block on a different tree, `blocksOverlap`), `make()`
323
+ * after every other check. A refusal there writes nothing; if the rows or the save fail after
324
+ * `make()` created a tree, its `rollback()` removes it again, and the original error is thrown
325
+ * with a sentence added naming anything the rollback could not remove. The entry stores the tree's
326
+ * `{path, branch, base_sha}` as `worktree`; a tree it made is a signed `worktree.created
327
+ * {block, path, branch, base}` row just before `dispatch`. On a re-open the block's base is the
328
+ * tree's `base_sha`, and a `base` that resolves to another commit is refused (`worktree-base`).
302
329
  * @param {{
303
330
  * runId: string, id: string, level: string, owned: string[], acceptance: unknown,
304
331
  * attempt?: number, base?: string, lines?: number, brief?: {path: string, content: Buffer | string},
305
- * kind?: string, cfg?: Record<string, any>, writeRow: WriteRow, now?: Date,
332
+ * kind?: string, cfg?: Record<string, any>, writeRow: WriteRow, now?: Date, tests?: string[],
333
+ * decision?: {question: 'lane', answer: string, source: 'rules', facts: Record<string, unknown>}, decisionId?: string,
334
+ * worktree?: (ctx: {baseSha: string, previous: TreeRecord | null}) => Promise<{path: string, make: () => Promise<OpenTree>}>,
306
335
  * }} opts
307
- * @returns {Promise<{block: Record<string, any>, pointer: string | null}>}
336
+ * @returns {Promise<{block: Record<string, any>, pointer: string | null, worktreeReused: boolean}>} `worktreeReused`:
337
+ * the block took an earlier attempt's worktree as it was.
308
338
  */
309
339
  export async function openBlock(opts) {
310
340
  const { runId, id, level, attempt = 1, lines = null, writeRow, now = new Date() } = opts;
@@ -313,8 +343,10 @@ export async function openBlock(opts) {
313
343
  if (!Number.isInteger(attempt) || attempt < 1) throw new StateError('bad-attempt', 'attempt must be an integer >= 1');
314
344
  if (lines !== null && !(Number.isInteger(lines) && lines > 0)) throw new StateError('bad-lines', 'lines forecast must be a positive integer');
315
345
  if (opts.kind !== undefined && !BLOCK_KINDS.includes(opts.kind)) throw new StateError('bad-kind', `kind must be one of ${BLOCK_KINDS.join(', ')}`);
346
+ if (opts.decision && opts.decision.answer !== level) throw new StateError('bad-decision', `the decision answer ${opts.decision.answer} is not the requested level ${level}`);
316
347
  const owned = assertOwned(opts.owned);
317
348
  const acceptance = assertAcceptance(opts.acceptance);
349
+ const tests = opts.tests === undefined ? null : [...assertOwned(opts.tests, { label: 'test files', code: 'bad-tests' })];
318
350
  const kind = blockKind({ owned, declared: opts.kind });
319
351
  const dispatched = dispatchLevel({ lane: level, kind, cfg: opts.cfg });
320
352
  const pointer = opts.brief ? briefPointer(opts.brief.path, opts.brief.content) : null;
@@ -323,32 +355,70 @@ export async function openBlock(opts) {
323
355
  // not wait on it); under the lock the base is resolved again and must be the one stamped against
324
356
  const before = await readRun(runId);
325
357
  const { workspace } = before;
326
- const stampedBase = await resolveCommit(opts.base ?? 'HEAD', workspace);
358
+ // B62a: a re-open of a block with a worktree keeps the tree's base — the block's base is the
359
+ // commit its tree started from, and a different --base is refused before anything is touched
360
+ /** @param {Record<string, any>} record @returns {{path: string, branch: string, base_sha: string} | null} */
361
+ const previousTree = (record) => (opts.worktree ? record.blocks[id]?.worktree ?? null : null);
362
+ const previous = previousTree(before);
363
+ const baseRef = previous ? previous.base_sha : opts.base ?? 'HEAD';
364
+ const stampedBase = await resolveCommit(baseRef, workspace);
365
+ if (previous && opts.base !== undefined && (await resolveCommit(opts.base, workspace)) !== previous.base_sha) {
366
+ throw new StateError('worktree-base', `block ${id}: --base ${opts.base} is not ${previous.base_sha.slice(0, 12)}, the base of the block's worktree ${previous.path}; a re-open keeps the worktree and its base — drop --base; nothing was recorded`);
367
+ }
327
368
  const treeOpen = await treeAtOpen(workspace, stampedBase);
328
369
  // B58: `review.external` cannot change mid-run, so the record read here decides
329
370
  const guarded = await guardedConfigPaths(before);
330
371
 
331
- const block = await mutateRun(runId, null, writeRow, async (record) => {
332
- if (record.blocks[id]?.status === 'open') throw new StateError('already-open', `block ${id} is already open`);
333
- // B57: what each exact owned entry is on disk, under the lock (as `block claim`) — a regular
334
- // file owns only itself, here and in every later check
335
- const kinds = ownedKinds(record.workspace, owned);
336
- refuseConfigOwner(owned, kinds, guarded);
337
- for (const [otherId, other] of Object.entries(activeBlocks(record))) {
338
- const pair = findOverlap(owned, other.owned_files, { kindsA: kinds, kindsB: other.owned_kinds });
339
- if (pair) throw new StateError('overlap', `block ${id} owns ${pair[0]}, which overlaps ${pair[1]} owned by open block ${otherId}`);
372
+ /** @type {OpenTree | null} */
373
+ let made = null;
374
+ try {
375
+ const { block, reused } = await mutateRun(runId, null, writeRow, async (record) => {
376
+ if (record.blocks[id]?.status === 'open') throw new StateError('already-open', `block ${id} is already open`);
377
+ // B57: what each exact owned entry is on disk, under the lock (as `block claim`) — a regular
378
+ // file owns only itself, here and in every later check
379
+ const kinds = ownedKinds(record.workspace, owned);
380
+ refuseConfigOwner(owned, kinds, guarded);
381
+ const prior = previousTree(record);
382
+ const baseSha = await resolveCommit(prior ? prior.base_sha : opts.base ?? 'HEAD', record.workspace);
383
+ if (baseSha !== stampedBase || record.workspace !== workspace) throw new StateError('base-moved', `the base moved while block ${id} was being opened; run block open again`);
384
+ // B62a: the tree is planned (read-only checks) before the overlap check, which needs its path
385
+ const planned = opts.worktree ? await opts.worktree({ baseSha, previous: prior }) : null;
386
+ const self = { owned_files: owned, owned_kinds: kinds, worktree: planned ? { path: planned.path } : null };
387
+ for (const [otherId, other] of Object.entries(activeBlocks(record))) {
388
+ const pair = blocksOverlap(self, other);
389
+ if (pair) throw new StateError('overlap', `block ${id} owns ${pair[0]}, which overlaps ${pair[1]} owned by open block ${otherId}`);
390
+ }
391
+ // B62a: the worktree is made (or an earlier attempt's reused) last, once every other check
392
+ // passed; a tree made here is rolled back below if the rows or the save then fail
393
+ made = planned ? await planned.make() : null;
394
+ const tree = made;
395
+ const entry = { block: id, base_sha: baseSha, owned_files: [...owned], owned_kinds: { ...kinds }, level: dispatched.level, kind, attempt, opened_at: now.toISOString(), acceptance, lines_forecast: lines, status: 'open', tree_at_open: treeOpen, ...(tests ? { test_files: tests } : {}), ...(tree ? { worktree: tree.worktree } : {}) };
396
+ record.blocks[id] = entry;
397
+ const floor = dispatched.trigger === 'docs_floor' ? { requested_level: level, trigger: 'docs_floor' } : {};
398
+ const created = tree?.created ? [{ event: 'worktree.created', block: id, path: tree.worktree.path, branch: tree.worktree.branch, base: tree.worktree.base_sha }] : [];
399
+ return {
400
+ result: { block: entry, reused: tree !== null && !tree.created },
401
+ rows: [
402
+ ...(opts.decision ? [{ ...opts.decision, event: 'decision', decision_id: opts.decisionId ?? randomUUID(), block: id }] : []),
403
+ ...created,
404
+ { event: 'dispatch', block: id, level: dispatched.level, kind, ...floor, attempt, base_sha: baseSha, owned_files: entry.owned_files, lines_forecast: lines, ...(tests ? { test_files: tests } : {}) },
405
+ ],
406
+ };
407
+ });
408
+ return { block, pointer, worktreeReused: reused };
409
+ } catch (err) {
410
+ // B62a: nothing was recorded, so a tree this open made must not outlive it
411
+ if (made?.created && made.rollback) {
412
+ // the original error is thrown either way; what could not be removed is named in it (the
413
+ // rollback checks each git result and resolves, it does not throw)
414
+ const left = await made.rollback();
415
+ if (left !== null) {
416
+ if (!(err instanceof Error)) throw new Error(`${String(err).replace(/\.?$/, '.')} ${left}`, { cause: err });
417
+ err.message = `${err.message.replace(/\.?$/, '.')} ${left}`;
418
+ }
340
419
  }
341
- const baseSha = await resolveCommit(opts.base ?? 'HEAD', record.workspace);
342
- if (baseSha !== stampedBase || record.workspace !== workspace) throw new StateError('base-moved', `the base moved while block ${id} was being opened; run block open again`);
343
- const entry = { block: id, base_sha: baseSha, owned_files: [...owned], owned_kinds: { ...kinds }, level: dispatched.level, kind, attempt, opened_at: now.toISOString(), acceptance, lines_forecast: lines, status: 'open', tree_at_open: treeOpen };
344
- record.blocks[id] = entry;
345
- const floor = dispatched.trigger === 'docs_floor' ? { requested_level: level, trigger: 'docs_floor' } : {};
346
- return {
347
- result: entry,
348
- rows: [{ event: 'dispatch', block: id, level: dispatched.level, kind, ...floor, attempt, base_sha: baseSha, owned_files: entry.owned_files, lines_forecast: lines }],
349
- };
350
- });
351
- return { block, pointer };
420
+ throw err;
421
+ }
352
422
  }
353
423
 
354
424
  /** @param {{runId: string, id: string, writeRow: WriteRow}} opts */
@@ -364,12 +434,15 @@ export async function attemptBlock({ runId, id, writeRow }) {
364
434
  * in between touched a file this block owns (that is a conflict ⇒ stop). Rename detection is OFF
365
435
  * (`--no-renames`): a rename lists both its old and its new path, so moving an owned file away
366
436
  * is a conflict too. Never silent. Owned means `ownsFile` (B57: a directory entry owns the files
367
- * below it).
437
+ * below it). B62a: refused for a block with its own worktree (`worktree-rebase`).
368
438
  * @param {{runId: string, id: string, head?: string, writeRow: WriteRow}} opts
369
439
  * @returns {Promise<{old_base: string, new_base: string}>}
370
440
  */
371
441
  export async function rebaseBlock({ runId, id, head = 'HEAD', writeRow }) {
372
442
  return mutateRun(runId, id, writeRow, async (record, block) => {
443
+ // B62a: a worktree block's base is the commit its tree started from; moving it would leave the
444
+ // tree on its old base, so the rebase is refused
445
+ if (block.worktree) throw new StateError('worktree-rebase', `block ${id} has its own worktree ${block.worktree.path} on branch ${block.worktree.branch}; its base is the base of that tree, so block rebase does not apply — work on the branch in the tree instead`);
373
446
  const cwd = record.workspace;
374
447
  const newBase = await resolveCommit(head, cwd);
375
448
  const ancestor = await git(isAncestorArgv(block.base_sha, newBase), cwd, [0, 1]);
@@ -386,10 +459,11 @@ export async function rebaseBlock({ runId, id, head = 'HEAD', writeRow }) {
386
459
  /**
387
460
  * `block claim` — assign one EXACT path (an orphan, or any unowned path) to `id`; refused for a
388
461
  * glob, and when another open block owns it — or anything it would own: the claimed path becomes an
389
- * owned entry, so it is checked with `findOverlap` (B57: an entry owns the paths below it too,
462
+ * owned entry, so it is checked with `blocksOverlap` (B57: an entry owns the paths below it too,
390
463
  * unless it is a regular file now — its kind is recorded in `owned_kinds` as at `block open`).
391
464
  * B58: with `review.external.required` set, a path that owns the project's config is refused
392
- * (`config-owned`), as at `block open`.
465
+ * (`config-owned`), as at `block open`. B62a: two blocks that each have their own worktree may
466
+ * own the same path (`blocksOverlap`).
393
467
  * @param {{runId: string, id: string, file: string, writeRow: WriteRow}} opts
394
468
  */
395
469
  export async function claimPath({ runId, id, file, writeRow }) {
@@ -400,7 +474,7 @@ export async function claimPath({ runId, id, file, writeRow }) {
400
474
  const kinds = { [file]: pathKind(record.workspace, file) };
401
475
  refuseConfigOwner([file], kinds, guarded);
402
476
  for (const [otherId, other] of Object.entries(activeBlocks(record))) {
403
- const pair = otherId === id ? null : findOverlap([file], other.owned_files, { kindsA: kinds, kindsB: other.owned_kinds });
477
+ const pair = otherId === id ? null : blocksOverlap({ owned_files: [file], owned_kinds: kinds, worktree: block.worktree ?? null }, other);
404
478
  if (pair) throw new StateError('overlap', `${file} overlaps ${pair[1]} owned by open block ${otherId}`);
405
479
  }
406
480
  if (!block.owned_files.includes(file)) {