@codedology/code-forge 0.7.7 → 0.9.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 (69) hide show
  1. package/CHANGELOG.md +97 -0
  2. package/README.md +68 -2
  3. package/docs/README.md +2 -1
  4. package/docs/reference/blocks.json +390 -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 +45 -7
  10. package/skill/references/ledger.md +8 -4
  11. package/skill/references/proof.md +44 -3
  12. package/skill/references/review.md +25 -5
  13. package/skill/references/security.md +1 -1
  14. package/skill/scripts/forge +1 -1
  15. package/skill/templates/coder-brief.md +19 -2
  16. package/src/autopilot/binnacle.mjs +21 -3
  17. package/src/cli/block.mjs +427 -41
  18. package/src/cli/context.mjs +175 -0
  19. package/src/cli/gates.mjs +125 -15
  20. package/src/cli/ledger.mjs +46 -2
  21. package/src/cli/proof.mjs +62 -16
  22. package/src/cli/report.mjs +7 -4
  23. package/src/cli/review-file.mjs +324 -18
  24. package/src/cli/status.mjs +212 -0
  25. package/src/config/validate.mjs +45 -11
  26. package/src/context/pack.mjs +319 -0
  27. package/src/doctor/index.mjs +5 -4
  28. package/src/doctor/local.mjs +91 -3
  29. package/src/engines/sentinel.mjs +27 -6
  30. package/src/gates/detect.mjs +21 -8
  31. package/src/gates/expand.mjs +202 -0
  32. package/src/gates/owned-count.mjs +66 -0
  33. package/src/gates/run.mjs +49 -12
  34. package/src/install/wizard/answers.mjs +34 -5
  35. package/src/install/wizard/flags.mjs +8 -1
  36. package/src/install/wizard/project-file.mjs +2 -0
  37. package/src/install/wizard/run.mjs +6 -0
  38. package/src/install/wizard/steps.mjs +24 -4
  39. package/src/install/wizard/summary.mjs +13 -5
  40. package/src/ledger/export.mjs +1 -1
  41. package/src/ledger/outside-review.mjs +164 -0
  42. package/src/ledger/report.mjs +17 -5
  43. package/src/ledger/write.mjs +7 -1
  44. package/src/proof/export.mjs +93 -10
  45. package/src/proof/lock.mjs +28 -5
  46. package/src/proof/worktree.mjs +482 -0
  47. package/src/review/answers.mjs +445 -0
  48. package/src/review/deletion.mjs +106 -0
  49. package/src/review/engine.mjs +36 -2
  50. package/src/review/fixloop.mjs +25 -15
  51. package/src/review/gate-check.mjs +384 -83
  52. package/src/review/open-findings.mjs +59 -0
  53. package/src/review/reopen.mjs +258 -0
  54. package/src/review/stop-exits.mjs +15 -0
  55. package/src/state/block-end.mjs +290 -0
  56. package/src/state/block.mjs +103 -39
  57. package/src/state/brief-status.mjs +131 -0
  58. package/src/state/config-snapshot.mjs +51 -0
  59. package/src/state/registry.mjs +35 -4
  60. package/src/state/run.mjs +10 -1
  61. package/src/util/breadcrumbs.mjs +1 -0
  62. package/src/util/forbidden.mjs +46 -1
  63. package/src/util/gate-token.mjs +8 -0
  64. package/src/util/tmp.mjs +16 -4
  65. package/src/worker/engine.mjs +230 -24
  66. package/src/worker/loop.mjs +29 -8
  67. package/src/worker/queue.mjs +296 -15
  68. package/src/worker/review-state.mjs +346 -51
  69. package/src/worker/ticket.mjs +46 -6
