@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
package/src/cli/block.mjs CHANGED
@@ -4,13 +4,31 @@
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)
13
14
  *
15
+ * B64a `--json` (every subcommand): on success stdout is exactly ONE JSON line instead of the
16
+ * text — `{"block", "event", …}` with the facts the text prints (open: level, asked, source,
17
+ * kind, attempt, base_sha, tests, worktree, brief; attempt: attempt; rebase (`rebased`): old_base,
18
+ * new_base; claim (`claimed`): path; stop (`stopped`); waive (`waived`): finding, file, by;
19
+ * close (`closed`): status, reason, close_event (the close result's own event word, as the text
20
+ * prints it in parentheses, else null), and `worktree {removed, path, branch}` with
21
+ * `--remove-worktree`). On
22
+ * failure the exit code and stderr are unchanged and stdout stays empty, except a close the gate
23
+ * refused (`closeBlock` said no), which also prints `{"block", "ok": false, "reason"}`. A usage
24
+ * error, a thrown refusal, a cancelled `--no-require-reviews` and a failed `--remove-worktree`
25
+ * print nothing on stdout. Without `--json` every output is byte-identical to before.
26
+ *
27
+ * B64b block end: a close that closes or stops the block and `block stop` write
28
+ * `<runs>/<run>/blocks/<id>.done.json` after the end's rows (`state/block-end.mjs`), then, with
29
+ * `hooks.on_block_end` in the run's snapshot, run that command outside the lock and add
30
+ * `done_file` to `close`/`stop --json`. Without the key the output is unchanged.
31
+ *
14
32
  * `block close [--transcript <file>] [--report <file>] [--no-require-reviews]` also runs B12b's review rows
15
33
  * (`review/gate-check.mjs`): a signed `review.approved` row for the current content hash of every
16
34
  * changed owned file (deletions included) that no later review of the same content made stale
@@ -59,11 +77,57 @@
59
77
  * else still refuses — `unowned_change`, `unproven`, `no_changes`, the transcript grep and
60
78
  * `closeBlock`'s own rows. With `--no-require-reviews` the human path runs exactly as without the key.
61
79
  *
80
+ * B63b: `context.require_coder_tests: true` in the run's config snapshot (immutable like
81
+ * `review.external`; the live config is never read for it) adds one check after the report check:
82
+ * `--report` must be given and readable, its last sentinel for the block must be COMPLETE, and a
83
+ * `TESTS <command> exit=<n>` line must come before it with `n` 0 — else `coder report FAILED:
84
+ * context.require_coder_tests needs --report` / `--report cannot be read` / `no ===BLOCK <id>
85
+ * COMPLETE=== sentinel` / `no TESTS line` / `TESTS exit=<n>`. Waived reviews (the human flag or
86
+ * `review.external`) do not lift it. On a pass a signed `coder.tests {block, command, exit}` row
87
+ * (the command redacted) is one of `closeBlock`'s close rows, after any waiver row. Without the key
88
+ * (or false) nothing is added.
89
+ *
62
90
  * B47: `block open` without `--level` takes the level `autopilot level` chose for the block (the
63
91
  * latest `autopilot.level` row of the run for it whose MAC verifies, `autopilotLevel`); with
64
92
  * neither it is a usage error (`no --level given and no autopilot level recorded for <id>`). An
65
93
  * explicit `--level` always wins; a repeated or valueless `--level` is a usage error as before.
66
94
  *
