@codedology/code-forge 0.14.1 → 0.15.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.
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 { attemptBlock, claimPath, closeBlock, humanPassBlock, openBlock, 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';
@@ -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,31 @@ 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' +
300
303
  ' every block verb takes --json: one JSON line on stdout ({"block","event",…}) instead of the text\n';
301
304
 
302
- const SUBCOMMANDS = ['open', 'attempt', 'rebase', 'claim', 'close', 'stop', 'waive', 'reopen', 'accept-hit', 'unwaive', 'ack-waivers'];
305
+ const SUBCOMMANDS = ['open', 'attempt', 'rebase', 'claim', 'close', 'stop', 'waive', 'reopen', 'accept-hit', 'unwaive', 'ack-waivers', 'human-pass'];
303
306
 
304
307
  /** Refuse likely shell quoting slips before any row is written.
305
308
  * @param {string} workspace @param {string[]} entries @param {string} label
306
309
  */
307
310
  function checkWhitespacePaths(workspace, entries, label) {
308
311
  for (const entry of entries) {
309
- if (!/\s/u.test(entry)) continue;
312
+ let depth = 0;
313
+ let separator = /\s/u.test(entry);
314
+ for (const char of entry) {
315
+ if (char === '{') depth += 1;
316
+ else if (char === '}') depth = Math.max(0, depth - 1);
317
+ else if (char === ',' && depth === 0) separator = true;
318
+ }
319
+ if (!separator) continue;
310
320
  let stat;
311
321
  try {
312
322
  stat = statSync(path.join(workspace, entry));
@@ -314,11 +324,11 @@ function checkWhitespacePaths(workspace, entries, label) {
314
324
  // A missing or unreadable path cannot establish a literal whitespace-containing name.
315
325
  }
316
326
  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`);
327
+ throw new StateError('usage', `${label} entry ${JSON.stringify(entry)} matches no file; use spaces, not commas`);
318
328
  }
319
329
  }
320
330
  }
321
- const FLAG_VALUES = ['run', 'level', 'acceptance', 'brief', 'attempt', 'base', 'lines', 'head', 'worker-pid', 'reason', 'file', 'transcript', 'kind', 'report', 'rule', 'line'];
331
+ const FLAG_VALUES = ['run', 'level', 'acceptance', 'brief', 'attempt', 'base', 'lines', 'head', 'worker-pid', 'reason', 'file', 'transcript', 'kind', 'report', 'rule', 'line', 'note', 'proof'];
322
332
 
323
333
  /**
324
334
  * @param {string[]} args
@@ -349,7 +359,7 @@ export async function runBlock(args, deps = {}) {
349
359
  return 2;
350
360
  }
351
361
  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'] : [])] });
362
+ 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
363
  // B64a: --json prints ONE JSON line on success instead of the text lines (`emit`, one object
354
364
  // per verb, built where the text is); without it every output is exactly as before (`say` is `out`)
355
365
  const json = flags.json === true;
@@ -377,16 +387,36 @@ export async function runBlock(args, deps = {}) {
377
387
  if (sub === 'reopen' && typeof flags.file !== 'string') throw new StateError('usage', 'block reopen needs --file <path>');
378
388
  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
389
  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);
390
+ // prepareAcceptHit (review/accept-hit.mjs) returns every accepted hit as rows (all hits on --line, or only the --rule hit).
391
+ const { record, rows } = await prepareAcceptHit({ runId, id, flags, positionals, extraTokens });
392
+ 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
393
  if (asked === 'no-tty') { err(`${humanVerbNoTTY(sub)}\n`); return 2; }
383
394
  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' });
395
+ for (const row of rows) {
396
+ await writeAcceptedHit({ runId, row, writeRow: (signed) => appendRow(signed, { slug: record.project }) });
397
+ say(`block ${id} accepted ${row.rule} transcript line ${row.line} (by: human)\n`);
398
+ }
399
+ const accepted = rows.map(({ rule, line, reason, by }) => ({ rule, line, reason, by }));
400
+ if (json) emit({ block: id, event: 'rule_break.accepted', ...(flags.rule === undefined ? { accepted_hits: accepted } : accepted[0]) });
387
401
  return 0;
388
402
  }
389
403
  const record = await readRun(runId);
404
+ const deciding = sub === 'close' && flags.decide === true;
405
+ if (deciding) {
406
+ 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');
407
+ if (flags['no-require-reviews']) throw new StateError('usage', '--decide cannot skip reviews');
408
+ if (!(deps.stdinIsTTY ?? deps.isTTY ?? process.stdin.isTTY === true) || !(deps.stdoutIsTTY ?? deps.isTTY ?? process.stdout.isTTY === true)) {
409
+ err(`${humanVerbNoTTY('close')}\n`); return 2;
410
+ }
411
+ 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');
412
+ }
413
+ if (sub === 'human-pass') {
414
+ 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>');
415
+ if (typeof flags.proof === 'string' && !existsSync(flags.proof)) throw new StateError('usage', '--proof must name an existing directory or file');
416
+ const asked = await confirmHumanVerb(sub, runId, id, { file: '' }, record, deps);
417
+ if (asked === 'no-tty') { err(`${humanVerbNoTTY(sub)}\n`); return 2; }
418
+ if (asked === 'cancelled') { err(`block ${id} ${sub}: cancelled — nothing was recorded\n`); return 1; }
419
+ }
390
420
  if (sub === 'unwaive' || sub === 'ack-waivers') {
391
421
  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
422
  const prepared = sub === 'unwaive'
@@ -403,8 +433,9 @@ export async function runBlock(args, deps = {}) {
403
433
  return 0;
404
434
  }
405
435
  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');
436
+ const base = record.blocks?.[id]?.worktree?.path ?? record.workspace;
437
+ checkWhitespacePaths(base, /** @type {string[]} */ (flags.owned), 'owned');
438
+ if (Array.isArray(flags.tests)) checkWhitespacePaths(base, flags.tests, 'tests');
408
439
  }
409
440
  // B80: waive and reopen are the human's — asked at a terminal before anything is written (not
410
441
  // even the autopilot expiry rows below); no terminal is exit 2, a "no" is exit 1
@@ -471,10 +502,22 @@ export async function runBlock(args, deps = {}) {
471
502
  },
472
503
  };
473
504
  };
505
+ if (sub === 'human-pass') {
506
+ 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()))() });
507
+ const end = await ended('closed', 'block.human_pass', null);
508
+ try {
509
+ say(`block ${id} closed (human check passed)\n`);
510
+ if (json) emit({ ...row, status: 'closed', ...end.json });
511
+ return 0;
512
+ } finally { await end.runHook(); }
513
+ }
474
514
  // B48: every block command runs the lazy autopilot checks (grant expiry, expired approvals);
475
515
  // a failed check is a warning, never a reason to stop the block command
516
+ const requireHumanClose = sub === 'close' && configInForce(record)?.close?.require_human === true;
476
517
  try {
477
- err(approvalCheckText(await expiryChecks(runId, (deps.now ?? (() => new Date()))(), { writeRow })));
518
+ if (!deciding && !requireHumanClose) {
519
+ err(approvalCheckText(await expiryChecks(runId, (deps.now ?? (() => new Date()))(), { writeRow })));
520
+ }
478
521
  } catch (thrown) {
479
522
  err(`autopilot: WARN the expiry check failed (${thrown?.message ?? String(thrown)})\n`);
480
523
  }
@@ -608,9 +651,22 @@ export async function runBlock(args, deps = {}) {
608
651
  throw thrown;
609
652
  });
610
653
  // 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)
654
+ // made from (kept equal to base_sha, including after a closed worktree rebase)
612
655
  const base = wt !== null ? wt.base_sha : entry.base_sha;
613
656
  const reviewsWaived = flags['no-require-reviews'] === true;
657
+ let routineGrant = null;
658
+ if (!deciding && !reviewsWaived && requireHumanClose) {
659
+ const active = record.autopilot && grantState(record.autopilot, (deps.now ?? (() => new Date()))()) === 'active';
660
+ if (active) {
661
+ const allowed = await grantFor(runId, 'close:routine', (deps.now ?? (() => new Date()))());
662
+ if (!allowed.ok) throw new StateError('close-not-routine', `close-not-routine: ${allowed.reason}`);
663
+ routineGrant = allowed.grant;
664
+ } else {
665
+ const asked = await confirmAtTerminal(deps, `Close block ${id} of run ${runId}? Only the owner may say yes.`);
666
+ if (asked === 'no-tty') { err(`${humanVerbNoTTY('close')}\n`); return 2; }
667
+ if (asked === 'cancelled') { err(`block ${id} close: cancelled — nothing was recorded\n`); return 1; }
668
+ }
669
+ }
614
670
  // B52: the human's way out is confirmed by the human, at a terminal, before anything is written
615
671
  if (reviewsWaived) {
616
672
  const asked = await confirmNoReviews(runId, id, deps);
@@ -673,14 +729,16 @@ export async function runBlock(args, deps = {}) {
673
729
  const others = closeRows.filter((r) => r.event !== 'gate.review_waived_all');
674
730
  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
731
  };
676
- const gate = reviewGateCheck(async () => {
677
- const transcript = await findTranscript(record, id, typeof flags.transcript === 'string' ? flags.transcript : null);
678
- if (transcript === null) {
732
+ let dryRun = deciding || routineGrant !== null;
733
+ let transcriptFile = '';
734
+ const loadGate = async () => {
735
+ const transcript = await findTranscript(record, id, typeof flags.transcript === 'string' ? flags.transcript : null, (file) => { transcriptFile = file; });
736
+ if (transcript === null && !dryRun) {
679
737
  await writeSigned(runId, writeRow, { event: 'gate.transcript_missing', block: id });
680
738
  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
739
  }
682
740
  // 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' });
741
+ if (reviewsWaived && !dryRun) await writeSigned(runId, writeRow, { event: 'gate.reviews_waived', block: id, by: 'human' });
684
742
  // B20: the high paths come first — a base the workspace does not know refuses the close here
685
743
  const highPaths = await highPathsAt(record.workspace, base);
686
744
  const { top, mainTop, where, inTree, wsPrefix, owns, changed, fileSet, reviewed } = await tree();
@@ -723,7 +781,8 @@ export async function runBlock(args, deps = {}) {
723
781
  proofFiles: fileSet,
724
782
  highPaths,
725
783
  };
726
- }, { onWaivedAll, onAcceptedHits: (hits) => { acceptedHits = hits; } });
784
+ };
785
+ const gate = reviewGateCheck(loadGate, { onWaivedAll, onAcceptedHits: (hits) => { acceptedHits = hits; } });
727
786
  // B96: `reviewsPassed` comes from the gate's own result, not from the onWaivedAll call. Each
728
787
  // extra check runs at most once per close (`closeBlock`'s one loop, src/state/block.mjs:562,
729
788
  // and `closeBlock` is called once below), so the gate's one pass is its final one.
@@ -799,6 +858,35 @@ export async function runBlock(args, deps = {}) {
799
858
  };
800
859
  /** @type {Array<() => Promise<{ok: boolean, reason?: string}>>} */
801
860
  const extraChecks = [reviews, report];
861
+ if (entry.kind === 'light') extraChecks.unshift(async () => {
862
+ const { top, changed, owns } = await tree();
863
+ const files = changed.filter((file) => owns(entry.owned_files, file, entry.owned_kinds));
864
+ let lines = 0;
865
+ for (const file of files) {
866
+ const diff = await readFileDiff({ repoRoot: top, file, base });
867
+ lines += diff.plusCount + diff.minusCount;
868
+ }
869
+ const cfg = configInForce(record);
870
+ return files.length > (cfg?.light?.max_files ?? 5) || lines > (cfg?.light?.max_lines ?? 300)
871
+ ? { ok: false, reason: `light-limits ${files.length} files, ${lines} lines` } : { ok: true };
872
+ });
873
+ if (configInForce(record)?.gates?.test != null) {
874
+ const testGate = async () => {
875
+ const green = rows.filter((row) => row.block === id && row.event === 'gate.done' && row.all_ok === true);
876
+ // Only commits after the block base that touch its owned paths belong to this block.
877
+ // Older rows without timestamps remain usable, but a failed history lookup cannot prove freshness.
878
+ const commit = green.length === 0 ? null : await exec(['git', 'log', '-1', '--format=%ct', `${base}..HEAD`, '--', ...entry.owned_files], {
879
+ cwd: wt?.workspace ?? record.workspace, env: gitChildEnv(), timeoutMs: 10000,
880
+ });
881
+ const committed = commit?.result === 'ok' ? Number.parseInt(commit.stdout.trim(), 10) * 1000 : Number.NaN;
882
+ const ok = commit?.result === 'ok' && (commit.stdout.trim() === '' || Number.isFinite(committed)) &&
883
+ green.some((row) => !Number.isFinite(committed) || !Number.isFinite(Date.parse(row.ts)) || Date.parse(row.ts) > committed);
884
+ return ok ? { ok: true } : { ok: false, reason: `no green gates run; run: code-forge gates run --run ${runId} --block ${id}` };
885
+ };
886
+ // Keep human-bypass diagnostics ahead of the test gate; waiving reviews still needs green tests.
887
+ if (reviewsWaived) extraChecks.push(testGate);
888
+ else extraChecks.unshift(testGate);
889
+ }
802
890
  extraChecks.unshift(async () => agentWaiversAcked(record, rows, id, await loadKey(runId))
803
891
  ? { ok: true } : { ok: false, reason: 'agent-waivers-not-acked' });
804
892
  // B63b: `context.require_coder_tests` in the run's config snapshot (never the live config)
@@ -824,7 +912,72 @@ export async function runBlock(args, deps = {}) {
824
912
  return hit ? { ok: false, reason: `rule_break transcript line ${hit.line}: ${hit.id}` } : { ok: true };
825
913
  });
826
914
  }
827
- const result = await closeBlock({ runId, id, rows, writeRow, livePid: intFlag(flags['worker-pid'], 'worker-pid'), probe, extraChecks, closeRows });
915
+ const closeOptions = { runId, id, rows, writeRow, livePid: intFlag(flags['worker-pid'], 'worker-pid'), probe, extraChecks, closeRows };
916
+ if (deciding) {
917
+ const data = await loadGate();
918
+ const initial = checkBlockReviews(data);
919
+ const descriptions = await resultFilesFor({ repoRoot: (await tree()).mainTop, runId, block: id, rows, includeCurrent: true });
920
+ const claimOf = (/** @type {string} */ file, /** @type {string} */ finding) => rows
921
+ .filter((r) => r.block === id && r.file === file && verifyRow(r, data.key).ok).flatMap((r) => {
922
+ const full = descriptions[r.ticket]?.findings;
923
+ return [...(r.finding === finding ? [r] : []), ...(Array.isArray(r.open) ? r.open : []), ...(Array.isArray(full) ? full : [])]
924
+ .filter((f) => (f?.id === finding || f?.finding === finding) && typeof f.claim === 'string');
925
+ }).at(-1)?.claim;
926
+ /** @type {Array<{label: string, write: (sink: (row: Record<string, any>) => Promise<unknown>) => Promise<unknown>}>} */
927
+ const actions = [];
928
+ const seen = new Set();
929
+ for (const refusal of initial.refusals) {
930
+ if (!['not_approved', 'late_unruled', 'review_cap', 'autopilot_waiver_invalid'].includes(refusal.code) || !refusal.file || !refusal.finding || refusal.finding === PROOF_FINDING) continue;
931
+ const { file, finding } = refusal;
932
+ const pair = `${file}\0${finding}`;
933
+ if (seen.has(pair)) continue;
934
+ seen.add(pair);
935
+ await waivePrecheck(record, runId, id, finding, file);
936
+ const severity = recordedSeverity(rows, { block: id, file, finding, key: data.key }).severity ?? 'unknown';
937
+ 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 }) });
938
+ }
939
+ if (!agentWaiversAcked(record, rows, id, data.key)) {
940
+ const ack = await prepareAckWaivers(record, runId, id);
941
+ 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) });
942
+ }
943
+ const hits = acceptedTranscriptHits(data).remaining;
944
+ for (const hit of hits) {
945
+ const prepared = await prepareAcceptHit({ runId, id, flags: { rule: hit.id, line: String(hit.line), transcript: transcriptFile, reason: flags.reason }, positionals: [id], extraTokens });
946
+ 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 }) });
947
+ }
948
+ const count = rows.length;
949
+ const buffered = async (/** @type {Record<string, any>} */ row) => { rows.push(row); };
950
+ for (const action of actions) await action.write(buffered);
951
+ const preview = await closeBlock({ ...closeOptions, dryRun: true });
952
+ rows.splice(count);
953
+ // Reset anything buffered by report, proof and review checks before the real pass.
954
+ closeRows.splice(0, closeRows.length, ...(external === null ? [] : [{ event: 'gate.reviews_waived', block: id, by: 'config', reviewer: external }]));
955
+ linesOnce = undefined;
956
+ actions.forEach((action, index) => (json ? err : say)(`${index + 1}. ${action.label}\n`));
957
+ if (!preview.ok) { err(`cannot decide here: ${preview.reason}\n`); return 1; }
958
+ const asked = await confirmAtTerminal(deps, `Apply these decisions and close block ${id} of run ${runId}? Only the owner may say yes.`);
959
+ if (asked === 'no-tty') { err(`${humanVerbNoTTY('close')}\n`); return 2; }
960
+ if (asked === 'cancelled') { err(`block ${id} close: cancelled — nothing was recorded\n`); return 1; }
961
+ for (const action of actions) await action.write(writeRow);
962
+ rows.splice(0, rows.length, ...(await readAllRows(record.project)).filter((row) => row.run === runId));
963
+ }
964
+ if (routineGrant !== null) {
965
+ const data = await loadGate();
966
+ const problem = external !== null ? 'external reviews' : routineCloseProblem(data);
967
+ if (problem !== null) { err(`close-not-routine: ${problem}\n`); return 1; }
968
+ const preview = await closeBlock({ ...closeOptions, dryRun: true });
969
+ if (!preview.ok) { err(`close-not-routine: ${preview.reason}\n`); return 1; }
970
+ closeRows.splice(0, closeRows.length, { event: 'autopilot.close', block: id, grant: routineGrant.grant_id });
971
+ linesOnce = undefined;
972
+ }
973
+ dryRun = false;
974
+ // The confirmation can take arbitrarily long: measure current bytes again at real close.
975
+ if (deciding || routineGrant !== null) {
976
+ treeOnce = undefined;
977
+ linesOnce = undefined;
978
+ lineWarnings.length = 0;
979
+ }
980
+ const result = await closeBlock(closeOptions);
828
981
  const agentRows = agentWaivers(rows, id, await loadKey(runId));
829
982
  const reopenedRows = agentReopens(rows.filter((r) => r.run === runId), id, await loadKey(runId));
830
983
  // B64b: the block ended (closed, or stopped by a worker pin event or ledger.tamper) — its rows
@@ -840,9 +993,7 @@ export async function runBlock(args, deps = {}) {
840
993
  // B70: each file that passed as reviewed_by_waiver is named before the closed line
841
994
  // B71: an answered finding is named as answered; a file with only answered findings has no `by`
842
995
  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
- }
996
+ await warnMovedBase(record.workspace, entry, id, runId, err);
846
997
  for (const w of lineWarnings) err(w);
847
998
  for (const l of reviewedJson) say(`reviewed ${l.file} ticket ${l.ticket}\n`);
848
999
  for (const f of waivedAll) {
@@ -854,7 +1005,10 @@ export async function runBlock(args, deps = {}) {
854
1005
  for (const r of reopenedRows) say(`agent reopen ${r.file}: ${r.reason} (verdict: ${r.verdict?.verdict})\n`);
855
1006
  for (const hit of acceptedHits) say(`accepted rule_break ${hit.rule} transcript line ${hit.line}: ${hit.reason} (by: human)\n`);
856
1007
  }
857
- (result.ok ? say : err)(`block ${id} ${result.status}${result.event ? ` (${result.event})` : ''}${result.reason ? `: ${result.reason}` : ''}\n`);
1008
+ if (result.status === 'awaiting_human_check') {
1009
+ for (const check of entry.human_checks) say(`block ${id} awaiting human check: ${check}\n`);
1010
+ say(`next: code-forge block human-pass ${id} --run ${runId} --note "<device check note>" [--proof <dir-or-file>]\n`);
1011
+ } else (result.ok ? say : err)(`block ${id} ${result.status}${result.event ? ` (${result.event})` : ''}${result.reason ? `: ${result.reason}` : ''}\n`);
858
1012
  // B64a: a refused close also names itself on stdout under --json (its stderr is unchanged)
859
1013
  if (json && !result.ok) emit({ block: id, ok: false, reason: result.reason ?? null, ...(end?.json ?? {}) });
860
1014
  const closedJson = {
@@ -863,6 +1017,7 @@ export async function runBlock(args, deps = {}) {
863
1017
  status: result.status,
864
1018
  reason: result.reason ?? null,
865
1019
  close_event: result.event ?? null,
1020
+ ...(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
1021
  ...(end?.json ?? {}),
867
1022
  // B70: only when a file passed as reviewed_by_waiver (otherwise the output is unchanged)
868
1023
  ...(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 +1027,7 @@ export async function runBlock(args, deps = {}) {
872
1027
  ...(reopenedRows.length > 0 ? { agent_reopens: reopenedRows } : {}),
873
1028
  ...(acceptedHits.length > 0 ? { accepted_hits: acceptedHits } : {}),
874
1029
  };
875
- if (!result.ok || !removeTree) {
1030
+ if (!result.ok || !removeTree || result.status === 'awaiting_human_check') {
876
1031
  if (json && result.ok) emit(closedJson);
877
1032
  return result.ok ? 0 : 1;
878
1033
  }
@@ -896,6 +1051,32 @@ export async function runBlock(args, deps = {}) {
896
1051
  return 2;
897
1052
  }
898
1053
 
1054
+ /**
1055
+ * Advisory only, after a successful close: missing refs or failed history reads cannot change
1056
+ * the close's exit code. Resolve the recorded branch, regardless of the workspace's checkout.
1057
+ * @param {string} cwd @param {Record<string, any>} block @param {string} id @param {string} runId
1058
+ * @param {(text: string) => unknown} warn
1059
+ */
1060
+ async function warnMovedBase(cwd, block, id, runId, warn) {
1061
+ if (!block.base_branch) return;
1062
+ try {
1063
+ const opts = { cwd, env: gitChildEnv(), timeoutMs: 10000 };
1064
+ const head = await exec(['git', 'rev-parse', '--verify', '--end-of-options', `refs/heads/${block.base_branch}^{commit}`], opts);
1065
+ if (head.result !== 'ok' || head.stdout.trim() === block.base_sha) return;
1066
+ const blockHead = await exec(['git', 'rev-parse', '--verify', 'HEAD^{commit}'], { ...opts, cwd: block.worktree?.path ?? cwd });
1067
+ if (blockHead.result !== 'ok') return;
1068
+ // A base advanced by this block's own commits is already incorporated. Only exit 1
1069
+ // means "not an ancestor"; errors and timeouts must not produce an advisory.
1070
+ const ancestor = await exec(['git', 'merge-base', '--is-ancestor', head.stdout.trim(), blockHead.stdout.trim()], { ...opts, okExitCodes: [0, 1] });
1071
+ if (ancestor.result !== 'ok' || ancestor.code !== 1) return;
1072
+ const count = await exec(['git', 'rev-list', '--count', `${block.base_sha}..${head.stdout.trim()}`], opts);
1073
+ if (count.result !== 'ok' || !/^\d+$/.test(count.stdout.trim())) return;
1074
+ 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`);
1075
+ } catch {
1076
+ // The close already succeeded; this warning is best effort.
1077
+ }
1078
+ }
1079
+
899
1080
  /**
900
1081
  * B62a: `block close --remove-worktree`, after the block closed: `git worktree remove <path>` (no
901
1082
  * `--force`, the branch is kept), then a signed `worktree.removed {block, path, branch}` or
@@ -959,7 +1140,7 @@ export const NO_REVIEWS_NO_TTY = '--no-require-reviews needs the human at a term
959
1140
 
960
1141
  /**
961
1142
  * 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}
1143
+ * @param {'waive' | 'reopen' | 'accept-hit' | 'unwaive' | 'ack-waivers' | 'human-pass' | 'close'} verb @returns {string}
963
1144
  */
964
1145
  export const humanVerbNoTTY = (verb) => verb === 'waive'
965
1146
  ? '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)'
@@ -1016,26 +1197,33 @@ async function confirmNoReviews(runId, id, deps) {
1016
1197
  /**
1017
1198
  * B80: `block waive` and `block reopen` are the human's alone (D1 = a): the human confirms at a
1018
1199
  * 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
1200
+ * autopilot grant changes nothing for waive/reopen (autopilot waives through its own path,
1201
+ * `by: autopilot`); human-pass instead refuses an active grant. The
1020
1202
  * pre-checks (`reopenPrecheck`, `waivePrecheck`) run first, at a terminal, so the human is never
1021
1203
  * 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
1204
+ * @param {'waive' | 'reopen' | 'accept-hit' | 'unwaive' | 'ack-waivers' | 'human-pass'} verb @param {string} runId @param {string} id
1205
+ * @param {{finding?: string, file: string, rule?: string, line?: number, hits?: {rule: string, line: number}[]}} what @param {Record<string, any>} record @param {ConfirmDeps} deps
1024
1206
  * @param {() => void} [before]
1025
1207
  * @returns {Promise<'no-tty' | 'confirmed' | 'cancelled'>}
1026
1208
  */
1027
1209
  function confirmHumanVerb(verb, runId, id, what, record, deps, before) {
1028
1210
  const message =
1029
- verb === 'waive'
1211
+ verb === 'human-pass'
1212
+ ? `Confirm all device checks of block ${id} (run ${runId}) passed? Only the owner may say yes.`
1213
+ : verb === 'waive'
1030
1214
  ? `Waive finding ${what.finding} in ${what.file} of block ${id} (run ${runId})? Only the owner may say yes.`
1031
1215
  : verb === 'unwaive'
1032
1216
  ? `Undo agent waivers for finding ${what.finding} in ${what.file} of block ${id} (run ${runId})? Only the owner may say yes.`
1033
1217
  : verb === 'ack-waivers'
1034
1218
  ? `Acknowledge the listed agent waivers of block ${id} (run ${runId})? Only the owner may say yes.`
1035
1219
  : 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.`
1220
+ ? `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
1221
  : `Reopen the review of ${what.file} in block ${id} (run ${runId})? Only the owner may say yes.`;
1038
1222
  return confirmAtTerminal(deps, message, async () => {
1223
+ if (verb === 'human-pass') {
1224
+ if (record.blocks?.[id]?.status !== 'awaiting_human_check') throw new StateError('not-awaiting', `block ${id} is not awaiting a human check`);
1225
+ 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}`);
1226
+ }
1039
1227
  if (verb === 'reopen') await reopenPrecheck(record, runId, id, what.file);
1040
1228
  else if (verb === 'waive') await waivePrecheck(record, runId, id, /** @type {string} */ (what.finding), what.file);
1041
1229
  before?.();
@@ -1148,10 +1336,11 @@ async function openConfig(workspace) {
1148
1336
  * The block's coder transcript: `--transcript`, else the run record's `blocks.<id>.transcript`
1149
1337
  * (workspace-relative), else `.code-forge/runs/<run>/<id>.log`; null when none exists.
1150
1338
  * @param {Record<string, any>} record @param {string} id @param {string | null} given
1339
+ * @param {(file: string) => void} [onFile]
1151
1340
  * @returns {Promise<string | null>}
1152
1341
  */
1153
- async function findTranscript(record, id, given) {
1154
- if (given !== null) return readFile(given, 'utf8');
1342
+ async function findTranscript(record, id, given, onFile) {
1343
+ if (given !== null) { const text = await readFile(given, 'utf8'); onFile?.(given); return text; }
1155
1344
  const recorded = record.blocks?.[id]?.transcript;
1156
1345
  const candidates = [
1157
1346
  ...(typeof recorded === 'string' && recorded.length > 0 ? [path.resolve(record.workspace, recorded)] : []),
@@ -1159,7 +1348,9 @@ async function findTranscript(record, id, given) {
1159
1348
  ];
1160
1349
  for (const file of candidates) {
1161
1350
  try {
1162
- return await readFile(file, 'utf8');
1351
+ const text = await readFile(file, 'utf8');
1352
+ onFile?.(file);
1353
+ return text;
1163
1354
  } catch (thrown) {
1164
1355
  if (/** @type {any} */ (thrown)?.code !== 'ENOENT') throw thrown;
1165
1356
  }
package/src/cli/gates.mjs CHANGED
@@ -39,11 +39,12 @@ import { writeSafe, redactJSON } from '../util/redact.mjs';
39
39
  import { appendRow } from '../ledger/write.mjs';
40
40
  import { loadProjectConfig } from '../config/load.mjs';
41
41
  import { detectGates } from '../gates/detect.mjs';
42
- import { printableGates, scopeFields, thresholdOf } from '../gates/expand.mjs';
42
+ import { printableGates, scopeFields, testScope, thresholdOf } from '../gates/expand.mjs';
43
43
  import { runGates } from '../gates/run.mjs';
44
44
  import { runGatesGuarded } from '../proof/lock.mjs';
45
45
  import { blockTree } from '../proof/worktree.mjs';
46
46
  import { readRun } from '../state/run.mjs';
47
+ import { loadKey, signRow } from '../state/signer.mjs';
47
48
  import { StateError } from '../state/paths.mjs';
48
49
  import { scanFiles } from '../gates/secret-scan.mjs';
49
50
  import { checkSafeEdit } from '../gates/safe-edit.mjs';
@@ -279,7 +280,29 @@ export async function runGatesVerb(args, deps = {}) {
279
280
  let ran;
280
281
  if (runFlag.value !== undefined && blockFlag.value !== undefined) {
281
282
  const threshold = thresholdOf(configured?.full_suite_threshold_files);
282
- const guarded = await runGatesGuarded({ runId: runFlag.value, blockId: blockFlag.value, gates, cwd, threshold, changed, slug, ...(timeoutMs ? { timeoutMs } : {}) });
283
+ /** @type {typeof gates & {repeatNewTests?: import('../gates/run.mjs').RepeatNewTests, lightTestFiles?: string[]}} */
284
+ let planned = gates;
285
+ if (configured?.repeat_new_tests !== undefined) {
286
+ const record = await readRun(runFlag.value);
287
+ const block = record.blocks?.[blockFlag.value];
288
+ if (block) planned = { ...gates,
289
+ repeatNewTests: { n: configured.repeat_new_tests, base: block.worktree?.base_sha ?? block.base_sha, owned: block.owned_files, kinds: block.owned_kinds, onSkip: (line) => err(`${line}\n`) },
290
+ };
291
+ }
292
+ // Missing records are diagnosed by the guarded runner, after its lock decision.
293
+ const block = await readRun(runFlag.value).then((record) => record.blocks?.[blockFlag.value]).catch((error) => {
294
+ if (error.code === 'no-run') return null;
295
+ throw error;
296
+ });
297
+ const light = block?.kind === 'light';
298
+ if (light) {
299
+ const scope = testScope({ testFiles: block.test_files, owned: block.owned_files, threshold: Infinity });
300
+ const command = planned.test;
301
+ const argv = Array.isArray(command) ? command : command?.argv;
302
+ if (scope.scope !== 'related' || !argv?.includes('{test_files}')) throw new StateError('light-tests', 'light-tests: block tests and a gates.test {test_files} item are required');
303
+ planned = { ...planned, lightTestFiles: scope.files };
304
+ }
305
+ const guarded = await runGatesGuarded({ runId: runFlag.value, blockId: blockFlag.value, gates: planned, cwd, threshold, changed: light ? false : changed, slug, ...(timeoutMs ? { timeoutMs } : {}) });
283
306
  if (guarded.result === 'proof.busy') {
284
307
  out({ result: 'proof.busy', holder: /** @type {{holder: string}} */ (guarded).holder });
285
308
  return 1;
@@ -289,24 +312,31 @@ export async function runGatesVerb(args, deps = {}) {
289
312
  ran = await runGates(gates, { cwd, ...(timeoutMs ? { timeoutMs } : {}) });
290
313
  }
291
314
  const { results, allOk } = ran;
315
+ const repeats = results.find((r) => r.repeats)?.repeats;
292
316
 
293
317
  const ledgerSlug = slug ?? ran.ledger_slug;
294
318
  if (ledgerSlug) {
319
+ // Preserve the existing rows when off; repeat evidence is covered by the run signature.
320
+ const writeGateRow = async (/** @type {Record<string, any>} */ row) => repeats && runFlag.value !== undefined
321
+ ? appendRow(signRow({ run: runFlag.value, ...row }, await loadKey(runFlag.value)), { slug: ledgerSlug })
322
+ : appendRow(row, { slug: ledgerSlug });
295
323
  // B60: with --run/--block the rows name the run and the block, so the run's binnacle reads them
296
324
  const owner = runFlag.value !== undefined ? { run: runFlag.value, block: blockFlag.value, ...inTree } : {};
297
325
  for (const result of results) {
298
326
  if (!result.skipped && !result.ok) {
299
- await appendRow({ ...owner, event: 'gate.red', gate: result.gate, code: result.code, ...scopeOf(result) }, { slug: ledgerSlug });
327
+ await writeGateRow({ ...owner, event: 'gate.red', gate: result.gate, code: result.code, ...scopeOf(result),
328
+ ...(result.reason === 'flaky-test' ? { reason: 'flaky-test' } : {}),
329
+ ...(result.repeats ? { repeats: result.repeats } : {}) });
300
330
  }
301
331
  }
302
332
  // B60: one scope per run (every `{test_files}` gate shares it), named on gate.done too;
303
333
  // without --run/--block that is `scope: full, reason: no_tests`
304
334
  const scoped = results.find((r) => r.scope !== undefined);
305
- await appendRow({ ...owner, event: 'gate.done', stack: gates.stack, all_ok: allOk,
335
+ await writeGateRow({ ...owner, event: 'gate.done', stack: gates.stack, all_ok: allOk,
306
336
  ...(ran.changed_scope ? scopeFields(ran.changed_scope) : scoped ? scopeOf(scoped) : {}),
307
- ...(ran.files ? { files: ran.files } : {}) }, { slug: ledgerSlug });
337
+ ...(ran.files ? { files: ran.files } : {}), ...(repeats ? { repeats } : {}) });
308
338
  }
309
- out({ stack: gates.stack, ...inTree, results, allOk });
339
+ out({ stack: gates.stack, ...inTree, results, allOk, ...(repeats ? { repeats } : {}) });
310
340
  return allOk ? 0 : 1;
311
341
  }
312
342