package/src/cli/block.mjs CHANGED
@@ -1,15 +1,34 @@
1
1
  /**
2
- * `code-forge block open|attempt|rebase|claim|close|stop` (plan §4.9). The skill calls
2
+ * `code-forge block open|attempt|rebase|claim|close|stop|waive|reopen` (plan §4.9). The skill calls
3
3
  * `block open` before every spawn in every engine; it prints the brief pointer (≤ 200 bytes).
4
4
  *
5
5
  * block open <id> --run <r> [--level L<n>] --owned <paths…> --acceptance <file>
6
6
  * [--brief <file>] [--attempt <n>] [--base <sha>] [--lines <forecast>] [--kind code|docs|contract]
7
+ * [--tests <paths…>]
7
8
  * block attempt <id> --run <r>
8
9
  * block rebase <id> --run <r> [--head <ref>]
9
10
  * block claim <id> <path> --run <r>
10
- * block close <id> --run <r> [--worker-pid <pid>]
11
+ * block close <id> --run <r> [--worker-pid <pid>] [--remove-worktree]
11
12
  * block stop <id> --run <r> --reason <text>
12
13
  * block waive <id> <finding-id> --run <r> --file <path> --reason <text> (human-only, §4.9)
14
+ * block reopen <id> --run <r> --file <path> --reason <text> (human-only, B72)
15
+ *
16
+ * B64a `--json` (every subcommand): on success stdout is exactly ONE JSON line instead of the
17
+ * text — `{"block", "event", …}` with the facts the text prints (open: level, asked, source,
18
+ * kind, attempt, base_sha, tests, worktree, brief; attempt: attempt; rebase (`rebased`): old_base,
19
+ * new_base; claim (`claimed`): path; stop (`stopped`); waive (`waived`): finding, file, by;
20
+ * close (`closed`): status, reason, close_event (the close result's own event word, as the text
21
+ * prints it in parentheses, else null), and `worktree {removed, path, branch}` with
22
+ * `--remove-worktree`). On
23
+ * failure the exit code and stderr are unchanged and stdout stays empty, except a close the gate
24
+ * refused (`closeBlock` said no), which also prints `{"block", "ok": false, "reason"}`. A usage
25
+ * error, a thrown refusal, a cancelled `--no-require-reviews` and a failed `--remove-worktree`
26
+ * print nothing on stdout. Without `--json` every output is byte-identical to before.
27
+ *
28
+ * B64b block end: a close that closes or stops the block and `block stop` write
29
+ * `<runs>/<run>/blocks/<id>.done.json` after the end's rows (`state/block-end.mjs`), then, with
30
+ * `hooks.on_block_end` in the run's snapshot, run that command outside the lock and add
31
+ * `done_file` to `close`/`stop --json`. Without the key the output is unchanged.
13
32
  *
14
33
  * `block close [--transcript <file>] [--report <file>] [--no-require-reviews]` also runs B12b's review rows
15
34
  * (`review/gate-check.mjs`): a signed `review.approved` row for the current content hash of every
@@ -59,6 +78,29 @@
59
78
  * else still refuses — `unowned_change`, `unproven`, `no_changes`, the transcript grep and
60
79
  * `closeBlock`'s own rows. With `--no-require-reviews` the human path runs exactly as without the key.
61
80
  *
81
+ * B70 (issue #9 a): a file with no approval for its current hash whose real review (a signed
82
+ * `review.round` at that hash) left findings that are all waived — `block waive`, or an autopilot
83
+ * waiver its grant covered for a non-critical — passes as `reviewed_by_waiver`, never approved
84
+ * (`checkBlockReviews`). When the block closes, one signed `gate.review_waived_all {file,
85
+ * content_hash, findings, by}` row per such file is written just before `block.close` (after any
86
+ * other close row); a refused close writes none. The close prints `block <id>: <file>
87
+ * reviewed_by_waiver (<ids>; by <who>)` before the closed line, and `--json` adds
88
+ * `reviewed_by_waiver: [{file, content_hash, status, findings, by}]` (only when there is one).
89
+ * B71: a finding a signed `review.answered` row clears (another owned file resolved it, judged by
90
+ * a reviewer) counts like a waived one, listed under `answered` next to `findings` in the close
91
+ * row and the `--json` entry; the line then reads `(<ids>; by <who>; answered <ids>)`, or
92
+ * `(answered <ids>)` when nothing was waived (`by` is then null).
93
+ *
94
+ * B63b: `context.require_coder_tests: true` in the run's config snapshot (immutable like
95
+ * `review.external`; the live config is never read for it) adds one check after the report check:
96
+ * `--report` must be given and readable, its last sentinel for the block must be COMPLETE, and a
97
+ * `TESTS <command> exit=<n>` line must come before it with `n` 0 — else `coder report FAILED:
98
+ * context.require_coder_tests needs --report` / `--report cannot be read` / `no ===BLOCK <id>
99
+ * COMPLETE=== sentinel` / `no TESTS line` / `TESTS exit=<n>`. Waived reviews (the human flag or
100
+ * `review.external`) do not lift it. On a pass a signed `coder.tests {block, command, exit}` row
101
+ * (the command redacted) is one of `closeBlock`'s close rows, after any waiver row. Without the key
102
+ * (or false) nothing is added.
103
+ *
62
104
  * B47: `block open` without `--level` takes the level `autopilot level` chose for the block (the
63
105
  * latest `autopilot.level` row of the run for it whose MAC verifies, `autopilotLevel`); with
64
106
  * neither it is a usage error (`no --level given and no autopilot level recorded for <id>`). An
@@ -72,6 +114,48 @@
72
114
  * `--level` still wins, with no decision row. Outside brief mode `--lines` stays the forecast it
73
115
  * always was, and no `--level` without an autopilot level is the usage error above.
74
116
  *
117
+ * B60: `--tests <paths…>` names the block's test files (the same path rules as `--owned`); they are
118
+ * recorded as the block's `test_files` and on the dispatch row, and a gate argv item that is
119
+ * exactly `{test_files}` runs them (`gates run --run --block`). Without it the gate takes the owned
120
+ * entries that look like tests.
121
+ *
122
+ * B62a: under `proof.isolation: worktree` (the run's config snapshot, `proof/worktree.mjs`) `block
123
+ * open` makes the block's own worktree and branch before the block is recorded — or reuses the one
124
+ * an earlier attempt of the same block made — and prints a `worktree <path> · branch <branch>` line
125
+ * between the open line and the brief pointer. A refused `git worktree add` refuses the open (exit
126
+ * 1) with nothing recorded. `block close --remove-worktree` runs `git worktree remove <path>` (no
127
+ * `--force`; the branch is kept) only after the block closed, and writes a signed
128
+ * `worktree.removed` or `worktree.remove_failed` row; a failed removal is exit 1 with the block
129
+ * still closed. The flag on a block without a worktree, or on another subcommand, is a usage error
130
+ * (exit 2). It is coder-forbidden (`CODER_ONLY_FORBIDDEN`).
131
+ *
132
+ * B62b: `block close` of a block with a worktree measures that tree (`blockTree`), not the main
133
+ * one: the changed files (committed on the block's branch and uncommitted, against
134
+ * `worktree.base_sha`), the file set and its content hashes, and the unowned changes all come from
135
+ * the tree, by the same rules as for the main tree. A change in the main tree or in another
136
+ * block's tree never counts for the block. A fresh tree holds nothing but the block's own work,
137
+ * so every change in it is the block's: the `tree_at_open` stamps (taken in the main tree) do not
138
+ * exempt anything there, and a file another block owns is not left to that block (its close
139
+ * measures only its own tree) — a change in the tree to any file this block does not own is
140
+ * `unowned_change`. The whole close uses one base, the tree's `base_sha`. A tree that is gone,
141
+ * not a directory, without the workspace's subdirectory, or recorded malformed refuses the close
142
+ * before anything is asked or written. The ledger, the signed result files and the high paths stay
143
+ * in the main tree. Nothing is checked out, pushed or merged.
144
+ *
145
+ * B72 (issues #12, #15): `block reopen <id> --run <r> --file <path> --reason <text>` is the
146
+ * human's restart of a file whose review STOPPED or whose state is LOST (`review/reopen.mjs`):
147
+ * coder-forbidden like `block waive`. The done marker of the current content's ticket is removed
148
+ * (the ticket of the block's worktree when it has one), then one signed `review.reopened {block,
149
+ * file, from, stop_reason, at_seq, from_hash, to_hash, reason, by: human}` row is written, so the
150
+ * next `review-file` runs a full round 1. Refused (exit 1, no row): `unknown_block` and
151
+ * `reopen-block-not-open` (checked before anything is read), a `worktree-*` code (the block's tree
152
+ * cannot be read), `reopen-config` (the run's config snapshot or `tmp.root` cannot be read),
153
+ * `reopen-not-owned`, `reopen-tampered`, `reopen-not-stopped`, `reopen-same-content`,
154
+ * `reopen-already-recorded`, `reopen-write-failed`; a missing `--file` or `--reason` is a usage
155
+ * error (exit 2). It prints `block <id> reopened <file> (from
156
+ * <from>[ <stop reason>]; by: human)`; `--json`: `{"block", "event": "reopened", "file", "from",
157
+ * "stop_reason", "at_seq", "from_hash", "to_hash", "ticket_cleared", "by"}`.
158
+ *
75
159
  * `--acceptance` is a YAML/JSON list of `{clause, tests: [test ids…]}`. `block close` runs B8's
76
160
  * rows of the gate (worker pin, MACs, orphans); the gate rows owned by later blocks join it
77
161
  * through `closeBlock`'s `extraChecks`.
@@ -86,26 +170,90 @@ import { laneFacts } from '../decide/lane-facts.mjs';
86
170
  import { appendRow, readAllRows } from '../ledger/write.mjs';
87
171
  import { attemptBlock, claimPath, closeBlock, openBlock, rebaseBlock, repoTop, stopBlock } from '../state/block.mjs';
88
172
  import { intFlag, parseFlags } from '../state/cli-args.mjs';
89
- import { configInForce, externalReviewer } from '../state/config-snapshot.mjs';
173
+ import { configInForce, externalReviewer, requireCoderTests } from '../state/config-snapshot.mjs';
90
174
  import { StateError } from '../state/paths.mjs';
91
175
  import { readRun, writeSigned } from '../state/run.mjs';
92
176
  import { loadKey, verifyRow } from '../state/signer.mjs';
93
177
  import { blockFileSet, changedFiles, ownsAt, resultFilesFor, reviewedFiles, reviewGateCheck, unownedChanges, waiveFinding } from '../review/gate-check.mjs';
94
178
  import { checkExpiry } from '../autopilot/grant.mjs';
95
- import { writeSafe } from '../util/redact.mjs';
179
+ import { redact, writeSafe } from '../util/redact.mjs';
180
+ import { blockTree, prepareWorktree, removeWorktree, worktreeSettings } from '../proof/worktree.mjs';
96
181
  import { DEFAULT_CONFIG_FILENAME, loadProjectConfig } from '../config/load.mjs';
97
182
  import { validateConfig } from '../config/validate.mjs';
98
183
  import { migrateConfig } from '../config/migrate.mjs';
99
184
  import { exec } from '../util/exec.mjs';
100
- import { gitChildEnv } from '../worker/ticket.mjs';
101
- import { missingReviewTickets } from '../engines/sentinel.mjs';
185
+ import { contentHash, gitChildEnv } from '../worker/ticket.mjs';
186
+ import { clearDone } from '../worker/queue.mjs';
187
+ import { loadState } from '../worker/review-state.mjs';
188
+ import { openBlockEntry, reopenFile } from '../review/reopen.mjs';
189
+ import { runRootPath } from '../util/tmp.mjs';
190
+ import { missingReviewTickets, parseSentinel } from '../engines/sentinel.mjs';
191
+ import { blockEndHook, runBlockEndHook, writeDoneFile } from '../state/block-end.mjs';
192
+ import { blockOpenFindings } from '../review/open-findings.mjs';
193
+ import { hookForbidden, scanTranscript } from '../util/forbidden.mjs';
194
+
195
+ /**
196
+ * B72: `block reopen` — the file's content is read where the block works (its worktree, else the
197
+ * main tree), its state is read (never moved) from the code-forge home or the run's old temp root,
198
+ * the checks, the done marker's removal and then the signed row are `reopenFile`'s.
199
+ * @param {{runId: string, id: string, file: string, reason: string, record: Record<string, any>, writeRow: (row: Record<string, any>) => Promise<unknown>}} opts
200
+ * @returns {Promise<{row: Record<string, any>, cleared: boolean}>}
201
+ */
202
+ async function reopenBlockFile({ runId, id, file, reason, record, writeRow }) {
203
+ // the stable block codes come first, before any tree, config, key or ledger read
204
+ const entry = openBlockEntry(record, runId, id);
205
+ const real = async (/** @type {string} */ p) => realpath(p).catch(() => path.resolve(p));
206
+ const mainTop = await real(await repoTop(record.workspace));
207
+ const wt = await blockTree(record.workspace, entry).catch((thrown) => {
208
+ if (typeof thrown?.code === 'string' && thrown.code.startsWith('worktree-')) throw new StateError(thrown.code, `block ${id}: ${thrown.message}, so the file's content cannot be read; nothing was recorded`);
209
+ throw thrown;
210
+ });
211
+ const contentRoot = wt === null ? mainTop : wt.top;
212
+ const wsPrefix = path.relative(mainTop, await real(record.workspace)).split(path.sep).join('/');
213
+ // the state is read where the worker keeps it: an unreadable snapshot or tmp.root is refused,
214
+ // never guessed (a wrong root would read as "no state"); only an absent tmp.root (undefined or
215
+ // null) means the default root, any other non-string is refused (B79)
216
+ /** @type {string} */
217
+ let runRootDir;
218
+ try {
219
+ const root = configInForce(record)?.tmp?.root;
220
+ if (root !== undefined && root !== null && typeof root !== 'string') throw new TypeError('tmp.root is not a string');
221
+ runRootDir = runRootPath(runId, typeof root === 'string' ? { root } : {});
222
+ } catch {
223
+ throw new StateError('reopen-config', `reopen-config: the run's config snapshot or its tmp.root cannot be read, so the review state of ${file} cannot be found; nothing was recorded`);
224
+ }
225
+ const key = await loadKey(runId);
226
+ const rows = await readAllRows(record.project);
227
+ const tree = contentRoot === mainTop ? {} : { tree: contentRoot };
228
+ const { row, cleared } = await reopenFile({
229
+ runId,
230
+ id,
231
+ file,
232
+ reason,
233
+ record,
234
+ rows,
235
+ key,
236
+ writeRow,
237
+ writeSigned,
238
+ io: {
239
+ owns: ownsAt(wsPrefix),
240
+ loadState: (where) => loadState({ .../** @type {any} */ (where), runRootDir, contentRoot, migrate: false }),
241
+ contentHash: (f) => contentHash(contentRoot, f),
242
+ // C11 (synchronous): the cached result of the current content's ticket would repeat the stop
243
+ clearDone: (f) => clearDone({ repoRoot: mainTop, run: runId, block: id, file: f, ...tree }).removed,
244
+ },
245
+ });
246
+ return { row, cleared };
247
+ }
102
248
 
