@codedology/code-forge 0.14.1 → 0.15.1

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 (41) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.md +1 -1
  3. package/docs/reference/blocks.json +495 -1
  4. package/docs/reference/config.md +7 -0
  5. package/package.json +1 -1
  6. package/schema/code-forge.schema.json +22 -0
  7. package/scripts/first-use.mjs +11 -5
  8. package/skill/SKILL.md +4 -0
  9. package/skill/references/autopilot.md +2 -0
  10. package/skill/references/code.md +28 -2
  11. package/skill/references/review.md +10 -2
  12. package/skill/references/security.md +2 -0
  13. package/skill/scripts/forge +1 -1
  14. package/skill/templates/coder-brief.md +3 -1
  15. package/src/autopilot/scopes.mjs +2 -1
  16. package/src/cli/block.mjs +271 -50
  17. package/src/cli/gates.mjs +36 -6
  18. package/src/cli/ledger.mjs +21 -2
  19. package/src/cli/report.mjs +14 -0
  20. package/src/cli/review-file.mjs +56 -16
  21. package/src/cli/run.mjs +44 -7
  22. package/src/cli/status.mjs +7 -3
  23. package/src/config/validate.mjs +13 -1
  24. package/src/gates/acceptance.mjs +7 -3
  25. package/src/gates/expand.mjs +25 -2
  26. package/src/gates/run.mjs +38 -8
  27. package/src/ledger/report.mjs +29 -7
  28. package/src/review/accept-hit.mjs +22 -4
  29. package/src/review/engine.mjs +30 -10
  30. package/src/review/finding.schema.json +2 -1
  31. package/src/review/fixloop.mjs +79 -14
  32. package/src/review/gate-check.mjs +33 -7
  33. package/src/review/lenses/recheck.md +3 -0
  34. package/src/review/packet.mjs +26 -0
  35. package/src/state/block.mjs +196 -22
  36. package/src/state/run.mjs +35 -8
  37. package/src/util/forbidden.mjs +230 -6
  38. package/src/util/reaper.mjs +3 -3
  39. package/src/worker/engine.mjs +74 -6
  40. package/src/worker/loop.mjs +19 -10
  41. package/src/worker/queue.mjs +38 -8
package/src/cli/block.mjs CHANGED
@@ -3,7 +3,7 @@
3
3
  * `block open` before every spawn in every engine; it prints the brief pointer (≤ 200 bytes).
4
4
  *
5
5
  * block open <id> --run <r> [--level L<n>] --owned <paths…> --acceptance <file>
6
- * [--brief <file>] [--attempt <n>] [--base <sha>] [--lines <forecast>] [--kind code|docs|contract]
6
+ * [--brief <file>] [--attempt <n>] [--base <sha>] [--lines <forecast>] [--kind code|docs|contract|light]
7
7
  * [--tests <paths…>]
8
8
  * block attempt <id> --run <r>
9
9
  * block rebase <id> --run <r> [--head <ref>]
