@codedology/code-forge 0.15.3 → 0.15.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -67,11 +67,14 @@
67
67
  * needs its own review.
68
68
  */
69
69
 
70
- import { lstatSync, readdirSync, readFileSync, realpathSync, rmSync, statSync } from 'node:fs';
70
+ import { lstatSync, readdirSync, readFileSync, realpathSync, rmSync, statSync, watch } from 'node:fs';
71
71
  import path from 'node:path';
72
- import { answersTicketId, assertAnswers, blockTickets, deliverBlockFindings, dropDoneIf, enqueue, enqueueAnswers, enqueueHash, isDone, liveWorker, MAX_ANSWERS, queueDir, resolveHashTicket, resolveTicket, reviewExclusionFor } from '../worker/queue.mjs';
72
+ import { setTimeout as delay } from 'node:timers/promises';
73
+ import { answersTicketId, assertAnswers, blockTickets, clearDone, deliverBlockFindings, dropDoneIf, enqueue, enqueueAnswers, enqueueHash, isDone, liveWorker, MAX_ANSWERS, queueDir, resolveHashTicket, resolveTicket, reviewExclusionFor } from '../worker/queue.mjs';
73
74
  import { assertBlockId, assertInsideRoot, assertTicketId, contentHash, mainRootOf, normalizeRequestPath, repoRootOf, WorkerError } from '../worker/ticket.mjs';
74
- import { changedFiles, namedOnHash } from '../review/gate-check.mjs';
75
+ import { changedFiles, checkBlockReviews, namedOnHash, ownsAt } from '../review/gate-check.mjs';
76
+ import { AUTO_REQUEUE_REASON, isAutomaticReopen, reopenFile, stopContentHash } from '../review/reopen.mjs';
77
+ import { loadState } from '../worker/review-state.mjs';
75
78
  import { STOP_EXITS } from '../review/stop-exits.mjs';
76
79
  import { budgetFor } from '../review/packet.mjs';
77
80
  import { ownsFile } from '../state/registry.mjs';
@@ -79,12 +82,15 @@ import { appendRow, readAllRows } from '../ledger/write.mjs';
79
82
  import { parseFlags } from '../state/cli-args.mjs';
80
83
  import { StateError } from '../state/paths.mjs';
81
84
  import { assertRunId } from '../state/paths.mjs';
82
- import { readRun } from '../state/run.mjs';
85
+ import { readRun, writeSigned } from '../state/run.mjs';
86
+ import { loadKey } from '../state/signer.mjs';
83
87
  import { rebaseBlock } from '../state/block.mjs';
84
88
  import { configInForce } from '../state/config-snapshot.mjs';
85
89
  import { reviewExcludeRule } from '../config/validate.mjs';
86
90
  import { blockTree } from '../proof/worktree.mjs';
87
91
  import { writeSafe } from '../util/redact.mjs';
92
+ import { daemonRequest } from '../util/native.mjs';
93
+ import { runRootPath } from '../util/tmp.mjs';
88
94
 
89
95
  const USAGE =
90
96
  'usage: code-forge review-file [--cwd <dir>] <path> --block <id> [--run <id>] [--fresh] | review-file --wait <ticket> [--max <seconds>s]\n' +
@@ -510,13 +516,64 @@ function ticketTimings(value) {
510
516
  .filter((key) => value && Object.hasOwn(value, key)).map((key) => [key, value[key]]));
511
517
  }
512
518
 