103
249
  const USAGE =
104
- 'usage: code-forge block open <id> --run <r> [--level L<n>] --owned <paths…> --acceptance <file> [--brief <file>] [--attempt <n>] [--base <sha>] [--lines <n>] [--kind code|docs|contract]\n' +
105
- ' code-forge block attempt|rebase <id> --run <r> · block close <id> --run <r> [--transcript <file>] [--report <file>] [--no-require-reviews] · block claim <id> <path> --run <r> · block stop <id> --run <r> --reason <text>\n' +
106
- ' code-forge block waive <id> <finding-id> --run <r> --file <path> --reason <text>\n';
250
+ 'usage: code-forge block open <id> --run <r> [--level L<n>] --owned <paths…> --acceptance <file> [--brief <file>] [--attempt <n>] [--base <sha>] [--lines <n>] [--kind code|docs|contract] [--tests <paths…>]\n' +
251
+ ' code-forge block attempt|rebase <id> --run <r> · block close <id> --run <r> [--transcript <file>] [--report <file>] [--no-require-reviews] [--remove-worktree] · block claim <id> <path> --run <r> · block stop <id> --run <r> --reason <text>\n' +
252
+ ' code-forge block waive <id> <finding-id> --run <r> --file <path> --reason <text>\n' +
253
+ ' code-forge block reopen <id> --run <r> --file <path> --reason <text>\n' +
254
+ ' every block verb takes --json: one JSON line on stdout ({"block","event",…}) instead of the text\n';
107
255
 