@@ -187,12 +187,14 @@
187
187
  * autopilot`). The prompt checks only that stdin and stdout are terminals; a pseudo-terminal
188
188
  * passes it, so it does not prove a human. The coder deny-list entries stay.
189
189
  *
190
- * `--acceptance` is a YAML/JSON list of `{clause, tests: [test ids…]}`. `block close` runs B8's
190
+ * `--acceptance` is a YAML/JSON list of `{clause, tests: [test ids…]}` or `{clause, kind: human_check}`
191
+ * without tests. Human checks leave a successful close awaiting the human's terminal pass.
192
+ * `block close` runs B8's
191
193
  * rows of the gate (worker pin, MACs, orphans); the gate rows owned by later blocks join it
192
194
  * through `closeBlock`'s `extraChecks`.
193
195
  */
194
196
 
195
- import { statSync } from 'node:fs';
197
+ import { existsSync, statSync } from 'node:fs';
196
198
  import { readFile, realpath } from 'node:fs/promises';
197
199
  import path from 'node:path';
198
200
  import { parse as parseYAML } from 'yaml';
@@ -200,16 +202,16 @@ import { approvalCheckText, expiryChecks } from '../autopilot/limits.mjs';
200
202
  import { fallbackLane } from '../decide/fallback-rules.mjs';
201
203
  import { laneFacts } from '../decide/lane-facts.mjs';
202
204
  import { appendRow, readAllRows } from '../ledger/write.mjs';
203
- import { attemptBlock, claimPath, closeBlock, openBlock, rebaseBlock, repoTop, stopBlock } from '../state/block.mjs';
204
- import { lastGate } from '../state/brief-status.mjs';
205
+ import { amendAcceptanceBlock, attemptBlock, claimPath, closeBlock, humanPassBlock, openBlock, prepareAcceptanceAmendment, rebaseBlock, repoTop, stopBlock } from '../state/block.mjs';
205
206
  import { intFlag, parseFlags } from '../state/cli-args.mjs';
206
207
  import { configInForce, externalReviewer, requireCoderTests } from '../state/config-snapshot.mjs';
208
+ import { readFileDiff } from '../review/packet.mjs';
207
209
  import { StateError } from '../state/paths.mjs';
208
210
  import { readRun, writeSigned } from '../state/run.mjs';
209
211
  import { loadKey, verifyRow } from '../state/signer.mjs';
210
- import { PROOF_FINDING, blockFileSet, changedFiles, openFindings, ownsAt, resultFilesFor, reviewedFiles, reviewGateCheck, unownedChanges, waiveFinding } from '../review/gate-check.mjs';
212
+ import { PROOF_FINDING, blockFileSet, changedFiles, checkBlockReviews, openFindings, ownsAt, recordedSeverity, resultFilesFor, reviewedFiles, reviewGateCheck, routineCloseProblem, unownedChanges, waiveFinding } from '../review/gate-check.mjs';
211
213
  import { agentWaivers, agentWaiversAcked, prepareAckWaivers, prepareUnwaive, waiveAsAgent } from '../review/agent-waiver.mjs';
212
- import { checkExpiry } from '../autopilot/grant.mjs';
214
+ import { checkExpiry, grantFor, grantState } from '../autopilot/grant.mjs';
213
215
  import { redact, writeSafe } from '../util/redact.mjs';
214
216
  import { blockTree, prepareWorktree, removeWorktree, worktreeSettings } from '../proof/worktree.mjs';
215
217
  import { DEFAULT_CONFIG_FILENAME, loadProjectConfig } from '../config/load.mjs';
@@ -217,7 +219,7 @@ import { validateConfig } from '../config/validate.mjs';
217
219
  import { migrateConfig } from '../config/migrate.mjs';
218
220
  import { exec } from '../util/exec.mjs';
219
221
  import { contentHash, gitChildEnv, readRegularFileNoFollow } from '../worker/ticket.mjs';
220
- import { clearDone, resolveTicket } from '../worker/queue.mjs';
222
+ import { clearDone, resetAcceptanceWatch, resolveTicket } from '../worker/queue.mjs';
221
223
  import { loadState } from '../worker/review-state.mjs';
222
224
  import { agentReopens, openBlockEntry, reopenFile } from '../review/reopen.mjs';
223
225
  import { checkFileForm, checkFindingId } from '../review/verb-forms.mjs';
@@ -227,7 +229,7 @@ import { reportMismatches, reviewedLines } from '../review/reviewed-lines.mjs';
227
229
  import { blockEndHook, runBlockEndHook, writeDoneFile } from '../state/block-end.mjs';
228
230
  import { blockOpenFindings } from '../review/open-findings.mjs';
229
231
  import { hookForbidden, scanTranscript } from '../util/forbidden.mjs';
230
- import { prepareAcceptHit, writeAcceptedHit } from '../review/accept-hit.mjs';
232
+ import { acceptedTranscriptHits, prepareAcceptHit, writeAcceptedHit } from '../review/accept-hit.mjs';
231
233
 
232
234
  /**
233
235
  * B72: `block reopen` — the file's content is read where the block works (its worktree, else the
@@ -290,23 +292,32 @@ async function reopenBlockFile({ runId, id, file, reason, record, writeRow, agen
290
292
  }
291
293
 
292
294
  const USAGE =
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' +
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' +
294
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' +
295
297
  ' code-forge block waive <id> <finding-id> --run <r> --file <path> --reason <text> [--yes]\n' +
296
298
  ' 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' +
299
+ ' code-forge block accept-hit <id> --run <r> [--rule <rule-id>] --line <n> --transcript <path> --reason <text>\n' +
298
300
  ' code-forge block unwaive <id> <finding-id> --run <r> --file <path> --reason <text>\n' +
299
301
  ' code-forge block ack-waivers <id> --run <r>\n' +
302
+ ' code-forge block human-pass <id> --run <r> --note <text> [--proof <dir-or-file>]\n' +
303
+ ' code-forge block amend-acceptance <id> --run <r> --acceptance <file> --reason <text>\n' +
300
304
  ' every block verb takes --json: one JSON line on stdout ({"block","event",…}) instead of the text\n';
301
305
 
302
- const SUBCOMMANDS = ['open', 'attempt', 'rebase', 'claim', 'close', 'stop', 'waive', 'reopen', 'accept-hit', 'unwaive', 'ack-waivers'];
306
+ const SUBCOMMANDS = ['open', 'attempt', 'rebase', 'claim', 'close', 'stop', 'waive', 'reopen', 'accept-hit', 'unwaive', 'ack-waivers', 'human-pass', 'amend-acceptance'];
303
307
 
304
308
  /** Refuse likely shell quoting slips before any row is written.
305
309
  * @param {string} workspace @param {string[]} entries @param {string} label
306
310
  */
307
311
  function checkWhitespacePaths(workspace, entries, label) {
308
312
  for (const entry of entries) {
309
- if (!/\s/u.test(entry)) continue;
313
+ let depth = 0;
314
+ let separator = /\s/u.test(entry);
315
+ for (const char of entry) {
316
+ if (char === '{') depth += 1;
317
+ else if (char === '}') depth = Math.max(0, depth - 1);
318
+ else if (char === ',' && depth === 0) separator = true;
319
+ }
320
+ if (!separator) continue;
310
321
  let stat;
311
322
  try {
312
323
  stat = statSync(path.join(workspace, entry));
@@ -314,11 +325,11 @@ function checkWhitespacePaths(workspace, entries, label) {
314
325
  // A missing or unreadable path cannot establish a literal whitespace-containing name.
315
326
  }
316
327
  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`);
328
+ throw new StateError('usage', `${label} entry ${JSON.stringify(entry)} matches no file; use spaces, not commas`);
318
329
  }
319
330
  }
320
331
  }
321
- const FLAG_VALUES = ['run', 'level', 'acceptance', 'brief', 'attempt', 'base', 'lines', 'head', 'worker-pid', 'reason', 'file', 'transcript', 'kind', 'report', 'rule', 'line'];
332
+ const FLAG_VALUES = ['run', 'level', 'acceptance', 'brief', 'attempt', 'base', 'lines', 'head', 'worker-pid', 'reason', 'file', 'transcript', 'kind', 'report', 'rule', 'line', 'note', 'proof'];
322
333
 