95
+ * B59: in a brief-mode run (`run start --mode brief`), `block open` without `--level` takes the
96
+ * lane from the §3.5 rules (`fallbackLane`) over facts built from the owned entries, `--lines`
97
+ * (estimated changed lines) and `proof.tiers.high.paths` of the run's config snapshot
98
+ * (`laneFacts`), and writes one signed `decision {question: lane, source: rules}` row naming the
99
+ * block, then the `dispatch` row (`openBlock`; neither when the open is refused). An explicit
100
+ * `--level` still wins, with no decision row. Outside brief mode `--lines` stays the forecast it
101
+ * always was, and no `--level` without an autopilot level is the usage error above.
102
+ *
103
+ * B60: `--tests <paths…>` names the block's test files (the same path rules as `--owned`); they are
104
+ * recorded as the block's `test_files` and on the dispatch row, and a gate argv item that is
105
+ * exactly `{test_files}` runs them (`gates run --run --block`). Without it the gate takes the owned
106
+ * entries that look like tests.
107
+ *
108
+ * B62a: under `proof.isolation: worktree` (the run's config snapshot, `proof/worktree.mjs`) `block
109
+ * open` makes the block's own worktree and branch before the block is recorded — or reuses the one
110
+ * an earlier attempt of the same block made — and prints a `worktree <path> · branch <branch>` line
111
+ * between the open line and the brief pointer. A refused `git worktree add` refuses the open (exit
112
+ * 1) with nothing recorded. `block close --remove-worktree` runs `git worktree remove <path>` (no
113
+ * `--force`; the branch is kept) only after the block closed, and writes a signed
114
+ * `worktree.removed` or `worktree.remove_failed` row; a failed removal is exit 1 with the block
115
+ * still closed. The flag on a block without a worktree, or on another subcommand, is a usage error
116
+ * (exit 2). It is coder-forbidden (`CODER_ONLY_FORBIDDEN`).
117
+ *
118
+ * B62b: `block close` of a block with a worktree measures that tree (`blockTree`), not the main
119
+ * one: the changed files (committed on the block's branch and uncommitted, against
120
+ * `worktree.base_sha`), the file set and its content hashes, and the unowned changes all come from
121
+ * the tree, by the same rules as for the main tree. A change in the main tree or in another
122
+ * block's tree never counts for the block. A fresh tree holds nothing but the block's own work,
123
+ * so every change in it is the block's: the `tree_at_open` stamps (taken in the main tree) do not
124
+ * exempt anything there, and a file another block owns is not left to that block (its close
125
+ * measures only its own tree) — a change in the tree to any file this block does not own is
126
+ * `unowned_change`. The whole close uses one base, the tree's `base_sha`. A tree that is gone,
127
+ * not a directory, without the workspace's subdirectory, or recorded malformed refuses the close
128
+ * before anything is asked or written. The ledger, the signed result files and the high paths stay
129
+ * in the main tree. Nothing is checked out, pushed or merged.
130
+ *
67
131
  * `--acceptance` is a YAML/JSON list of `{clause, tests: [test ids…]}`. `block close` runs B8's
68
132
  * rows of the gate (worker pin, MACs, orphans); the gate rows owned by later blocks join it
69
133
  * through `closeBlock`'s `extraChecks`.
@@ -73,27 +137,34 @@ import { readFile, realpath } from 'node:fs/promises';
73
137
  import path from 'node:path';
74
138
  import { parse as parseYAML } from 'yaml';
75
139
  import { approvalCheckText, expiryChecks } from '../autopilot/limits.mjs';
140
+ import { fallbackLane } from '../decide/fallback-rules.mjs';
141
+ import { laneFacts } from '../decide/lane-facts.mjs';
76
142
  import { appendRow, readAllRows } from '../ledger/write.mjs';
77
143
  import { attemptBlock, claimPath, closeBlock, openBlock, rebaseBlock, repoTop, stopBlock } from '../state/block.mjs';
78
144
  import { intFlag, parseFlags } from '../state/cli-args.mjs';
79
- import { externalReviewer } from '../state/config-snapshot.mjs';
145
+ import { configInForce, externalReviewer, requireCoderTests } from '../state/config-snapshot.mjs';
80
146
  import { StateError } from '../state/paths.mjs';
81
147
  import { readRun, writeSigned } from '../state/run.mjs';
82
148
  import { loadKey, verifyRow } from '../state/signer.mjs';
83
149
  import { blockFileSet, changedFiles, ownsAt, resultFilesFor, reviewedFiles, reviewGateCheck, unownedChanges, waiveFinding } from '../review/gate-check.mjs';
84
150
  import { checkExpiry } from '../autopilot/grant.mjs';
85
- import { writeSafe } from '../util/redact.mjs';
151
+ import { redact, writeSafe } from '../util/redact.mjs';
152
+ import { blockTree, prepareWorktree, removeWorktree, worktreeSettings } from '../proof/worktree.mjs';
86
153
  import { DEFAULT_CONFIG_FILENAME, loadProjectConfig } from '../config/load.mjs';
87
154
  import { validateConfig } from '../config/validate.mjs';
88
155
  import { migrateConfig } from '../config/migrate.mjs';
89
156
  import { exec } from '../util/exec.mjs';
90
157
  import { gitChildEnv } from '../worker/ticket.mjs';
91
- import { missingReviewTickets } from '../engines/sentinel.mjs';
158
+ import { missingReviewTickets, parseSentinel } from '../engines/sentinel.mjs';
159
+ import { blockEndHook, runBlockEndHook, writeDoneFile } from '../state/block-end.mjs';
160
+ import { blockOpenFindings } from '../review/open-findings.mjs';
161
+ import { hookForbidden, scanTranscript } from '../util/forbidden.mjs';
92
162
 
93
163
  const USAGE =
94
- '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' +
95
- ' 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' +
96
- ' code-forge block waive <id> <finding-id> --run <r> --file <path> --reason <text>\n';
164
+ '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' +
165
+ ' 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' +
166
+ ' code-forge block waive <id> <finding-id> --run <r> --file <path> --reason <text>\n' +
167
+ ' every block verb takes --json: one JSON line on stdout ({"block","event",…}) instead of the text\n';
97
168
 
98
169
  const SUBCOMMANDS = ['open', 'attempt', 'rebase', 'claim', 'close', 'stop', 'waive'];
99
170
  const FLAG_VALUES = ['run', 'level', 'acceptance', 'brief', 'attempt', 'base', 'lines', 'head', 'worker-pid', 'reason', 'file', 'transcript', 'kind', 'report'];
@@ -104,9 +175,11 @@ const FLAG_VALUES = ['run', 'level', 'acceptance', 'brief', 'attempt', 'base', '
104
175
  * stdout?: {write: (s: string) => unknown}, stderr?: {write: (s: string) => unknown}, probe?: import('../state/run.mjs').StartTimeProbe, now?: () => Date,
105
176
  * isTTY?: boolean, stdinIsTTY?: boolean, stdoutIsTTY?: boolean,
106
177
  * ui?: {confirm: (o: {message: string, initialValue?: boolean}) => Promise<unknown>, isCancel: (v: unknown) => boolean},
178
+ * removeBlockTree?: typeof removeBlockTree,
107
179
  * }} [deps] - `now` (B48): the clock of the autopilot expiry checks. `stdinIsTTY` / `stdoutIsTTY`
108
180
  * (default `process.stdin.isTTY` / `process.stdout.isTTY`; `isTTY` sets both) and `ui` (default
109
181
  * `@clack/prompts`): B52's confirmation of `--no-require-reviews`, which needs both to be TTYs.
182
+ * `removeBlockTree` (B64b, tests only): the `--remove-worktree` step, default the real one.
110
183
  * @returns {Promise<number>}
111
184
  */
112
185
  export async function runBlock(args, deps = {}) {
@@ -119,19 +192,66 @@ export async function runBlock(args, deps = {}) {
119
192
  return 2;
120
193
  }
121
194
  try {
122
- const { flags, positionals } = parseFlags(rest, { values: FLAG_VALUES, multi: ['owned'], booleans: ['no-require-reviews'] });
195
+ const { flags, positionals } = parseFlags(rest, { values: FLAG_VALUES, multi: ['owned', 'tests'], booleans: ['no-require-reviews', 'remove-worktree', 'json'] });
196
+ // B64a: --json prints ONE JSON line on success instead of the text lines (`emit`, one object
197
+ // per verb, built where the text is); without it every output is exactly as before (`say` is `out`)
198
+ const json = flags.json === true;
199
+ const say = json ? (/** @type {string} */ _s) => {} : out;
200
+ const emit = (/** @type {Record<string, unknown>} */ obj) => out(`${JSON.stringify(obj)}\n`);
201
+ // B60: --tests names exact paths (a file or a directory — PHPUnit and Pest take directories).
202
+ // `*` and `?` are refused: a glob would reach the gate unexpanded. `[` and `{` are allowed,
203
+ // since real paths use them (`app/[id]/page.test.tsx`) and argv never reaches a shell. A
204
+ // leading "-" would be read as an option.
205
+ for (const t of sub === 'open' && Array.isArray(flags.tests) ? flags.tests : []) {
206
+ if (/[*?]/.test(t)) throw new StateError('usage', `--tests takes exact paths (a file or a directory); "*" and "?" are refused: ${JSON.stringify(t)}`);
207
+ if (t.startsWith('-')) throw new StateError('usage', `--tests: a path may not start with "-": ${JSON.stringify(t)}`);
208
+ }
123
209
  const [id, extra] = positionals;
124
210
  const runId = typeof flags.run === 'string' ? flags.run : undefined;
125
211
  if (!id || !runId) throw new StateError('usage', 'a block id and --run are required');
126
212
  if (sub === 'open' && (typeof flags.acceptance !== 'string' || !Array.isArray(flags.owned) || (flags.level !== undefined && typeof flags.level !== 'string'))) {
127
213
  throw new StateError('usage', 'block open needs --level, --owned and --acceptance');
128
214
  }
215
+ if (sub !== 'close' && flags['remove-worktree'] === true) throw new StateError('usage', '--remove-worktree is a block close flag');
129
216
  if (sub === 'claim' && (!extra || positionals.length !== 2)) throw new StateError('usage', 'block claim needs exactly one path');
130
217
  if ((sub === 'stop' || sub === 'waive') && typeof flags.reason !== 'string') throw new StateError('usage', `block ${sub} needs --reason`);
131
218
  if (sub === 'waive' && (!extra || positionals.length !== 2)) throw new StateError('usage', 'block waive needs exactly one finding id');
132
219
  if (sub === 'waive' && typeof flags.file !== 'string') throw new StateError('usage', 'block waive needs --file <path> (a finding id is scoped to one file)');
133
220
  const record = await readRun(runId);
134
221
  const writeRow = (/** @type {Record<string, any>} */ row) => appendRow(row, { slug: record.project });
222
+ // B64b: the block-end signal — after an end's rows (and outside the run lock) the done file,
223
+ // then the owner's `hooks.on_block_end` from the run-start snapshot. Without the hook key the
224
+ // done file is the only addition: no row, and every output stays exactly as before.
225
+ const hook = blockEndHook(record);
226
+ const ended = async (/** @type {'closed' | 'stopped'} */ status, /** @type {string} */ event, /** @type {string | null} */ reason) => {
227
+ // ruling (e): `ended` never throws — any failure here is the done-file warning and a null
228
+ // `doneFile`, so the caller's try/finally still reaches the hook
229
+ /** @type {string | null} */
230
+ let doneFile = null;
231
+ try {
232
+ doneFile = await writeDoneFile({
233
+ record,
234
+ id,
235
+ status,
236
+ event,
237
+ reason: typeof reason === 'string' ? redact(reason) : null,
238
+ openFindings: (rows) => blockOpenFindings(record, rows, id),
239
+ err,
240
+ });
241
+ } catch (thrown) {
242
+ const code = /** @type {any} */ (thrown)?.code;
243
+ err(`warning: done file not written: ${typeof code === 'string' && /^[A-Z0-9_]{1,32}$/.test(code) ? code : 'UNKNOWN'}\n`);
244
+ doneFile = null;
245
+ }
246
+ return {
247
+ json: hook === null ? {} : { done_file: doneFile },
248
+ runHook: async () => {
249
+ if (hook === null) return;
250
+ const top = await repoTop(record.workspace).catch(() => record.workspace);
251
+ await runBlockEndHook({ hook, runId, id, status, doneFile: doneFile ?? '', repoTop: top, writeRow, err });
252
+ },
253
+ };
254
+ };
135
255
  // B48: every block command runs the lazy autopilot checks (grant expiry, expired approvals);
136
256
  // a failed check is a warning, never a reason to stop the block command
137
257
  try {
@@ -141,8 +261,25 @@ export async function runBlock(args, deps = {}) {
141
261
  }
142
262
 
143
263
  if (sub === 'open') {
144
- // B47: no --level ⇒ the level autopilot chose for the block (a signed autopilot.level row)
145
- const level = typeof flags.level === 'string' ? flags.level : await autopilotLevel(runId, id, record);
264
+ // an explicit --level wins; B59: else in a brief-mode run the rules lane; B47: else the level
265
+ // autopilot chose for the block (a signed autopilot.level row)
266
+ /** @type {{lane: string, facts: ReturnType<typeof laneFacts>} | null} */
267
+ let rules = null;
268
+ /** @type {string | null} */
269
+ let level;
270
+ // B59: only the rules lane parses --lines this early; every other open parses it where it
271
+ // always was (after the level check and --attempt), so its errors keep their pre-B59 order
272
+ /** @type {number | null | undefined} */
273
+ let lines;
274
+ if (typeof flags.level === 'string') {
275
+ level = flags.level;
276
+ } else if (record.mode === 'brief') {
277
+ lines = intFlag(flags.lines, 'lines') ?? null;
278
+ rules = rulesLane(record, /** @type {string[]} */ (flags.owned), lines);
279
+ level = rules.lane;
280
+ } else {
281
+ level = await autopilotLevel(runId, id, record);
282
+ }
146
283
  if (level === null) throw new StateError('usage', `no --level given and no autopilot level recorded for ${id}`);
147
284
  const acceptance = parseYAML(await readFile(/** @type {string} */ (flags.acceptance), 'utf8'), { prettyErrors: false });
148
285
  let brief;
@@ -152,7 +289,9 @@ export async function runBlock(args, deps = {}) {
152
289
  if (relative.startsWith('..') || path.isAbsolute(relative)) throw new StateError('usage', '--brief must be a file inside the workspace');
153
290
  brief = { path: relative, content: await readFile(absolute) };
154
291
  }
155
- const { block, pointer } = await openBlock({
292
+ // B62a: the run's isolation, from its config snapshot (fixed for the run)
293
+ const isolated = worktreeSettings(record);
294
+ const { block, pointer, worktreeReused } = await openBlock({
156
295
  runId,
157
296
  id,
158
297
  level,
@@ -160,40 +299,72 @@ export async function runBlock(args, deps = {}) {
160
299
  acceptance,
161
300
  attempt: intFlag(flags.attempt, 'attempt'),
162
301
  base: typeof flags.base === 'string' ? flags.base : undefined,
163
- lines: intFlag(flags.lines, 'lines') ?? null,
302
+ lines: lines === undefined ? (intFlag(flags.lines, 'lines') ?? null) : lines,
164
303
  brief,
304
+ ...(rules ? { decision: { question: 'lane', answer: rules.lane, source: 'rules', facts: rules.facts } } : {}),
165
305
  ...(typeof flags.kind === 'string' ? { kind: flags.kind } : {}),
306
+ ...(Array.isArray(flags.tests) ? { tests: flags.tests } : {}),
166
307
  cfg: await openConfig(record.workspace),
167
308
  writeRow,
309
+ ...(isolated ? { worktree: (/** @type {{baseSha: string, previous: any}} */ ctx) => prepareWorktree({ cwd: record.workspace, settings: isolated, runId, id, ...ctx }) } : {}),
168
310
  });
169
311
  const raised = block.level !== level ? ` (${block.kind} floor, asked ${level})` : '';
170
- const chosen = typeof flags.level === 'string' ? '' : ' (autopilot level)';
171
- out(`block ${id} open · ${block.level}${raised}${chosen} · ${block.kind} · attempt ${block.attempt} · base ${block.base_sha.slice(0, 12)}\n`);
172
- out(`${pointer ?? 'brief: none given (--brief <file>)'}\n`);
312
+ const chosen = typeof flags.level === 'string' ? '' : rules ? ' (rules lane)' : ' (autopilot level)';
313
+ const testCount = Array.isArray(block.test_files) ? block.test_files.length : 0;
314
+ const tests = testCount > 0 ? ` · ${testCount} test file${testCount === 1 ? '' : 's'}` : '';
315
+ say(`block ${id} open · ${block.level}${raised}${chosen} · ${block.kind} · attempt ${block.attempt} · base ${block.base_sha.slice(0, 12)}${tests}\n`);
316
+ if (block.worktree) say(`worktree ${block.worktree.path} · branch ${block.worktree.branch}${worktreeReused ? ' · reused' : ''}\n`);
317
+ say(`${pointer ?? 'brief: none given (--brief <file>)'}\n`);
318
+ if (json) {
319
+ emit({
320
+ block: id,
321
+ event: 'open',
322
+ level: block.level,
323
+ asked: level ?? null,
324
+ source: typeof flags.level === 'string' ? 'flag' : rules ? 'rules' : 'autopilot',
325
+ kind: block.kind,
326
+ attempt: block.attempt,
327
+ base_sha: block.base_sha,
328
+ tests: testCount,
329
+ worktree: block.worktree ? { path: block.worktree.path, branch: block.worktree.branch, reused: Boolean(worktreeReused) } : null,
330
+ brief: pointer ?? null,
331
+ });
332
+ }
173
333
  return 0;
174
334
  }
175
335
  if (sub === 'attempt') {
176
- out(`block ${id} attempt ${await attemptBlock({ runId, id, writeRow })}\n`);
336
+ const n = await attemptBlock({ runId, id, writeRow });
337
+ say(`block ${id} attempt ${n}\n`);
338
+ if (json) emit({ block: id, event: 'attempt', attempt: n });
177
339
  return 0;
178
340
  }
179
341
  if (sub === 'rebase') {
180
342
  const moved = await rebaseBlock({ runId, id, head: typeof flags.head === 'string' ? flags.head : undefined, writeRow });
181
- out(`block ${id} rebased ${moved.old_base.slice(0, 12)} → ${moved.new_base.slice(0, 12)}\n`);
343
+ say(`block ${id} rebased ${moved.old_base.slice(0, 12)} → ${moved.new_base.slice(0, 12)}\n`);
344
+ if (json) emit({ block: id, event: 'rebased', old_base: moved.old_base, new_base: moved.new_base });
182
345
  return 0;
183
346
  }
184
347
  if (sub === 'claim') {
185
348
  await claimPath({ runId, id, file: extra, writeRow });
186
- out(`block ${id} claimed ${extra}\n`);
349
+ say(`block ${id} claimed ${extra}\n`);
350
+ if (json) emit({ block: id, event: 'claimed', path: extra });
187
351
  return 0;
188
352
  }
189
353
  if (sub === 'stop') {
190
354
  await stopBlock({ runId, id, reason: /** @type {string} */ (flags.reason), writeRow });
191
- out(`block ${id} stopped\n`);
192
- return 0;
355
+ const end = await ended('stopped', 'block.stop', /** @type {string} */ (flags.reason));
356
+ try {
357
+ say(`block ${id} stopped\n`);
358
+ if (json) emit({ block: id, event: 'stopped', ...end.json });
359
+ return 0;
360
+ } finally {
361
+ await end.runHook();
362
+ }
193
363
  }
194
364
  if (sub === 'waive') {
195
365
  await waiveFinding({ runId, id, finding: extra, reason: /** @type {string} */ (flags.reason), file: /** @type {string} */ (flags.file), writeRow });
196
- out(`block ${id} waived ${extra} in ${flags.file} (by: human)\n`);
366
+ say(`block ${id} waived ${extra} in ${flags.file} (by: human)\n`);
367
+ if (json) emit({ block: id, event: 'waived', finding: extra, file: flags.file, by: 'human' });
197
368
  return 0;
198
369
  }
199
370
  if (sub === 'close') {
@@ -202,6 +373,21 @@ export async function runBlock(args, deps = {}) {
202
373
  // B52: a block that is not open is refused here, with or without --no-require-reviews, by
203
374
  // the same check and message as `closeBlock` — and the owner is never asked about it
204
375
  if (entry.status !== 'open') throw new StateError('no-block', `block ${id} is not open in run ${runId}`);
376
+ // B62a: removing a tree the block does not have is a usage error, before anything runs
377
+ const removeTree = flags['remove-worktree'] === true;
378
+ if (removeTree && !entry.worktree) throw new StateError('usage', `block ${id} has no worktree (proof.isolation is not worktree); --remove-worktree does not apply`);
379
+ // B62b: the block's tree is where its work is measured — a tree that is gone refuses the close
380
+ // before anything is asked or written
381
+ const wt = await blockTree(record.workspace, entry).catch((thrown) => {
382
+ if (thrown?.code === 'worktree-missing') {
383
+ 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`);
384
+ }
385
+ 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`);
386
+ throw thrown;
387
+ });
388
+ // ONE base for the whole close: a worktree block is measured against the commit its tree was
389
+ // made from (B62a keeps it equal to the block's base_sha: a re-open keeps it, a rebase is refused)
390
+ const base = wt !== null ? wt.base_sha : entry.base_sha;
205
391
  const reviewsWaived = flags['no-require-reviews'] === true;
206
392
  // B52: the human's way out is confirmed by the human, at a terminal, before anything is written
207
393
  if (reviewsWaived) {
@@ -220,25 +406,29 @@ export async function runBlock(args, deps = {}) {
220
406
  // relative `file` and keeps its queue and signed result files under `<top>/.code-forge/`
221
407
  // (`repoRootOf`), and `tree_at_open` is stamped there too. An owned entry matches a path
222
408
  // as given or, for a workspace in a subdirectory, relative to the workspace (`ownsAt`).
223
- // A failure here is the usual `git-failed` refusal.
224
- /** @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} */
409
+ // A failure here is the usual `git-failed` refusal. B62b: for a block with a worktree the
410
+ // listing and the content are the tree's (`top`); `mainTop` keeps the run's state.
411
+ /** @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} */
225
412
  let treeOnce;
226
413
  const tree = () =>
227
414
  (treeOnce ??= (async () => {
228
- const top = await repoTop(record.workspace);
415
+ const mainTop = await repoTop(record.workspace);
416
+ const inTree = wt !== null;
417
+ const top = inTree ? wt.top : mainTop;
418
+ const where = inTree ? `the worktree ${wt.path}` : 'the workspace';
229
419
  // both sides as realpaths: git prints the top resolved (/private/var on macOS) while the
230
420
  // run record may keep a symlinked spelling (/var)
231
421
  const real = async (/** @type {string} */ p) => realpath(p).catch(() => path.resolve(p));
232
- const wsPrefix = path.relative(await real(top), await real(record.workspace)).split(path.sep).join('/');
422
+ const wsPrefix = path.relative(await real(mainTop), await real(record.workspace)).split(path.sep).join('/');
233
423
  const owns = ownsAt(wsPrefix);
234
424
  const isMine = (/** @type {string} */ f) => owns(entry.owned_files, f, entry.owned_kinds);
235
- const changed = await changedFiles({ repoRoot: top, base: entry.base_sha });
425
+ const changed = await changedFiles({ repoRoot: top, base });
236
426
  // the file set is the diff since base ∪ every owned file with review rows of this block —
237
427
  // work the diff cannot see (committed before `block open`, done in another worktree) is
238
- // still checked, at its content in the workspace
428
+ // still checked, at its content in the workspace (in the block's worktree when it has one)
239
429
  const reviewed = reviewedFiles(rows, id).filter(isMine);
240
- const fileSet = await blockFileSet({ repoRoot: top, base: entry.base_sha, owned: entry.owned_files, matchOwned: isMine, changed: [...new Set([...changed, ...reviewed])].sort() });
241
- return { top, wsPrefix, owns, changed, fileSet, reviewed };
430
+ const fileSet = await blockFileSet({ repoRoot: top, base, owned: entry.owned_files, matchOwned: isMine, changed: [...new Set([...changed, ...reviewed])].sort() });
431
+ return { top, mainTop, where, inTree, wsPrefix, owns, changed, fileSet, reviewed };
242
432
  })());
243
433
  const reviews = reviewGateCheck(async () => {
244
434
  const transcript = await findTranscript(record, id, typeof flags.transcript === 'string' ? flags.transcript : null);
@@ -249,13 +439,14 @@ export async function runBlock(args, deps = {}) {
249
439
  // the human's way out of the per-file review check leaves a signed audit row before the close
250
440
  if (reviewsWaived) await writeSigned(runId, writeRow, { event: 'gate.reviews_waived', block: id, by: 'human' });
251
441
  // B20: the high paths come first — a base the workspace does not know refuses the close here
252
- const highPaths = await highPathsAt(record.workspace, entry.base_sha);
253
- const { top, wsPrefix, owns, changed, fileSet, reviewed } = await tree();
442
+ const highPaths = await highPathsAt(record.workspace, base);
443
+ const { top, mainTop, where, inTree, wsPrefix, owns, changed, fileSet, reviewed } = await tree();
254
444
  const isMine = (/** @type {string} */ f) => owns(entry.owned_files, f, entry.owned_kinds);
255
445
  const key = await loadKey(runId);
256
446
  // B52: an empty diff with review rows is never silent — name the likely cause
257
447
  if (reviewed.length > 0 && !changed.some(isMine)) {
258
- 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`);
448
+ 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';
449
+ 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`);
259
450
  }
260
451
  // B52: only code-forge's own state is exempt — `<top>/.code-forge/`, where the worker keeps
261
452
  // its queue and signed results — never a `--transcript` or `--report` path in the source
@@ -265,7 +456,9 @@ export async function runBlock(args, deps = {}) {
265
456
  // `.code-forge/runs/<run>/<id>.log`)
266
457
  const wsRel = (/** @type {string} */ p) => path.posix.join(wsPrefix, p);
267
458
  const ownState = new Set([wsRel(`.code-forge/runs/${runId}.json`), wsRel(`.code-forge/runs/${runId}/${id}.log`)]);
268
- const scope = await unownedChanges({ top, changed: changed.filter((f) => !ownState.has(f)), id, blocks: record.blocks, rows, key, isMine, owns, stateDir: '.code-forge' });
459
+ // B62b: a block's own tree started clean at its base and no other block works in it — no
460
+ // stamp from the main tree and no other block's ownership exempts a change there
461
+ 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 });
269
462
  if (scope === null) err(`WARN block ${id}: opened before code-forge recorded the tree at block open; unowned changes were not checked\n`);
270
463
  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`);