519
+ /** Restart only verified stopped reviews whose findings were not waived and whose bytes changed.
520
+ * Shared by the join and close; ordinary progress polling needs no signing key.
521
+ * @param {{repoRoot: string, run: string, block: string, tree: string, files: string[], print: (line: string) => unknown}} opts
522
+ * @returns {Promise<Map<string, string>>} restart timestamp by file
523
+ */
524
+ export async function requeueStoppedFiles({ repoRoot, run, block, tree, files, print }) {
525
+ const restarted = new Map();
526
+ if (files.length === 0) return restarted;
527
+ const record = await readRun(run);
528
+ const entry = record.blocks?.[block];
529
+ if (entry?.status !== 'open') return restarted;
530
+ if (realpathSync(await mainRootOf(record.workspace)) !== realpathSync(repoRoot)) throw new WorkerError('other-workspace', 'the run belongs to another workspace');
531
+ const recordedTree = (await blockTree(record.workspace, entry))?.top ?? repoRoot;
532
+ if (realpathSync(recordedTree) !== realpathSync(tree)) throw new WorkerError('worktree-mismatch', 'the block scope does not match the recorded content tree');
533
+ const owns = ownsAt(path.relative(realpathSync(repoRoot), realpathSync(record.workspace)).split(path.sep).join('/'));
534
+ const key = await loadKey(run);
535
+ const rows = (await readAllRows(record.project)).filter((row) => row.run === run);
536
+ const root = configInForce(record)?.tmp?.root;
537
+ const runRootDir = runRootPath(run, root == null ? {} : { root });
538
+ const where = realpathSync(tree) === realpathSync(repoRoot) ? {} : { tree: realpathSync(tree) };
539
+ const writeRow = (row) => appendRow(row, { slug: record.project });
540
+ for (const file of files) {
541
+ if (!owns(entry.owned_files, file, entry.owned_kinds) || reviewExcludeRule(configInForce(record), file) !== null) continue;
542
+ if (Object.entries(record.blocks).some(([id, other]) => id !== block && other.status === 'open' && owns(other.owned_files, file, other.owned_kinds))) continue;
543
+ const loaded = loadState({ runId: run, block, file, key, rows, runRootDir, contentRoot: tree, migrate: false });
544
+ if (!['ok', 'restored'].includes(loaded.status) || loaded.state?.status !== 'stopped' || !loaded.state.open?.length) continue;
545
+ const fromHash = stopContentHash(loaded.state, rows, { runId: run, block, file, key });
546
+ const currentHash = contentHash(tree, file);
547
+ if (fromHash === null || fromHash === currentHash) continue;
548
+ // A newer accepted review wins over a stopped snapshot. At the stopped hash, use the
549
+ // same waiver rules as the gate, including severity and signature checks.
550
+ const needsReview = (hash) => checkBlockReviews({ runId: run, block, key,
551
+ rows: rows.filter((row) => row.file === undefined || row.file === file),
552
+ files: [{ file, content_hash: hash }], proofFiles: [], highPaths: [],
553
+ }).refusals.some((value) => value.file === file && ['unreviewed', 'unsigned', 'not_approved', 'review_cap', 'autopilot_waiver_invalid'].includes(value.code));
554
+ if (!needsReview(currentHash) || !needsReview(fromHash)) continue;
555
+ const result = await reopenFile({ runId: run, id: block, file, reason: AUTO_REQUEUE_REASON,
556
+ record, rows, key, writeRow, writeSigned, automatic: true,
557
+ io: {
558
+ owns, loadState: () => loaded, contentHash: (f) => contentHash(tree, f),
559
+ clearDone: (f) => clearDone({ repoRoot, run, block, file: f, ...where }).removed,
560
+ enqueue: (f, row) => enqueue({ repoRoot, run, block, file: f, ...where, after: row.ts }).ticket,
561
+ },
562
+ });
563
+ restarted.set(file, result.row.ts);
564
+ if (!result.existing) print(`auto-requeue ${file}: content changed after stopped review (ticket ${result.ticket})\n`);
565
+ }
566
+ return restarted;
567
+ }
568
+
513
569
  /**
514
- * A block wait uses unsigned results only to classify retries; it never reads the signing key.
570
+ * Ordinary block progress uses unsigned results. Restarting changed stopped findings verifies
571
+ * the durable state and writes a signed restart before queuing the new attempt.
515
572
  * Re-scan metadata each poll and read content only after its stamp changes. Deliver unseen findings of the newest earlier ticket
516
- * before rechecking a blind edit; the next join queues the fix. Stopped files stay finished.
517
- * @param {{repoRoot: string, cwd: string, block: string, run: string, maxMs: number, pollMs: number, say: (obj: Record<string, any>) => unknown, dropDone?: typeof dropDoneIf, now?: () => number, sleep?: (ms: number) => Promise<unknown>}} opts
573
+ * before rechecking a blind edit; the next join queues the fix. Changed stopped findings restart.
574
+ * @param {{repoRoot: string, cwd: string, block: string, run: string, maxMs: number, pollMs: number, say: (obj: Record<string, any>) => unknown, print: (line: string) => unknown, dropDone?: typeof dropDoneIf, now?: () => number, sleep?: (ms: number, signal?: AbortSignal) => Promise<unknown>}} opts
518
575
  */