323
334
  /**
324
335
  * @param {string[]} args
@@ -349,7 +360,7 @@ export async function runBlock(args, deps = {}) {
349
360
  return 2;
350
361
  }
351
362
  try {
352
- const { flags, positionals } = parseFlags(rest, { values: FLAG_VALUES, multi: ['owned', 'tests'], booleans: ['no-require-reviews', 'remove-worktree', 'json', ...(['waive', 'reopen'].includes(sub) ? ['yes'] : [])] });
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'] : [])] });
353
364
  // B64a: --json prints ONE JSON line on success instead of the text lines (`emit`, one object
354
365
  // per verb, built where the text is); without it every output is exactly as before (`say` is `out`)
355
366
  const json = flags.json === true;
@@ -366,6 +377,9 @@ export async function runBlock(args, deps = {}) {
366
377
  const [id, extra] = positionals;
367
378
  const runId = typeof flags.run === 'string' ? flags.run : undefined;
368
379
  if (!id || !runId) throw new StateError('usage', 'a block id and --run are required');
380
+ 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
+ throw new StateError('usage', 'amend-acceptance needs one block id, --acceptance and a nonblank --reason without control characters');
382
+ }
369
383
  if (sub === 'open' && (typeof flags.acceptance !== 'string' || !Array.isArray(flags.owned) || (flags.level !== undefined && typeof flags.level !== 'string'))) {
370
384
  throw new StateError('usage', 'block open needs --level, --owned and --acceptance');
371
385
  }
@@ -377,16 +391,60 @@ export async function runBlock(args, deps = {}) {
377
391
  if (sub === 'reopen' && typeof flags.file !== 'string') throw new StateError('usage', 'block reopen needs --file <path>');
378
392
  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
393
  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);
394
+ // prepareAcceptHit (review/accept-hit.mjs) returns every accepted hit as rows (all hits on --line, or only the --rule hit).
395
+ const { record, rows } = await prepareAcceptHit({ runId, id, flags, positionals, extraTokens });
396
+ const asked = await confirmHumanVerb(sub, runId, id, { rule: rows[0].rule, line: rows[0].line, ...(flags.rule === undefined ? { hits: rows } : {}), file: /** @type {string} */ (flags.transcript) }, record, deps);
382
397
  if (asked === 'no-tty') { err(`${humanVerbNoTTY(sub)}\n`); return 2; }
383
398
  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' });
399
+ for (const row of rows) {
400
+ await writeAcceptedHit({ runId, row, writeRow: (signed) => appendRow(signed, { slug: record.project }) });
401
+ say(`block ${id} accepted ${row.rule} transcript line ${row.line} (by: human)\n`);
402
+ }
403
+ const accepted = rows.map(({ rule, line, reason, by }) => ({ rule, line, reason, by }));
404
+ if (json) emit({ block: id, event: 'rule_break.accepted', ...(flags.rule === undefined ? { accepted_hits: accepted } : accepted[0]) });
387
405
  return 0;
388
406
  }
389
407
  const record = await readRun(runId);
408
+ if (sub === 'amend-acceptance') {
409
+ /** @type {ReturnType<typeof prepareAcceptanceAmendment> | null} */
410
+ let prepared = null;
411
+ const what = { file: '', oldCount: 0, newCount: 0 };
412
+ const noGrant = (/** @type {Record<string, any>} */ current) => {
413
+ if (current.autopilot && grantState(current.autopilot, (deps.now ?? (() => new Date()))()) === 'active') throw new StateError('autopilot-active', 'amend-acceptance is refused while an autopilot grant is active');
414
+ };
415
+ const asked = await confirmHumanVerb(sub, runId, id, what, record, deps, async () => {
416
+ noGrant(record);
417
+ const clauses = parseYAML(await readFile(/** @type {string} */ (flags.acceptance), 'utf8'), { prettyErrors: false });
418
+ prepared = prepareAcceptanceAmendment(record, id, clauses);
419
+ what.oldCount = prepared.oldCount;
420
+ what.newCount = prepared.acceptance.length;
421
+ });
422
+ if (asked === 'no-tty') { err(`${humanVerbNoTTY(sub)}\n`); return 2; }
423
+ if (asked === 'cancelled') { err(`block ${id} ${sub}: cancelled — nothing was recorded\n`); return 1; }
424
+ if (prepared === null) throw new Error('acceptance was not prepared');
425
+ const accepted = /** @type {ReturnType<typeof prepareAcceptanceAmendment>} */ (prepared);
426
+ const row = await amendAcceptanceBlock({ runId, id, acceptance: accepted.acceptance, old_sha256: accepted.old_sha256, reason: /** @type {string} */ (flags.reason), writeRow: (signed) => appendRow(signed, { slug: record.project }), before: noGrant });
427
+ resetAcceptanceWatch(await repoTop(record.workspace), id);
428
+ say(`block ${id} acceptance amended (${row.clauses} clauses; by: human)\n`);
429
+ if (json) emit(row);
430
+ return 0;
431
+ }
432
+ const deciding = sub === 'close' && flags.decide === true;
433
+ if (deciding) {
434
+ if (positionals.length !== 1 || typeof flags.reason !== 'string' || !flags.reason.trim() || /[\x00-\x1f\x7f]/.test(flags.reason)) throw new StateError('usage', '--decide needs one block id and a nonblank --reason without control characters');
435
+ if (flags['no-require-reviews']) throw new StateError('usage', '--decide cannot skip reviews');
436
+ if (!(deps.stdinIsTTY ?? deps.isTTY ?? process.stdin.isTTY === true) || !(deps.stdoutIsTTY ?? deps.isTTY ?? process.stdout.isTTY === true)) {
437
+ err(`${humanVerbNoTTY('close')}\n`); return 2;
438
+ }
439
+ if (record.autopilot && grantState(record.autopilot, (deps.now ?? (() => new Date()))()) === 'active') throw new StateError('autopilot-active', 'block close --decide is refused while an autopilot grant is active');
440
+ }
441
+ if (sub === 'human-pass') {
442
+ if (positionals.length !== 1 || typeof flags.note !== 'string' || flags.note.trim().length === 0) throw new StateError('usage', 'block human-pass needs one block id and --note <text>');
443
+ if (typeof flags.proof === 'string' && !existsSync(flags.proof)) throw new StateError('usage', '--proof must name an existing directory or file');
444
+ const asked = await confirmHumanVerb(sub, runId, id, { file: '' }, record, deps);
445
+ if (asked === 'no-tty') { err(`${humanVerbNoTTY(sub)}\n`); return 2; }
446
+ if (asked === 'cancelled') { err(`block ${id} ${sub}: cancelled — nothing was recorded\n`); return 1; }
447
+ }
390
448
  if (sub === 'unwaive' || sub === 'ack-waivers') {
391
449
  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
450
  const prepared = sub === 'unwaive'
@@ -403,8 +461,9 @@ export async function runBlock(args, deps = {}) {
403
461
  return 0;
404
462
  }
405
463
  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');
464
+ const base = record.blocks?.[id]?.worktree?.path ?? record.workspace;
465
+ checkWhitespacePaths(base, /** @type {string[]} */ (flags.owned), 'owned');
466
+ if (Array.isArray(flags.tests)) checkWhitespacePaths(base, flags.tests, 'tests');
408
467
  }