271
464
  return {
@@ -277,7 +470,7 @@ export async function runBlock(args, deps = {}) {
277
470
  // B52: a change no block of the run owns is refused — `--no-require-reviews` does not lift it
278
471
  unowned: scope?.unowned ?? [],
279
472
  // B52: an old ledger's review.result rows are judged by their signed result files
280
- resultFiles: await resultFilesFor({ repoRoot: top, runId, block: id, rows }),
473
+ resultFiles: await resultFilesFor({ repoRoot: mainTop, runId, block: id, rows }),
281
474
  files: reviewsWaived || external !== null ? [] : fileSet,
282
475
  // B52: nothing changed and nothing reviewed is never a pass (the owner's way out is
283
476
  // --no-require-reviews); B58: `review.external` does not lift it
@@ -302,10 +495,60 @@ export async function runBlock(args, deps = {}) {
302
495
  };
303
496
  // B58: the config waiver row is written by `closeBlock` under the run lock, just before
304
497
  // `block.close` and only when every other check passed — never by a refused close
498
+ /** @type {Record<string, any>[]} */
305
499
  const closeRows = external === null ? [] : [{ event: 'gate.reviews_waived', block: id, by: 'config', reviewer: external }];
306
- const result = await closeBlock({ runId, id, rows, writeRow, livePid: intFlag(flags['worker-pid'], 'worker-pid'), probe, extraChecks: [reviews, report], closeRows });
307
- (result.ok ? out : err)(`block ${id} ${result.status}${result.event ? ` (${result.event})` : ''}${result.reason ? `: ${result.reason}` : ''}\n`);
308
- return result.ok ? 0 : 1;
500
+ /** @type {Array<() => Promise<{ok: boolean, reason?: string}>>} */
501
+ const extraChecks = [reviews, report];
502
+ // B63b: `context.require_coder_tests` in the run's config snapshot (never the live config)
503
+ // needs the coder's green test run — waived reviews do not lift it. It runs FIRST: it only
504
+ // reads the report, so a refusal here leaves no row (not even the human waiver row the review
505
+ // check writes). Absent or false: nothing is added, the close is exactly as before.
506
+ if (requireCoderTests(record)) {
507
+ extraChecks.unshift(async () => {
508
+ const tests = await coderTests(flags.report, id);
509
+ if (!tests.ok) return tests;
510
+ // signed under the run lock by `closeBlock`, after the waiver row, only when the block closes
511
+ closeRows.push({ event: 'coder.tests', block: id, command: redact(tests.command), exit: tests.exit });
512
+ return { ok: true };
513
+ });
514
+ }
515
+ // B64b: the hook command is the orchestrator's, never a coder's — a coder transcript that runs
516
+ // it refuses the close. Only with the hook configured (the list is otherwise unchanged); it
517
+ // only reads the transcript, so it runs first and a refusal here leaves no row.
518
+ if (hook !== null) {
519
+ extraChecks.unshift(async () => {
520
+ const text = await findTranscript(record, id, typeof flags.transcript === 'string' ? flags.transcript : null);
521
+ const [hit] = text === null ? [] : scanTranscript(text, hookForbidden(hook.argv));
522
+ return hit ? { ok: false, reason: `rule_break transcript line ${hit.line}: ${hit.id}` } : { ok: true };
523
+ });
524
+ }
525
+ const result = await closeBlock({ runId, id, rows, writeRow, livePid: intFlag(flags['worker-pid'], 'worker-pid'), probe, extraChecks, closeRows });
526
+ // B64b: the block ended (closed, or stopped by a worker pin event or ledger.tamper) — its rows
527
+ // are written and the run lock released; the done file is written before the tree is removed
528
+ 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;
529
+ // B64b ruling (e): the hook runs exactly once for this end, after everything else the verb
530
+ // does — also when a later step (the output, `--remove-worktree`) throws; it never changes
531
+ // the exit code or stdout (`runHook` never throws)
532
+ try {
533
+ (result.ok ? say : err)(`block ${id} ${result.status}${result.event ? ` (${result.event})` : ''}${result.reason ? `: ${result.reason}` : ''}\n`);
534
+ // B64a: a refused close also names itself on stdout under --json (its stderr is unchanged)
535
+ if (json && !result.ok) emit({ block: id, ok: false, reason: result.reason ?? null, ...(end?.json ?? {}) });
536
+ const closedJson = { block: id, event: result.status, status: result.status, reason: result.reason ?? null, close_event: result.event ?? null, ...(end?.json ?? {}) };
537
+ if (!result.ok || !removeTree) {
538
+ if (json && result.ok) emit(closedJson);
539
+ return result.ok ? 0 : 1;
540
+ }
541
+ const removed = await (deps.removeBlockTree ?? removeBlockTree)(runId, id, entry.worktree, record.workspace, writeRow, say, err);
542
+ // a failed removal is exit 1 with its stderr text and nothing on stdout, like every failure
543
+ if (json && removed === 0) {
544
+ // built from the block's own tree entry; `removed` is what removeBlockTree reported (0 = removed)
545
+ const treeJson = entry.worktree ? { removed: removed === 0, path: entry.worktree.path, branch: entry.worktree.branch } : null;
546
+ emit({ ...closedJson, worktree: treeJson });
547
+ }
548
+ return removed;
549
+ } finally {
550
+ await end?.runHook();
551
+ }
309
552
  }
310
553
  } catch (thrown) {
311
554
  err(`block ${sub ?? ''}: ${thrown?.message ?? String(thrown)}\n`);
@@ -315,6 +558,50 @@ export async function runBlock(args, deps = {}) {
315
558
  return 2;
316
559
  }
317
560
 
561
+ /**
562
+ * B62a: `block close --remove-worktree`, after the block closed: `git worktree remove <path>` (no
563
+ * `--force`, the branch is kept), then a signed `worktree.removed {block, path, branch}` or
564
+ * `worktree.remove_failed {block, path, reason}` row. A failed removal (a tree with modified or
565
+ * untracked files) leaves the tree where it is and the block closed, and is exit 1.
566
+ * @param {string} runId @param {string} id @param {{path: string, branch: string, base_sha: string}} worktree
567
+ * @param {string} workspace @param {(row: Record<string, any>) => Promise<unknown>} writeRow
568
+ * @param {(s: string) => unknown} out @param {(s: string) => unknown} err
569
+ * @returns {Promise<number>}
570
+ */
571
+ async function removeBlockTree(runId, id, worktree, workspace, writeRow, out, err) {
572
+ const failed = await removeWorktree({ cwd: workspace, worktree });
573
+ if (failed === null) {
574
+ await writeSigned(runId, writeRow, { event: 'worktree.removed', block: id, path: worktree.path, branch: worktree.branch });
575
+ out(`block ${id} worktree removed: ${worktree.path} (branch ${worktree.branch} kept)\n`);
576
+ return 0;
577
+ }
578
+ await writeSigned(runId, writeRow, { event: 'worktree.remove_failed', block: id, path: worktree.path, reason: failed });
579
+ 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`);
580
+ return 1;
581
+ }
582
+
583
+ /**
584
+ * B63b: the coder's green test run, read from its final report — the block's LAST sentinel must
585
+ * be COMPLETE, with a `TESTS <command> exit=0` line before it (`parseSentinel`).
586
+ * @param {unknown} reportFlag - the `--report` value (absent, or `true` for a valueless flag).
587
+ * @param {string} id
588
+ * @returns {Promise<{ok: true, command: string, exit: number} | {ok: false, reason: string}>}
589
+ */
590
+ async function coderTests(reportFlag, id) {
591
+ if (typeof reportFlag !== 'string') return { ok: false, reason: 'coder report FAILED: context.require_coder_tests needs --report' };
592
+ let text;
593
+ try {
594
+ text = await readFile(reportFlag, 'utf8');
595
+ } catch {
596
+ return { ok: false, reason: 'coder report FAILED: --report cannot be read' };
597
+ }
598
+ const sentinel = parseSentinel(text, id);
599
+ if (sentinel?.status !== 'complete') return { ok: false, reason: `coder report FAILED: no ===BLOCK ${id} COMPLETE=== sentinel` };
600
+ if (!sentinel.tests) return { ok: false, reason: 'coder report FAILED: no TESTS line' };
601
+ if (sentinel.tests.exit !== 0) return { ok: false, reason: `coder report FAILED: TESTS exit=${sentinel.tests.exit}` };
602
+ return { ok: true, command: sentinel.tests.command, exit: 0 };
603
+ }
604
+
318
605
  /** B52: the refusal when `--no-require-reviews` has no terminal to ask the human at. */
319
606
  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)';
320
607
 
@@ -344,6 +631,19 @@ async function confirmNoReviews(runId, id, deps) {
344
631
  return ui.isCancel(go) || go !== true ? 'cancelled' : 'confirmed';
345
632
  }
346
633
 
634
+ /**
635
+ * B59: the rules lane for a brief-mode block — `fallbackLane` over the facts of its owned entries,
636
+ * the `--lines` estimate and the snapshot's `proof.tiers.high.paths` (none for a record from
637
+ * before snapshots).
638
+ * @param {Record<string, any>} record @param {string[]} owned @param {number | null} lines
639
+ * @returns {{lane: string, facts: ReturnType<typeof laneFacts>}}
640
+ */
641
+ function rulesLane(record, owned, lines) {
642
+ const high = configInForce(record)?.proof?.tiers?.high?.paths;
643
+ const facts = laneFacts({ owned, lines, highPaths: high });
644
+ return { lane: fallbackLane(facts), facts };
645
+ }
646
+
347
647
  /**
348
648
  * B47: the level `autopilot level` chose for the block — the latest `autopilot.level` row of this
349
649
  * run for it whose MAC verifies — or null when there is none.