@codedology/code-forge 0.15.2 → 0.15.4

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 (79) hide show
  1. package/CHANGELOG.md +81 -0
  2. package/README.md +5 -1
  3. package/docs/README.md +2 -0
  4. package/docs/reference/blocks.json +162 -1
  5. package/docs/reference/config.md +38 -15
  6. package/native/README.md +10 -0
  7. package/native/bin/darwin-arm64/code-forge-native +0 -0
  8. package/native/bin/darwin-x64/code-forge-native +0 -0
  9. package/native/bin/linux-arm64/code-forge-native +0 -0
  10. package/native/bin/linux-x64/code-forge-native +0 -0
  11. package/package.json +4 -1
  12. package/schema/code-forge.schema.json +83 -3
  13. package/schema/outcome.schema.json +45 -0
  14. package/schema/pipeline.schema.json +30 -0
  15. package/scripts/first-use.mjs +8 -0
  16. package/scripts/gen-config-doc.mjs +1 -1
  17. package/skill/references/autopilot.md +6 -1
  18. package/skill/references/code.md +52 -0
  19. package/skill/references/ledger.md +13 -1
  20. package/skill/references/plan.md +5 -1
  21. package/skill/references/review.md +46 -8
  22. package/skill/scripts/forge +1 -1
  23. package/src/cli/block.mjs +427 -38
  24. package/src/cli/context.mjs +17 -7
  25. package/src/cli/doctor.mjs +32 -1
  26. package/src/cli/gates.mjs +76 -10
  27. package/src/cli/help.mjs +5 -0
  28. package/src/cli/native-status.mjs +13 -0
  29. package/src/cli/outcome.mjs +87 -0
  30. package/src/cli/pipeline.mjs +61 -0
  31. package/src/cli/proof.mjs +10 -7
  32. package/src/cli/report.mjs +53 -6
  33. package/src/cli/review-file.mjs +278 -41
  34. package/src/cli/run.mjs +3 -2
  35. package/src/cli/status.mjs +43 -9
  36. package/src/cli/sweep.mjs +28 -0
  37. package/src/cli/validate.mjs +6 -0
  38. package/src/config/validate.mjs +83 -1
  39. package/src/context/pack.mjs +53 -3
  40. package/src/doctor/index.mjs +11 -0
  41. package/src/doctor/local.mjs +22 -4
  42. package/src/doctor/worker-probe.mjs +1 -1
  43. package/src/engines/builders/codex.mjs +27 -7
  44. package/src/engines/builders/grok.mjs +11 -5
  45. package/src/engines/codex-home.mjs +18 -15
  46. package/src/engines/sentinel.mjs +11 -0
  47. package/src/gates/detect.mjs +6 -3
  48. package/src/gates/run.mjs +295 -27
  49. package/src/ledger/export.mjs +1 -1
  50. package/src/ledger/report.mjs +143 -5
  51. package/src/outcome/outcome.mjs +108 -0
  52. package/src/pipeline/pipeline.mjs +112 -0
  53. package/src/proof/red-green.mjs +41 -13
  54. package/src/proof/runners.mjs +28 -4
  55. package/src/proof/worktree.mjs +25 -15
  56. package/src/review/agent-waiver.mjs +113 -12
  57. package/src/review/engine.mjs +25 -5
  58. package/src/review/gate-check.mjs +52 -10
  59. package/src/review/lenses/full.md +1 -0
  60. package/src/review/lenses/quick.md +1 -0
  61. package/src/review/model-stats.mjs +125 -0
  62. package/src/review/packet.mjs +88 -9
  63. package/src/review/related.mjs +112 -0
  64. package/src/review/reopen.mjs +42 -16
  65. package/src/review/route.mjs +58 -4
  66. package/src/review/session-retry.mjs +59 -0
  67. package/src/session/facts.mjs +19 -7
  68. package/src/session/plan-check.mjs +4 -1
  69. package/src/session/spawn.mjs +54 -3
  70. package/src/state/block.mjs +139 -49
  71. package/src/state/config-snapshot.mjs +13 -3
  72. package/src/state/run.mjs +3 -2
  73. package/src/util/exec.mjs +48 -31
  74. package/src/util/native.mjs +131 -0
  75. package/src/util/reaper.mjs +238 -8
  76. package/src/worker/engine.mjs +7 -3
  77. package/src/worker/loop.mjs +211 -30
  78. package/src/worker/queue.mjs +107 -24
  79. package/src/worker/watch.mjs +18 -7
package/src/cli/block.mjs CHANGED
@@ -4,11 +4,12 @@
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|light]
7
- * [--tests <paths…>]
7
+ * [--tests <paths…>] [--after <ids…>] [--after-outcome <ids…>]
8
8
  * block attempt <id> --run <r>
9
9
  * block rebase <id> --run <r> [--head <ref>]
10
10
  * block claim <id> <path> --run <r>
11
- * block close <id> --run <r> [--worker-pid <pid>] [--remove-worktree]
11
+ * block close <id> --run <r> [--worker-pid <pid>] [--remove-worktree] [--auto-claim]
12
+ * block land <id> --run <r> --report <file> --transcript <file> [--max <dur>] [--merge] [--no-merge-gate] [--json]
12
13
  * block stop <id> --run <r> --reason <text>
13
14
  * block waive <id> <finding-id> --run <r> --file <path> --reason <text> [--yes] (orchestrator --yes: evidence and a fresh judge)
14
15
  * block reopen <id> --run <r> --file <path> --reason <text> [--yes] (B124c: evidence and a fresh judge for the agent path)
@@ -20,8 +21,9 @@
20
21
  * close (`closed`): status, reason, close_event (the close result's own event word, as the text
21
22
  * prints it in parentheses, else null), and `worktree {removed, path, branch}` with