409
468
  // B80: waive and reopen are the human's — asked at a terminal before anything is written (not
410
469
  // even the autopilot expiry rows below); no terminal is exit 2, a "no" is exit 1
@@ -471,10 +530,22 @@ export async function runBlock(args, deps = {}) {
471
530
  },
472
531
  };
473
532
  };
533
+ if (sub === 'human-pass') {
534
+ const row = await humanPassBlock({ runId, id, note: /** @type {string} */ (flags.note), proof: typeof flags.proof === 'string' ? flags.proof : null, writeRow, now: (deps.now ?? (() => new Date()))() });
535
+ const end = await ended('closed', 'block.human_pass', null);
536
+ try {
537
+ say(`block ${id} closed (human check passed)\n`);
538
+ if (json) emit({ ...row, status: 'closed', ...end.json });
539
+ return 0;
540
+ } finally { await end.runHook(); }
541
+ }
474
542
  // B48: every block command runs the lazy autopilot checks (grant expiry, expired approvals);
475
543
  // a failed check is a warning, never a reason to stop the block command
544
+ const requireHumanClose = sub === 'close' && configInForce(record)?.close?.require_human === true;
476
545
  try {
477
- err(approvalCheckText(await expiryChecks(runId, (deps.now ?? (() => new Date()))(), { writeRow })));
546
+ if (!deciding && !requireHumanClose) {
547
+ err(approvalCheckText(await expiryChecks(runId, (deps.now ?? (() => new Date()))(), { writeRow })));
548
+ }
478
549
  } catch (thrown) {
479
550
  err(`autopilot: WARN the expiry check failed (${thrown?.message ?? String(thrown)})\n`);
480
551
  }
@@ -608,9 +679,22 @@ export async function runBlock(args, deps = {}) {
608
679
  throw thrown;
609
680
  });
610
681
  // ONE base for the whole close: a worktree block is measured against the commit its tree was
611
- // made from (B62a keeps it equal to the block's base_sha: a re-open keeps it, a rebase is refused)
682
+ // made from (kept equal to base_sha, including after a closed worktree rebase)
612
683
  const base = wt !== null ? wt.base_sha : entry.base_sha;
613
684
  const reviewsWaived = flags['no-require-reviews'] === true;
685
+ let routineGrant = null;
686
+ if (!deciding && !reviewsWaived && requireHumanClose) {
687
+ const active = record.autopilot && grantState(record.autopilot, (deps.now ?? (() => new Date()))()) === 'active';
688
+ if (active) {
689
+ const allowed = await grantFor(runId, 'close:routine', (deps.now ?? (() => new Date()))());
690
+ if (!allowed.ok) throw new StateError('close-not-routine', `close-not-routine: ${allowed.reason}`);
691
+ routineGrant = allowed.grant;
692
+ } else {
693
+ const asked = await confirmAtTerminal(deps, `Close block ${id} of run ${runId}? Only the owner may say yes.`);
694
+ if (asked === 'no-tty') { err(`${humanVerbNoTTY('close')}\n`); return 2; }
695
+ if (asked === 'cancelled') { err(`block ${id} close: cancelled — nothing was recorded\n`); return 1; }
696
+ }
697
+ }
614
698
  // B52: the human's way out is confirmed by the human, at a terminal, before anything is written