519
- async function waitBlock({ repoRoot, cwd, block, run, maxMs, pollMs, say, dropDone = dropDoneIf, now = Date.now, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) }) {
576
+ async function waitBlock({ repoRoot, cwd, block, run, maxMs, pollMs, say, print, dropDone = dropDoneIf, now = Date.now, sleep = (ms, signal) => delay(ms, undefined, { signal }).catch((err) => { if (err.name !== 'AbortError') throw err; }) }) {
520
577
  let scope = await waitBlockScope(repoRoot, cwd, run, block);
521
578
  const { tree } = scope;
522
579
  const gitPaths = waitGitPaths(tree);
@@ -531,7 +588,43 @@ async function waitBlock({ repoRoot, cwd, block, run, maxMs, pollMs, say, dropDo
531
588
  const hashes = new Map();
532
589
  let downPolls = 0;
533
590
  const deliveredHere = new Set();
591
+ let changed = false;
592
+ /** @type {(() => void) | null} */
593
+ let wake = null;
594
+ /** @type {import('node:fs').FSWatcher | undefined} */
595
+ let watcher;
596
+ const notify = () => { changed = true; wake?.(); };
597
+ // Install before scanning so a completion between the scan and the wait is retained.
598
+ // Queue .done markers are already published after their results, on macOS and Linux.
599
+ try {
600
+ watcher = watch(queueDir(repoRoot), (_event, name) => {
601
+ if (name === null || String(name).endsWith('.done')) notify();
602
+ });
603
+ watcher.on('error', () => { watcher.close(); notify(); });
604
+ } catch { /* unavailable watches retain the existing backoff polling */ }
605
+ try {
534
606
  for (;;) {
607
+ changed = false;
608
+ scope = await waitBlockScope(repoRoot, cwd, run, block, scope);
609
+ // Keep one daemon join for the remaining deadline: it retains findings already
610
+ // delivered while other files are pending, just like deliveredHere below.
611
+ const answer = await daemonRequest(run, { method: 'wait-block', block, max_ms: Math.max(0, deadline - now()) }, { config: scope.cfg });
612
+ // A stopped review may need a signed automatic reopen. Keep that mutation
613
+ // on the existing Node path; ordinary pending/completed polls stay read-free.
614
+ if (answer && !answer.files.some((/** @type {Record<string, any>} */ file) => file.outcome === 'stopped')) {
615
+ if (answer.reason === 'wait_timeout' && now() < deadline) continue;
616
+ // Go serializes map keys alphabetically; retain the CLI's byte order.
617
+ const files = answer.files.map((/** @type {Record<string, any>} */ f) => ({
618
+ file: f.file, ticket: f.ticket, status: f.status, outcome: f.outcome,
619
+ ...(f.rule !== undefined ? { rule: f.rule } : {}), ...ticketTimings(f),
620
+ }));
621
+ say({ block, files, done: answer.done,
622
+ ...(answer.status !== undefined ? { status: answer.status } : {}),
623
+ ...(answer.reason !== undefined ? { reason: answer.reason } : {}),
624
+ ...(answer.next !== undefined ? { next: answer.next } : {}),
625
+ });
626
+ return answer.status === 'worker_down' ? 3 : 0;
627
+ }
535
628
  const worker = liveWorker(repoRoot);
536
629
  const live = worker?.run === run;
537
630
  downPolls = live ? 0 : downPolls + 1;
@@ -543,6 +636,12 @@ async function waitBlock({ repoRoot, cwd, block, run, maxMs, pollMs, say, dropDo
543
636
  current = (await changedFiles({ repoRoot: tree, base: entry.base_sha })).filter((file) => ownsFile(entry.owned_files, file, entry.owned_kinds));
544
637
  treeStamp = stamp;
545
638
  }
639
+ const stopped = current.filter((file) => {
640
+ const latest = tickets.filter((ticket) => ticket.file === file).at(-1);
641
+ return latest && blockTicketMarker(repoRoot, latest.ticket)?.status === 'stopped'
642
+ && doneResult(repoRoot, latest.ticket)?.findings?.length > 0;
643
+ });
644
+ const restarted = live ? await requeueStoppedFiles({ repoRoot, run, block, tree, files: stopped, print }) : new Map();
546
645
  // Progress only, like answersPrecheck: sandboxed callers may not read the private ledger.
547
646
  // Reopens issued here also renew the workspace ticket, which needs no private read to join.
548
647
  let rows = [];
@@ -550,9 +649,10 @@ async function waitBlock({ repoRoot, cwd, block, run, maxMs, pollMs, say, dropDo
550
649
  const reopens = new Map();
551
650
  for (const row of rows) {
552
651
  if (row.event !== 'review.reopened' || row.run !== run || row.block !== block || !Number.isFinite(Date.parse(row.ts))) continue;
553
- if (row.by !== 'human' && !(row.by === 'agent' && row.verdict?.verdict === 'agree')) continue;
652
+ if (row.by !== 'human' && !(row.by === 'agent' && row.verdict?.verdict === 'agree') && !isAutomaticReopen(row)) continue;
554
653
  if (!reopens.has(row.file) || row.ts > reopens.get(row.file)) reopens.set(row.file, row.ts);
555
654
  }
655
+ for (const [file, ts] of restarted) reopens.set(file, ts);
556
656
  const files = current.map((file) => {
557
657
  const rule = reviewExcludeRule(cfg, file);
558
658
  if (rule !== null) return { file, ticket: null, status: 'excluded', outcome: null, rule };
@@ -604,15 +704,22 @@ async function waitBlock({ repoRoot, cwd, block, run, maxMs, pollMs, say, dropDo
604
704
  }
605
705
  const ticketIds = [...new Set([...tickets.map((t) => t.ticket), ...files.map((f) => f.ticket).filter(Boolean)])].sort();
606
706
  const state = JSON.stringify([stamp, live, ticketIds, files]);
607
- interval = seen === state ? Math.min(MAX_IDLE_MS, interval * 2) : pollMs;
608
- seen = state;
609
- await sleep(Math.min(interval, Math.max(0, deadline - now())));
707
+ if (!changed) {
708
+ interval = seen === state ? Math.min(MAX_IDLE_MS, interval * 2) : pollMs;
709
+ seen = state;
710
+ const abort = new AbortController();
711
+ try {
712
+ const event = new Promise((resolve) => { wake = () => resolve(undefined); });
713
+ await Promise.race([event, sleep(Math.min(interval, Math.max(0, deadline - now())), abort.signal)]);
714
+ } finally { wake = null; abort.abort(); }
715
+ }
610
716
  }
717
+ } finally { watcher?.close(); }
611
718
  }
612
719
 
613
720
  /**
614
721
  * @param {string[]} args
615
- * @param {{stdout?: {write: (s: string) => unknown}, stderr?: {write: (s: string) => unknown}, cwd?: string, pollMs?: number, dropDone?: typeof dropDoneIf, now?: () => number, sleep?: (ms: number) => Promise<unknown>}} [deps] - `dropDone`: B75's claim, for tests.
722
+ * @param {{stdout?: {write: (s: string) => unknown}, stderr?: {write: (s: string) => unknown}, cwd?: string, pollMs?: number, dropDone?: typeof dropDoneIf, now?: () => number, sleep?: (ms: number, signal?: AbortSignal) => Promise<unknown>}} [deps] - `dropDone`: B75's claim, for tests.
616
723
  * @returns {Promise<number>}
617
724
  */
618
725
  export async function runReviewFile(args, deps = {}) {
@@ -652,7 +759,7 @@ export async function runReviewFile(args, deps = {}) {
652
759
  if (positionals.length > 0 || flags.block !== undefined || flags.wait !== undefined || flags.answers !== undefined || flags.of !== undefined || flags.fresh === true || typeof flags.run !== 'string') {
653
760
  throw new WorkerError('usage', '--wait-block takes a block, --run and --max only');
654
761
  }
655
- 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, now: deps.now, sleep: deps.sleep });
762
+ 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, print: (line) => writeSafe(stderr, line), dropDone: deps.dropDone, now: deps.now, sleep: deps.sleep });
656
763
  }
657
764
 
658
765
  if (typeof flags.wait === 'string') {
@@ -54,9 +54,11 @@ import { budgetUsdOf } from '../ledger/spend.mjs';
54
54
  import { readAllRows } from '../ledger/write.mjs';
55
55
  import { findingsKey, openFindingsOf } from '../review/open-findings.mjs';
56
56
  import { briefStatus } from '../state/brief-status.mjs';
57
+ import { configInForce } from '../state/config-snapshot.mjs';
57
58
  import { parseFlags } from '../state/cli-args.mjs';
58
59
  import { runsDir, StateError } from '../state/paths.mjs';
59
60
  import { readRun } from '../state/run.mjs';
61
+ import { daemonRequest } from '../util/native.mjs';
60
62
  import { writeSafe } from '../util/redact.mjs';
61
63
 
62
64
  const USAGE = 'usage: code-forge status [--run <id>] [--json]\n';
@@ -80,15 +82,18 @@ async function realOr(p) {
80
82
  /**
81
83
  * The status data of one run.
82
84
  * @param {Record<string, any>} record
85
+ * @param {Array<Record<string, any>> | null | undefined} [cachedRows] - daemon's incrementally read ledger
83
86
  * @returns {Promise<RunStatus>}
84
87
  */
85
- async function statusOf(record) {
88
+ export async function statusOf(record, cachedRows) {
86
89
  /** @type {Array<Record<string, any>> | null} null: the ledger cannot be read */
87
- let rows = null;
88
- try {
89
- rows = await readAllRows(record.project);
90
- } catch {
91
- rows = null;
90
+ let rows = cachedRows ?? null;
91
+ if (cachedRows === undefined) {
92
+ try {
93
+ rows = await readAllRows(record.project);
94
+ } catch {
95
+ rows = null;
96
+ }
92
97
  }
93
98
  let budget = null;
94
99
  try {
@@ -113,6 +118,22 @@ async function statusOf(record) {
113
118
  ...(awaiting.length > 0 ? { awaiting_human_check: awaiting } : {}) };
114
119
  }
115
120
 
121
+ /** Restore the Node renderer's insertion order after Go's sorted JSON map encoding.
122
+ * @param {Record<string, any>} s @returns {RunStatus}
123
+ */
124
+ function daemonStatus(s) {
125
+ return {
126
+ run_id: s.run_id, status: s.status, mode: s.mode, spent_usd: s.spent_usd,
127
+ unknown_usd_sessions: s.unknown_usd_sessions, budget_usd: s.budget_usd,
128
+ blocks: s.blocks.map((/** @type {Record<string, any>} */ b) => ({
129
+ id: b.id, state: b.state, level: b.level, model: b.model,
130
+ gate: { result: b.gate.result, name: b.gate.name }, findings: b.findings,
131
+ })),
132
+ more: s.more, closed: s.closed, stopped: s.stopped,
133
+ ...(s.awaiting_human_check ? { awaiting_human_check: s.awaiting_human_check } : {}),
134
+ };
135
+ }
136
+
116
137
  /**
117
138
  * Every run that is not ended whose workspace is `root`, newest first.
118
139
  * @param {string} root
@@ -155,7 +176,7 @@ export function runLines(s) {
155
176
  const lines = [`run ${s.run_id} · ${s.status ?? '-'} · ${s.mode} · spent ${spent}${s.budget_usd !== null ? ` of ${usd(s.budget_usd)}` : ''}`];
156
177
  const tally = s.closed + s.stopped > 0 ? `closed ${s.closed} · stopped ${s.stopped}` : null;
157
178
  const awaiting = s.awaiting_human_check?.length ? `awaiting_human_check: ${JSON.stringify(s.awaiting_human_check)}` : null;
158
- const room = STATUS_MAX_LINES - 1 - (tally === null ? 0 : 1) - (awaiting === null ? 0 : 1);
179
+ const room = STATUS_MAX_LINES - lines.length - (tally === null ? 0 : 1) - (awaiting === null ? 0 : 1);
159
180
  const shown = s.blocks.length <= room ? s.blocks : s.blocks.slice(0, room - 1);
160
181
  for (const b of shown) {
161
182
  lines.push(`${b.id} ${b.state} ${b.level ?? '-'} · ${b.model ?? '-'} · gate ${b.gate.result}${b.gate.name ? ` (${b.gate.name})` : ''} · findings ${b.findings}`);
@@ -198,9 +219,22 @@ export default async function status(args) {
198
219
  records = await activeRunsFor(projectRootFor(process.cwd()));
199
220
  }
200
221
  const shown = [];
201
- for (const record of records) shown.push(await statusOf(record));
222
+ for (const record of records) {
223
+ let config;
224
+ try {
225
+ config = configInForce(record);
226
+ if (!config) {
227
+ const loaded = await loadProjectConfig(record.workspace);
228
+ config = loaded.ok ? loaded.config : { native: { daemon: false } };
229
+ }
230
+ } catch { config = { native: { daemon: false } }; }
231
+ const answer = await daemonRequest(String(record.run_id), { method: 'status' }, { config });
232
+ let summary;
233
+ try { summary = answer ? daemonStatus(answer.runs[0]) : null; } catch { summary = null; }
234
+ shown.push(summary ?? await statusOf(record));
235
+ }
202
236
  if (json) {
203
- out(`${JSON.stringify({ runs: shown })}\n`); // briefStatus builds exactly the clause-3 run object
237
+ out(`${JSON.stringify({ runs: shown })}\n`);
204
238
  return 0;
205
239
  }
206
240
  if (shown.length === 0) {
@@ -11,6 +11,8 @@ import { mkdirSync, rmSync } from 'node:fs';
11
11
  import os from 'node:os';
12
12
  import path from 'node:path';
13
13
  import { cliNameForProvider } from '../engines/provider-cli.mjs';
14
+ import { readAllRows } from '../ledger/write.mjs';
15
+ import { modelStats, reviewerLines } from '../review/model-stats.mjs';
14
16
  import { newRunId } from '../state/paths.mjs';
15
17
  import { runRoot } from '../util/tmp.mjs';
16
18
  import { checkCommands, checkConfig, checkJev, checkKeys, checkLedger, checkLinks, checkLocks, checkReviewConcurrency, checkReviewState, checkTmp, checkTools } from './local.mjs';
@@ -53,6 +55,15 @@ export async function runDoctor(opts = {}, deps = {}) {
53
55
  const keys = await checkKeys(cfg, { env, store: deps.store, opRead: deps.opRead });
54
56
  /** @type {Row[]} */
55
57
  const rows = [...checked.rows, ...links, ...keys.rows];
58
+ if (cfg?.project?.slug) {
59
+ try {
60
+ const lines = reviewerLines(modelStats(await readAllRows(cfg.project.slug), cfg.review?.adaptive));
61
+ rows.push(...lines.map((line, i) => {
62
+ const at = line.indexOf(': ');
63
+ return row(`reviewer.${i}`, 'INFO', line.slice(0, at), line.slice(at + 2));
64
+ }));
65
+ } catch { /* a corrupt ledger is diagnosed by the existing ledger check */ }
66
+ }
56
67
  if (opts.quick) return rows;
57
68
 
58
69
  if (cfg) {
@@ -69,7 +69,7 @@ function removeRun(runId) {
69
69
  } catch {
70
70
  return;
71
71
  }
72
- for (const name of names) if (name === `${runId}.json` || name === `${runId}.key` || name === `${runId}.lock` || name.startsWith(`${runId}.json.`)) rmSync(path.join(runsDir(), name), { recursive: true, force: true });
72
+ for (const name of names) if (name === runId || name === `${runId}.json` || name === `${runId}.key` || name === `${runId}.lock` || name.startsWith(`${runId}.json.`)) rmSync(path.join(runsDir(), name), { recursive: true, force: true });
73
73
  }
74
74
 
75
75
  /**
@@ -5,6 +5,7 @@ import { createHash } from 'node:crypto';
5
5
  import { DELEGABLE_SEVERITIES, ownsAt, recordedSeverity, waiveFinding } from './gate-check.mjs';
6
6
  import { checkFileForm, checkFindingId } from './verb-forms.mjs';
7
7
  import { StateError } from '../state/paths.mjs';
8
+ import { readRun } from '../state/run.mjs';
8
9
  import { loadKey, verifyRow } from '../state/signer.mjs';
9
10
  import { configInForce } from '../state/config-snapshot.mjs';
10
11
  import { readAllRows } from '../ledger/write.mjs';
@@ -15,6 +16,10 @@ import { verifyResult } from '../worker/queue.mjs';
15
16
  import { resolveLevel } from '../config/known-ids.mjs';
16
17
  import { runRootFor, spawnSession } from '../session/spawn.mjs';
17
18
  import { spawnWithBrokenFallback } from './session-retry.mjs';
19
+ import { splitLines } from './context.mjs';
20
+ import { grantState } from '../autopilot/grant.mjs';
21
+ import { lastGate } from '../state/brief-status.mjs';
22
+ import { fileBaseline, testScope } from '../gates/expand.mjs';
18
23
 
19
24
  const SCHEMA = {
20
25
  type: 'object', additionalProperties: false, required: ['verdict', 'reason'],
@@ -35,6 +40,15 @@ function refuse(code, detail) {
35
40
  throw new StateError(code, `${code}: ${detail}; nothing was recorded`);
36
41
  }
37
42
 
43
+ /** The checkExpiry grant rule, without lazy expiry writes on a refused waiver.
44
+ * @param {Record<string, any>} record
45
+ */
46
+ function activeGrant(record) {
47
+ const grant = record.autopilot;
48
+ return record.status === 'active' && grant?.status === 'active'
49
+ && Number.isFinite(Date.parse(grant.until)) && grantState(grant, new Date()) === 'active';
50
+ }
51
+
38
52
  /** A citation must name a real regular file inside this tree, without following symlinks.
39
53
  * @param {string} root @param {string} file @returns {boolean}
40
54
  */
@@ -47,16 +61,48 @@ function existsInTree(root, file) {
47
61
  }
48
62
  }
49
63
 
64
+ /** @param {string} reason */
65
+ const evidenceTokens = (reason) => reason.split(/[\s"'`()[\]{},;<>]+/u).filter(Boolean);
66
+
67
+ /** Numbered windows for the first ten readable repo citations, never following a link out.
68
+ * Each window includes ten lines either side, clipped to the file and eighty lines total.
69
+ * @param {string} reason @param {string} root
70
+ */
71
+ export function citedEvidence(reason, root) {
72
+ const evidence = [];
73
+ for (const token of evidenceTokens(reason)) {
74
+ const match = /^(.+):([1-9][0-9]*)(?:-([1-9][0-9]*))?[.!?]?$/.exec(token);
75
+ if (!match) continue;
76
+ const first = Number(match[2]);
77
+ const last = Number(match[3] ?? match[2]);
78
+ if (!Number.isSafeInteger(first) || !Number.isSafeInteger(last) || last < first) continue;
79
+ try {
80
+ const file = assertRowPath(match[1]);
81
+ const bytes = readRegularFileNoFollow(root, file);
82
+ if (bytes === null) continue;
83
+ const lines = splitLines(bytes.toString('utf8'));
84
+ if (first > lines.length) continue;
85
+ const start = Math.max(1, first - 10);
86
+ const end = Math.min(lines.length, last + 10, start + 79);
87
+ const content = lines.slice(start - 1, end).map((line, i) => `${start + i}| ${line}`).join('\n');
88
+ evidence.push({ path: file, start, end, content });
89
+ if (evidence.length === 10) break;
90
+ } catch {
91
+ // Invalid, missing, unreadable and escaping citations never become packet content.
92
+ }
93
+ }
94
+ return evidence;
95
+ }
96
+
50
97
  /** @param {string} reason @param {string} root @param {Record<string, any>} entry */
51
98
  export function hasEvidence(reason, root, entry) {
99
+ if (citedEvidence(reason, root).length > 0) return true;
52
100
  for (const match of reason.matchAll(/\bclause\s+([0-9]+)\b/gi)) {
53
101
  const n = Number(match[1]);
54
102
  if (n >= 1 && n <= (Array.isArray(entry.acceptance) ? entry.acceptance.length : 0)) return true;
55
103
  }
56
- const tokens = reason.split(/[\s"'`()[\]{},;<>]+/u).filter(Boolean);
104
+ const tokens = evidenceTokens(reason);
57
105
  for (const token of tokens) {
58
- const line = /^(.+):([1-9][0-9]*)[.!?]?$/.exec(token);
59
- if (line && existsInTree(root, line[1])) return true;
60
106
  const file = token.replace(/[.!?]+$/, '');
61
107
  const testFile = /(^|\/)(?:test|tests|__tests__)\//.test(file) || /(?:^|[./_-])(?:test|spec)\.[^/]+$/.test(file) || entry.test_files?.includes(file);
62
108
  if (testFile && existsInTree(root, file)) return true;
@@ -64,6 +110,37 @@ export function hasEvidence(reason, root, entry) {
64
110
  return false;
65
111
  }
66
112
 
113
+ /** Require a cited acceptance test measured by the latest signed gate at these exact bytes.
114
+ * Gate rows summarize test success, not individual assertions; only full or known related
115
+ * scopes prove that the cited file ran. A changed scope does not record its file list.
116
+ * @param {string} reason @param {string} root @param {Record<string, any>} entry
117
+ * @param {Record<string, any>[]} rows @param {string} id @param {Buffer} key
118
+ */
119
+ async function passingAcceptanceTest(reason, root, entry, rows, id, key) {
120
+ const named = new Set((entry.acceptance ?? []).flatMap((clause) => clause.tests ?? []));
121
+ const cited = evidenceTokens(reason).map((token) => token.replace(/:[1-9][0-9]*(?:-[1-9][0-9]*)?[.!?]?$/, '').replace(/[.!?]+$/, ''))
122
+ .filter((file) => named.has(file) && existsInTree(root, file));
123
+ if (cited.length === 0) refuse('waive-yes-critical-no-test', 'cite an existing test file named in this block\'s acceptance tests');
124
+ const mine = rows.filter((row) => row.block === id);
125
+ const latest = mine.filter((row) => row.event === 'gate.done' || row.event === 'gate.red').at(-1);
126
+ if (!latest || !verifyRow(latest, key).ok || lastGate(mine).result !== 'pass'
127
+ || latest.worktree !== entry.worktree?.path
128
+ || !Array.isArray(latest.results)
129
+ || !latest.results.some((result) => result.gate === 'test' && result.ok === true && result.skipped === false)) {
130
+ refuse('waive-yes-critical-no-test', 'the cited acceptance test needs a passing test result in this block\'s latest signed gate record');
131
+ }
132
+ const current = await fileBaseline(root, entry.owned_files ?? [], entry.owned_kinds);
133
+ if (!current || !latest.files || Object.keys(current.files).length !== Object.keys(latest.files).length
134
+ || Object.entries(current.files).some(([file, hash]) => latest.files[file] !== hash)) {
135
+ refuse('waive-yes-critical-no-test', 'rerun the acceptance test: the latest gate record does not cover the current block content');
136
+ }
137
+ const scope = testScope({ testFiles: entry.test_files, owned: entry.owned_files ?? [] });
138
+ const test = cited.find((file) => latest.files[file] && (latest.scope === 'full'
139
+ || (latest.scope === 'related' && scope.scope === 'related' && scope.files.includes(file))));
140
+ if (!test) refuse('waive-yes-critical-no-test', 'the latest passing gate record must cover the cited acceptance test file and its current content');
141
+ return test;
142
+ }
143
+
67
144
  /** The full finding is normally in the signed worker result; older rows may carry it directly.
68
145
  * @param {Record<string, any>[]} rows @param {{root: string, runId: string, id: string, file: string, finding: string, key: Buffer}} where
69
146
  */
@@ -130,17 +207,26 @@ export async function waiveAsAgent({ runId, id, finding, file, reason, record, w
130
207
  const rows = (await readAllRows(record.project)).filter((r) => r.run === runId);
131
208
  const key = await loadKey(runId);
132
209
  const recorded = recordedSeverity(rows, { block: id, file, finding, key });
133
- if (!DELEGABLE_SEVERITIES.includes(recorded.severity)) refuse('waive-yes-critical', `finding ${finding} is ${recorded.severity ?? recorded.why}; only warning or nit findings may use --yes`);
210
+ const critical = recorded.severity === 'critical';
211
+ if (!DELEGABLE_SEVERITIES.includes(recorded.severity) && !(critical && activeGrant(record))) {
212
+ refuse('waive-yes-critical', `finding ${finding} is ${recorded.severity ?? recorded.why}; a critical finding needs an active autopilot grant; otherwise only warning or nit findings may use --yes`);
213
+ }
134
214
  const tree = await blockTree(record.workspace, entry);
135
215
  const contentRoot = tree?.top ?? root;
136
- if (!hasEvidence(reason, contentRoot, entry)) refuse('waive-yes-no-evidence', 'cite an existing repo path with :line, an existing test file, or a block acceptance clause number');
216
+ const test = critical ? await passingAcceptanceTest(reason, contentRoot, entry, rows, id, key) : null;
217
+ if (critical ? !citedEvidence(reason, contentRoot).some((item) => item.path === file) : !hasEvidence(reason, contentRoot, entry)) {
218
+ refuse('waive-yes-no-evidence', critical ? `cite the finding's path ${file} with :line or :start-end`
219
+ : 'cite an existing repo path with :line or :start-end, an existing test file, or a block acceptance clause number');
220
+ }
137
221
  const bytes = readRegularFileNoFollow(contentRoot, file);
138
222
  if (bytes === null) refuse('waive-yes-disagreed', 'The current file cannot be read');
139
223
  const text = await findingText(rows, { root, runId, id, file, finding, key });
140
- const answer = await judgeAgentReview({ finding: { id: finding, severity: recorded.severity, ...text }, reason, file: { path: file, content: bytes.toString('utf8') } }, { record, runId, id, judge });
224
+ const evidence = citedEvidence(reason, contentRoot);
225
+ const answer = await judgeAgentReview({ finding: { id: finding, severity: recorded.severity, ...text }, reason, file: { path: file, content: bytes.toString('utf8') }, ...(evidence.length > 0 ? { evidence } : {}) }, { record, runId, id, judge });
141
226
  if (answer.verdict === 'broken') refuse('waive-yes-broken', answer.reason);
142
227
  if (answer.verdict !== 'agree') refuse('waive-yes-disagreed', answer.reason);
143
- return waiveFinding({ runId, id, finding, file, reason, writeRow, by: 'agent', extra: { severity: recorded.severity, verdict: 'agree', judge_reason: answer.reason } });
228
+ if (critical && !activeGrant(await readRun(runId))) refuse('waive-yes-critical', 'the critical finding still needs an active autopilot grant after the judge returns');
229
+ return waiveFinding({ runId, id, finding, file, reason, writeRow, by: 'agent', extra: { severity: recorded.severity, verdict: 'agree', judge_reason: answer.reason, ...(critical ? { autopilot: true, test } : {}) } });
144
230
  }
145
231
 
146
232
  /** Verified ledger-order cancellation, scoped to one run, block, file and finding.
@@ -159,7 +245,8 @@ export function isUnwaived(rows, waiver, key, verify = (r) => verifyRow(r, key).
159
245
  */
160
246
  export function countedAgentWaivers(rows, block, key, verify = (r) => verifyRow(r, key).ok) {
161
247
  return rows.filter((r) => r.block === block && r.event === 'review.waived' && r.by === 'agent'
162
- && r.verdict === 'agree' && DELEGABLE_SEVERITIES.includes(r.severity) && verify(r)
248
+ && r.verdict === 'agree' && (DELEGABLE_SEVERITIES.includes(r.severity)
249
+ || (r.severity === 'critical' && r.autopilot === true && typeof r.test === 'string' && r.test.length > 0)) && verify(r)
163
250
  && !isUnwaived(rows, r, key, verify)
164
251
  && recordedSeverity(rows.filter((other) => other.run === r.run), { block, file: r.file, finding: r.finding, key, verify }).severity === r.severity);
165
252
  }
@@ -17,7 +17,7 @@
17
17
  * the latest refusal row for the hash is signed, carries `content_hash` (a hash-less one does
18
18
  * not qualify, though it still refuses) and names at least one finding, and every finding it
19
19
  * names and every open id of the file's unsuperseded `review.cap` rows is cleared by a waiver the gate
20
- * accepts (the same rule as below: a critical only by `by: human`). Such a file is never
20
+ * accepts (a critical needs a human or an agreed agent waiver issued in autopilot). Such a file is never
21
21
  * approved; the close writes one signed `gate.review_waived_all` row for it ({@link reviewGateCheck});
22
22
  * - `unsigned`: the file's approvals for that hash exist but none carries a valid MAC;
23
23
  * - `not_approved`: (B52) the file's signed approval for its current hash is STALE — a later row of
@@ -26,8 +26,8 @@
26
26
  * reviewed the content, {@link neverReviewed}; a `review.round` with `open_after` not 0; a
27
27
  * `review.cap`; a refusal row with no `content_hash` counts for every hash of its file). One
28
28
  * refusal per open finding the latest such row names, each cleared only by a valid waiver for
29
- * the severity that row RECORDS (anything but warning/nit — a critical — only by a signed
30
- * `by: human` waiver); a row that names no finding is one `not_approved <file>` that only a
29
+ * the severity that row RECORDS (a critical needs a signed human waiver or an agreed agent
30
+ * waiver issued in autopilot); a row that names no finding is one `not_approved <file>` that only a
31
31
  * fresh approval clears. An old ledger's `review.result` row has no `approved` field and is
32
32
  * trusted neither way: the ticket's signed result file decides ({@link resultFilesFor}); a
33
33
  * file that is missing or fails its MAC refuses `not_approved <file>`. Refusal rows count
@@ -43,7 +43,7 @@
43
43
  * - `review_cap`: a file stopped at `review_cap` has an open finding with no signed
44
44
  * `review.waived {by: human}` row — unless a signed approval of the file's current hash comes
45
45
  * AFTER the cap row (ledger order) and no later refusal row made it stale (the human fixed it
46
- * by hand and it was reviewed), or a verified human or agreed agent reopen after the cap is followed by a
46
+ * by hand and it was reviewed), or a verified human, agreed agent or automatic reopen after the cap is followed by a
47
47
  * signed review round of the current bytes; a finding already refused as `not_approved` for that file is
48
48
  * not repeated. B73: this detail, and an `unreviewed` detail for a file with a cap row, ends
49
49
  * with the stop's ways out (`exits: block reopen, block waive, review-file --answers,
@@ -85,6 +85,7 @@ import { lstatSync } from 'node:fs';
85
85
  import path from 'node:path';
86
86
  import { acceptedTranscriptHits } from './accept-hit.mjs';
87
87
  import { countedAgentWaivers } from './agent-waiver.mjs';
88
+ import { isAutomaticReopen } from './reopen.mjs';
88
89
  import { exec } from '../util/exec.mjs';
89
90
  import { readRun, writeSigned } from '../state/run.mjs';
90
91
  import { StateError } from '../state/paths.mjs';
@@ -287,7 +288,7 @@ function reviewRules({ block, runId, rows, key, resultFiles = {}, current, verif
287
288
  // Acceptance is a review input. Every older review verdict belongs to the previous input.
288
289
  const mine = rows.filter((r, i) => r?.block === block && (runId === undefined || r.run === undefined || r.run === runId)
289
290
  && !(i < amendedAt && typeof r.event === 'string' && r.event.startsWith('review.')));
290
- /** A verified human or agreed agent reopen followed by a real review clears older cap ids.
291
+ /** A verified human, agreed agent or automatic reopen followed by a real review clears older cap ids.
291
292
  * @param {Record<string, any>} cap @returns {boolean}
292
293
  */
293
294
  const supersededCap = (cap) => {
@@ -295,7 +296,7 @@ function reviewRules({ block, runId, rows, key, resultFiles = {}, current, verif
295
296
  if (hash === undefined) return false;
296
297
  const at = mine.indexOf(cap);
297
298
  return mine.some((r, i) => i > at && r.event === 'review.reopened' && r.run === runId && r.file === cap.file
298
- && (r.by === 'human' || (r.by === 'agent' && r.verdict?.verdict === 'agree')) && ok(r)
299
+ && (r.by === 'human' || (r.by === 'agent' && r.verdict?.verdict === 'agree') || isAutomaticReopen(r)) && ok(r)
299
300
  && mine.some((round, j) => j > i && round.event === 'review.round' && round.run === runId && round.file === cap.file && round.content_hash === hash && ok(round)));
300
301
  };
301
302
  /**
@@ -368,7 +369,8 @@ function reviewRules({ block, runId, rows, key, resultFiles = {}, current, verif
368
369
  * row RECORDS for it — REQUIRED: only an explicit null (none recorded there; the signed review
369
370
  * rows decide, {@link autopilotWaiverProblem}) or warning/nit may be delegated; anything else
370
371
  * (`undefined`, a critical, an unknown value) is the human's alone ({@link delegableSeverity}),
371
- * except that an answer also clears a critical. `invalid`: why the autopilot waivers naming it do
372
+ * except that an answer or a counted agent waiver issued in autopilot also clears a critical.
373
+ * `invalid`: why the autopilot waivers naming it do
372
374
  * not count (null when none names it).
373
375
  * `exceptTicket`: see {@link answered}. `answerSeverity`: the severity the answer rule reads
374
376
  * (default `severity`); the late-finding, cap and proof loops pass the one RECORDED for the id,
@@ -379,9 +381,11 @@ function reviewRules({ block, runId, rows, key, resultFiles = {}, current, verif
379
381
  const waiverOf = (file, finding, severity, exceptTicket = null, answerSeverity = severity) => {
380
382
  if (waivers.some((r) => matches(r, file, finding))) return { by: 'human', invalid: null };
381
383
  if (answered(file, finding, answerSeverity, exceptTicket)) return { by: 'answer', invalid: null };
382
- if (finding !== PROOF_FINDING && delegableSeverity(severity)) {
384
+ if (finding !== PROOF_FINDING && (delegableSeverity(severity) || severity === 'critical')) {
383
385
  const recorded = recordedSeverity(mine, { block, file, finding, key, verify: ok });
384
- if (DELEGABLE_SEVERITIES.includes(recorded.severity) && agentWaivers.some((r) => matches(r, file, finding) && r.severity === recorded.severity)) return { by: 'agent', invalid: null };
386
+ const agentMayWaive = (delegableSeverity(severity) && DELEGABLE_SEVERITIES.includes(recorded.severity))
387
+ || (severity === 'critical' && recorded.severity === 'critical');
388
+ if (agentMayWaive && agentWaivers.some((r) => matches(r, file, finding) && r.severity === recorded.severity)) return { by: 'agent', invalid: null };
385
389
  }
386
390
  const named = autopilotWaivers.filter((r) => matches(r, file, finding));
387
391
  // the unverified pre-check (`verify` given) reads no autopilot waiver: it cannot check a grant