108
- const SUBCOMMANDS = ['open', 'attempt', 'rebase', 'claim', 'close', 'stop', 'waive'];
256
+ const SUBCOMMANDS = ['open', 'attempt', 'rebase', 'claim', 'close', 'stop', 'waive', 'reopen'];
109
257
  const FLAG_VALUES = ['run', 'level', 'acceptance', 'brief', 'attempt', 'base', 'lines', 'head', 'worker-pid', 'reason', 'file', 'transcript', 'kind', 'report'];
110
258
 
111
259
  /**
@@ -114,9 +262,11 @@ const FLAG_VALUES = ['run', 'level', 'acceptance', 'brief', 'attempt', 'base', '
114
262
  * stdout?: {write: (s: string) => unknown}, stderr?: {write: (s: string) => unknown}, probe?: import('../state/run.mjs').StartTimeProbe, now?: () => Date,
115
263
  * isTTY?: boolean, stdinIsTTY?: boolean, stdoutIsTTY?: boolean,
116
264
  * ui?: {confirm: (o: {message: string, initialValue?: boolean}) => Promise<unknown>, isCancel: (v: unknown) => boolean},
265
+ * removeBlockTree?: typeof removeBlockTree,
117
266
  * }} [deps] - `now` (B48): the clock of the autopilot expiry checks. `stdinIsTTY` / `stdoutIsTTY`
118
267
  * (default `process.stdin.isTTY` / `process.stdout.isTTY`; `isTTY` sets both) and `ui` (default
119
268
  * `@clack/prompts`): B52's confirmation of `--no-require-reviews`, which needs both to be TTYs.
269
+ * `removeBlockTree` (B64b, tests only): the `--remove-worktree` step, default the real one.
120
270
  * @returns {Promise<number>}
121
271
  */