615
699
  if (reviewsWaived) {
616
700
  const asked = await confirmNoReviews(runId, id, deps);
@@ -673,14 +757,16 @@ export async function runBlock(args, deps = {}) {
673
757
  const others = closeRows.filter((r) => r.event !== 'gate.review_waived_all');
674
758
  closeRows.splice(0, closeRows.length, ...others, ...files.map((f) => ({ event: 'gate.review_waived_all', block: id, file: f.file, content_hash: f.content_hash, findings: f.findings, by: f.by, ...(f.answered?.length ? { answered: f.answered } : {}) })));
675
759
  };
676
- const gate = reviewGateCheck(async () => {
677
- const transcript = await findTranscript(record, id, typeof flags.transcript === 'string' ? flags.transcript : null);
678
- if (transcript === null) {
760
+ let dryRun = deciding || routineGrant !== null;
761
+ let transcriptFile = '';
762
+ const loadGate = async () => {
763
+ const transcript = await findTranscript(record, id, typeof flags.transcript === 'string' ? flags.transcript : null, (file) => { transcriptFile = file; });
764
+ if (transcript === null && !dryRun) {
679
765
  await writeSigned(runId, writeRow, { event: 'gate.transcript_missing', block: id });
680
766
  err(`WARN block ${id}: no coder transcript found (--transcript, run record, .code-forge/runs/${runId}/${id}.log); the transcript grep did not run\n`);
681
767
  }
682
768
  // the human's way out of the per-file review check leaves a signed audit row before the close
683
- if (reviewsWaived) await writeSigned(runId, writeRow, { event: 'gate.reviews_waived', block: id, by: 'human' });
769
+ if (reviewsWaived && !dryRun) await writeSigned(runId, writeRow, { event: 'gate.reviews_waived', block: id, by: 'human' });
684
770
  // B20: the high paths come first — a base the workspace does not know refuses the close here
685
771
  const highPaths = await highPathsAt(record.workspace, base);
686
772
  const { top, mainTop, where, inTree, wsPrefix, owns, changed, fileSet, reviewed } = await tree();
@@ -723,7 +809,8 @@ export async function runBlock(args, deps = {}) {
723
809
  proofFiles: fileSet,
724
810
  highPaths,
725
811
  };
726
- }, { onWaivedAll, onAcceptedHits: (hits) => { acceptedHits = hits; } });
812
+ };
813
+ const gate = reviewGateCheck(loadGate, { onWaivedAll, onAcceptedHits: (hits) => { acceptedHits = hits; } });
727
814
  // B96: `reviewsPassed` comes from the gate's own result, not from the onWaivedAll call. Each
728
815
  // extra check runs at most once per close (`closeBlock`'s one loop, src/state/block.mjs:562,
729
816
  // and `closeBlock` is called once below), so the gate's one pass is its final one.
@@ -799,6 +886,35 @@ export async function runBlock(args, deps = {}) {
799
886
  };
800
887
  /** @type {Array<() => Promise<{ok: boolean, reason?: string}>>} */
801
888
  const extraChecks = [reviews, report];
889
+ if (entry.kind === 'light') extraChecks.unshift(async () => {
890
+ const { top, changed, owns } = await tree();
891
+ const files = changed.filter((file) => owns(entry.owned_files, file, entry.owned_kinds));
892
+ let lines = 0;
893
+ for (const file of files) {
894
+ const diff = await readFileDiff({ repoRoot: top, file, base });
895
+ lines += diff.plusCount + diff.minusCount;
896
+ }
897
+ const cfg = configInForce(record);
898
+ return files.length > (cfg?.light?.max_files ?? 5) || lines > (cfg?.light?.max_lines ?? 300)
899
+ ? { ok: false, reason: `light-limits ${files.length} files, ${lines} lines` } : { ok: true };
900
+ });
901
+ if (configInForce(record)?.gates?.test != null) {
902
+ const testGate = async () => {
903
+ const green = rows.filter((row) => row.block === id && row.event === 'gate.done' && row.all_ok === true);
904
+ // Only commits after the block base that touch its owned paths belong to this block.
905
+ // Older rows without timestamps remain usable, but a failed history lookup cannot prove freshness.
906
+ const commit = green.length === 0 ? null : await exec(['git', 'log', '-1', '--format=%ct', `${base}..HEAD`, '--', ...entry.owned_files], {
907
+ cwd: wt?.workspace ?? record.workspace, env: gitChildEnv(), timeoutMs: 10000,
908
+ });
909
+ const committed = commit?.result === 'ok' ? Number.parseInt(commit.stdout.trim(), 10) * 1000 : Number.NaN;
910
+ const ok = commit?.result === 'ok' && (commit.stdout.trim() === '' || Number.isFinite(committed)) &&
911
+ green.some((row) => !Number.isFinite(committed) || !Number.isFinite(Date.parse(row.ts)) || Date.parse(row.ts) > committed);
912
+ return ok ? { ok: true } : { ok: false, reason: `no green gates run; run: code-forge gates run --run ${runId} --block ${id}` };
913
+ };
914
+ // Keep human-bypass diagnostics ahead of the test gate; waiving reviews still needs green tests.
915
+ if (reviewsWaived) extraChecks.push(testGate);
916
+ else extraChecks.unshift(testGate);
917
+ }
802
918
  extraChecks.unshift(async () => agentWaiversAcked(record, rows, id, await loadKey(runId))
803
919
  ? { ok: true } : { ok: false, reason: 'agent-waivers-not-acked' });
804
920
  // B63b: `context.require_coder_tests` in the run's config snapshot (never the live config)
@@ -824,7 +940,72 @@ export async function runBlock(args, deps = {}) {
824
940
  return hit ? { ok: false, reason: `rule_break transcript line ${hit.line}: ${hit.id}` } : { ok: true };
825
941
  });
826
942
  }
