@codedology/code-forge 0.12.0 → 0.14.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 (40) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/README.md +28 -2
  3. package/docs/reference/blocks.json +354 -1
  4. package/docs/reference/config.md +4 -1
  5. package/package.json +1 -1
  6. package/schema/code-forge.schema.json +10 -1
  7. package/skill/references/adapters/claude-code.md +1 -1
  8. package/skill/references/adapters/solo.md +1 -1
  9. package/skill/references/code.md +15 -8
  10. package/skill/references/ledger.md +4 -2
  11. package/skill/references/proof.md +1 -1
  12. package/skill/references/review.md +21 -7
  13. package/skill/references/security.md +5 -3
  14. package/skill/scripts/forge +1 -1
  15. package/src/autopilot/actions.mjs +4 -5
  16. package/src/cli/block.mjs +130 -31
  17. package/src/cli/context.mjs +3 -2
  18. package/src/cli/report.mjs +2 -2
  19. package/src/cli/review-file.mjs +67 -42
  20. package/src/cli/review.mjs +2 -2
  21. package/src/context/pack.mjs +13 -9
  22. package/src/doctor/index.mjs +2 -1
  23. package/src/doctor/local.mjs +19 -0
  24. package/src/doctor/worker-probe.mjs +15 -2
  25. package/src/ledger/export.mjs +1 -1
  26. package/src/ledger/report.mjs +9 -3
  27. package/src/ledger/spend.mjs +3 -3
  28. package/src/proof/lock.mjs +5 -1
  29. package/src/review/accept-hit.mjs +78 -0
  30. package/src/review/agent-waiver.mjs +201 -0
  31. package/src/review/gate-check.mjs +62 -28
  32. package/src/review/reopen.mjs +35 -14
  33. package/src/review/schema-fallback.mjs +3 -3
  34. package/src/state/config-snapshot.mjs +1 -0
  35. package/src/util/file-locks.mjs +118 -0
  36. package/src/util/forbidden.mjs +51 -2
  37. package/src/worker/engine.mjs +2 -2
  38. package/src/worker/loop.mjs +83 -33
  39. package/src/worker/queue.mjs +141 -33
  40. package/src/worker/watch.mjs +4 -2
package/src/cli/block.mjs CHANGED
@@ -10,8 +10,8 @@
10
10
  * block claim <id> <path> --run <r>
11
11
  * block close <id> --run <r> [--worker-pid <pid>] [--remove-worktree]
12
12
  * block stop <id> --run <r> --reason <text>
13
- * block waive <id> <finding-id> --run <r> --file <path> --reason <text> (human-only, §4.9; asks at a terminal, B80)
14
- * block reopen <id> --run <r> --file <path> --reason <text> (human-only, B72; asks at a terminal, B80)
13
+ * block waive <id> <finding-id> --run <r> --file <path> --reason <text> [--yes] (orchestrator --yes: evidence and a fresh judge)
14
+ * block reopen <id> --run <r> --file <path> --reason <text> [--yes] (B124c: evidence and a fresh judge for the agent path)
15
15
  *
16
16
  * B64a `--json` (every subcommand): on success stdout is exactly ONE JSON line instead of the
17
17
  * text — `{"block", "event", …}` with the facts the text prints (open: level, asked, source,
@@ -172,18 +172,17 @@
172
172
  * <from>[ <stop reason>]; by: human)`; `--json`: `{"block", "event": "reopened", "file", "from",
173
173
  * "stop_reason", "at_seq", "from_hash", "to_hash", "ticket_cleared", "by"}`.
174
174
  *
175
- * B80 (D1 = a): `block waive` and `block reopen` need the human at a terminal, through the same
175
+ * B80: without `--yes`, `block waive` and `block reopen` need the human at a terminal, through the same
176
176
  * confirm as `--no-require-reviews` (`confirmAtTerminal`). Right after the usage checks and the run
177
177
  * record read — before the autopilot expiry checks or anything else is written — stdin or stdout
178
- * not a TTY prints `block <verb> needs the human at a terminal to confirm; nothing was recorded
179
- * (there is no --yes)` (exit 2). At a terminal, what the verb would refuse is refused before the
178
+ * not a TTY prints `humanVerbNoTTY` (exit 2). At a terminal, what the verb would refuse is refused before the
180
179
  * question: a `--file` or finding id with a control character or of the wrong form (`usage`, exit
181
180
  * 2, `checkFileForm` / `checkFindingId` — the verbs' own checks), an unknown block, for reopen a
182
181
  * block that is not open or a file it does not own (`reopen-not-owned`), for waive a file the block
183
182
  * does not own (`waive-not-owned`) or a finding id no review row of the block names for the file
184
183
  * (`waive-unknown-finding`; `proof` is exempt) — exit 1. A "no" or a cancel prints `block <id>
185
184
  * <verb>: cancelled — nothing was recorded` (exit 1); a "yes" runs the verb exactly as before (the
186
- * same signed row). There is no `--yes` (an unknown flag, exit 2). An active autopilot grant
185
+ * same signed row). Only `block waive` accepts `--yes` (B124a, evidence and a fresh L3 judge). An active autopilot grant
187
186
  * changes nothing here — autopilot waives through its own path (`autopilot waive`, `by:
188
187
  * autopilot`). The prompt checks only that stdin and stdout are terminals; a pseudo-terminal
189
188
  * passes it, so it does not prove a human. The coder deny-list entries stay.
@@ -193,6 +192,7 @@
193
192
  * through `closeBlock`'s `extraChecks`.
194
193
  */
195
194
 
195
+ import { statSync } from 'node:fs';
196
196
  import { readFile, realpath } from 'node:fs/promises';
197
197
  import path from 'node:path';
198
198
  import { parse as parseYAML } from 'yaml';
@@ -201,12 +201,14 @@ import { fallbackLane } from '../decide/fallback-rules.mjs';
201
201
  import { laneFacts } from '../decide/lane-facts.mjs';
202
202
  import { appendRow, readAllRows } from '../ledger/write.mjs';
203
203
  import { attemptBlock, claimPath, closeBlock, openBlock, rebaseBlock, repoTop, stopBlock } from '../state/block.mjs';
204
+ import { lastGate } from '../state/brief-status.mjs';
204
205
  import { intFlag, parseFlags } from '../state/cli-args.mjs';
205
206
  import { configInForce, externalReviewer, requireCoderTests } from '../state/config-snapshot.mjs';
206
207
  import { StateError } from '../state/paths.mjs';
207
208
  import { readRun, writeSigned } from '../state/run.mjs';
208
209
  import { loadKey, verifyRow } from '../state/signer.mjs';
209
210
  import { PROOF_FINDING, blockFileSet, changedFiles, openFindings, ownsAt, resultFilesFor, reviewedFiles, reviewGateCheck, unownedChanges, waiveFinding } from '../review/gate-check.mjs';
211
+ import { agentWaivers, agentWaiversAcked, prepareAckWaivers, prepareUnwaive, waiveAsAgent } from '../review/agent-waiver.mjs';
210
212
  import { checkExpiry } from '../autopilot/grant.mjs';
211
213
  import { redact, writeSafe } from '../util/redact.mjs';
212
214
  import { blockTree, prepareWorktree, removeWorktree, worktreeSettings } from '../proof/worktree.mjs';
@@ -214,10 +216,10 @@ import { DEFAULT_CONFIG_FILENAME, loadProjectConfig } from '../config/load.mjs';
214
216
  import { validateConfig } from '../config/validate.mjs';
215
217
  import { migrateConfig } from '../config/migrate.mjs';
216
218
  import { exec } from '../util/exec.mjs';
217
- import { contentHash, gitChildEnv } from '../worker/ticket.mjs';
219
+ import { contentHash, gitChildEnv, readRegularFileNoFollow } from '../worker/ticket.mjs';
218
220
  import { clearDone, resolveTicket } from '../worker/queue.mjs';
219
221
  import { loadState } from '../worker/review-state.mjs';
220
- import { openBlockEntry, reopenFile } from '../review/reopen.mjs';
222
+ import { agentReopens, openBlockEntry, reopenFile } from '../review/reopen.mjs';
221
223
  import { checkFileForm, checkFindingId } from '../review/verb-forms.mjs';
222
224
  import { runRootPath } from '../util/tmp.mjs';
223
225
  import { missingReviewTickets, parseSentinel } from '../engines/sentinel.mjs';
@@ -225,15 +227,20 @@ import { reportMismatches, reviewedLines } from '../review/reviewed-lines.mjs';
225
227
  import { blockEndHook, runBlockEndHook, writeDoneFile } from '../state/block-end.mjs';
226
228
  import { blockOpenFindings } from '../review/open-findings.mjs';
227
229
  import { hookForbidden, scanTranscript } from '../util/forbidden.mjs';
230
+ import { prepareAcceptHit, writeAcceptedHit } from '../review/accept-hit.mjs';
228
231
 
229
232
  /**
230
233
  * B72: `block reopen` — the file's content is read where the block works (its worktree, else the
231
234
  * main tree), its state is read (never moved) from the code-forge home or the run's old temp root,
232
235
  * the checks, the done marker's removal and then the signed row are `reopenFile`'s.
233
- * @param {{runId: string, id: string, file: string, reason: string, record: Record<string, any>, writeRow: (row: Record<string, any>) => Promise<unknown>}} opts
236
+ * @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
234
237
  * @returns {Promise<{row: Record<string, any>, cleared: boolean}>}
235
238
  */
236
- async function reopenBlockFile({ runId, id, file, reason, record, writeRow }) {
239
+ async function reopenBlockFile({ runId, id, file, reason, record, writeRow, agent = false, judge }) {
240
+ if (agent) {
241
+ if (!reason.trim()) throw new StateError('usage', 'block reopen needs --reason');
242
+ checkFileForm(file, { normalized: true });
243
+ }
237
244
  // the stable block codes come first, before any tree, config, key or ledger read
238
245
  const entry = openBlockEntry(record, runId, id);
239
246
  const real = async (/** @type {string} */ p) => realpath(p).catch(() => path.resolve(p));
@@ -269,10 +276,12 @@ async function reopenBlockFile({ runId, id, file, reason, record, writeRow }) {
269
276
  key,
270
277
  writeRow,
271
278
  writeSigned,
279
+ ...(agent ? { agent: { root: contentRoot, judge } } : {}),
272
280
  io: {
273
281
  owns: ownsAt(wsPrefix),
274
282
  loadState: (where) => loadState({ .../** @type {any} */ (where), runRootDir, contentRoot, migrate: false }),
275
283
  contentHash: (f) => contentHash(contentRoot, f),
284
+ readFile: (f) => readRegularFileNoFollow(contentRoot, f),
276
285
  // C11 (synchronous): the cached result of the current content's ticket would repeat the stop
277
286
  clearDone: (f) => clearDone({ repoRoot: mainTop, run: runId, block: id, file: f, ...tree }).removed,
278
287
  },
@@ -283,12 +292,33 @@ async function reopenBlockFile({ runId, id, file, reason, record, writeRow }) {
283
292
  const USAGE =
284
293
  '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' +
285
294
  ' 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' +
286
- ' code-forge block waive <id> <finding-id> --run <r> --file <path> --reason <text>\n' +
287
- ' code-forge block reopen <id> --run <r> --file <path> --reason <text>\n' +
295
+ ' code-forge block waive <id> <finding-id> --run <r> --file <path> --reason <text> [--yes]\n' +
296
+ ' code-forge block reopen <id> --run <r> --file <path> --reason <text> [--yes]\n' +
297
+ ' code-forge block accept-hit <id> --run <r> --rule <rule-id> --line <n> --transcript <path> --reason <text>\n' +
298
+ ' code-forge block unwaive <id> <finding-id> --run <r> --file <path> --reason <text>\n' +
299
+ ' code-forge block ack-waivers <id> --run <r>\n' +
288
300
  ' every block verb takes --json: one JSON line on stdout ({"block","event",…}) instead of the text\n';
289
301
 
290
- const SUBCOMMANDS = ['open', 'attempt', 'rebase', 'claim', 'close', 'stop', 'waive', 'reopen'];
291
- const FLAG_VALUES = ['run', 'level', 'acceptance', 'brief', 'attempt', 'base', 'lines', 'head', 'worker-pid', 'reason', 'file', 'transcript', 'kind', 'report'];
302
+ const SUBCOMMANDS = ['open', 'attempt', 'rebase', 'claim', 'close', 'stop', 'waive', 'reopen', 'accept-hit', 'unwaive', 'ack-waivers'];
303
+
304
+ /** Refuse likely shell quoting slips before any row is written.
305
+ * @param {string} workspace @param {string[]} entries @param {string} label
306
+ */
307
+ function checkWhitespacePaths(workspace, entries, label) {
308
+ for (const entry of entries) {
309
+ if (!/\s/u.test(entry)) continue;
310
+ let stat;
311
+ try {
312
+ stat = statSync(path.join(workspace, entry));
313
+ } catch {
314
+ // A missing or unreadable path cannot establish a literal whitespace-containing name.
315
+ }
316
+ if (!stat?.isFile() && !stat?.isDirectory()) {
317
+ throw new StateError(label === 'owned files' ? 'bad-owned' : 'bad-tests', `${label}: invalid path ${JSON.stringify(entry)}: contains whitespace but names no existing file or directory`);
318
+ }
319
+ }
320
+ }
321
+ const FLAG_VALUES = ['run', 'level', 'acceptance', 'brief', 'attempt', 'base', 'lines', 'head', 'worker-pid', 'reason', 'file', 'transcript', 'kind', 'report', 'rule', 'line'];
292
322
 
293
323
  /**
294
324
  * @param {string[]} args
@@ -298,6 +328,8 @@ const FLAG_VALUES = ['run', 'level', 'acceptance', 'brief', 'attempt', 'base', '
298
328
  * ui?: {confirm: (o: {message: string, initialValue?: boolean}) => Promise<unknown>, isCancel: (v: unknown) => boolean},
299
329
  * removeBlockTree?: typeof removeBlockTree,
300
330
  * resolveTicket?: typeof resolveTicket, reviewedLines?: typeof reviewedLines,
331
+ * waiverJudge?: import('../review/agent-waiver.mjs').WaiverJudge,
332
+ * extraTokens?: string[],
301
333
  * }} [deps] - `now` (B48): the clock of the autopilot expiry checks. `stdinIsTTY` / `stdoutIsTTY`
302
334
  * (default `process.stdin.isTTY` / `process.stdout.isTTY`; `isTTY` sets both) and `ui` (default
303
335
  * `@clack/prompts`): the human's confirmation (B52 `--no-require-reviews`, B80 `block waive` and
@@ -308,7 +340,7 @@ const FLAG_VALUES = ['run', 'level', 'acceptance', 'brief', 'attempt', 'base', '
308
340
  * @returns {Promise<number>}
309
341
  */
310
342
  export async function runBlock(args, deps = {}) {
311
- const { stdout = process.stdout, stderr = process.stderr, probe } = deps;
343
+ const { stdout = process.stdout, stderr = process.stderr, probe, extraTokens = [] } = deps;
312
344
  const out = (/** @type {string} */ s) => writeSafe(stdout, s);
313
345
  const err = (/** @type {string} */ s) => writeSafe(stderr, s);
314
346
  const [sub, ...rest] = args;
@@ -317,7 +349,7 @@ export async function runBlock(args, deps = {}) {
317
349
  return 2;
318
350
  }
319
351
  try {
320
- const { flags, positionals } = parseFlags(rest, { values: FLAG_VALUES, multi: ['owned', 'tests'], booleans: ['no-require-reviews', 'remove-worktree', 'json'] });
352
+ const { flags, positionals } = parseFlags(rest, { values: FLAG_VALUES, multi: ['owned', 'tests'], booleans: ['no-require-reviews', 'remove-worktree', 'json', ...(['waive', 'reopen'].includes(sub) ? ['yes'] : [])] });
321
353
  // B64a: --json prints ONE JSON line on success instead of the text lines (`emit`, one object
322
354
  // per verb, built where the text is); without it every output is exactly as before (`say` is `out`)
323
355
  const json = flags.json === true;
@@ -344,10 +376,39 @@ export async function runBlock(args, deps = {}) {
344
376
  if (sub === 'reopen' && positionals.length !== 1) throw new StateError('usage', 'block reopen takes one block id (the file goes in --file)');
345
377
  if (sub === 'reopen' && typeof flags.file !== 'string') throw new StateError('usage', 'block reopen needs --file <path>');
346
378
  if (sub === 'waive' && typeof flags.file !== 'string') throw new StateError('usage', 'block waive needs --file <path> (a finding id is scoped to one file)');
379
+ if (sub === 'accept-hit') {
380
+ const { record, row } = await prepareAcceptHit({ runId, id, flags, positionals, extraTokens });
381
+ const asked = await confirmHumanVerb(sub, runId, id, { rule: row.rule, line: row.line, file: /** @type {string} */ (flags.transcript) }, record, deps);
382
+ if (asked === 'no-tty') { err(`${humanVerbNoTTY(sub)}\n`); return 2; }
383
+ if (asked === 'cancelled') { err(`block ${id} ${sub}: cancelled — nothing was recorded\n`); return 1; }
384
+ await writeAcceptedHit({ runId, row, writeRow: (signed) => appendRow(signed, { slug: record.project }) });
385
+ say(`block ${id} accepted ${row.rule} transcript line ${row.line} (by: human)\n`);
386
+ if (json) emit({ block: id, event: 'rule_break.accepted', rule: row.rule, line: row.line, reason: row.reason, by: 'human' });
387
+ return 0;
388
+ }
347
389
  const record = await readRun(runId);
390
+ if (sub === 'unwaive' || sub === 'ack-waivers') {
391
+ if (positionals.length !== (sub === 'unwaive' ? 2 : 1)) throw new StateError('usage', `block ${sub} takes ${sub === 'unwaive' ? 'one block id and one finding id' : 'one block id'}`);
392
+ const prepared = sub === 'unwaive'
393
+ ? { counted: [], row: await prepareUnwaive({ runId, id, finding: extra, file: /** @type {string} */ (flags.file), reason: /** @type {string} */ (flags.reason), record }) }
394
+ : await prepareAckWaivers(record, runId, id);
395
+ const asked = await confirmHumanVerb(sub, runId, id, { finding: extra, file: /** @type {string} */ (flags.file) }, record, deps, () => {
396
+ for (const w of prepared.counted) (json ? err : say)(`block ${id}: agent waiver ${w.file} ${w.finding} reason: ${w.reason}; verdict: ${w.verdict}\n`);
397
+ });
398
+ if (asked === 'no-tty') { err(`${humanVerbNoTTY(sub)}\n`); return 2; }
399
+ if (asked === 'cancelled') { err(`block ${id} ${sub}: cancelled — nothing was recorded\n`); return 1; }
400
+ await writeSigned(runId, (row) => appendRow(row, { slug: record.project }), prepared.row);
401
+ say(sub === 'unwaive' ? `block ${id} unwaived ${extra} in ${flags.file} (by: human)\n` : `block ${id} acknowledged ${prepared.counted.length} agent waiver(s) (by: human)\n`);
402
+ if (json) emit(prepared.row);
403
+ return 0;
404
+ }
405
+ if (sub === 'open') {
406
+ checkWhitespacePaths(record.workspace, /** @type {string[]} */ (flags.owned), 'owned files');
407
+ if (Array.isArray(flags.tests)) checkWhitespacePaths(record.workspace, flags.tests, 'test files');
408
+ }
348
409
  // B80: waive and reopen are the human's — asked at a terminal before anything is written (not
349
410
  // even the autopilot expiry rows below); no terminal is exit 2, a "no" is exit 1
350
- if (sub === 'waive' || sub === 'reopen') {
411
+ if ((sub === 'waive' || sub === 'reopen') && flags.yes !== true) {
351
412
  const asked = await confirmHumanVerb(sub, runId, id, { finding: extra, file: /** @type {string} */ (flags.file) }, record, deps);
352
413
  if (asked === 'no-tty') {
353
414
  err(`${humanVerbNoTTY(sub)}\n`);
@@ -359,6 +420,22 @@ export async function runBlock(args, deps = {}) {
359
420
  }
360
421
  }
361
422
  const writeRow =(/** @type {Record<string, any>} */ row) => appendRow(row, { slug: record.project });
423
+ const reopened = async (/** @type {boolean} */ agent) => {
424
+ const file = /** @type {string} */ (flags.file);
425
+ const { row, cleared } = await reopenBlockFile({ runId, id, file, reason: /** @type {string} */ (flags.reason), record, writeRow, agent, judge: deps.waiverJudge });
426
+ const fromText = row.stop_reason == null ? row.from : `${row.from} ${row.stop_reason}`;
427
+ say(`block ${id} reopened ${file} (from ${fromText}; by: ${row.by})\n`);
428
+ 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 });
429
+ return 0;
430
+ };
431
+ // Agent refusals precede even the lazy expiry checks: no rows on refusal.
432
+ if (sub === 'reopen' && flags.yes === true) return await reopened(true);
433
+ if (sub === 'waive' && flags.yes === true) {
434
+ await waiveAsAgent({ runId, id, finding: extra, file: /** @type {string} */ (flags.file), reason: /** @type {string} */ (flags.reason), record, writeRow, judge: deps.waiverJudge });
435
+ say(`block ${id} waived ${extra} in ${flags.file} (by: agent)\n`);
436
+ if (json) emit({ block: id, event: 'waived', finding: extra, file: flags.file, by: 'agent' });
437
+ return 0;
438
+ }
362
439
  // B64b: the block-end signal — after an end's rows (and outside the run lock) the done file,
363
440
  // then the owner's `hooks.on_block_end` from the run-start snapshot. Without the hook key the
364
441
  // done file is the only addition: no row, and every output stays exactly as before.
@@ -510,12 +587,7 @@ export async function runBlock(args, deps = {}) {
510
587
  return 0;
511
588
  }
512
589
  if (sub === 'reopen') {
513
- const file = /** @type {string} */ (flags.file);
514
- const { row, cleared } = await reopenBlockFile({ runId, id, file, reason: /** @type {string} */ (flags.reason), record, writeRow });
515
- const fromText = row.stop_reason == null ? row.from : `${row.from} ${row.stop_reason}`;
516
- say(`block ${id} reopened ${file} (from ${fromText}; by: human)\n`);
517
- if (json) emit({ block: id, event: 'reopened', file, from: row.from, stop_reason: row.stop_reason, at_seq: row.at_seq, from_hash: row.from_hash, to_hash: row.to_hash, ticket_cleared: cleared, by: 'human' });
518
- return 0;
590
+ return await reopened(false);
519
591
  }
520
592
  if (sub === 'close') {
521
593
  const entry = record.blocks?.[id];
@@ -586,6 +658,8 @@ export async function runBlock(args, deps = {}) {
586
658
  const closeRows = external === null ? [] : [{ event: 'gate.reviews_waived', block: id, by: 'config', reviewer: external }];
587
659
  /** @type {import('../review/gate-check.mjs').WaivedAll[]} */
588
660
  let waivedAll = [];
661
+ /** @type {import('../review/accept-hit.mjs').AcceptedHit[]} */
662
+ let acceptedHits = [];
589
663
  // B70: a file whose review left only waived findings passes as `reviewed_by_waiver`; its signed
590
664
  // `gate.review_waived_all` row joins the close rows (written just before `block.close`, only
591
665
  // when the block closes — a refused close writes none)
@@ -636,6 +710,7 @@ export async function runBlock(args, deps = {}) {
636
710
  rows,
637
711
  key,
638
712
  transcript,
713
+ extraTokens,
639
714
  // B52: a change no block of the run owns is refused — `--no-require-reviews` does not lift it
640
715
  unowned: scope?.unowned ?? [],
641
716
  // B52: an old ledger's review.result rows are judged by their signed result files
@@ -648,7 +723,7 @@ export async function runBlock(args, deps = {}) {
648
723
  proofFiles: fileSet,
649
724
  highPaths,
650
725
  };
651
- }, { onWaivedAll });
726
+ }, { onWaivedAll, onAcceptedHits: (hits) => { acceptedHits = hits; } });
652
727
  // B96: `reviewsPassed` comes from the gate's own result, not from the onWaivedAll call. Each
653
728
  // extra check runs at most once per close (`closeBlock`'s one loop, src/state/block.mjs:562,
654
729
  // and `closeBlock` is called once below), so the gate's one pass is its final one.
@@ -724,6 +799,8 @@ export async function runBlock(args, deps = {}) {
724
799
  };
725
800
  /** @type {Array<() => Promise<{ok: boolean, reason?: string}>>} */
726
801
  const extraChecks = [reviews, report];
802
+ extraChecks.unshift(async () => agentWaiversAcked(record, rows, id, await loadKey(runId))
803
+ ? { ok: true } : { ok: false, reason: 'agent-waivers-not-acked' });
727
804
  // B63b: `context.require_coder_tests` in the run's config snapshot (never the live config)
728
805
  // needs the coder's green test run — waived reviews do not lift it. It runs FIRST: it only
729
806
  // reads the report, so a refusal here leaves no row (not even the human waiver row the review
@@ -748,6 +825,8 @@ export async function runBlock(args, deps = {}) {
748
825
  });
749
826
  }
750
827
  const result = await closeBlock({ runId, id, rows, writeRow, livePid: intFlag(flags['worker-pid'], 'worker-pid'), probe, extraChecks, closeRows });
828
+ const agentRows = agentWaivers(rows, id, await loadKey(runId));
829
+ const reopenedRows = agentReopens(rows.filter((r) => r.run === runId), id, await loadKey(runId));
751
830
  // B64b: the block ended (closed, or stopped by a worker pin event or ledger.tamper) — its rows
752
831
  // are written and the run lock released; the done file is written before the tree is removed
753
832
  // B96: the tool's reviewed lines of a block that closed (also kept in its done file)
@@ -761,6 +840,9 @@ export async function runBlock(args, deps = {}) {
761
840
  // B70: each file that passed as reviewed_by_waiver is named before the closed line
762
841
  // B71: an answered finding is named as answered; a file with only answered findings has no `by`
763
842
  if (result.ok) {
843
+ if (configInForce(record)?.gates?.test != null && lastGate(rows.filter((row) => row.block === id)).result !== 'pass') {
844
+ err(`WARN block ${id}: no green gates run for this block; run gates run --run ${runId} --block ${id} before close\n`);
845
+ }
764
846
  for (const w of lineWarnings) err(w);
765
847
  for (const l of reviewedJson) say(`reviewed ${l.file} ticket ${l.ticket}\n`);
766
848
  for (const f of waivedAll) {
@@ -768,6 +850,9 @@ export async function runBlock(args, deps = {}) {
768
850
  if (f.answered?.length) parts.push(`answered ${f.answered.join(', ')}`);
769
851
  say(`block ${id}: ${f.file} reviewed_by_waiver (${parts.join('; ')})\n`);
770
852
  }
853
+ for (const w of agentRows) say(`block ${id}: agent waiver ${w.file} ${w.finding} reason: ${w.reason}; verdict: ${w.verdict}\n`);
854
+ for (const r of reopenedRows) say(`agent reopen ${r.file}: ${r.reason} (verdict: ${r.verdict?.verdict})\n`);
855
+ for (const hit of acceptedHits) say(`accepted rule_break ${hit.rule} transcript line ${hit.line}: ${hit.reason} (by: human)\n`);
771
856
  }
772
857
  (result.ok ? say : err)(`block ${id} ${result.status}${result.event ? ` (${result.event})` : ''}${result.reason ? `: ${result.reason}` : ''}\n`);
773
858
  // B64a: a refused close also names itself on stdout under --json (its stderr is unchanged)
@@ -783,6 +868,9 @@ export async function runBlock(args, deps = {}) {
783
868
  ...(result.ok && waivedAll.length > 0 ? { reviewed_by_waiver: waivedAll.map((f) => ({ file: f.file, content_hash: f.content_hash, status: f.status, findings: f.findings, by: f.by, ...(f.answered?.length ? { answered: f.answered } : {}) })) } : {}),
784
869
  // B96: only when the tool gave reviewed lines (a waived or external close is unchanged)
785
870
  ...(reviewedJson.length > 0 ? { reviewed: reviewedJson } : {}),
871
+ ...(agentRows.length > 0 ? { agent_waivers: agentRows } : {}),
872
+ ...(reopenedRows.length > 0 ? { agent_reopens: reopenedRows } : {}),
873
+ ...(acceptedHits.length > 0 ? { accepted_hits: acceptedHits } : {}),
786
874
  };
787
875
  if (!result.ok || !removeTree) {
788
876
  if (json && result.ok) emit(closedJson);
@@ -871,9 +959,12 @@ export const NO_REVIEWS_NO_TTY = '--no-require-reviews needs the human at a term
871
959
 
872
960
  /**
873
961
  * B80: the refusal when `block waive` or `block reopen` has no terminal to ask the human at.
874
- * @param {'waive' | 'reopen'} verb @returns {string}
962
+ * @param {'waive' | 'reopen' | 'accept-hit' | 'unwaive' | 'ack-waivers'} verb @returns {string}
875
963
  */
876
- export const humanVerbNoTTY = (verb) => `block ${verb} needs the human at a terminal to confirm; nothing was recorded (there is no --yes)`;
964
+ export const humanVerbNoTTY = (verb) => verb === 'waive'
965
+ ? '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)'
966
+ : verb === 'reopen' ? 'block reopen needs the human at a terminal to confirm; nothing was recorded (--yes works only with evidence and agreement from a fresh judge)'
967
+ : `block ${verb} needs the human at a terminal to confirm; nothing was recorded (there is no --yes)`;
877
968
 
878
969
  /**
879
970
  * @typedef {{isTTY?: boolean, stdinIsTTY?: boolean, stdoutIsTTY?: boolean, ui?: {confirm: (o: {message: string, initialValue?: boolean}) => Promise<unknown>, isCancel: (v: unknown) => boolean}, now?: () => Date}} ConfirmDeps
@@ -928,18 +1019,26 @@ async function confirmNoReviews(runId, id, deps) {
928
1019
  * autopilot grant changes nothing (autopilot waives through its own path, `by: autopilot`). The
929
1020
  * pre-checks (`reopenPrecheck`, `waivePrecheck`) run first, at a terminal, so the human is never
930
1021
  * asked about a waive or reopen the verb would refuse that way.
931
- * @param {'waive' | 'reopen'} verb @param {string} runId @param {string} id
932
- * @param {{finding?: string, file: string}} what @param {Record<string, any>} record @param {ConfirmDeps} deps
1022
+ * @param {'waive' | 'reopen' | 'accept-hit' | 'unwaive' | 'ack-waivers'} verb @param {string} runId @param {string} id
1023
+ * @param {{finding?: string, file: string, rule?: string, line?: number}} what @param {Record<string, any>} record @param {ConfirmDeps} deps
1024
+ * @param {() => void} [before]
933
1025
  * @returns {Promise<'no-tty' | 'confirmed' | 'cancelled'>}
934
1026
  */
935
- function confirmHumanVerb(verb, runId, id, what, record, deps) {
1027
+ function confirmHumanVerb(verb, runId, id, what, record, deps, before) {
936
1028
  const message =
937
1029
  verb === 'waive'
938
1030
  ? `Waive finding ${what.finding} in ${what.file} of block ${id} (run ${runId})? Only the owner may say yes.`
939
- : `Reopen the review of ${what.file} in block ${id} (run ${runId})? Only the owner may say yes.`;
1031
+ : verb === 'unwaive'
1032
+ ? `Undo agent waivers for finding ${what.finding} in ${what.file} of block ${id} (run ${runId})? Only the owner may say yes.`
1033
+ : verb === 'ack-waivers'
1034
+ ? `Acknowledge the listed agent waivers of block ${id} (run ${runId})? Only the owner may say yes.`
1035
+ : verb === 'accept-hit'
1036
+ ? `Accept false transcript hit ${what.rule} at line ${what.line} in ${what.file} of block ${id} (run ${runId})? Only the owner may say yes.`
1037
+ : `Reopen the review of ${what.file} in block ${id} (run ${runId})? Only the owner may say yes.`;
940
1038
  return confirmAtTerminal(deps, message, async () => {
941
1039
  if (verb === 'reopen') await reopenPrecheck(record, runId, id, what.file);
942
- else await waivePrecheck(record, runId, id, /** @type {string} */ (what.finding), what.file);
1040
+ else if (verb === 'waive') await waivePrecheck(record, runId, id, /** @type {string} */ (what.finding), what.file);
1041
+ before?.();
943
1042
  });
944
1043
  }
945
1044
 
@@ -16,7 +16,8 @@
16
16
  * `<block>.md`. A row that cannot be appended removes only the temp file, so an earlier pack stays
17
17
  * as it was and nothing was written. A rename that fails after the row was appended says so.
18
18
  *
19
- * Exit codes: 0 written, 1 refused (no `context` key, a rules or lessons file missing or outside
19
+ * With no `context` key, the pack holds only the block header and built-in proof rule (300 lines).
20
+ * Exit codes: 0 written, 1 refused (a rules or lessons file missing or outside
20
21
  * the repository, a bad `context` value, a control character, an unknown run or block, a file or
21
22
  * ledger write that fails), 2 usage. Never runs any command it lists.
22
23
  */
@@ -94,7 +95,7 @@ export async function runContext(args, deps = {}) {
94
95
  if (!loaded.ok) throw new ContextError('bad-config', loaded.message);
95
96
  const live = /** @type {Record<string, any>} */ (loaded.config);
96
97
  const settings = contextSettings(live);
97
- if (settings === null) throw new ContextError('no-context', 'the config has no context key: add context (rules, lessons, commands, max_lines) to .code-forge.yml');
98
+ if (settings === null && Object.hasOwn(live, 'context')) throw new ContextError('bad-context', 'context must be a mapping');
98
99
  const sources = readSources(repoRoot, settings);
99
100
  const { text, lines, cut } = assemblePack({
100
101
  settings,
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * `code-forge report [--slug <slug>] [--json] [--export <dir>]` (plan §6.2, R4). Terminal output
3
- * only, plus an optional `--export <dir>` that writes the same 14 sections as JSON files. The
3
+ * only, plus an optional `--export <dir>` that writes the same 15 sections as JSON files. The
4
4
  * terminal output ends with the spend per run (B33) and the mode of each run (B59: brief or plan);
5
5
  * both are text only, never in `--json` or `--export`. The 14th section, `outside_review` (B66), is
6
6
  * printed as text lines under its header (`outside review: none`, or one line per PR).
@@ -75,7 +75,7 @@ export async function runReport(args, deps = {}) {
75
75
  }
76
76
 
77
77
  /**
78
- * B33: the running spend per run (open blocks included), after the 14 sections. Unknown-price
78
+ * B33: the running spend per run (open blocks included), after the 15 sections. Unknown-price
79
79
  * sessions are named, never priced.
80
80
  * @param {ReturnType<typeof buildSpend>} spend @returns {string}
81
81
  */
@@ -69,7 +69,7 @@
69
69
 
70
70
  import { readFileSync, realpathSync, rmSync } from 'node:fs';
71
71
  import path from 'node:path';
72
- import { answersTicketId, assertAnswers, blockTickets, dropDoneIf, enqueue, enqueueAnswers, isDone, liveWorker, MAX_ANSWERS, queueDir, resolveTicket } from '../worker/queue.mjs';
72
+ import { answersTicketId, assertAnswers, blockTickets, deliverBlockFindings, dropDoneIf, enqueue, enqueueAnswers, isDone, liveWorker, MAX_ANSWERS, queueDir, resolveTicket } from '../worker/queue.mjs';
73
73
  import { assertBlockId, assertInsideRoot, assertTicketId, contentHash, mainRootOf, normalizeRequestPath, repoRootOf, WorkerError } from '../worker/ticket.mjs';
74
74
  import { blockFileSet, namedOnHash } from '../review/gate-check.mjs';
75
75
  import { STOP_EXITS } from '../review/stop-exits.mjs';
@@ -163,21 +163,15 @@ const FRESH_ATTEMPTS = 3;
163
163
  /** B75: the ways out a counted result names (human-only; a coder stops and reports). */
164
164
  export const FRESH_EXITS = Object.freeze(['block reopen', 'block waive']);
165
165
 
166
+ /** The automatic retry allow-list excludes refused results, even those --fresh can rerun. */
167
+ const RETRY_STATUSES = new Set(['stale', 'unavailable', 'split_required']);
168
+
166
169
  /**
167
- * B75: `review-file <path> --block <id> --fresh`. No done marker: a normal enqueue. The marker is
168
- * read and classified IN PLACE; only a cached result that never counted is then claimed by
169
- * `dropDoneIf` (rename, then a byte compare with what was classified) and the ticket queued again.
170
- * A marker that changed under the claim is restored (link only) and classified again, at most
171
- * {@link FRESH_ATTEMPTS} times. The queue has no lock: the rename claim is the exclusion, and a
172
- * crash between the drop and the enqueue leaves only a re-runnable pending ticket. Anything else is refused and the marker kept byte-for-byte:
173
- * `fresh-needs-reopen` (names `block reopen` and `block waive`) or `fresh-tampered` (names only
174
- * the owner's `block close --no-require-reviews`; reopen refuses a tampered state).
175
- * B110: a plain cached round-1 `split_required` uses the same claim, with `retry: true`.
176
- * @param {{repoRoot: string, run: string, block: string, file: string, where: {tree?: string}, say: (obj: Record<string, any>) => unknown, dropDone?: typeof dropDoneIf, retry?: boolean}} opts
177
- * @returns {number} the exit code.
170
+ * Classify in place and use the same byte-checked claim for fresh and automatic retries.
171
+ * @param {{repoRoot: string, ticket: string, file: string, dropDone?: typeof dropDoneIf, once?: boolean, statuses?: Set<string>}} opts
172
+ * @returns {{verdict: 'rerun' | 'tampered' | 'counted', outcome: 'dropped' | 'gone' | 'kept'}}
178
173
  */
179
- function freshTicket({ repoRoot, run, block, file, where, say, dropDone = dropDoneIf, retry = false }) {
180
- const { ticket } = resolveTicket({ repoRoot, run, block, file, tree: where.tree ?? null });
174
+ function dropFreshDone({ repoRoot, ticket, file, dropDone = dropDoneIf, once = false, statuses }) {
181
175
  const markerPath = path.join(queueDir(repoRoot), `${ticket}.done`);
182
176
  /** @type {'rerun' | 'tampered' | 'counted'} */
183
177
  let verdict = 'counted';
@@ -191,18 +185,17 @@ function freshTicket({ repoRoot, run, block, file, where, say, dropDone = dropDo
191
185
  // no marker: a normal enqueue; a marker that exists but cannot be read is never dropped
192
186
  bytes = /** @type {{code?: unknown}} */ (err)?.code === 'ENOENT' ? null : Buffer.alloc(0);
193
187
  }
194
- if (bytes === null) {
195
- say(enqueue({ repoRoot, run, block, file, ...where }));
196
- return 0;
197
- }
188
+ if (bytes === null) return { verdict: 'counted', outcome: 'gone' };
198
189
  // classified IN PLACE: a refusal never moves, renames or touches the marker
199
190
  const text = bytes.toString('utf8');
200
- verdict = freshVerdict(text, resultOfMarker(repoRoot, text));
191
+ const result = resultOfMarker(repoRoot, text);
192
+ verdict = freshVerdict(text, result);
193
+ if (statuses && !statuses.has(result?.status)) verdict = 'counted';
201
194
  if (verdict !== 'rerun') break;
202
195
  /** @type {unknown} */
203
196
  let outcome;
204
197
  try {
205
- outcome = dropDone(repoRoot, ticket, bytes);
198
+ outcome = dropDone(repoRoot, ticket, bytes, once ? { once: true } : {});
206
199
  } catch (err) {
207
200
  // a named refusal passes through; any other throw is named here, never echoed
208
201
  if (err instanceof WorkerError) throw err;
@@ -212,10 +205,31 @@ function freshTicket({ repoRoot, run, block, file, where, say, dropDone = dropDo
212
205
  verdict = 'counted';
213
206
  continue;
214
207
  }
208
+ if (outcome === 'kept') break;
215
209
  // an allow-list of outcomes: anything else is refused and nothing is queued
216
210
  if (outcome !== 'dropped' && outcome !== 'gone') throw new WorkerError('fresh-claim-unknown', `the claim of the cached result of ${file} gave an unknown outcome; nothing was queued`);
217
- // dropped: queue it again now (a crash right here leaves a pending ticket with no marker, which
218
- // the next review-file queues anyway); gone: another caller took the marker, a normal enqueue
211
+ return { verdict, outcome };
212
+ }
213
+ return { verdict, outcome: 'kept' };
214
+ }
215
+
216
+ /**
217
+ * B75: `review-file <path> --block <id> --fresh`. No done marker: a normal enqueue. The marker is
218
+ * read and classified IN PLACE; only a cached result that never counted is then claimed by
219
+ * `dropDoneIf` (rename, then a byte compare with what was classified) and the ticket queued again.
220
+ * A marker that changed under the claim is restored (link only) and classified again, at most
221
+ * {@link FRESH_ATTEMPTS} times. The queue has no lock: the rename claim is the exclusion, and a
222
+ * crash between the drop and the enqueue leaves only a re-runnable pending ticket. Anything else is refused and the marker kept byte-for-byte:
223
+ * `fresh-needs-reopen` (names `block reopen` and `block waive`) or `fresh-tampered` (names only
224
+ * the owner's `block close --no-require-reviews`; reopen refuses a tampered state).
225
+ * B110: a plain cached round-1 `split_required` uses the same claim, with `retry: true`.
226
+ * @param {{repoRoot: string, run: string, block: string, file: string, where: {tree?: string}, say: (obj: Record<string, any>) => unknown, dropDone?: typeof dropDoneIf, retry?: boolean}} opts
227
+ * @returns {number} the exit code.
228
+ */
229
+ function freshTicket({ repoRoot, run, block, file, where, say, dropDone = dropDoneIf, retry = false }) {
230
+ const { ticket } = resolveTicket({ repoRoot, run, block, file, tree: where.tree ?? null });
231
+ const { verdict, outcome } = dropFreshDone({ repoRoot, ticket, file, dropDone });
232
+ if (outcome === 'dropped' || outcome === 'gone') {
219
233
  const queued = enqueue({ repoRoot, run, block, file, ...where });
220
234
  say(outcome === 'dropped' ? { ...queued, ...(retry ? { retry: true } : { fresh: true }) } : queued);
221
235
  return 0;
@@ -390,29 +404,31 @@ async function waitBlockScope(repoRoot, cwd, run, block) {
390
404
  throw new WorkerError('block-scope-unreadable', 'the block scope cannot be read');
391
405
  }
392
406
 
393
- /** A block wait reads only the marker, never review results or the signing key.
394
- * @param {string} repoRoot @param {string} ticket @returns {boolean}
407
+ /** Read a completion marker without the signing key.
408
+ * @param {string} repoRoot @param {string} ticket @returns {Record<string, any> | null}
395
409
  */
396
- function blockTicketDone(repoRoot, ticket) {
410
+ function blockTicketMarker(repoRoot, ticket) {
397
411
  try {
398
412
  const marker = JSON.parse(readFileSync(path.join(queueDir(repoRoot), `${ticket}.done`), 'utf8'));
399
- if (marker?.ticket === ticket && typeof marker.status === 'string' && marker.status.length > 0) return true;
413
+ if (marker?.ticket === ticket && typeof marker.status === 'string' && marker.status.length > 0) return marker;
400
414
  } catch (err) {
401
- if (err.code === 'ENOENT') return false;
415
+ if (err.code === 'ENOENT') return null;
402
416
  }
403
417
  throw new WorkerError('result-unreadable', 'a ticket completion marker cannot be read');
404
418
  }
405
419
 
406
420
  /**
407
- * Join current-content tickets only. Re-scan on every poll, including before returning done,
408
- * so a file edited while waiting gets a new ticket. The worker alone finishes old tickets.
409
- * @param {{repoRoot: string, cwd: string, block: string, run: string, maxMs: number, pollMs: number, say: (obj: Record<string, any>) => unknown}} opts
421
+ * A block wait uses unsigned results only to classify retries; it never reads the signing key.
422
+ * Re-scan current content on every poll. Deliver unseen findings of the newest earlier ticket
423
+ * before rechecking a blind edit; the next join queues the fix. Stopped files stay finished.
424
+ * @param {{repoRoot: string, cwd: string, block: string, run: string, maxMs: number, pollMs: number, say: (obj: Record<string, any>) => unknown, dropDone?: typeof dropDoneIf}} opts
410
425
  */
411
- async function waitBlock({ repoRoot, cwd, block, run, maxMs, pollMs, say }) {
426
+ async function waitBlock({ repoRoot, cwd, block, run, maxMs, pollMs, say, dropDone = dropDoneIf }) {
412
427
  const { entry, tree } = await waitBlockScope(repoRoot, cwd, run, block);
413
428
  const where = realpathSync(tree) === realpathSync(repoRoot) ? {} : { tree: realpathSync(tree) };
414
429
  const deadline = Date.now() + maxMs;
415
430
  let downPolls = 0;
431
+ const deliveredHere = new Set();
416
432
  for (;;) {
417
433
  const worker = liveWorker(repoRoot);
418
434
  const live = worker?.run === run;
@@ -424,17 +440,33 @@ async function waitBlock({ repoRoot, cwd, block, run, maxMs, pollMs, say }) {
424
440
  const { ticket } = resolveTicket(opts);
425
441
  const previous = tickets.filter((t) => t.file === file);
426
442
  let present = previous.find((t) => t.ticket === ticket);
443
+ if (!present && previous.length > 0) {
444
+ const newest = previous[previous.length - 1];
445
+ const marker = blockTicketMarker(repoRoot, newest.ticket);
446
+ if (marker?.status === 'stopped') return { file, ticket: newest.ticket, status: 'done' };
447
+ if (marker?.status === 'reviewed' && marker.open_findings === true && live) {
448
+ if (deliveredHere.has(newest.ticket) || deliverBlockFindings({ repoRoot, block, file, ticket: newest.ticket })) {
449
+ deliveredHere.add(newest.ticket);
450
+ return { file, ticket: newest.ticket, status: 'findings' };
451
+ }
452
+ }
453
+ }
427
454
  if (!present && previous.length > 0 && live) {
428
455
  enqueue(opts);
429
456
  present = { ...previous[previous.length - 1], ticket };
430
457
  }
431
- return { file, ticket: present ? ticket : null, status: !present ? 'not_queued' : blockTicketDone(repoRoot, ticket) ? 'done' : 'pending' };
458
+ let marker = present ? blockTicketMarker(repoRoot, ticket) : null;
459
+ if (live && marker && RETRY_STATUSES.has(marker.status)) {
460
+ dropFreshDone({ repoRoot, ticket, file, dropDone, once: true, statuses: RETRY_STATUSES });
461
+ marker = blockTicketMarker(repoRoot, ticket);
462
+ }
463
+ return { file, ticket: present ? ticket : null, status: !present ? 'not_queued' : marker ? 'done' : 'pending' };
432
464
  });
433
465
  if (downPolls >= 2) {
434
466
  say({ block, files, done: false, status: 'worker_down' });
435
467
  return 3;
436
468
  }
437
- if (live && files.every((f) => f.status === 'done')) {
469
+ if (live && files.every((f) => f.status === 'done' || f.status === 'findings')) {
438
470
  say({ block, files, done: true });
439
471
  return 0;
440
472
  }
@@ -465,7 +497,7 @@ export async function runReviewFile(args, deps = {}) {
465
497
  if (positionals.length > 0 || flags.block !== undefined || flags.wait !== undefined || flags.answers !== undefined || flags.of !== undefined || flags.fresh === true || typeof flags.run !== 'string') {
466
498
  throw new WorkerError('usage', '--wait-block takes a block, --run and --max only');
467
499
  }
468
- return await waitBlock({ repoRoot, cwd, block: assertBlockId(flags['wait-block']), run: assertRunId(flags.run), maxMs: flags.max === undefined ? DEFAULT_WAIT_MS : parseMax(flags.max), pollMs: deps.pollMs ?? POLL_MS, say });
500
+ return await waitBlock({ repoRoot, cwd, block: assertBlockId(flags['wait-block']), run: assertRunId(flags.run), maxMs: flags.max === undefined ? DEFAULT_WAIT_MS : parseMax(flags.max), pollMs: deps.pollMs ?? POLL_MS, say, dropDone: deps.dropDone });
469
501
  }
470
502
 
471
503
  if (typeof flags.wait === 'string') {
@@ -511,16 +543,9 @@ export async function runReviewFile(args, deps = {}) {
511
543
  const where = realpathSync(tree) === realpathSync(repoRoot) ? {} : { tree };
512
544
  if (flags.fresh === true) return freshTicket({ repoRoot, run: worker.run, block, file, where, say, ...(deps.dropDone ? { dropDone: deps.dropDone } : {}) });
513
545
  const queued = enqueue({ repoRoot, run: worker.run, block, file, ...where });
514
- if (queued.status === 'done' && doneResult(repoRoot, queued.ticket)?.status === 'split_required') {
546
+ if (queued.status === 'done' && ['unavailable', 'split_required'].includes(doneResult(repoRoot, queued.ticket)?.status)) {
515
547
  return freshTicket({ repoRoot, run: worker.run, block, file, where, say, retry: true, ...(deps.dropDone ? { dropDone: deps.dropDone } : {}) });
516
548
  }
517
- if (queued.status === 'done' && doneResult(repoRoot, queued.ticket)?.status === 'unavailable') {
518
- // `unavailable` is never approval AND never final: drop the done marker so the worker runs
519
- // the ticket again (the fix loop re-runs its pending round); the signed result is replaced.
520
- rmSync(path.join(queueDir(repoRoot), `${queued.ticket}.done`), { force: true });
521
- say({ ...enqueue({ repoRoot, run: worker.run, block, file, ...where }), retry: true });
522
- return 0;
523
- }
524
549
  say(queued);
525
550
  return 0;
526
551
  } catch (err) {
@@ -48,7 +48,7 @@ import { semaphore } from '../util/locks.mjs';
48
48
  import { writeSafe } from '../util/redact.mjs';
49
49
  import { isAlive, listEntries, readStartTime, UNKNOWN_START_TIME } from '../util/reaper.mjs';
50
50
  import { pidsDir, runRoot, setRunRoot, tmpBase } from '../util/tmp.mjs';
51
- import { readWorker } from '../worker/queue.mjs';
51
+ import { liveWorker } from '../worker/queue.mjs';
52
52
  import { gitChildEnv, normalizeRequestPath, repoRootOf, WorkerError } from '../worker/ticket.mjs';
53
53
 
54
54
  const BIN = fileURLToPath(new URL('../../bin/code-forge.mjs', import.meta.url));
@@ -288,7 +288,7 @@ async function runWorkers(repoRoot, runId) {
288
288
  } catch {
289
289
  // no record: nothing pinned
290
290
  }
291
- const w = readWorker(repoRoot);
291
+ const w = liveWorker(repoRoot);
292
292
  if (w?.run === runId && !found.some((f) => f.pid === w.pid)) {
293
293
  found.push({ pid: w.pid, same: async () => w.start_time === UNKNOWN_START_TIME || readStartTime(w.pid) === w.start_time });
294
294
  }