22
23
  * `--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
24
+ * failure the exit code and stderr are unchanged and stdout stays empty, except an open refused
25
+ * for `after-not-done`, `bad-after`, `outcome-not-ready` or `bad-after-outcome`, which prints `{"block", "event": "refused", "code", "message"}`,
26
+ * or a close the gate refused (`closeBlock` said no), which also prints `{"block", "ok": false, "reason"}`. A usage
25
27
  * error, a thrown refusal, a cancelled `--no-require-reviews`, `block waive` or `block reopen` (or
26
28
  * one with no terminal, B80) and a failed `--remove-worktree`
27
29
  * print nothing on stdout. Without `--json` every output is byte-identical to before.
@@ -194,14 +196,20 @@
194
196
  * through `closeBlock`'s `extraChecks`.
195
197
  */
196
198
 
197
- import { existsSync, statSync } from 'node:fs';
198
- import { readFile, realpath } from 'node:fs/promises';
199
+ import { createHash } from 'node:crypto';
200
+ import { existsSync, readFileSync, statSync } from 'node:fs';
201
+ import { mkdtemp, readFile, realpath, rm } from 'node:fs/promises';
199
202
  import path from 'node:path';
200
203
  import { parse as parseYAML } from 'yaml';
204
+ import { runGatesVerb } from './gates.mjs';
205
+ import { parseTapPassed } from '../gates/acceptance.mjs';
206
+ import { parseMax, requeueStoppedFiles, runReviewFile } from './review-file.mjs';
201
207
  import { approvalCheckText, expiryChecks } from '../autopilot/limits.mjs';
202
208
  import { fallbackLane } from '../decide/fallback-rules.mjs';
203
209
  import { laneFacts } from '../decide/lane-facts.mjs';
204
210
  import { appendRow, readAllRows } from '../ledger/write.mjs';
211
+ import { checkOutcome } from '../outcome/outcome.mjs';
212
+ import { loadPipeline } from '../pipeline/pipeline.mjs';
205
213
  import { amendAcceptanceBlock, attemptBlock, claimPath, closeBlock, humanPassBlock, openBlock, prepareAcceptanceAmendment, rebaseBlock, repoTop, stopBlock } from '../state/block.mjs';
206
214
  import { intFlag, parseFlags } from '../state/cli-args.mjs';
207
215
  import { configInForce, externalReviewer, requireCoderTests } from '../state/config-snapshot.mjs';
@@ -209,17 +217,17 @@ import { readFileDiff } from '../review/packet.mjs';
209
217
  import { StateError } from '../state/paths.mjs';
210
218
  import { readRun, writeSigned } from '../state/run.mjs';
211
219
  import { loadKey, verifyRow } from '../state/signer.mjs';
212
- import { PROOF_FINDING, blockFileSet, changedFiles, checkBlockReviews, openFindings, ownsAt, recordedSeverity, resultFilesFor, reviewedFiles, reviewGateCheck, routineCloseProblem, unownedChanges, waiveFinding } from '../review/gate-check.mjs';
220
+ import { PROOF_FINDING, blockFileSet, changedFiles, checkBlockReviews, lockfileProblem, openFindings, ownsAt, recordedSeverity, resultFilesFor, reviewedFiles, reviewGateCheck, routineCloseProblem, unownedChanges, waiveFinding } from '../review/gate-check.mjs';
213
221
  import { agentWaivers, agentWaiversAcked, prepareAckWaivers, prepareUnwaive, waiveAsAgent } from '../review/agent-waiver.mjs';
214
222
  import { checkExpiry, grantFor, grantState } from '../autopilot/grant.mjs';
215
223
  import { redact, writeSafe } from '../util/redact.mjs';
216
224
  import { blockTree, prepareWorktree, removeWorktree, worktreeSettings } from '../proof/worktree.mjs';
217
225
  import { DEFAULT_CONFIG_FILENAME, loadProjectConfig } from '../config/load.mjs';
218
- import { validateConfig } from '../config/validate.mjs';
226
+ import { reviewExcludeRule, validateConfig } from '../config/validate.mjs';
219
227
  import { migrateConfig } from '../config/migrate.mjs';
220
228
  import { exec } from '../util/exec.mjs';
221
229
  import { contentHash, gitChildEnv, readRegularFileNoFollow } from '../worker/ticket.mjs';
222
- import { clearDone, resetAcceptanceWatch, resolveTicket } from '../worker/queue.mjs';
230
+ import { clearDone, enqueue, resetAcceptanceWatch, resolveTicket } from '../worker/queue.mjs';
223
231
  import { loadState } from '../worker/review-state.mjs';
224
232
  import { agentReopens, openBlockEntry, reopenFile } from '../review/reopen.mjs';
225
233
  import { checkFileForm, checkFindingId } from '../review/verb-forms.mjs';
@@ -236,7 +244,7 @@ import { acceptedTranscriptHits, prepareAcceptHit, writeAcceptedHit } from '../r
236
244
  * main tree), its state is read (never moved) from the code-forge home or the run's old temp root,
237
245
  * the checks, the done marker's removal and then the signed row are `reopenFile`'s.
238
246
  * @param {{runId: string, id: string, file: string, reason: string, record: Record<string, any>, writeRow: (row: Record<string, any>) => Promise<unknown>, agent?: boolean, judge?: import('../review/agent-waiver.mjs').WaiverJudge}} opts
239
- * @returns {Promise<{row: Record<string, any>, cleared: boolean}>}
247
+ * @returns {Promise<{row: Record<string, any>, cleared: boolean, ticket?: string}>}
240
248
  */
241
249
  async function reopenBlockFile({ runId, id, file, reason, record, writeRow, agent = false, judge }) {
242
250
  if (agent) {
@@ -268,7 +276,7 @@ async function reopenBlockFile({ runId, id, file, reason, record, writeRow, agen
268
276
  const key = await loadKey(runId);
269
277
  const rows = await readAllRows(record.project);
270
278
  const tree = contentRoot === mainTop ? {} : { tree: contentRoot };
271
- const { row, cleared } = await reopenFile({
279
+ const { row, cleared, ticket } = await reopenFile({
272
280
  runId,
273
281
  id,
274
282
  file,
@@ -286,14 +294,242 @@ async function reopenBlockFile({ runId, id, file, reason, record, writeRow, agen
286
294
  readFile: (f) => readRegularFileNoFollow(contentRoot, f),
287
295
  // C11 (synchronous): the cached result of the current content's ticket would repeat the stop
288
296
  clearDone: (f) => clearDone({ repoRoot: mainTop, run: runId, block: id, file: f, ...tree }).removed,
297
+ enqueue: (f, reopened) => enqueue({ repoRoot: mainTop, run: runId, block: id, file: f, ...tree, after: reopened.ts }).ticket,
289
298
  },
290
299
  });
291
- return { row, cleared };
300
+ return { row, cleared, ticket };
301
+ }
302
+
303
+ /** Quote a displayed command for a POSIX shell; execution always uses argv.
304
+ * @param {string[]} argv @returns {string}
305
+ */
306
+ function landCommand(argv) {
307
+ return argv.map((arg) => /^[\w./:@=-]+$/.test(arg) ? arg : `'${arg.replaceAll("'", "'\\''")}'`).join(' ');
308
+ }
309
+
310
+ /** Claim only changes the normal scope check cannot account for, then join real review tickets.
311
+ * @param {{record: Record<string, any>, runId: string, id: string, writeRow: import('../state/run.mjs').WriteRow, print: (line: string) => void, join?: boolean}} opts
312
+ */
313
+ async function autoClaimChanges({ record, runId, id, writeRow, print, join = true }) {
314
+ const entry = record.blocks[id];
315
+ const wt = await blockTree(record.workspace, entry);
316
+ const mainTop = await repoTop(record.workspace);
317
+ const top = wt?.top ?? mainTop;
318
+ const prefix = path.relative(mainTop, await realpath(record.workspace)).split(path.sep).join('/');
319
+ const owns = ownsAt(prefix);
320
+ const ownState = new Set([path.posix.join(prefix, `.code-forge/runs/${runId}.json`), path.posix.join(prefix, `.code-forge/runs/${runId}/${id}.log`)]);
321
+ const changed = (await changedFiles({ repoRoot: top, base: wt?.base_sha ?? entry.base_sha })).filter((f) => !ownState.has(f));
322
+ const scope = await unownedChanges({ top, changed, id, blocks: record.blocks, rows: (await readAllRows(record.project)).filter((r) => r.run === runId), key: await loadKey(runId),
323
+ isMine: (file) => owns(entry.owned_files, file, entry.owned_kinds), owns, ownTree: wt !== null });
324
+ const files = [...new Set([...(scope?.unowned ?? []), ...(scope?.siblings.map((s) => s.file) ?? [])])].sort();
325
+ if (files.length === 0) return;
326
+ const invoke = async (/** @type {string[]} */ args) => {
327
+ let output = '', error = '';
328
+ const code = await runReviewFile(args, { cwd: top, stdout: { write: (s) => { output += s; } }, stderr: { write: (s) => { error += s; } } });
329
+ let data;
330
+ try { data = JSON.parse(output); } catch { /* Excluded files print a text line. */ }
331
+ if (code !== 0) throw new StateError('auto-claim-review', error || output || 'auto-claim review failed');
332
+ return { data, output };
333
+ };
334
+ for (const file of files) {
335
+ await claimPath({ runId, id, file, writeRow, auto: true });
336
+ Object.assign(entry, (await readRun(runId)).blocks[id]);
337
+ print(`block ${id} auto-claimed ${file}\n`);
338
+ await invoke([file, '--block', id, '--run', runId]);
339
+ }
340
+ if (!join) return; // Land joins all claimed and originally owned paths under one deadline.
341
+ const joined = await invoke(['--wait-block', id, '--run', runId]);
342
+ if (joined.data?.done !== true) throw new StateError('auto-claim-review', `auto-claim reviews did not finish: ${joined.output.trim()}`);
343
+ for (const file of joined.data.files) {
344
+ if (file.status === 'excluded' || file.outcome === 'approved') continue;
345
+ const detail = file.ticket ? (await invoke(['--wait', file.ticket])).output : file.outcome ?? file.status;
346
+ throw new StateError('auto-claim-review', `auto-claim review refused ${file.file}: ${detail}`);
347
+ }
348
+ }
349
+
350
+ /** Join, gates and close share their existing CLI handlers, without spawning the CLI.
351
+ * @param {{id: string, runId: string, record: Record<string, any>, report: string, transcript: string, maxMs: number, merge: boolean, noMergeGate: boolean, json: boolean}} options
352
+ * @param {NonNullable<Parameters<typeof runBlock>[1]>} deps
353
+ * @returns {Promise<number>}
354
+ */
355
+ async function landBlock({ id, runId, record, report, transcript, maxMs, merge, noMergeGate, json }, deps) {
356
+ const stdout = deps.stdout ?? process.stdout;
357
+ const stderr = deps.stderr ?? process.stderr;
358
+ const emit = (/** @type {Record<string, any>} */ value) => writeSafe(stdout, `${JSON.stringify(value)}\n`);
359
+ let step = 'join';
360
+ let next = landCommand(['code-forge', 'review-file', '--wait-block', id, '--run', runId]);
361
+ const stop = (/** @type {string} */ reason) => {
362
+ if (step === 'join' && reason === 'wait_timeout') {
363
+ next = landCommand(['code-forge', 'block', 'land', id, '--run', runId, '--report', report, '--transcript', transcript,
364
+ '--max', `${Math.max(1800, Math.ceil(maxMs / 500))}s`, ...(merge ? ['--merge'] : []), ...(noMergeGate ? ['--no-merge-gate'] : []), ...(json ? ['--json'] : [])]);
365
+ }
366
+ writeSafe(stderr, `block ${id} land stopped at ${step}: ${reason.trim()}\nnext: ${next}\n`);
367
+ if (json) emit({ block: id, event: 'refused', step, reason: reason.trim(), next });
368
+ return 1;
369
+ };
370
+ /** @param {(streams: {stdout: {write: (s: string) => void}, stderr: {write: (s: string) => void}}) => Promise<number>} run */
371
+ const capture = async (run) => {
372
+ let output = '';
373
+ let error = '';
374
+ const code = await run({ stdout: { write: (s) => { output += s; } }, stderr: { write: (s) => { error += s; } } });
375
+ /** @type {Record<string, any> | null} */
376
+ let data = null;
377
+ try { data = JSON.parse(output); } catch { /* A thrown refusal may only write stderr. */ }
378
+ return { code, output, error, data };
379
+ };
380
+ try {
381
+ const entry = record.blocks?.[id];
382
+ if (!entry) return stop(`unknown block in run ${runId}`);
383
+ if (entry.status !== 'open') return stop(`block is ${entry.status}, not open`);
384
+ const tree = await blockTree(record.workspace, entry);
385
+ if (!tree) return stop('block has no recorded worktree and branch');
386
+ const deadline = Date.now() + maxMs;
387
+ await autoClaimChanges({ record, runId, id, writeRow: (row) => appendRow(row, { slug: record.project }), print: (line) => writeSafe(json ? stderr : stdout, line), join: false });
388
+ let joined;
389
+ // Findings are usually final; cap retries so land does not wait the full --max on them.
390
+ for (let attempt = 0; attempt < 3; attempt += 1) {
391
+ const remaining = Math.max(0, deadline - Date.now());
392
+ if (remaining === 0) return stop('wait_timeout');
393
+ const duration = remaining > 9_999_999 ? `${Math.floor(remaining / 1000)}s` : `${remaining}ms`;
394
+ joined = await capture((streams) => runReviewFile(['--wait-block', id, '--run', runId, '--max', duration], { ...streams, cwd: tree.workspace }));
395
+ for (const line of joined.error.split('\n')) if (line.startsWith('auto-requeue ')) writeSafe(stderr, `${line}\n`);
396
+ if (joined.code !== 0 || !joined.data?.done || !joined.data.files.some((file) => file.status === 'findings')) break;
397
+ }
398
+ if (joined.code !== 0 || !joined.data?.done) {
399
+ if (joined.data?.status === 'wait_timeout' || joined.data?.reason === 'wait_timeout' || Date.now() >= deadline) return stop('wait_timeout');
400
+ return stop(joined.error || joined.data?.message || joined.data?.reason || joined.data?.status || 'reviews did not finish');
401
+ }
402
+ const problems = joined.data.files.filter((/** @type {Record<string, any>} */ file) => file.status !== 'excluded' && file.outcome !== 'approved');
403
+ if (problems.length > 0) {
404
+ const details = [];
405
+ for (const file of problems) {
406
+ details.push(`${file.file}: ${file.outcome ?? file.status}`);
407
+ if (file.ticket) {
408
+ const result = await capture((streams) => runReviewFile(['--wait', file.ticket], { ...streams, cwd: tree.workspace }));
409
+ details.push(result.output.trim() || result.error.trim());
410
+ }
411
+ }
412
+ next = landCommand(['code-forge', 'review-file', '--wait', problems[0].ticket]);
413
+ return stop(details.join('\n'));
414
+ }
415
+
416
+ step = 'gates';
417
+ next = landCommand(['code-forge', 'gates', 'run', '--run', runId, '--block', id, '--changed']);
418
+ const gates = await capture((streams) => runGatesVerb(['run', '--run', runId, '--block', id, '--changed', '--cwd', tree.workspace], streams));
419
+ if (gates.code !== 0 || gates.data?.allOk !== true) return stop(gates.error + (gates.output || 'gates did not pass'));
420
+ if (gates.error) writeSafe(stderr, gates.error);
421
+
422
+ step = 'close';
423
+ const closeArgs = ['close', id, '--run', runId, '--report', report, '--transcript', transcript];
424
+ next = landCommand(['code-forge', 'block', ...closeArgs]);
425
+ const closed = await capture((streams) => runBlock([...closeArgs, '--json'], { ...deps, ...streams }));
426
+ if (closed.code !== 0 || closed.data?.status !== 'closed') {
427
+ if (typeof closed.data?.next_command === 'string') next = closed.data.next_command;
428
+ return stop(closed.error || closed.data?.reason || closed.data?.status || 'close refused');
429
+ }
430
+ if (closed.error) writeSafe(stderr, closed.error);
431
+
432
+ step = 'merge';
433
+ const main = await repoTop(record.workspace);
434
+ if (typeof entry.base_branch !== 'string' || !entry.base_branch) return stop('block has no recorded base branch');
435
+ const mergeArgs = ['git', '-C', main, 'merge', '--no-ff', '--no-edit', '--', tree.branch];
436
+ const command = landCommand(mergeArgs);
437
+ next = command;
438
+ if (!merge) {
439
+ writeSafe(json ? stderr : stdout, 'hint: the merge gate runs only with --merge\n');
440
+ if (json) emit({ block: id, event: 'closed', merge: command });
441
+ else writeSafe(stdout, `block ${id} closed; merge: ${command}\n`);
442
+ return 0;
443
+ }
444
+ const gitOptions = { cwd: main, env: gitChildEnv(), timeoutMs: 120000 };
445
+ const branch = await exec(['git', 'symbolic-ref', '-q', 'HEAD'], gitOptions);
446
+ if (branch.result !== 'ok' || branch.stdout.trim() !== `refs/heads/${entry.base_branch}`) {
447
+ next = `${landCommand(['git', '-C', main, 'checkout', entry.base_branch])} && ${command}`;
448
+ return stop(`main checkout must be on recorded base branch ${entry.base_branch}`);
449
+ }
450
+ const status = await exec(['git', 'status', '--porcelain', '--untracked-files=all'], gitOptions);
451
+ if (status.result !== 'ok') return stop(status.stderr || 'cannot check main checkout');
452
+ if (status.stdout) {
453
+ next = landCommand(['git', '-C', main, 'status', '--short']);
454
+ return stop('main checkout is dirty; commit or stash its changes before merging');
455
+ }
456
+ // Close measures uncommitted content too; never call that content landed if git cannot merge it.
457
+ const blockStatus = await exec(['git', 'status', '--porcelain', '--untracked-files=all'], { ...gitOptions, cwd: tree.top });
458
+ if (blockStatus.result !== 'ok' || blockStatus.stdout) {
459
+ next = landCommand(['git', '-C', tree.top, 'status', '--short']);
460
+ return stop('block worktree is dirty or unreadable; commit and review its changes before merging');
461
+ }
462
+ if (noMergeGate || configInForce(record)?.gates?.merge_gate === false) {
463
+ writeSafe(stderr, 'warning: merge gate skipped; merged full suite was not checked\n');
464
+ } else {
465
+ step = 'merge-gate';
466
+ next = landCommand(['code-forge', 'gates', 'run', '--cwd', tree.workspace]);
467
+ const mainHead = await exec(['git', 'rev-parse', '--verify', 'HEAD'], gitOptions);
468
+ const blockHead = await exec(['git', 'rev-parse', '--verify', `refs/heads/${tree.branch}`], gitOptions);
469
+ if (mainHead.result !== 'ok' || blockHead.result !== 'ok') return stop('cannot resolve merge parents');
470
+ const started = Date.now();
471
+ // A sibling of this run's block worktree, never the process temp directory.
472
+ const temporary = await mkdtemp(path.join(path.dirname(tree.path), '.merge-gate-'));
473
+ let added = false;
474
+ let failure = '';
475
+ try {
476
+ // mkdtemp gives an empty directory; git worktree add accepts an existing empty path.
477
+ const created = await exec(['git', 'worktree', 'add', '--detach', temporary, mainHead.stdout.trim()], gitOptions);
478
+ added = created.result === 'ok';
479
+ if (!added) throw new Error(created.stderr || 'cannot create merge-gate worktree');
480
+ const merged = await exec(['git', 'merge', '--no-ff', '--no-edit', '--', blockHead.stdout.trim()], { ...gitOptions, cwd: temporary });
481
+ if (merged.result !== 'ok') {
482
+ const conflicts = await exec(['git', 'diff', '--name-only', '--diff-filter=U'], { ...gitOptions, cwd: temporary });
483
+ failure = conflicts.stdout.trim() ? `merge conflict:\n${conflicts.stdout.trim()}` : merged.stderr || merged.stdout || 'trial merge failed';
484
+ } else {
485
+ const workspace = path.join(temporary, path.relative(main, record.workspace));
486
+ // No block scope: the shared gate runner drops scope tokens and runs the full suite.
487
+ const suite = await capture((streams) => runGatesVerb(['run', '--cwd', workspace], streams));
488
+ if (suite.code !== 0 || suite.data?.allOk !== true) {
489
+ const results = suite.data?.results ?? [];
490
+ const output = results.filter((r) => !r.ok).map((r) => `${r.stdout}\n${r.stderr}`).join('\n');
491
+ const failed = [...parseTapPassed(output).failed].slice(0, 10);
492
+ failure = failed.length ? `full suite failed:\n${failed.join('\n')}`
493
+ : `full suite failed:\n${(suite.error || output || suite.output).trim().split('\n').slice(-20).join('\n')}`;
494
+ } else if (!suite.data.results.some((r) => r.gate === 'test' && !r.skipped)) {
495
+ failure = 'full suite unavailable: configure gates.test before merging';
496
+ }
497
+ }
498
+ } finally {
499
+ const removed = await exec(['git', 'worktree', 'remove', '--force', temporary], gitOptions);
500
+ if (added && removed.result !== 'ok') throw new Error(`cannot remove merge-gate worktree ${temporary}: ${removed.stderr}`);
501
+ await rm(temporary, { recursive: true, force: true });
502
+ }
503
+ if (failure) return stop(failure);
504
+ // Refuse a moving target: only merge the same parents whose combined suite passed.
505
+ const currentMain = await exec(['git', 'rev-parse', '--verify', 'HEAD'], gitOptions);
506
+ const currentBlock = await exec(['git', 'rev-parse', '--verify', `refs/heads/${tree.branch}`], gitOptions);
507
+ const currentBranch = await exec(['git', 'symbolic-ref', '-q', 'HEAD'], gitOptions);
508
+ const currentStatus = await exec(['git', 'status', '--porcelain', '--untracked-files=all'], gitOptions);
509
+ if (currentMain.result !== 'ok' || currentBlock.result !== 'ok' || currentBranch.result !== 'ok' || currentStatus.result !== 'ok'
510
+ || currentMain.stdout !== mainHead.stdout || currentBlock.stdout !== blockHead.stdout || currentBranch.stdout !== branch.stdout || currentStatus.stdout) {
511
+ return stop('merge parents or main checkout changed during the full suite; check the checkout and rerun the merge gate');
512
+ }
513
+ writeSafe(json ? stderr : stdout, `merge gate: full suite passed (${((Date.now() - started) / 1000).toFixed(1)}s)\n`);
514
+ }
515
+ step = 'merge';
516
+ next = command;
517
+ const merged = await exec(mergeArgs, gitOptions);
518
+ if (merged.result !== 'ok') {
519
+ next = landCommand(['git', '-C', main, 'status']);
520
+ return stop(merged.stderr || merged.stdout || 'git merge failed; resolve the merge before continuing');
521
+ }
522
+ if (json) emit({ block: id, event: 'landed', merge: command });
523
+ else writeSafe(stdout, `block ${id} landed\n`);
524
+ return 0;
525
+ } catch (thrown) {
526
+ return stop(thrown?.message ?? String(thrown));
527
+ }
292
528
  }
293
529
 
294
530
  const USAGE =
295
- '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|light] [--tests <paths…>]\n' +
296
- ' 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' +
531
+ '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|light] [--tests <paths…>] [--after <ids…>] [--after-outcome <ids…>]\n' +
532
+ ' code-forge block attempt|rebase <id> --run <r> · block close <id> --run <r> [--transcript <file>] [--report <file>] [--no-require-reviews] [--remove-worktree] [--auto-claim] · block claim <id> <path> --run <r> · block stop <id> --run <r> --reason <text>\n' +
297
533
  ' code-forge block waive <id> <finding-id> --run <r> --file <path> --reason <text> [--yes]\n' +
298
534
  ' code-forge block reopen <id> --run <r> --file <path> --reason <text> [--yes]\n' +
299
535
  ' code-forge block accept-hit <id> --run <r> [--rule <rule-id>] --line <n> --transcript <path> --reason <text>\n' +
@@ -301,9 +537,42 @@ const USAGE =
301
537
  ' code-forge block ack-waivers <id> --run <r>\n' +
302
538
  ' code-forge block human-pass <id> --run <r> --note <text> [--proof <dir-or-file>]\n' +
303
539
  ' code-forge block amend-acceptance <id> --run <r> --acceptance <file> --reason <text>\n' +
540
+ ' code-forge block land <id> --run <r> --report <file> --transcript <file> [--max <dur>] [--merge] [--no-merge-gate]\n' +
304
541
  ' every block verb takes --json: one JSON line on stdout ({"block","event",…}) instead of the text\n';
305
542
 
306
- const SUBCOMMANDS = ['open', 'attempt', 'rebase', 'claim', 'close', 'stop', 'waive', 'reopen', 'accept-hit', 'unwaive', 'ack-waivers', 'human-pass', 'amend-acceptance'];
543
+ /** @type {Record<string, string>} */
544
+ const VERB_USAGE = {
545
+ open: '<id> --run <r> [--level L<n>] --owned <paths…> --acceptance <file> [--brief <file>] [--attempt <n>] [--base <sha>] [--lines <n>] [--kind code|docs|contract|light] [--tests <paths…>] [--after <ids…>] [--after-outcome <ids…>]',
546
+ attempt: '<id> --run <r>',
547
+ rebase: '<id> --run <r> [--head <ref>] [--accept-changed <file>… --reason <text>]',
548
+ claim: '<id> <path> --run <r>',
549
+ close: '<id> --run <r> [--worker-pid <pid>] [--transcript <file>] [--report <file>] [--no-require-reviews] [--remove-worktree] [--auto-claim] [--decide]',
550
+ land: '<id> --run <r> --report <file> --transcript <file> [--max <dur>] [--merge] [--no-merge-gate]',
551
+ stop: '<id> --run <r> --reason <text>',
552
+ waive: '<id> <finding-id> --run <r> --file <path> --reason <text> [--yes]',
553
+ reopen: '<id> --run <r> --file <path> --reason <text> [--yes]',
554
+ 'accept-hit': '<id> --run <r> [--rule <rule-id>] --line <n> --transcript <path> --reason <text>',
555
+ unwaive: '<id> <finding-id> --run <r> --file <path> --reason <text>',
556
+ 'ack-waivers': '<id> --run <r>',
557
+ 'human-pass': '<id> --run <r> --note <text> [--proof <dir-or-file>]',
558
+ 'amend-acceptance': '<id> --run <r> --acceptance <file> --reason <text>',
559
+ };
560
+ const SUBCOMMANDS = Object.keys(VERB_USAGE);
561
+
562
+ /** @param {string} sub @returns {string} */
563
+ function verbUsage(sub) {
564
+ return `usage: code-forge block ${sub} ${VERB_USAGE[sub]} [--json] [--help|-h]\n` +
565
+ ' --help, -h Print this usage and exit without changing state.\n' +
566
+ ' --json Print one JSON line on success instead of text.\n' +
567
+ (sub === 'open' || sub === 'amend-acceptance'
568
+ ? ' --acceptance <file> JSON/YAML list [{clause, tests}]; tests is a nonempty list of test ids.\n' +
569
+ ' Human checks use {clause, kind: human_check} without tests.\n' : '') +
570
+ (sub === 'open' ? ' --brief <file> A file inside the repository, not inline text.\n' : '') +
571
+ (sub === 'land' ? ' Join block reviews, run changed gates in its worktree, then close.\n' +
572
+ ' --max <dur> Total review wait (default 30m); accepts 90s, 30m or 1500ms.\n' +
573
+ ' --merge Check the merged full suite, then merge with --no-ff in the clean main checkout.\n' +
574
+ ' --no-merge-gate Skip the merged full suite with a warning.\n' : '');
575
+ }
307
576
 
308
577
  /** Refuse likely shell quoting slips before any row is written.
309
578
  * @param {string} workspace @param {string[]} entries @param {string} label
@@ -329,7 +598,33 @@ function checkWhitespacePaths(workspace, entries, label) {
329
598
  }
330
599
  }
331
600
  }
332
- const FLAG_VALUES = ['run', 'level', 'acceptance', 'brief', 'attempt', 'base', 'lines', 'head', 'worker-pid', 'reason', 'file', 'transcript', 'kind', 'report', 'rule', 'line', 'note', 'proof'];
601
+ const FLAG_VALUES = ['run', 'level', 'acceptance', 'brief', 'attempt', 'base', 'lines', 'head', 'worker-pid', 'reason', 'file', 'transcript', 'kind', 'report', 'rule', 'line', 'note', 'proof', 'max'];
602
+
603
+ /** Check specialist outcomes against the run's project, before state open can write anything.
604
+ * @param {string} root @param {string} id @param {string[]} ids
605
+ * @returns {{step: string, sha256: string}[]}
606
+ */
607
+ function acceptedOutcomes(root, id, ids) {
608
+ const loaded = loadPipeline(root);
609
+ const bad = (/** @type {string} */ problem) => new StateError('bad-after-outcome', `block ${id}: --after-outcome: ${problem}`);
610
+ if (!loaded.exists) throw bad(`no code-forge.pipeline.yml in ${root}`);
611
+ if (!loaded.ok) throw bad(loaded.problems[0]);
612
+ const steps = [...new Set(ids)].map((stepId) => {
613
+ const step = loaded.pipeline.steps.find((candidate) => candidate.id === stepId);
614
+ if (!step) throw bad(`unknown step ${stepId}`);
615
+ return step;
616
+ });
617
+ return steps.map((step) => {
618
+ const file = path.resolve(root, step.outcome);
619
+ const result = existsSync(file) ? checkOutcome(file, { root }) : { ok: false, problems: ['missing outcome'] };
620
+ if (!result.ok) throw new StateError('outcome-not-ready', `block ${id}: --after-outcome step ${step.id}: ${result.problems[0]}`);
621
+ try {
622
+ return { step: step.id, sha256: createHash('sha256').update(readFileSync(file)).digest('hex') };
623
+ } catch (err) {
624
+ throw new StateError('outcome-not-ready', `block ${id}: --after-outcome step ${step.id}: cannot read outcome (${err.code ?? 'error'})`);
625
+ }
626
+ });
627
+ }
333
628
 
334
629
  /**
335
630
  * @param {string[]} args
@@ -359,8 +654,13 @@ export async function runBlock(args, deps = {}) {
359
654
  err(USAGE);
360
655
  return 2;
361
656
  }
657
+ if (rest.includes('--help') || rest.includes('-h')) {
658
+ out(verbUsage(sub));
659
+ return 0;
660
+ }
661
+ let jsonOpenId;
362
662
  try {
363
- const { flags, positionals } = parseFlags(rest, { values: FLAG_VALUES, multi: ['owned', 'tests'], booleans: ['no-require-reviews', 'remove-worktree', 'json', ...(sub === 'close' ? ['decide'] : []), ...(['waive', 'reopen'].includes(sub) ? ['yes'] : [])] });
663
+ const { flags, positionals } = parseFlags(rest, { values: FLAG_VALUES, multi: ['owned', 'tests', ...(sub === 'rebase' ? ['accept-changed'] : []), ...(sub === 'open' ? ['after', 'after-outcome'] : [])], booleans: ['no-require-reviews', 'remove-worktree', 'json', ...(sub === 'land' ? ['merge', 'no-merge-gate'] : []), ...(sub === 'close' ? ['decide', 'auto-claim'] : []), ...(['waive', 'reopen'].includes(sub) ? ['yes'] : [])] });
364
664
  // B64a: --json prints ONE JSON line on success instead of the text lines (`emit`, one object
365
665
  // per verb, built where the text is); without it every output is exactly as before (`say` is `out`)
366
666
  const json = flags.json === true;
@@ -375,8 +675,22 @@ export async function runBlock(args, deps = {}) {
375
675
  if (t.startsWith('-')) throw new StateError('usage', `--tests: a path may not start with "-": ${JSON.stringify(t)}`);
376
676
  }
377
677
  const [id, extra] = positionals;
678
+ if (sub === 'open' && json) jsonOpenId = id;
378
679
  const runId = typeof flags.run === 'string' ? flags.run : undefined;
379
680
  if (!id || !runId) throw new StateError('usage', 'a block id and --run are required');
681
+ if (sub === 'land' && (positionals.length !== 1 || typeof flags.report !== 'string' || typeof flags.transcript !== 'string' || Object.keys(flags).some((key) => !['run', 'report', 'transcript', 'max', 'merge', 'no-merge-gate', 'json'].includes(key)))) {
682
+ throw new StateError('usage', 'block land takes one block id, --run, --report, --transcript and optional --max/--merge/--no-merge-gate/--json');
683
+ }
684
+ let landMaxMs = 30 * 60_000;
685
+ if (sub === 'land' && flags.max !== undefined) {
686
+ // Reuse review-file's parser, with minutes as a convenience for long lands.
687
+ try {
688
+ landMaxMs = typeof flags.max === 'string' && /^\d{1,7}m$/.test(flags.max)
689
+ ? parseMax(`${Number(flags.max.slice(0, -1)) * 60}s`) : parseMax(flags.max);
690
+ } catch {
691
+ throw new StateError('usage', '--max must look like 90s, 30m or 1500ms');
692
+ }
693
+ }
380
694
  if (sub === 'amend-acceptance' && (positionals.length !== 1 || typeof flags.acceptance !== 'string' || typeof flags.reason !== 'string' || !flags.reason.trim() || /\p{Cc}/u.test(flags.reason))) {
381
695
  throw new StateError('usage', 'amend-acceptance needs one block id, --acceptance and a nonblank --reason without control characters');
382
696
  }
@@ -405,6 +719,7 @@ export async function runBlock(args, deps = {}) {
405
719
  return 0;
406
720
  }
407
721
  const record = await readRun(runId);
722
+ if (sub === 'land') return await landBlock({ id, runId, record, report: /** @type {string} */ (flags.report), transcript: /** @type {string} */ (flags.transcript), maxMs: landMaxMs, merge: flags.merge === true, noMergeGate: flags['no-merge-gate'] === true, json }, deps);
408
723
  if (sub === 'amend-acceptance') {
409
724
  /** @type {ReturnType<typeof prepareAcceptanceAmendment> | null} */
410
725
  let prepared = null;
@@ -481,10 +796,11 @@ export async function runBlock(args, deps = {}) {
481
796
  const writeRow =(/** @type {Record<string, any>} */ row) => appendRow(row, { slug: record.project });
482
797
  const reopened = async (/** @type {boolean} */ agent) => {
483
798
  const file = /** @type {string} */ (flags.file);
484
- const { row, cleared } = await reopenBlockFile({ runId, id, file, reason: /** @type {string} */ (flags.reason), record, writeRow, agent, judge: deps.waiverJudge });
799
+ const { row, cleared, ticket } = await reopenBlockFile({ runId, id, file, reason: /** @type {string} */ (flags.reason), record, writeRow, agent, judge: deps.waiverJudge });
485
800
  const fromText = row.stop_reason == null ? row.from : `${row.from} ${row.stop_reason}`;
486
801
  say(`block ${id} reopened ${file} (from ${fromText}; by: ${row.by})\n`);
487
- 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: row.by });
802
+ if (ticket) say(`ticket ${ticket}\nnext: code-forge review-file --wait ${ticket}\n`);
803
+ 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: row.by, ticket });
488
804
  return 0;
489
805
  };
490
806
  // Agent refusals precede even the lazy expiry checks: no rows on refusal.
@@ -542,13 +858,18 @@ export async function runBlock(args, deps = {}) {
542
858
  // B48: every block command runs the lazy autopilot checks (grant expiry, expired approvals);
543
859
  // a failed check is a warning, never a reason to stop the block command
544
860
  const requireHumanClose = sub === 'close' && configInForce(record)?.close?.require_human === true;
545
- try {
546
- if (!deciding && !requireHumanClose) {
547
- err(approvalCheckText(await expiryChecks(runId, (deps.now ?? (() => new Date()))(), { writeRow })));
861
+ const expire = async () => {
862
+ try {
863
+ if (!deciding && !requireHumanClose) {
864
+ err(approvalCheckText(await expiryChecks(runId, (deps.now ?? (() => new Date()))(), { writeRow })));
865
+ }
866
+ } catch (thrown) {
867
+ err(`autopilot: WARN the expiry check failed (${thrown?.message ?? String(thrown)})\n`);
548
868
  }
549
- } catch (thrown) {
550
- err(`autopilot: WARN the expiry check failed (${thrown?.message ?? String(thrown)})\n`);
551
- }
869
+ };
870
+ // B148: a refused dependency must not leave even an expiry row behind.
871
+ const deferExpiry = sub === 'open' && (Array.isArray(flags.after) || Array.isArray(flags['after-outcome']));
872
+ if (!deferExpiry) await expire();
552
873
 
553
874
  if (sub === 'open') {
554
875
  // an explicit --level wins; B59: else in a brief-mode run the rules lane; B47: else the level
@@ -581,7 +902,21 @@ export async function runBlock(args, deps = {}) {
581
902
  }
582
903
  // B62a: the run's isolation, from its config snapshot (fixed for the run)
583
904
  const isolated = worktreeSettings(record);
584
- const { block, pointer, worktreeReused } = await openBlock({
905
+ // Preserve --after's self-before-unfinished order before examining specialist outcomes.
906
+ // State open still checks these dependencies under the run lock.
907
+ let afterOutcome;
908
+ if (Array.isArray(flags['after-outcome'])) {
909
+ if (Array.isArray(flags.after)) {
910
+ if (flags.after.includes(id)) throw new StateError('bad-after', `block ${id} cannot name itself in --after`);
911
+ const blocking = [...new Set(flags.after)].filter((dependency) => record.blocks[dependency]?.status !== 'closed');
912
+ if (blocking.length > 0) {
913
+ const statuses = blocking.map((dependency) => `${dependency} (${record.blocks[dependency]?.status ?? 'not in run'})`);
914
+ throw new StateError('after-not-done', `block ${id}: --after dependencies not closed: ${statuses.join(', ')}`);
915
+ }
916
+ }
917
+ afterOutcome = acceptedOutcomes(record.workspace, id, flags['after-outcome']);
918
+ }
919
+ const { block, pointer, worktreeReused, keptClaims } = await openBlock({
585
920
  runId,
586
921
  id,
587
922
  level,
@@ -594,15 +929,19 @@ export async function runBlock(args, deps = {}) {
594
929
  ...(rules ? { decision: { question: 'lane', answer: rules.lane, source: 'rules', facts: rules.facts } } : {}),
595
930
  ...(typeof flags.kind === 'string' ? { kind: flags.kind } : {}),
596
931
  ...(Array.isArray(flags.tests) ? { tests: flags.tests } : {}),
932
+ ...(Array.isArray(flags.after) ? { after: flags.after } : {}),
933
+ ...(afterOutcome ? { after_outcome: afterOutcome } : {}),
597
934
  cfg: await openConfig(record.workspace),
598
935
  writeRow,
599
936
  ...(isolated ? { worktree: (/** @type {{baseSha: string, previous: any}} */ ctx) => prepareWorktree({ cwd: record.workspace, settings: isolated, runId, id, ...ctx }) } : {}),
600
937
  });
938
+ if (deferExpiry) await expire();
601
939
  const raised = block.level !== level ? ` (${block.kind} floor, asked ${level})` : '';
602
940
  const chosen = typeof flags.level === 'string' ? '' : rules ? ' (rules lane)' : ' (autopilot level)';
603
941
  const testCount = Array.isArray(block.test_files) ? block.test_files.length : 0;
604
942
  const tests = testCount > 0 ? ` · ${testCount} test file${testCount === 1 ? '' : 's'}` : '';
605
943
  say(`block ${id} open · ${block.level}${raised}${chosen} · ${block.kind} · attempt ${block.attempt} · base ${block.base_sha.slice(0, 12)}${tests}\n`);
944
+ if (keptClaims > 0) say(`kept claims: ${keptClaims}\n`);
606
945
  if (block.worktree) say(`worktree ${block.worktree.path} · branch ${block.worktree.branch}${worktreeReused ? ' · reused' : ''}\n`);
607
946
  say(`${pointer ?? 'brief: none given (--brief <file>)'}\n`);
608
947
  if (json) {
@@ -614,6 +953,7 @@ export async function runBlock(args, deps = {}) {
614
953
  source: typeof flags.level === 'string' ? 'flag' : rules ? 'rules' : 'autopilot',
615
954
  kind: block.kind,
616
955
  attempt: block.attempt,
956
+ ...(keptClaims > 0 ? { kept_claims: keptClaims } : {}),
617
957
  base_sha: block.base_sha,
618
958
  tests: testCount,
619
959
  worktree: block.worktree ? { path: block.worktree.path, branch: block.worktree.branch, reused: Boolean(worktreeReused) } : null,
@@ -623,13 +963,24 @@ export async function runBlock(args, deps = {}) {
623
963
  return 0;
624
964
  }
625
965
  if (sub === 'attempt') {
626
- const n = await attemptBlock({ runId, id, writeRow });
966
+ const { attempt: n, keptClaims } = await attemptBlock({ runId, id, writeRow });
627
967
  say(`block ${id} attempt ${n}\n`);
628
- if (json) emit({ block: id, event: 'attempt', attempt: n });
968
+ if (keptClaims > 0) say(`kept claims: ${keptClaims}\n`);
969
+ if (json) emit({ block: id, event: 'attempt', attempt: n, ...(keptClaims > 0 ? { kept_claims: keptClaims } : {}) });
629
970
  return 0;
630
971
  }
631
972
  if (sub === 'rebase') {
632
- const moved = await rebaseBlock({ runId, id, head: typeof flags.head === 'string' ? flags.head : undefined, writeRow });
973
+ const acceptChanged = Array.isArray(flags['accept-changed']) ? [...new Set(flags['accept-changed'])] : [];
974
+ if (acceptChanged.length) {
975
+ if (positionals.length !== 1 || typeof flags.reason !== 'string' || !flags.reason.trim() || /\p{Cc}/u.test(flags.reason)) throw new StateError('usage', '--accept-changed needs one block id and a nonblank --reason without control characters');
976
+ for (const file of acceptChanged) checkFileForm(file, { normalized: true });
977
+ const asked = await confirmHumanVerb('rebase', runId, id, { file: acceptChanged.join(', ') }, record, deps, async () => {
978
+ if ((await checkExpiry(runId, (deps.now ?? (() => new Date()))())).state === 'active') throw new StateError('autopilot-active', 'block rebase --accept-changed is refused while an autopilot grant is active');
979
+ });
980
+ if (asked === 'no-tty') { err(`${humanVerbNoTTY('rebase')}\n`); return 2; }
981
+ if (asked === 'cancelled') { err(`block ${id} rebase: cancelled — nothing was recorded\n`); return 1; }
982
+ }
983
+ const moved = await rebaseBlock({ runId, id, head: typeof flags.head === 'string' ? flags.head : undefined, writeRow, acceptChanged, reason: typeof flags.reason === 'string' ? flags.reason : undefined });
633
984
  say(`block ${id} rebased ${moved.old_base.slice(0, 12)} → ${moved.new_base.slice(0, 12)}\n`);
634
985
  if (json) emit({ block: id, event: 'rebased', old_base: moved.old_base, new_base: moved.new_base });
635
986
  return 0;
@@ -679,7 +1030,7 @@ export async function runBlock(args, deps = {}) {
679
1030
  throw thrown;
680
1031
  });
681
1032
  // ONE base for the whole close: a worktree block is measured against the commit its tree was
682
- // made from (kept equal to base_sha, including after a closed worktree rebase)
1033
+ // made from (kept equal to base_sha, including after a worktree rebase)
683
1034
  const base = wt !== null ? wt.base_sha : entry.base_sha;
684
1035
  const reviewsWaived = flags['no-require-reviews'] === true;
685
1036
  let routineGrant = null;
@@ -705,8 +1056,28 @@ export async function runBlock(args, deps = {}) {
705
1056
  }
706
1057
  // B58: the owner's other way out — `review.external.required` in the run's config snapshot
707
1058
  // (set before `run start`, immutable): no TTY, allowed under autopilot, the human flag wins
1059
+ if (flags['auto-claim'] === true) await autoClaimChanges({ record, runId, id, writeRow, print: json ? err : say });
708
1060
  const external = reviewsWaived ? null : externalReviewer(record);
709
- const rows = (await readAllRows(record.project)).filter((row) => row.run === runId);
1061
+ const reviewConfig = configInForce(record);
1062
+ let rows = (await readAllRows(record.project)).filter((row) => row.run === runId);
1063
+ // Restart changed stopped findings before entering closeBlock's run lock: the worker
1064
+ // needs that lock to publish its fresh review. Refresh rows after the join finishes.
1065
+ if (!reviewsWaived && external === null && !deciding && routineGrant === null) {
1066
+ const mainTop = await repoTop(record.workspace);
1067
+ const restarted = await requeueStoppedFiles({ repoRoot: mainTop, run: runId, block: id,
1068
+ tree: wt?.top ?? mainTop, files: reviewedFiles(rows, id), print: err });
1069
+ if (restarted.size > 0) {
1070
+ let output = '';
1071
+ const code = await runReviewFile(['--wait-block', id, '--run', runId], {
1072
+ cwd: wt?.workspace ?? record.workspace, stdout: { write: (text) => { output += text; } }, stderr: { write: err },
1073
+ });
1074
+ if (code !== 0 || JSON.parse(output).done !== true) {
1075
+ err(`block ${id} open: automatic requeue did not finish\n`);
1076
+ return code || 1;
1077
+ }
1078
+ rows = (await readAllRows(record.project)).filter((row) => row.run === runId);
1079
+ }
1080
+ }
710
1081
  // B52: ONE path base and ONE listing, memoised as a promise and shared by every check. The
711
1082
  // base is the repository top level: the worker signs every review row with a repo-root-
712
1083
  // relative `file` and keeps its queue and signed result files under `<top>/.code-forge/`
@@ -770,6 +1141,16 @@ export async function runBlock(args, deps = {}) {
770
1141
  // B20: the high paths come first — a base the workspace does not know refuses the close here
771
1142
  const highPaths = await highPathsAt(record.workspace, base);
772
1143
  const { top, mainTop, where, inTree, wsPrefix, owns, changed, fileSet, reviewed } = await tree();
1144
+ const excluded = fileSet.flatMap((f) => {
1145
+ const rule = reviewExcludeRule(reviewConfig, f.file);
1146
+ return rule === null ? [] : [{ ...f, rule }];
1147
+ });
1148
+ for (const f of excluded) {
1149
+ const problem = lockfileProblem(top, f.file, reviewConfig);
1150
+ if (problem !== null) throw new StateError('lockfile', `lockfile ${f.file}: ${problem}`);
1151
+ }
1152
+ const otherRows = closeRows.filter((r) => r.event !== 'gate.review_excluded');
1153
+ closeRows.splice(0, closeRows.length, ...otherRows, ...excluded.map((f) => ({ event: 'gate.review_excluded', block: id, ...f })));
773
1154
  const isMine = (/** @type {string} */ f) => owns(entry.owned_files, f, entry.owned_kinds);
774
1155
  const key = await loadKey(runId);
775
1156
  // B52: an empty diff with review rows is never silent — name the likely cause
@@ -801,7 +1182,7 @@ export async function runBlock(args, deps = {}) {
801
1182
  unowned: scope?.unowned ?? [],
802
1183
  // B52: an old ledger's review.result rows are judged by their signed result files
803
1184
  resultFiles: await resultFilesFor({ repoRoot: mainTop, runId, block: id, rows }),
804
- files: reviewsWaived || external !== null ? [] : fileSet,
1185
+ files: reviewsWaived || external !== null ? [] : fileSet.filter((f) => reviewExcludeRule(reviewConfig, f.file) === null),
805
1186
  // B52: nothing changed and nothing reviewed is never a pass (the owner's way out is
806
1187
  // --no-require-reviews); B58: `review.external` does not lift it
807
1188
  noChanges: !reviewsWaived && fileSet.length === 0,
@@ -852,7 +1233,7 @@ export async function runBlock(args, deps = {}) {
852
1233
  }
853
1234
  };
854
1235
  return await (deps.reviewedLines ?? reviewedLines)({
855
- files: fileSet,
1236
+ files: fileSet.filter((f) => reviewExcludeRule(reviewConfig, f.file) === null),
856
1237
  waivedAll,
857
1238
  ticketOf,
858
1239
  onNoTicket: (file) => lineWarnings.push(`WARN no ticket for ${file}\n`),
@@ -879,7 +1260,7 @@ export async function runBlock(args, deps = {}) {
879
1260
  const { fileSet } = await tree();
880
1261
  const lines = await toolLines();
881
1262
  const given = new Set(lines.map((l) => l.file));
882
- const missing = missingReviewTickets(text, fileSet.map((f) => f.file)).filter((f) => !given.has(f));
1263
+ const missing = missingReviewTickets(text, fileSet.filter((f) => reviewExcludeRule(reviewConfig, f.file) === null).map((f) => f.file)).filter((f) => !given.has(f));
883
1264
  if (missing.length > 0) return { ok: false, reason: `coder report FAILED: no review-file ticket id for ${missing.join(', ')}` };
884
1265
  for (const file of reportMismatches(text, lines)) lineWarnings.push(`WARN report ticket mismatch ${file}\n`);
885
1266
  return { ok: true };
@@ -900,7 +1281,9 @@ export async function runBlock(args, deps = {}) {
900
1281
  });
901
1282
  if (configInForce(record)?.gates?.test != null) {
902
1283
  const testGate = async () => {
903
- const green = rows.filter((row) => row.block === id && row.event === 'gate.done' && row.all_ok === true);
1284
+ // A rebase changes the tested tree even when owned commit timestamps are preserved.
1285
+ const lastRebase = rows.reduce((last, row, index) => row.block === id && row.event === 'block.rebase' ? index : last, -1);
1286
+ const green = rows.filter((row, index) => index > lastRebase && row.block === id && row.event === 'gate.done' && row.all_ok === true);
904
1287
  // Only commits after the block base that touch its owned paths belong to this block.
905
1288
  // Older rows without timestamps remain usable, but a failed history lookup cannot prove freshness.
906
1289
  const commit = green.length === 0 ? null : await exec(['git', 'log', '-1', '--format=%ct', `${base}..HEAD`, '--', ...entry.owned_files], {
@@ -1024,6 +1407,7 @@ export async function runBlock(args, deps = {}) {
1024
1407
  await warnMovedBase(record.workspace, entry, id, runId, err);
1025
1408
  for (const w of lineWarnings) err(w);
1026
1409
  for (const l of reviewedJson) say(`reviewed ${l.file} ticket ${l.ticket}\n`);
1410
+ for (const f of closeRows.filter((r) => r.event === 'gate.review_excluded')) say(`not reviewed (review.exclude ${f.rule}): ${f.file}\n`);
1027
1411
  for (const f of waivedAll) {
1028
1412
  const parts = f.findings.length > 0 ? [`${f.findings.join(', ')}; by ${f.by}`] : [];
1029
1413
  if (f.answered?.length) parts.push(`answered ${f.answered.join(', ')}`);
@@ -1072,6 +1456,9 @@ export async function runBlock(args, deps = {}) {
1072
1456
  }
1073
1457
  }
1074
1458
  } catch (thrown) {
1459
+ if (jsonOpenId && thrown instanceof StateError && ['after-not-done', 'bad-after', 'outcome-not-ready', 'bad-after-outcome'].includes(thrown.code)) {
1460
+ out(`${JSON.stringify({ block: jsonOpenId, event: 'refused', code: thrown.code, message: thrown.message })}\n`);
1461
+ }
1075
1462
  err(`block ${sub ?? ''}: ${thrown?.message ?? String(thrown)}\n`);
1076
1463
  return thrown instanceof StateError && thrown.code === 'usage' ? 2 : 1;
1077
1464
  }
@@ -1168,7 +1555,7 @@ export const NO_REVIEWS_NO_TTY = '--no-require-reviews needs the human at a term
1168
1555
 
1169
1556
  /**
1170
1557
  * B80: the refusal when `block waive` or `block reopen` has no terminal to ask the human at.
1171
- * @param {'waive' | 'reopen' | 'accept-hit' | 'unwaive' | 'ack-waivers' | 'human-pass' | 'amend-acceptance' | 'close'} verb @returns {string}
1558
+ * @param {'waive' | 'reopen' | 'accept-hit' | 'unwaive' | 'ack-waivers' | 'human-pass' | 'amend-acceptance' | 'close' | 'rebase'} verb @returns {string}
1172
1559
  */
1173
1560
  export const humanVerbNoTTY = (verb) => verb === 'waive'
1174
1561
  ? 'block waive needs the human at a terminal to confirm; nothing was recorded (--yes works only for a recorded warning or nit finding, with evidence)'
@@ -1229,14 +1616,16 @@ async function confirmNoReviews(runId, id, deps) {
1229
1616
  * `by: autopilot`); human-pass instead refuses an active grant. The
1230
1617
  * pre-checks (`reopenPrecheck`, `waivePrecheck`) run first, at a terminal, so the human is never
1231
1618
  * asked about a waive or reopen the verb would refuse that way.
1232
- * @param {'waive' | 'reopen' | 'accept-hit' | 'unwaive' | 'ack-waivers' | 'human-pass' | 'amend-acceptance'} verb @param {string} runId @param {string} id
1619
+ * @param {'waive' | 'reopen' | 'accept-hit' | 'unwaive' | 'ack-waivers' | 'human-pass' | 'amend-acceptance' | 'rebase'} verb @param {string} runId @param {string} id
1233
1620
  * @param {{finding?: string, file: string, rule?: string, line?: number, hits?: {rule: string, line: number}[], oldCount?: number, newCount?: number}} what @param {Record<string, any>} record @param {ConfirmDeps} deps
1234
1621
  * @param {() => Promise<unknown> | unknown} [before]
1235
1622
  * @returns {Promise<'no-tty' | 'confirmed' | 'cancelled'>}
1236
1623
  */
1237
1624
  function confirmHumanVerb(verb, runId, id, what, record, deps, before) {
1238
1625
  const message = () =>
1239
- verb === 'amend-acceptance'
1626
+ verb === 'rebase'
1627
+ ? `Accept rebased changes in ${what.file} of block ${id} (run ${runId})? Only the owner may say yes.`
1628
+ : verb === 'amend-acceptance'
1240
1629
  ? `Amend acceptance of block ${id} (run ${runId}) from ${what.oldCount} clauses to ${what.newCount} clauses? Only the owner may say yes.`
1241
1630
  : verb === 'human-pass'
1242
1631
  ? `Confirm all device checks of block ${id} (run ${runId}) passed? Only the owner may say yes.`