827
- const result = await closeBlock({ runId, id, rows, writeRow, livePid: intFlag(flags['worker-pid'], 'worker-pid'), probe, extraChecks, closeRows });
943
+ const closeOptions = { runId, id, rows, writeRow, livePid: intFlag(flags['worker-pid'], 'worker-pid'), probe, extraChecks, closeRows };
944
+ if (deciding) {
945
+ const data = await loadGate();
946
+ const initial = checkBlockReviews(data);
947
+ const descriptions = await resultFilesFor({ repoRoot: (await tree()).mainTop, runId, block: id, rows, includeCurrent: true });
948
+ const claimOf = (/** @type {string} */ file, /** @type {string} */ finding) => rows
949
+ .filter((r) => r.block === id && r.file === file && verifyRow(r, data.key).ok).flatMap((r) => {
950
+ const full = descriptions[r.ticket]?.findings;
951
+ return [...(r.finding === finding ? [r] : []), ...(Array.isArray(r.open) ? r.open : []), ...(Array.isArray(full) ? full : [])]
952
+ .filter((f) => (f?.id === finding || f?.finding === finding) && typeof f.claim === 'string');
953
+ }).at(-1)?.claim;
954
+ /** @type {Array<{label: string, write: (sink: (row: Record<string, any>) => Promise<unknown>) => Promise<unknown>}>} */
955
+ const actions = [];
956
+ const seen = new Set();
957
+ for (const refusal of initial.refusals) {
958
+ if (!['not_approved', 'late_unruled', 'review_cap', 'autopilot_waiver_invalid'].includes(refusal.code) || !refusal.file || !refusal.finding || refusal.finding === PROOF_FINDING) continue;
959
+ const { file, finding } = refusal;
960
+ const pair = `${file}\0${finding}`;
961
+ if (seen.has(pair)) continue;
962
+ seen.add(pair);
963
+ await waivePrecheck(record, runId, id, finding, file);
964
+ const severity = recordedSeverity(rows, { block: id, file, finding, key: data.key }).severity ?? 'unknown';
965
+ actions.push({ label: `${file} ${finding} ${severity}: ${claimOf(file, finding) ?? refusal.detail}`, write: (sink) => waiveFinding({ runId, id, file, finding, reason: /** @type {string} */ (flags.reason), writeRow: sink }) });
966
+ }
967
+ if (!agentWaiversAcked(record, rows, id, data.key)) {
968
+ const ack = await prepareAckWaivers(record, runId, id);
969
+ actions.push({ label: ack.counted.map((w) => `${w.file} ${w.finding} ${w.severity}: agent-waivers-not-acked ${claimOf(w.file, w.finding) ?? w.reason}; reason: ${w.reason}; verdict: ${w.verdict}`).join('; '), write: (sink) => writeSigned(runId, sink, ack.row) });
970
+ }
971
+ const hits = acceptedTranscriptHits(data).remaining;
972
+ for (const hit of hits) {
973
+ const prepared = await prepareAcceptHit({ runId, id, flags: { rule: hit.id, line: String(hit.line), transcript: transcriptFile, reason: flags.reason }, positionals: [id], extraTokens });
974
+ for (const row of prepared.rows) actions.push({ label: `${transcriptFile} ${row.rule} severity rule_break line ${row.line}: ${data.transcript?.split('\n')[row.line - 1]}`, write: (sink) => writeAcceptedHit({ runId, row, writeRow: sink }) });
975
+ }
976
+ const count = rows.length;
977
+ const buffered = async (/** @type {Record<string, any>} */ row) => { rows.push(row); };
978
+ for (const action of actions) await action.write(buffered);
979
+ const preview = await closeBlock({ ...closeOptions, dryRun: true });
980
+ rows.splice(count);
981
+ // Reset anything buffered by report, proof and review checks before the real pass.
982
+ closeRows.splice(0, closeRows.length, ...(external === null ? [] : [{ event: 'gate.reviews_waived', block: id, by: 'config', reviewer: external }]));
983
+ linesOnce = undefined;
984
+ actions.forEach((action, index) => (json ? err : say)(`${index + 1}. ${action.label}\n`));
985
+ if (!preview.ok) { err(`cannot decide here: ${preview.reason}\n`); return 1; }
986
+ const asked = await confirmAtTerminal(deps, `Apply these decisions and close block ${id} of run ${runId}? Only the owner may say yes.`);
987
+ if (asked === 'no-tty') { err(`${humanVerbNoTTY('close')}\n`); return 2; }
988
+ if (asked === 'cancelled') { err(`block ${id} close: cancelled — nothing was recorded\n`); return 1; }
989
+ for (const action of actions) await action.write(writeRow);
990
+ rows.splice(0, rows.length, ...(await readAllRows(record.project)).filter((row) => row.run === runId));
991
+ }
992
+ if (routineGrant !== null) {
993
+ const data = await loadGate();
994
+ const problem = external !== null ? 'external reviews' : routineCloseProblem(data);
995
+ if (problem !== null) { err(`close-not-routine: ${problem}\n`); return 1; }
996
+ const preview = await closeBlock({ ...closeOptions, dryRun: true });
997
+ if (!preview.ok) { err(`close-not-routine: ${preview.reason}\n`); return 1; }
998
+ closeRows.splice(0, closeRows.length, { event: 'autopilot.close', block: id, grant: routineGrant.grant_id });
999
+ linesOnce = undefined;
1000
+ }
1001
+ dryRun = false;
1002
+ // The confirmation can take arbitrarily long: measure current bytes again at real close.
1003
+ if (deciding || routineGrant !== null) {
1004
+ treeOnce = undefined;
1005
+ linesOnce = undefined;
1006
+ lineWarnings.length = 0;
1007
+ }
1008
+ const result = await closeBlock(closeOptions);
828
1009
  const agentRows = agentWaivers(rows, id, await loadKey(runId));
829
1010
  const reopenedRows = agentReopens(rows.filter((r) => r.run === runId), id, await loadKey(runId));
830
1011
  // B64b: the block ended (closed, or stopped by a worker pin event or ledger.tamper) — its rows
@@ -840,9 +1021,7 @@ export async function runBlock(args, deps = {}) {
840
1021
  // B70: each file that passed as reviewed_by_waiver is named before the closed line
841
1022
  // B71: an answered finding is named as answered; a file with only answered findings has no `by`
842
1023
  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
- }
1024
+ await warnMovedBase(record.workspace, entry, id, runId, err);
846
1025
  for (const w of lineWarnings) err(w);
847
1026
  for (const l of reviewedJson) say(`reviewed ${l.file} ticket ${l.ticket}\n`);