122
272
  export async function runBlock(args, deps = {}) {
@@ -129,19 +279,68 @@ export async function runBlock(args, deps = {}) {
129
279
  return 2;
130
280
  }
131
281
  try {
132
- const { flags, positionals } = parseFlags(rest, { values: FLAG_VALUES, multi: ['owned'], booleans: ['no-require-reviews'] });
282
+ const { flags, positionals } = parseFlags(rest, { values: FLAG_VALUES, multi: ['owned', 'tests'], booleans: ['no-require-reviews', 'remove-worktree', 'json'] });
283
+ // B64a: --json prints ONE JSON line on success instead of the text lines (`emit`, one object
284
+ // per verb, built where the text is); without it every output is exactly as before (`say` is `out`)
285
+ const json = flags.json === true;
286
+ const say = json ? (/** @type {string} */ _s) => {} : out;
287
+ const emit = (/** @type {Record<string, unknown>} */ obj) => out(`${JSON.stringify(obj)}\n`);
288
+ // B60: --tests names exact paths (a file or a directory — PHPUnit and Pest take directories).
289
+ // `*` and `?` are refused: a glob would reach the gate unexpanded. `[` and `{` are allowed,
290
+ // since real paths use them (`app/[id]/page.test.tsx`) and argv never reaches a shell. A
291
+ // leading "-" would be read as an option.
292
+ for (const t of sub === 'open' && Array.isArray(flags.tests) ? flags.tests : []) {
293
+ if (/[*?]/.test(t)) throw new StateError('usage', `--tests takes exact paths (a file or a directory); "*" and "?" are refused: ${JSON.stringify(t)}`);
294
+ if (t.startsWith('-')) throw new StateError('usage', `--tests: a path may not start with "-": ${JSON.stringify(t)}`);
295
+ }
133
296
  const [id, extra] = positionals;
134
297
  const runId = typeof flags.run === 'string' ? flags.run : undefined;
135
298
  if (!id || !runId) throw new StateError('usage', 'a block id and --run are required');
136
299
  if (sub === 'open' && (typeof flags.acceptance !== 'string' || !Array.isArray(flags.owned) || (flags.level !== undefined && typeof flags.level !== 'string'))) {
137
300
  throw new StateError('usage', 'block open needs --level, --owned and --acceptance');
138
301
  }
302
+ if (sub !== 'close' && flags['remove-worktree'] === true) throw new StateError('usage', '--remove-worktree is a block close flag');
139
303
  if (sub === 'claim' && (!extra || positionals.length !== 2)) throw new StateError('usage', 'block claim needs exactly one path');
140
- if ((sub === 'stop' || sub === 'waive') && typeof flags.reason !== 'string') throw new StateError('usage', `block ${sub} needs --reason`);
304
+ if ((sub === 'stop' || sub === 'waive' || sub === 'reopen') && typeof flags.reason !== 'string') throw new StateError('usage', `block ${sub} needs --reason`);
141
305
  if (sub === 'waive' && (!extra || positionals.length !== 2)) throw new StateError('usage', 'block waive needs exactly one finding id');
306
+ if (sub === 'reopen' && positionals.length !== 1) throw new StateError('usage', 'block reopen takes one block id (the file goes in --file)');
307
+ if (sub === 'reopen' && typeof flags.file !== 'string') throw new StateError('usage', 'block reopen needs --file <path>');
142
308
  if (sub === 'waive' && typeof flags.file !== 'string') throw new StateError('usage', 'block waive needs --file <path> (a finding id is scoped to one file)');
143
309
  const record = await readRun(runId);
144
310
  const writeRow = (/** @type {Record<string, any>} */ row) => appendRow(row, { slug: record.project });
311
+ // B64b: the block-end signal — after an end's rows (and outside the run lock) the done file,
312
+ // then the owner's `hooks.on_block_end` from the run-start snapshot. Without the hook key the
313
+ // done file is the only addition: no row, and every output stays exactly as before.
314
+ const hook = blockEndHook(record);
315
+ const ended = async (/** @type {'closed' | 'stopped'} */ status, /** @type {string} */ event, /** @type {string | null} */ reason) => {
316
+ // ruling (e): `ended` never throws — any failure here is the done-file warning and a null
317
+ // `doneFile`, so the caller's try/finally still reaches the hook
318
+ /** @type {string | null} */
319
+ let doneFile = null;
320
+ try {
321
+ doneFile = await writeDoneFile({
322
+ record,
323
+ id,
324
+ status,
325
+ event,
326
+ reason: typeof reason === 'string' ? redact(reason) : null,
327
+ openFindings: (rows) => blockOpenFindings(record, rows, id),
328
+ err,
329
+ });
330
+ } catch (thrown) {
331
+ const code = /** @type {any} */ (thrown)?.code;
332
+ err(`warning: done file not written: ${typeof code === 'string' && /^[A-Z0-9_]{1,32}$/.test(code) ? code : 'UNKNOWN'}\n`);
333
+ doneFile = null;
334
+ }
335
+ return {
336
+ json: hook === null ? {} : { done_file: doneFile },
337
+ runHook: async () => {
338
+ if (hook === null) return;
339
+ const top = await repoTop(record.workspace).catch(() => record.workspace);
340
+ await runBlockEndHook({ hook, runId, id, status, doneFile: doneFile ?? '', repoTop: top, writeRow, err });
341
+ },
342
+ };
343
+ };
145
344
  // B48: every block command runs the lazy autopilot checks (grant expiry, expired approvals);
146
345
  // a failed check is a warning, never a reason to stop the block command
147
346
  try {
@@ -179,7 +378,9 @@ export async function runBlock(args, deps = {}) {
179
378
  if (relative.startsWith('..') || path.isAbsolute(relative)) throw new StateError('usage', '--brief must be a file inside the workspace');
180
379
  brief = { path: relative, content: await readFile(absolute) };
181
380
  }
182
- const { block, pointer } = await openBlock({
381
+ // B62a: the run's isolation, from its config snapshot (fixed for the run)
382
+ const isolated = worktreeSettings(record);
383
+ const { block, pointer, worktreeReused } = await openBlock({
183
384
  runId,
184
385
  id,
185
386
  level,
@@ -191,37 +392,76 @@ export async function runBlock(args, deps = {}) {
191
392
  brief,
192
393
  ...(rules ? { decision: { question: 'lane', answer: rules.lane, source: 'rules', facts: rules.facts } } : {}),
193
394
  ...(typeof flags.kind === 'string' ? { kind: flags.kind } : {}),
395
+ ...(Array.isArray(flags.tests) ? { tests: flags.tests } : {}),
194
396
  cfg: await openConfig(record.workspace),
195
397
  writeRow,
398
+ ...(isolated ? { worktree: (/** @type {{baseSha: string, previous: any}} */ ctx) => prepareWorktree({ cwd: record.workspace, settings: isolated, runId, id, ...ctx }) } : {}),
196
399
  });
197
400
  const raised = block.level !== level ? ` (${block.kind} floor, asked ${level})` : '';
198
401
  const chosen = typeof flags.level === 'string' ? '' : rules ? ' (rules lane)' : ' (autopilot level)';
199
- out(`block ${id} open · ${block.level}${raised}${chosen} · ${block.kind} · attempt ${block.attempt} · base ${block.base_sha.slice(0, 12)}\n`);
200
- out(`${pointer ?? 'brief: none given (--brief <file>)'}\n`);
402
+ const testCount = Array.isArray(block.test_files) ? block.test_files.length : 0;
403
+ const tests = testCount > 0 ? ` · ${testCount} test file${testCount === 1 ? '' : 's'}` : '';
404
+ say(`block ${id} open · ${block.level}${raised}${chosen} · ${block.kind} · attempt ${block.attempt} · base ${block.base_sha.slice(0, 12)}${tests}\n`);
405
+ if (block.worktree) say(`worktree ${block.worktree.path} · branch ${block.worktree.branch}${worktreeReused ? ' · reused' : ''}\n`);
406
+ say(`${pointer ?? 'brief: none given (--brief <file>)'}\n`);
407
+ if (json) {
408
+ emit({
409
+ block: id,
410
+ event: 'open',
411
+ level: block.level,
412
+ asked: level ?? null,
413
+ source: typeof flags.level === 'string' ? 'flag' : rules ? 'rules' : 'autopilot',
414
+ kind: block.kind,
415
+ attempt: block.attempt,
416
+ base_sha: block.base_sha,
417
+ tests: testCount,
418
+ worktree: block.worktree ? { path: block.worktree.path, branch: block.worktree.branch, reused: Boolean(worktreeReused) } : null,
419
+ brief: pointer ?? null,
420
+ });
421
+ }
201
422
  return 0;
202
423
  }
203
424
  if (sub === 'attempt') {
204
- out(`block ${id} attempt ${await attemptBlock({ runId, id, writeRow })}\n`);
425
+ const n = await attemptBlock({ runId, id, writeRow });
426
+ say(`block ${id} attempt ${n}\n`);
427
+ if (json) emit({ block: id, event: 'attempt', attempt: n });
205
428
  return 0;
206
429
  }
207
430
  if (sub === 'rebase') {
208
431
  const moved = await rebaseBlock({ runId, id, head: typeof flags.head === 'string' ? flags.head : undefined, writeRow });
209
- out(`block ${id} rebased ${moved.old_base.slice(0, 12)} → ${moved.new_base.slice(0, 12)}\n`);
432
+ say(`block ${id} rebased ${moved.old_base.slice(0, 12)} → ${moved.new_base.slice(0, 12)}\n`);
433
+ if (json) emit({ block: id, event: 'rebased', old_base: moved.old_base, new_base: moved.new_base });
210
434
  return 0;
211
435
  }
212
436
  if (sub === 'claim') {
213
437
  await claimPath({ runId, id, file: extra, writeRow });
214
- out(`block ${id} claimed ${extra}\n`);
438
+ say(`block ${id} claimed ${extra}\n`);
439
+ if (json) emit({ block: id, event: 'claimed', path: extra });
215
440
  return 0;
216
441
  }
217
442
  if (sub === 'stop') {
218
443
  await stopBlock({ runId, id, reason: /** @type {string} */ (flags.reason), writeRow });
219
- out(`block ${id} stopped\n`);
220
- return 0;
444
+ const end = await ended('stopped', 'block.stop', /** @type {string} */ (flags.reason));
445
+ try {
446
+ say(`block ${id} stopped\n`);
447
+ if (json) emit({ block: id, event: 'stopped', ...end.json });
448
+ return 0;
449
+ } finally {
450
+ await end.runHook();
451
+ }
221
452
  }
222
453
  if (sub === 'waive') {
223
454
  await waiveFinding({ runId, id, finding: extra, reason: /** @type {string} */ (flags.reason), file: /** @type {string} */ (flags.file), writeRow });
224
- out(`block ${id} waived ${extra} in ${flags.file} (by: human)\n`);
455
+ say(`block ${id} waived ${extra} in ${flags.file} (by: human)\n`);
456
+ if (json) emit({ block: id, event: 'waived', finding: extra, file: flags.file, by: 'human' });
457
+ return 0;
458
+ }
459
+ if (sub === 'reopen') {
460
+ const file = /** @type {string} */ (flags.file);
461
+ const { row, cleared } = await reopenBlockFile({ runId, id, file, reason: /** @type {string} */ (flags.reason), record, writeRow });
462
+ const fromText = row.stop_reason == null ? row.from : `${row.from} ${row.stop_reason}`;
463
+ say(`block ${id} reopened ${file} (from ${fromText}; by: human)\n`);
464
+ if (json) emit({ block: id, event: 'reopened', file, from: row.from, stop_reason: row.stop_reason, at_seq: row.at_seq, from_hash: row.from_hash, to_hash: row.to_hash, ticket_cleared: cleared, by: 'human' });
225
465
  return 0;
226
466
  }
227
467
  if (sub === 'close') {
@@ -230,6 +470,21 @@ export async function runBlock(args, deps = {}) {
230
470
  // B52: a block that is not open is refused here, with or without --no-require-reviews, by
231
471
  // the same check and message as `closeBlock` — and the owner is never asked about it
232
472
  if (entry.status !== 'open') throw new StateError('no-block', `block ${id} is not open in run ${runId}`);
473
+ // B62a: removing a tree the block does not have is a usage error, before anything runs
474
+ const removeTree = flags['remove-worktree'] === true;
475
+ if (removeTree && !entry.worktree) throw new StateError('usage', `block ${id} has no worktree (proof.isolation is not worktree); --remove-worktree does not apply`);
476
+ // B62b: the block's tree is where its work is measured — a tree that is gone refuses the close
477
+ // before anything is asked or written
478
+ const wt = await blockTree(record.workspace, entry).catch((thrown) => {
479
+ if (thrown?.code === 'worktree-missing') {
480
+ throw new StateError('worktree-missing', `block ${id}: its worktree ${entry.worktree.path} is gone (removed or moved), so its work cannot be measured — restore it on branch ${entry.worktree.branch} (git worktree prune, then git worktree add ${entry.worktree.path} ${entry.worktree.branch}) and close again; nothing was recorded`);
481
+ }
482
+ if (typeof thrown?.code === 'string' && thrown.code.startsWith('worktree-')) throw new StateError(thrown.code, `block ${id}: ${thrown.message}, so its work cannot be measured; nothing was recorded`);
483
+ throw thrown;
484
+ });
485
+ // ONE base for the whole close: a worktree block is measured against the commit its tree was
486
+ // made from (B62a keeps it equal to the block's base_sha: a re-open keeps it, a rebase is refused)
487
+ const base = wt !== null ? wt.base_sha : entry.base_sha;
233
488
  const reviewsWaived = flags['no-require-reviews'] === true;
234
489
  // B52: the human's way out is confirmed by the human, at a terminal, before anything is written
235
490
  if (reviewsWaived) {
@@ -248,26 +503,46 @@ export async function runBlock(args, deps = {}) {
248
503
  // relative `file` and keeps its queue and signed result files under `<top>/.code-forge/`
249
504
  // (`repoRootOf`), and `tree_at_open` is stamped there too. An owned entry matches a path
250
505
  // as given or, for a workspace in a subdirectory, relative to the workspace (`ownsAt`).
251
- // A failure here is the usual `git-failed` refusal.
252
- /** @type {Promise<{top: string, wsPrefix: string, owns: (owned: ReadonlyArray<string>, file: string) => boolean, changed: string[], fileSet: Array<{file: string, content_hash: string}>, reviewed: string[]}> | undefined} */
506
+ // A failure here is the usual `git-failed` refusal. B62b: for a block with a worktree the
507
+ // listing and the content are the tree's (`top`); `mainTop` keeps the run's state.
508
+ /** @type {Promise<{top: string, mainTop: string, where: string, inTree: boolean, wsPrefix: string, owns: (owned: ReadonlyArray<string>, file: string) => boolean, changed: string[], fileSet: Array<{file: string, content_hash: string}>, reviewed: string[]}> | undefined} */
253
509
  let treeOnce;
254
510
  const tree = () =>
255
511
  (treeOnce ??= (async () => {
256
- const top = await repoTop(record.workspace);
512
+ const mainTop = await repoTop(record.workspace);
513
+ const inTree = wt !== null;
514
+ const top = inTree ? wt.top : mainTop;
515
+ const where = inTree ? `the worktree ${wt.path}` : 'the workspace';
257
516
  // both sides as realpaths: git prints the top resolved (/private/var on macOS) while the
258
517
  // run record may keep a symlinked spelling (/var)
259
518
  const real = async (/** @type {string} */ p) => realpath(p).catch(() => path.resolve(p));
260
- const wsPrefix = path.relative(await real(top), await real(record.workspace)).split(path.sep).join('/');
519
+ const wsPrefix = path.relative(await real(mainTop), await real(record.workspace)).split(path.sep).join('/');
261
520
  const owns = ownsAt(wsPrefix);
262
521
  const isMine = (/** @type {string} */ f) => owns(entry.owned_files, f, entry.owned_kinds);
263
- const changed = await changedFiles({ repoRoot: top, base: entry.base_sha });
522
+ const changed = await changedFiles({ repoRoot: top, base });
264
523
  // the file set is the diff since base ∪ every owned file with review rows of this block —
265
524
  // work the diff cannot see (committed before `block open`, done in another worktree) is
266
- // still checked, at its content in the workspace
525
+ // still checked, at its content in the workspace (in the block's worktree when it has one)
267
526
  const reviewed = reviewedFiles(rows, id).filter(isMine);
268
- const fileSet = await blockFileSet({ repoRoot: top, base: entry.base_sha, owned: entry.owned_files, matchOwned: isMine, changed: [...new Set([...changed, ...reviewed])].sort() });
269
- return { top, wsPrefix, owns, changed, fileSet, reviewed };
527
+ const fileSet = await blockFileSet({ repoRoot: top, base, owned: entry.owned_files, matchOwned: isMine, changed: [...new Set([...changed, ...reviewed])].sort() });
528
+ return { top, mainTop, where, inTree, wsPrefix, owns, changed, fileSet, reviewed };
270
529
  })());
530
+ // B58: the config waiver row is written by `closeBlock` under the run lock, just before
531
+ // `block.close` and only when every other check passed — never by a refused close
532
+ /** @type {Record<string, any>[]} */
533
+ const closeRows = external === null ? [] : [{ event: 'gate.reviews_waived', block: id, by: 'config', reviewer: external }];
534
+ /** @type {import('../review/gate-check.mjs').WaivedAll[]} */
535
+ let waivedAll = [];
536
+ // B70: a file whose review left only waived findings passes as `reviewed_by_waiver`; its signed
537
+ // `gate.review_waived_all` row joins the close rows (written just before `block.close`, only
538
+ // when the block closes — a refused close writes none)
539
+ const onWaivedAll = (/** @type {import('../review/gate-check.mjs').WaivedAll[]} */ files) => {
540
+ // idempotent: the waived rows are rebuilt from the final list, never appended per call
541
+ // (closeBlock reads closeRows only after every extra check passed)
542
+ waivedAll = files;
543
+ const others = closeRows.filter((r) => r.event !== 'gate.review_waived_all');
544
+ closeRows.splice(0, closeRows.length, ...others, ...files.map((f) => ({ event: 'gate.review_waived_all', block: id, file: f.file, content_hash: f.content_hash, findings: f.findings, by: f.by, ...(f.answered?.length ? { answered: f.answered } : {}) })));
545
+ };
271
546
  const reviews = reviewGateCheck(async () => {
272
547
  const transcript = await findTranscript(record, id, typeof flags.transcript === 'string' ? flags.transcript : null);
273
548
  if (transcript === null) {
@@ -277,13 +552,14 @@ export async function runBlock(args, deps = {}) {
277
552
  // the human's way out of the per-file review check leaves a signed audit row before the close
278
553
  if (reviewsWaived) await writeSigned(runId, writeRow, { event: 'gate.reviews_waived', block: id, by: 'human' });
279
554
  // B20: the high paths come first — a base the workspace does not know refuses the close here
280
- const highPaths = await highPathsAt(record.workspace, entry.base_sha);
281
- const { top, wsPrefix, owns, changed, fileSet, reviewed } = await tree();
555
+ const highPaths = await highPathsAt(record.workspace, base);
556
+ const { top, mainTop, where, inTree, wsPrefix, owns, changed, fileSet, reviewed } = await tree();
282
557
  const isMine = (/** @type {string} */ f) => owns(entry.owned_files, f, entry.owned_kinds);
283
558
  const key = await loadKey(runId);
284
559
  // B52: an empty diff with review rows is never silent — name the likely cause
285
560
  if (reviewed.length > 0 && !changed.some(isMine)) {
286
- err(`WARN block ${id}: no owned file differs from the block base in the workspace, but ${reviewed.length} owned file(s) have review rows — the work was likely committed before block open or done in another worktree; they are checked at their content in the workspace\n`);
561
+ const likely = inTree ? 'done outside the block\'s worktree (in the main checkout or another tree)' : 'committed before block open or done in another worktree';
562
+ err(`WARN block ${id}: no owned file differs from the block base in ${where}, but ${reviewed.length} owned file(s) have review rows — the work was likely ${likely}; they are checked at their content in ${where}\n`);
287
563
  }
288
564
  // B52: only code-forge's own state is exempt — `<top>/.code-forge/`, where the worker keeps
289
565
  // its queue and signed results — never a `--transcript` or `--report` path in the source
@@ -293,7 +569,9 @@ export async function runBlock(args, deps = {}) {
293
569
  // `.code-forge/runs/<run>/<id>.log`)
294
570
  const wsRel = (/** @type {string} */ p) => path.posix.join(wsPrefix, p);
295
571
  const ownState = new Set([wsRel(`.code-forge/runs/${runId}.json`), wsRel(`.code-forge/runs/${runId}/${id}.log`)]);
296
- const scope = await unownedChanges({ top, changed: changed.filter((f) => !ownState.has(f)), id, blocks: record.blocks, rows, key, isMine, owns, stateDir: '.code-forge' });
572
+ // B62b: a block's own tree started clean at its base and no other block works in it — no
573
+ // stamp from the main tree and no other block's ownership exempts a change there
574
+ const scope = await unownedChanges({ top, changed: changed.filter((f) => !ownState.has(f)), id, blocks: record.blocks, rows, key, isMine, owns, stateDir: '.code-forge', ownTree: inTree });
297
575
  if (scope === null) err(`WARN block ${id}: opened before code-forge recorded the tree at block open; unowned changes were not checked\n`);
298
576
  for (const { file, block: other } of scope?.siblings ?? []) err(`WARN block ${id}: ${file} changed and is owned by open block ${other}; not checked here (its own gate reviews it)\n`);
299
577
  return {
@@ -305,7 +583,7 @@ export async function runBlock(args, deps = {}) {
305
583
  // B52: a change no block of the run owns is refused — `--no-require-reviews` does not lift it
306
584
  unowned: scope?.unowned ?? [],
307
585
  // B52: an old ledger's review.result rows are judged by their signed result files
308
- resultFiles: await resultFilesFor({ repoRoot: top, runId, block: id, rows }),
586
+ resultFiles: await resultFilesFor({ repoRoot: mainTop, runId, block: id, rows }),
309
587
  files: reviewsWaived || external !== null ? [] : fileSet,
310
588
  // B52: nothing changed and nothing reviewed is never a pass (the owner's way out is
311
589
  // --no-require-reviews); B58: `review.external` does not lift it
@@ -314,7 +592,7 @@ export async function runBlock(args, deps = {}) {
314
592
  proofFiles: fileSet,
315
593
  highPaths,
316
594
  };
317
- });
595
+ }, { onWaivedAll });
318
596
  // B36: the coder's report names a review-file ticket for every changed file, or it is FAILED
319
597
  const report = async () => {
320
598
  if (typeof flags.report !== 'string' || reviewsWaived || external !== null) return { ok: true };
@@ -328,12 +606,76 @@ export async function runBlock(args, deps = {}) {
328
606
  const missing = missingReviewTickets(text, fileSet.map((f) => f.file));
329
607
  return missing.length === 0 ? { ok: true } : { ok: false, reason: `coder report FAILED: no review-file ticket id for ${missing.join(', ')}` };
330
608
  };
331
- // B58: the config waiver row is written by `closeBlock` under the run lock, just before
332
- // `block.close` and only when every other check passed — never by a refused close
333
- const closeRows = external === null ? [] : [{ event: 'gate.reviews_waived', block: id, by: 'config', reviewer: external }];
334
- const result = await closeBlock({ runId, id, rows, writeRow, livePid: intFlag(flags['worker-pid'], 'worker-pid'), probe, extraChecks: [reviews, report], closeRows });
335
- (result.ok ? out : err)(`block ${id} ${result.status}${result.event ? ` (${result.event})` : ''}${result.reason ? `: ${result.reason}` : ''}\n`);
336
- return result.ok ? 0 : 1;
609
+ /** @type {Array<() => Promise<{ok: boolean, reason?: string}>>} */
610
+ const extraChecks = [reviews, report];
611
+ // B63b: `context.require_coder_tests` in the run's config snapshot (never the live config)
612
+ // needs the coder's green test run — waived reviews do not lift it. It runs FIRST: it only
613
+ // reads the report, so a refusal here leaves no row (not even the human waiver row the review
614
+ // check writes). Absent or false: nothing is added, the close is exactly as before.
615
+ if (requireCoderTests(record)) {
616
+ extraChecks.unshift(async () => {
617
+ const tests = await coderTests(flags.report, id);
618
+ if (!tests.ok) return tests;
619
+ // signed under the run lock by `closeBlock`, after the waiver row, only when the block closes
620
+ closeRows.push({ event: 'coder.tests', block: id, command: redact(tests.command), exit: tests.exit });
621
+ return { ok: true };
622
+ });
623
+ }
624
+ // B64b: the hook command is the orchestrator's, never a coder's — a coder transcript that runs
625
+ // it refuses the close. Only with the hook configured (the list is otherwise unchanged); it
626
+ // only reads the transcript, so it runs first and a refusal here leaves no row.
627
+ if (hook !== null) {
628
+ extraChecks.unshift(async () => {
629
+ const text = await findTranscript(record, id, typeof flags.transcript === 'string' ? flags.transcript : null);
630
+ const [hit] = text === null ? [] : scanTranscript(text, hookForbidden(hook.argv));
631
+ return hit ? { ok: false, reason: `rule_break transcript line ${hit.line}: ${hit.id}` } : { ok: true };
632
+ });
633
+ }
634
+ const result = await closeBlock({ runId, id, rows, writeRow, livePid: intFlag(flags['worker-pid'], 'worker-pid'), probe, extraChecks, closeRows });
635
+ // B64b: the block ended (closed, or stopped by a worker pin event or ledger.tamper) — its rows
636
+ // are written and the run lock released; the done file is written before the tree is removed
637
+ const end = result.status === 'closed' || result.status === 'stopped' ? await ended(result.status, result.event ?? (result.status === 'stopped' ? 'block.stop' : 'block.close'), result.reason ?? null) : null;
638
+ // B64b ruling (e): the hook runs exactly once for this end, after everything else the verb
639
+ // does — also when a later step (the output, `--remove-worktree`) throws; it never changes
640
+ // the exit code or stdout (`runHook` never throws)
641
+ try {
642
+ // B70: each file that passed as reviewed_by_waiver is named before the closed line
643
+ // B71: an answered finding is named as answered; a file with only answered findings has no `by`
644
+ if (result.ok) {
645
+ for (const f of waivedAll) {
646
+ const parts = f.findings.length > 0 ? [`${f.findings.join(', ')}; by ${f.by}`] : [];
647
+ if (f.answered?.length) parts.push(`answered ${f.answered.join(', ')}`);
648
+ say(`block ${id}: ${f.file} reviewed_by_waiver (${parts.join('; ')})\n`);
649
+ }
650
+ }
651
+ (result.ok ? say : err)(`block ${id} ${result.status}${result.event ? ` (${result.event})` : ''}${result.reason ? `: ${result.reason}` : ''}\n`);
652
+ // B64a: a refused close also names itself on stdout under --json (its stderr is unchanged)
653
+ if (json && !result.ok) emit({ block: id, ok: false, reason: result.reason ?? null, ...(end?.json ?? {}) });
654
+ const closedJson = {
655
+ block: id,
656
+ event: result.status,
657
+ status: result.status,
658
+ reason: result.reason ?? null,
659
+ close_event: result.event ?? null,
660
+ ...(end?.json ?? {}),
661
+ // B70: only when a file passed as reviewed_by_waiver (otherwise the output is unchanged)
662
+ ...(result.ok && waivedAll.length > 0 ? { reviewed_by_waiver: waivedAll.map((f) => ({ file: f.file, content_hash: f.content_hash, status: f.status, findings: f.findings, by: f.by, ...(f.answered?.length ? { answered: f.answered } : {}) })) } : {}),
663
+ };
664
+ if (!result.ok || !removeTree) {
665
+ if (json && result.ok) emit(closedJson);
666
+ return result.ok ? 0 : 1;
667
+ }
668
+ const removed = await (deps.removeBlockTree ?? removeBlockTree)(runId, id, entry.worktree, record.workspace, writeRow, say, err);
669
+ // a failed removal is exit 1 with its stderr text and nothing on stdout, like every failure
670
+ if (json && removed === 0) {
671
+ // built from the block's own tree entry; `removed` is what removeBlockTree reported (0 = removed)
672
+ const treeJson = entry.worktree ? { removed: removed === 0, path: entry.worktree.path, branch: entry.worktree.branch } : null;
673
+ emit({ ...closedJson, worktree: treeJson });
674
+ }
675
+ return removed;
676
+ } finally {
677
+ await end?.runHook();
678
+ }
337
679
  }
338
680
  } catch (thrown) {
339
681
  err(`block ${sub ?? ''}: ${thrown?.message ?? String(thrown)}\n`);
@@ -343,6 +685,50 @@ export async function runBlock(args, deps = {}) {
343
685
  return 2;
344
686
  }
345
687
 
688
+ /**
689
+ * B62a: `block close --remove-worktree`, after the block closed: `git worktree remove <path>` (no
690
+ * `--force`, the branch is kept), then a signed `worktree.removed {block, path, branch}` or
691
+ * `worktree.remove_failed {block, path, reason}` row. A failed removal (a tree with modified or
692
+ * untracked files) leaves the tree where it is and the block closed, and is exit 1.
693
+ * @param {string} runId @param {string} id @param {{path: string, branch: string, base_sha: string}} worktree
694
+ * @param {string} workspace @param {(row: Record<string, any>) => Promise<unknown>} writeRow
695
+ * @param {(s: string) => unknown} out @param {(s: string) => unknown} err
696
+ * @returns {Promise<number>}
697
+ */
698
+ async function removeBlockTree(runId, id, worktree, workspace, writeRow, out, err) {
699
+ const failed = await removeWorktree({ cwd: workspace, worktree });
700
+ if (failed === null) {
701
+ await writeSigned(runId, writeRow, { event: 'worktree.removed', block: id, path: worktree.path, branch: worktree.branch });
702
+ out(`block ${id} worktree removed: ${worktree.path} (branch ${worktree.branch} kept)\n`);
703
+ return 0;
704
+ }
705
+ await writeSigned(runId, writeRow, { event: 'worktree.remove_failed', block: id, path: worktree.path, reason: failed });
706
+ err(`block ${id} worktree NOT removed: ${failed} — the block stays closed; the tree stays at ${worktree.path} (commit or clean it, then git worktree remove it)\n`);
707
+ return 1;
708
+ }
709
+
710
+ /**
711
+ * B63b: the coder's green test run, read from its final report — the block's LAST sentinel must
712
+ * be COMPLETE, with a `TESTS <command> exit=0` line before it (`parseSentinel`).
713
+ * @param {unknown} reportFlag - the `--report` value (absent, or `true` for a valueless flag).
714
+ * @param {string} id
715
+ * @returns {Promise<{ok: true, command: string, exit: number} | {ok: false, reason: string}>}
716
+ */
717
+ async function coderTests(reportFlag, id) {
718
+ if (typeof reportFlag !== 'string') return { ok: false, reason: 'coder report FAILED: context.require_coder_tests needs --report' };
719
+ let text;
720
+ try {
721
+ text = await readFile(reportFlag, 'utf8');
722
+ } catch {
723
+ return { ok: false, reason: 'coder report FAILED: --report cannot be read' };
724
+ }
725
+ const sentinel = parseSentinel(text, id);
726
+ if (sentinel?.status !== 'complete') return { ok: false, reason: `coder report FAILED: no ===BLOCK ${id} COMPLETE=== sentinel` };
727
+ if (!sentinel.tests) return { ok: false, reason: 'coder report FAILED: no TESTS line' };
728
+ if (sentinel.tests.exit !== 0) return { ok: false, reason: `coder report FAILED: TESTS exit=${sentinel.tests.exit}` };
729
+ return { ok: true, command: sentinel.tests.command, exit: 0 };
730
+ }
731
+
346
732
  /** B52: the refusal when `--no-require-reviews` has no terminal to ask the human at. */
347
733
  export const NO_REVIEWS_NO_TTY = '--no-require-reviews needs the human at a terminal to confirm; the block stays open (there is no --yes)';
348
734