848
1027
  for (const f of waivedAll) {
@@ -854,7 +1033,10 @@ export async function runBlock(args, deps = {}) {
854
1033
  for (const r of reopenedRows) say(`agent reopen ${r.file}: ${r.reason} (verdict: ${r.verdict?.verdict})\n`);
855
1034
  for (const hit of acceptedHits) say(`accepted rule_break ${hit.rule} transcript line ${hit.line}: ${hit.reason} (by: human)\n`);
856
1035
  }
857
- (result.ok ? say : err)(`block ${id} ${result.status}${result.event ? ` (${result.event})` : ''}${result.reason ? `: ${result.reason}` : ''}\n`);
1036
+ if (result.status === 'awaiting_human_check') {
1037
+ for (const check of entry.human_checks) say(`block ${id} awaiting human check: ${check}\n`);
1038
+ say(`next: code-forge block human-pass ${id} --run ${runId} --note "<device check note>" [--proof <dir-or-file>]\n`);
1039
+ } else (result.ok ? say : err)(`block ${id} ${result.status}${result.event ? ` (${result.event})` : ''}${result.reason ? `: ${result.reason}` : ''}\n`);
858
1040
  // B64a: a refused close also names itself on stdout under --json (its stderr is unchanged)
859
1041
  if (json && !result.ok) emit({ block: id, ok: false, reason: result.reason ?? null, ...(end?.json ?? {}) });
860
1042
  const closedJson = {
@@ -863,6 +1045,7 @@ export async function runBlock(args, deps = {}) {
863
1045
  status: result.status,
864
1046
  reason: result.reason ?? null,
865
1047
  close_event: result.event ?? null,
1048
+ ...(result.status === 'awaiting_human_check' ? { human_checks: entry.human_checks, next_command: `code-forge block human-pass ${id} --run ${runId} --note "<device check note>"` } : {}),
866
1049
  ...(end?.json ?? {}),
867
1050
  // B70: only when a file passed as reviewed_by_waiver (otherwise the output is unchanged)
868
1051
  ...(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 } : {}) })) } : {}),
@@ -872,7 +1055,7 @@ export async function runBlock(args, deps = {}) {
872
1055
  ...(reopenedRows.length > 0 ? { agent_reopens: reopenedRows } : {}),
873
1056
  ...(acceptedHits.length > 0 ? { accepted_hits: acceptedHits } : {}),
874
1057
  };
875
- if (!result.ok || !removeTree) {
1058
+ if (!result.ok || !removeTree || result.status === 'awaiting_human_check') {
876
1059
  if (json && result.ok) emit(closedJson);
877
1060
  return result.ok ? 0 : 1;
878
1061
  }
@@ -896,6 +1079,32 @@ export async function runBlock(args, deps = {}) {
896
1079
  return 2;
897
1080
  }
898
1081
 
1082
+ /**
1083
+ * Advisory only, after a successful close: missing refs or failed history reads cannot change
1084
+ * the close's exit code. Resolve the recorded branch, regardless of the workspace's checkout.
1085
+ * @param {string} cwd @param {Record<string, any>} block @param {string} id @param {string} runId
1086
+ * @param {(text: string) => unknown} warn
1087
+ */
1088
+ async function warnMovedBase(cwd, block, id, runId, warn) {
1089
+ if (!block.base_branch) return;
1090
+ try {
1091
+ const opts = { cwd, env: gitChildEnv(), timeoutMs: 10000 };
1092
+ const head = await exec(['git', 'rev-parse', '--verify', '--end-of-options', `refs/heads/${block.base_branch}^{commit}`], opts);
1093
+ if (head.result !== 'ok' || head.stdout.trim() === block.base_sha) return;
1094
+ const blockHead = await exec(['git', 'rev-parse', '--verify', 'HEAD^{commit}'], { ...opts, cwd: block.worktree?.path ?? cwd });
1095
+ if (blockHead.result !== 'ok') return;
1096
+ // A base advanced by this block's own commits is already incorporated. Only exit 1
1097
+ // means "not an ancestor"; errors and timeouts must not produce an advisory.
1098
+ const ancestor = await exec(['git', 'merge-base', '--is-ancestor', head.stdout.trim(), blockHead.stdout.trim()], { ...opts, okExitCodes: [0, 1] });
1099
+ if (ancestor.result !== 'ok' || ancestor.code !== 1) return;
1100
+ const count = await exec(['git', 'rev-list', '--count', `${block.base_sha}..${head.stdout.trim()}`], opts);
1101
+ if (count.result !== 'ok' || !/^\d+$/.test(count.stdout.trim())) return;
1102
+ warn(`WARN block ${id}: base moved: ${block.base_branch} is ${count.stdout.trim()} commit(s) ahead of the block base; run: code-forge block rebase ${id} --run ${runId}\n`);
1103
+ } catch {
1104
+ // The close already succeeded; this warning is best effort.
1105
+ }
1106
+ }
1107
+
899
1108
  /**
900
1109
  * B62a: `block close --remove-worktree`, after the block closed: `git worktree remove <path>` (no
901
1110
  * `--force`, the branch is kept), then a signed `worktree.removed {block, path, branch}` or
@@ -959,7 +1168,7 @@ export const NO_REVIEWS_NO_TTY = '--no-require-reviews needs the human at a term
959
1168
 
960
1169
  /**
961
1170
  * B80: the refusal when `block waive` or `block reopen` has no terminal to ask the human at.
962
- * @param {'waive' | 'reopen' | 'accept-hit' | 'unwaive' | 'ack-waivers'} verb @returns {string}
1171
+ * @param {'waive' | 'reopen' | 'accept-hit' | 'unwaive' | 'ack-waivers' | 'human-pass' | 'amend-acceptance' | 'close'} verb @returns {string}
963
1172
  */
964
1173
  export const humanVerbNoTTY = (verb) => verb === 'waive'
965
1174
  ? '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)'
@@ -979,7 +1188,7 @@ export const humanVerbNoTTY = (verb) => verb === 'waive'
979
1188
  * terminal check and the question (a refusal it throws means the human is never asked).
980
1189
  * `deps.stdinIsTTY` / `deps.stdoutIsTTY` inject each side, `deps.isTTY` both at once, `deps.ui`
981
1190
  * the prompt (default `@clack/prompts`). A "no" or a cancel (Ctrl-C) is `cancelled`.
982
- * @param {ConfirmDeps} deps @param {string} message
1191
+ * @param {ConfirmDeps} deps @param {string | (() => string)} message
983
1192
  * @param {() => Promise<unknown> | unknown} [before]
984
1193
  * @returns {Promise<'no-tty' | 'confirmed' | 'cancelled'>}
985
1194
  */
@@ -989,7 +1198,7 @@ async function confirmAtTerminal(deps, message, before) {
989
1198
  if (!(stdinTTY && stdoutTTY)) return 'no-tty';
990
1199
  if (before) await before();
991
1200
  const ui = deps.ui ?? /** @type {any} */ (await import('@clack/prompts'));
992
- const go = await ui.confirm({ message, initialValue: false });
1201
+ const go = await ui.confirm({ message: typeof message === 'function' ? message() : message, initialValue: false });
993
1202
  return ui.isCancel(go) || go !== true ? 'cancelled' : 'confirmed';
994
1203
  }
995
1204
 
@@ -1016,29 +1225,38 @@ async function confirmNoReviews(runId, id, deps) {
1016
1225
  /**
1017
1226
  * B80: `block waive` and `block reopen` are the human's alone (D1 = a): the human confirms at a
1018
1227
  * terminal through `confirmAtTerminal`, exactly like `--no-require-reviews`, but an active
1019
- * autopilot grant changes nothing (autopilot waives through its own path, `by: autopilot`). The
1228
+ * autopilot grant changes nothing for waive/reopen (autopilot waives through its own path,
1229
+ * `by: autopilot`); human-pass instead refuses an active grant. The
1020
1230
  * pre-checks (`reopenPrecheck`, `waivePrecheck`) run first, at a terminal, so the human is never
1021
1231
  * asked about a waive or reopen the verb would refuse that way.
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]
1232
+ * @param {'waive' | 'reopen' | 'accept-hit' | 'unwaive' | 'ack-waivers' | 'human-pass' | 'amend-acceptance'} verb @param {string} runId @param {string} id
1233
+ * @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
+ * @param {() => Promise<unknown> | unknown} [before]
1025
1235
  * @returns {Promise<'no-tty' | 'confirmed' | 'cancelled'>}
1026
1236
  */
1027
1237
  function confirmHumanVerb(verb, runId, id, what, record, deps, before) {
1028
- const message =
1029
- verb === 'waive'
1238
+ const message = () =>
1239
+ verb === 'amend-acceptance'
1240
+ ? `Amend acceptance of block ${id} (run ${runId}) from ${what.oldCount} clauses to ${what.newCount} clauses? Only the owner may say yes.`
1241
+ : verb === 'human-pass'
1242
+ ? `Confirm all device checks of block ${id} (run ${runId}) passed? Only the owner may say yes.`
1243
+ : verb === 'waive'
1030
1244
  ? `Waive finding ${what.finding} in ${what.file} of block ${id} (run ${runId})? Only the owner may say yes.`
1031
1245
  : verb === 'unwaive'
1032
1246
  ? `Undo agent waivers for finding ${what.finding} in ${what.file} of block ${id} (run ${runId})? Only the owner may say yes.`
1033
1247
  : verb === 'ack-waivers'
1034
1248
  ? `Acknowledge the listed agent waivers of block ${id} (run ${runId})? Only the owner may say yes.`
1035
1249
  : 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.`
1250
+ ? `Accept false transcript ${what.hits ? `hits ${what.hits.map((hit) => `${hit.rule} at line ${hit.line}`).join(', ')}` : `hit ${what.rule} at line ${what.line}`} in ${what.file} of block ${id} (run ${runId})? Only the owner may say yes.`
1037
1251
  : `Reopen the review of ${what.file} in block ${id} (run ${runId})? Only the owner may say yes.`;
1038
1252
  return confirmAtTerminal(deps, message, async () => {
1253
+ if (verb === 'human-pass') {
1254
+ if (record.blocks?.[id]?.status !== 'awaiting_human_check') throw new StateError('not-awaiting', `block ${id} is not awaiting a human check`);
1255
+ if ((await checkExpiry(runId, (deps.now ?? (() => new Date()))())).state === 'active') throw new StateError('autopilot-active', `block human-pass is refused while an autopilot grant is active on run ${runId}`);
1256
+ }
1039
1257
  if (verb === 'reopen') await reopenPrecheck(record, runId, id, what.file);
1040
1258
  else if (verb === 'waive') await waivePrecheck(record, runId, id, /** @type {string} */ (what.finding), what.file);
1041
- before?.();
1259
+ await before?.();
1042
1260
  });
1043
1261
  }
1044
1262
 
@@ -1148,10 +1366,11 @@ async function openConfig(workspace) {
1148
1366
  * The block's coder transcript: `--transcript`, else the run record's `blocks.<id>.transcript`
1149
1367
  * (workspace-relative), else `.code-forge/runs/<run>/<id>.log`; null when none exists.
1150
1368
  * @param {Record<string, any>} record @param {string} id @param {string | null} given
1369
+ * @param {(file: string) => void} [onFile]
1151
1370
  * @returns {Promise<string | null>}
1152
1371
  */
1153
- async function findTranscript(record, id, given) {
1154
- if (given !== null) return readFile(given, 'utf8');
1372
+ async function findTranscript(record, id, given, onFile) {
1373
+ if (given !== null) { const text = await readFile(given, 'utf8'); onFile?.(given); return text; }
1155
1374
  const recorded = record.blocks?.[id]?.transcript;
1156
1375
  const candidates = [
1157
1376
  ...(typeof recorded === 'string' && recorded.length > 0 ? [path.resolve(record.workspace, recorded)] : []),
@@ -1159,7 +1378,9 @@ async function findTranscript(record, id, given) {
1159
1378
  ];
1160
1379
  for (const file of candidates) {
1161
1380
  try {
1162
- return await readFile(file, 'utf8');
1381
+ const text = await readFile(file, 'utf8');
1382
+ onFile?.(file);
1383
+ return text;
1163
1384
  } catch (thrown) {
1164
1385
  if (/** @type {any} */ (thrown)?.code !== 'ENOENT') throw thrown;
1165
1386
  }