@codedology/code-forge 0.13.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/cli/block.mjs CHANGED
@@ -10,8 +10,8 @@
10
10
  * block claim <id> <path> --run <r>
11
11
  * block close <id> --run <r> [--worker-pid <pid>] [--remove-worktree]
12
12
  * block stop <id> --run <r> --reason <text>
13
- * block waive <id> <finding-id> --run <r> --file <path> --reason <text> (human-only, §4.9; asks at a terminal, B80)
14
- * block reopen <id> --run <r> --file <path> --reason <text> (human-only, B72; asks at a terminal, B80)
13
+ * block waive <id> <finding-id> --run <r> --file <path> --reason <text> [--yes] (orchestrator --yes: evidence and a fresh judge)
14
+ * block reopen <id> --run <r> --file <path> --reason <text> [--yes] (B124c: evidence and a fresh judge for the agent path)
15
15
  *
16
16
  * B64a `--json` (every subcommand): on success stdout is exactly ONE JSON line instead of the
17
17
  * text — `{"block", "event", …}` with the facts the text prints (open: level, asked, source,
@@ -172,18 +172,17 @@
172
172
  * <from>[ <stop reason>]; by: human)`; `--json`: `{"block", "event": "reopened", "file", "from",
173
173
  * "stop_reason", "at_seq", "from_hash", "to_hash", "ticket_cleared", "by"}`.
174
174
  *
175
- * B80 (D1 = a): `block waive` and `block reopen` need the human at a terminal, through the same
175
+ * B80: without `--yes`, `block waive` and `block reopen` need the human at a terminal, through the same
176
176
  * confirm as `--no-require-reviews` (`confirmAtTerminal`). Right after the usage checks and the run
177
177
  * record read — before the autopilot expiry checks or anything else is written — stdin or stdout
178
- * not a TTY prints `block <verb> needs the human at a terminal to confirm; nothing was recorded
179
- * (there is no --yes)` (exit 2). At a terminal, what the verb would refuse is refused before the
178
+ * not a TTY prints `humanVerbNoTTY` (exit 2). At a terminal, what the verb would refuse is refused before the
180
179
  * question: a `--file` or finding id with a control character or of the wrong form (`usage`, exit
181
180
  * 2, `checkFileForm` / `checkFindingId` — the verbs' own checks), an unknown block, for reopen a
182
181
  * block that is not open or a file it does not own (`reopen-not-owned`), for waive a file the block
183
182
  * does not own (`waive-not-owned`) or a finding id no review row of the block names for the file
184
183
  * (`waive-unknown-finding`; `proof` is exempt) — exit 1. A "no" or a cancel prints `block <id>
185
184
  * <verb>: cancelled — nothing was recorded` (exit 1); a "yes" runs the verb exactly as before (the
186
- * same signed row). There is no `--yes` (an unknown flag, exit 2). An active autopilot grant
185
+ * same signed row). Only `block waive` accepts `--yes` (B124a, evidence and a fresh L3 judge). An active autopilot grant
187
186
  * changes nothing here — autopilot waives through its own path (`autopilot waive`, `by:
188
187
  * autopilot`). The prompt checks only that stdin and stdout are terminals; a pseudo-terminal
189
188
  * passes it, so it does not prove a human. The coder deny-list entries stay.
@@ -209,6 +208,7 @@ import { StateError } from '../state/paths.mjs';
209
208
  import { readRun, writeSigned } from '../state/run.mjs';
210
209
  import { loadKey, verifyRow } from '../state/signer.mjs';
211
210
  import { PROOF_FINDING, blockFileSet, changedFiles, openFindings, ownsAt, resultFilesFor, reviewedFiles, reviewGateCheck, unownedChanges, waiveFinding } from '../review/gate-check.mjs';
211
+ import { agentWaivers, agentWaiversAcked, prepareAckWaivers, prepareUnwaive, waiveAsAgent } from '../review/agent-waiver.mjs';
212
212
  import { checkExpiry } from '../autopilot/grant.mjs';
213
213
  import { redact, writeSafe } from '../util/redact.mjs';
214
214
  import { blockTree, prepareWorktree, removeWorktree, worktreeSettings } from '../proof/worktree.mjs';
@@ -216,10 +216,10 @@ import { DEFAULT_CONFIG_FILENAME, loadProjectConfig } from '../config/load.mjs';
216
216
  import { validateConfig } from '../config/validate.mjs';
217
217
  import { migrateConfig } from '../config/migrate.mjs';
218
218
  import { exec } from '../util/exec.mjs';
219
- import { contentHash, gitChildEnv } from '../worker/ticket.mjs';
219
+ import { contentHash, gitChildEnv, readRegularFileNoFollow } from '../worker/ticket.mjs';
220
220
  import { clearDone, resolveTicket } from '../worker/queue.mjs';
221
221
  import { loadState } from '../worker/review-state.mjs';
222
- import { openBlockEntry, reopenFile } from '../review/reopen.mjs';
222
+ import { agentReopens, openBlockEntry, reopenFile } from '../review/reopen.mjs';
223
223
  import { checkFileForm, checkFindingId } from '../review/verb-forms.mjs';
224
224
  import { runRootPath } from '../util/tmp.mjs';
225
225
  import { missingReviewTickets, parseSentinel } from '../engines/sentinel.mjs';
@@ -227,15 +227,20 @@ import { reportMismatches, reviewedLines } from '../review/reviewed-lines.mjs';
227
227
  import { blockEndHook, runBlockEndHook, writeDoneFile } from '../state/block-end.mjs';
228
228
  import { blockOpenFindings } from '../review/open-findings.mjs';
229
229
  import { hookForbidden, scanTranscript } from '../util/forbidden.mjs';
230
+ import { prepareAcceptHit, writeAcceptedHit } from '../review/accept-hit.mjs';
230
231
 
231
232
  /**
232
233
  * B72: `block reopen` — the file's content is read where the block works (its worktree, else the
233
234
  * main tree), its state is read (never moved) from the code-forge home or the run's old temp root,
234
235
  * the checks, the done marker's removal and then the signed row are `reopenFile`'s.
235
- * @param {{runId: string, id: string, file: string, reason: string, record: Record<string, any>, writeRow: (row: Record<string, any>) => Promise<unknown>}} opts
236
+ * @param {{runId: string, id: string, file: string, reason: string, record: Record<string, any>, writeRow: (row: Record<string, any>) => Promise<unknown>, agent?: boolean, judge?: import('../review/agent-waiver.mjs').WaiverJudge}} opts
236
237
  * @returns {Promise<{row: Record<string, any>, cleared: boolean}>}
237
238
  */
238
- async function reopenBlockFile({ runId, id, file, reason, record, writeRow }) {
239
+ async function reopenBlockFile({ runId, id, file, reason, record, writeRow, agent = false, judge }) {
240
+ if (agent) {
241
+ if (!reason.trim()) throw new StateError('usage', 'block reopen needs --reason');
242
+ checkFileForm(file, { normalized: true });
243
+ }
239
244
  // the stable block codes come first, before any tree, config, key or ledger read
240
245
  const entry = openBlockEntry(record, runId, id);
241
246
  const real = async (/** @type {string} */ p) => realpath(p).catch(() => path.resolve(p));
@@ -271,10 +276,12 @@ async function reopenBlockFile({ runId, id, file, reason, record, writeRow }) {
271
276
  key,
272
277
  writeRow,
273
278
  writeSigned,
279
+ ...(agent ? { agent: { root: contentRoot, judge } } : {}),
274
280
  io: {
275
281
  owns: ownsAt(wsPrefix),
276
282
  loadState: (where) => loadState({ .../** @type {any} */ (where), runRootDir, contentRoot, migrate: false }),
277
283
  contentHash: (f) => contentHash(contentRoot, f),
284
+ readFile: (f) => readRegularFileNoFollow(contentRoot, f),
278
285
  // C11 (synchronous): the cached result of the current content's ticket would repeat the stop
279
286
  clearDone: (f) => clearDone({ repoRoot: mainTop, run: runId, block: id, file: f, ...tree }).removed,
280
287
  },
@@ -285,11 +292,14 @@ async function reopenBlockFile({ runId, id, file, reason, record, writeRow }) {
285
292
  const USAGE =
286
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' +
287
294
  ' code-forge block attempt|rebase <id> --run <r> · block close <id> --run <r> [--transcript <file>] [--report <file>] [--no-require-reviews] [--remove-worktree] · block claim <id> <path> --run <r> · block stop <id> --run <r> --reason <text>\n' +
288
- ' code-forge block waive <id> <finding-id> --run <r> --file <path> --reason <text>\n' +
289
- ' code-forge block reopen <id> --run <r> --file <path> --reason <text>\n' +
295
+ ' code-forge block waive <id> <finding-id> --run <r> --file <path> --reason <text> [--yes]\n' +
296
+ ' code-forge block reopen <id> --run <r> --file <path> --reason <text> [--yes]\n' +
297
+ ' code-forge block accept-hit <id> --run <r> --rule <rule-id> --line <n> --transcript <path> --reason <text>\n' +
298
+ ' code-forge block unwaive <id> <finding-id> --run <r> --file <path> --reason <text>\n' +
299
+ ' code-forge block ack-waivers <id> --run <r>\n' +
290
300
  ' every block verb takes --json: one JSON line on stdout ({"block","event",…}) instead of the text\n';
291
301
 
292
- const SUBCOMMANDS = ['open', 'attempt', 'rebase', 'claim', 'close', 'stop', 'waive', 'reopen'];
302
+ const SUBCOMMANDS = ['open', 'attempt', 'rebase', 'claim', 'close', 'stop', 'waive', 'reopen', 'accept-hit', 'unwaive', 'ack-waivers'];
293
303
 
294
304
  /** Refuse likely shell quoting slips before any row is written.
295
305
  * @param {string} workspace @param {string[]} entries @param {string} label
@@ -308,7 +318,7 @@ function checkWhitespacePaths(workspace, entries, label) {
308
318
  }
309
319
  }
310
320
  }
311
- const FLAG_VALUES = ['run', 'level', 'acceptance', 'brief', 'attempt', 'base', 'lines', 'head', 'worker-pid', 'reason', 'file', 'transcript', 'kind', 'report'];
321
+ const FLAG_VALUES = ['run', 'level', 'acceptance', 'brief', 'attempt', 'base', 'lines', 'head', 'worker-pid', 'reason', 'file', 'transcript', 'kind', 'report', 'rule', 'line'];
312
322
 
313
323
  /**
314
324
  * @param {string[]} args
@@ -318,6 +328,8 @@ const FLAG_VALUES = ['run', 'level', 'acceptance', 'brief', 'attempt', 'base', '
318
328
  * ui?: {confirm: (o: {message: string, initialValue?: boolean}) => Promise<unknown>, isCancel: (v: unknown) => boolean},
319
329
  * removeBlockTree?: typeof removeBlockTree,
320
330
  * resolveTicket?: typeof resolveTicket, reviewedLines?: typeof reviewedLines,
331
+ * waiverJudge?: import('../review/agent-waiver.mjs').WaiverJudge,
332
+ * extraTokens?: string[],
321
333
  * }} [deps] - `now` (B48): the clock of the autopilot expiry checks. `stdinIsTTY` / `stdoutIsTTY`
322
334
  * (default `process.stdin.isTTY` / `process.stdout.isTTY`; `isTTY` sets both) and `ui` (default
323
335
  * `@clack/prompts`): the human's confirmation (B52 `--no-require-reviews`, B80 `block waive` and
@@ -328,7 +340,7 @@ const FLAG_VALUES = ['run', 'level', 'acceptance', 'brief', 'attempt', 'base', '
328
340
  * @returns {Promise<number>}
329
341
  */
330
342
  export async function runBlock(args, deps = {}) {
331
- const { stdout = process.stdout, stderr = process.stderr, probe } = deps;
343
+ const { stdout = process.stdout, stderr = process.stderr, probe, extraTokens = [] } = deps;
332
344
  const out = (/** @type {string} */ s) => writeSafe(stdout, s);
333
345
  const err = (/** @type {string} */ s) => writeSafe(stderr, s);
334
346
  const [sub, ...rest] = args;
@@ -337,7 +349,7 @@ export async function runBlock(args, deps = {}) {
337
349
  return 2;
338
350
  }
339
351
  try {
340
- const { flags, positionals } = parseFlags(rest, { values: FLAG_VALUES, multi: ['owned', 'tests'], booleans: ['no-require-reviews', 'remove-worktree', 'json'] });
352
+ const { flags, positionals } = parseFlags(rest, { values: FLAG_VALUES, multi: ['owned', 'tests'], booleans: ['no-require-reviews', 'remove-worktree', 'json', ...(['waive', 'reopen'].includes(sub) ? ['yes'] : [])] });
341
353
  // B64a: --json prints ONE JSON line on success instead of the text lines (`emit`, one object
342
354
  // per verb, built where the text is); without it every output is exactly as before (`say` is `out`)
343
355
  const json = flags.json === true;
@@ -364,14 +376,39 @@ export async function runBlock(args, deps = {}) {
364
376
  if (sub === 'reopen' && positionals.length !== 1) throw new StateError('usage', 'block reopen takes one block id (the file goes in --file)');
365
377
  if (sub === 'reopen' && typeof flags.file !== 'string') throw new StateError('usage', 'block reopen needs --file <path>');
366
378
  if (sub === 'waive' && typeof flags.file !== 'string') throw new StateError('usage', 'block waive needs --file <path> (a finding id is scoped to one file)');
379
+ if (sub === 'accept-hit') {
380
+ const { record, row } = await prepareAcceptHit({ runId, id, flags, positionals, extraTokens });
381
+ const asked = await confirmHumanVerb(sub, runId, id, { rule: row.rule, line: row.line, file: /** @type {string} */ (flags.transcript) }, record, deps);
382
+ if (asked === 'no-tty') { err(`${humanVerbNoTTY(sub)}\n`); return 2; }
383
+ if (asked === 'cancelled') { err(`block ${id} ${sub}: cancelled — nothing was recorded\n`); return 1; }
384
+ await writeAcceptedHit({ runId, row, writeRow: (signed) => appendRow(signed, { slug: record.project }) });
385
+ say(`block ${id} accepted ${row.rule} transcript line ${row.line} (by: human)\n`);
386
+ if (json) emit({ block: id, event: 'rule_break.accepted', rule: row.rule, line: row.line, reason: row.reason, by: 'human' });
387
+ return 0;
388
+ }
367
389
  const record = await readRun(runId);
390
+ if (sub === 'unwaive' || sub === 'ack-waivers') {
391
+ if (positionals.length !== (sub === 'unwaive' ? 2 : 1)) throw new StateError('usage', `block ${sub} takes ${sub === 'unwaive' ? 'one block id and one finding id' : 'one block id'}`);
392
+ const prepared = sub === 'unwaive'
393
+ ? { counted: [], row: await prepareUnwaive({ runId, id, finding: extra, file: /** @type {string} */ (flags.file), reason: /** @type {string} */ (flags.reason), record }) }
394
+ : await prepareAckWaivers(record, runId, id);
395
+ const asked = await confirmHumanVerb(sub, runId, id, { finding: extra, file: /** @type {string} */ (flags.file) }, record, deps, () => {
396
+ for (const w of prepared.counted) (json ? err : say)(`block ${id}: agent waiver ${w.file} ${w.finding} reason: ${w.reason}; verdict: ${w.verdict}\n`);
397
+ });
398
+ if (asked === 'no-tty') { err(`${humanVerbNoTTY(sub)}\n`); return 2; }
399
+ if (asked === 'cancelled') { err(`block ${id} ${sub}: cancelled — nothing was recorded\n`); return 1; }
400
+ await writeSigned(runId, (row) => appendRow(row, { slug: record.project }), prepared.row);
401
+ say(sub === 'unwaive' ? `block ${id} unwaived ${extra} in ${flags.file} (by: human)\n` : `block ${id} acknowledged ${prepared.counted.length} agent waiver(s) (by: human)\n`);
402
+ if (json) emit(prepared.row);
403
+ return 0;
404
+ }
368
405
  if (sub === 'open') {
369
406
  checkWhitespacePaths(record.workspace, /** @type {string[]} */ (flags.owned), 'owned files');
370
407
  if (Array.isArray(flags.tests)) checkWhitespacePaths(record.workspace, flags.tests, 'test files');
371
408
  }
372
409
  // B80: waive and reopen are the human's — asked at a terminal before anything is written (not
373
410
  // even the autopilot expiry rows below); no terminal is exit 2, a "no" is exit 1
374
- if (sub === 'waive' || sub === 'reopen') {
411
+ if ((sub === 'waive' || sub === 'reopen') && flags.yes !== true) {
375
412
  const asked = await confirmHumanVerb(sub, runId, id, { finding: extra, file: /** @type {string} */ (flags.file) }, record, deps);
376
413
  if (asked === 'no-tty') {
377
414
  err(`${humanVerbNoTTY(sub)}\n`);
@@ -383,6 +420,22 @@ export async function runBlock(args, deps = {}) {
383
420
  }
384
421
  }
385
422
  const writeRow =(/** @type {Record<string, any>} */ row) => appendRow(row, { slug: record.project });
423
+ const reopened = async (/** @type {boolean} */ agent) => {
424
+ const file = /** @type {string} */ (flags.file);
425
+ const { row, cleared } = await reopenBlockFile({ runId, id, file, reason: /** @type {string} */ (flags.reason), record, writeRow, agent, judge: deps.waiverJudge });
426
+ const fromText = row.stop_reason == null ? row.from : `${row.from} ${row.stop_reason}`;
427
+ say(`block ${id} reopened ${file} (from ${fromText}; by: ${row.by})\n`);
428
+ if (json) emit({ block: id, event: 'reopened', file, from: row.from, stop_reason: row.stop_reason, at_seq: row.at_seq, from_hash: row.from_hash, to_hash: row.to_hash, ticket_cleared: cleared, by: row.by });
429
+ return 0;
430
+ };
431
+ // Agent refusals precede even the lazy expiry checks: no rows on refusal.
432
+ if (sub === 'reopen' && flags.yes === true) return await reopened(true);
433
+ if (sub === 'waive' && flags.yes === true) {
434
+ await waiveAsAgent({ runId, id, finding: extra, file: /** @type {string} */ (flags.file), reason: /** @type {string} */ (flags.reason), record, writeRow, judge: deps.waiverJudge });
435
+ say(`block ${id} waived ${extra} in ${flags.file} (by: agent)\n`);
436
+ if (json) emit({ block: id, event: 'waived', finding: extra, file: flags.file, by: 'agent' });
437
+ return 0;
438
+ }
386
439
  // B64b: the block-end signal — after an end's rows (and outside the run lock) the done file,
387
440
  // then the owner's `hooks.on_block_end` from the run-start snapshot. Without the hook key the
388
441
  // done file is the only addition: no row, and every output stays exactly as before.
@@ -534,12 +587,7 @@ export async function runBlock(args, deps = {}) {
534
587
  return 0;
535
588
  }
536
589
  if (sub === 'reopen') {
537
- const file = /** @type {string} */ (flags.file);
538
- const { row, cleared } = await reopenBlockFile({ runId, id, file, reason: /** @type {string} */ (flags.reason), record, writeRow });
539
- const fromText = row.stop_reason == null ? row.from : `${row.from} ${row.stop_reason}`;
540
- say(`block ${id} reopened ${file} (from ${fromText}; by: human)\n`);
541
- if (json) emit({ block: id, event: 'reopened', file, from: row.from, stop_reason: row.stop_reason, at_seq: row.at_seq, from_hash: row.from_hash, to_hash: row.to_hash, ticket_cleared: cleared, by: 'human' });
542
- return 0;
590
+ return await reopened(false);
543
591
  }
544
592
  if (sub === 'close') {
545
593
  const entry = record.blocks?.[id];
@@ -610,6 +658,8 @@ export async function runBlock(args, deps = {}) {
610
658
  const closeRows = external === null ? [] : [{ event: 'gate.reviews_waived', block: id, by: 'config', reviewer: external }];
611
659
  /** @type {import('../review/gate-check.mjs').WaivedAll[]} */
612
660
  let waivedAll = [];
661
+ /** @type {import('../review/accept-hit.mjs').AcceptedHit[]} */
662
+ let acceptedHits = [];
613
663
  // B70: a file whose review left only waived findings passes as `reviewed_by_waiver`; its signed
614
664
  // `gate.review_waived_all` row joins the close rows (written just before `block.close`, only
615
665
  // when the block closes — a refused close writes none)
@@ -660,6 +710,7 @@ export async function runBlock(args, deps = {}) {
660
710
  rows,
661
711
  key,
662
712
  transcript,
713
+ extraTokens,
663
714
  // B52: a change no block of the run owns is refused — `--no-require-reviews` does not lift it
664
715
  unowned: scope?.unowned ?? [],
665
716
  // B52: an old ledger's review.result rows are judged by their signed result files
@@ -672,7 +723,7 @@ export async function runBlock(args, deps = {}) {
672
723
  proofFiles: fileSet,
673
724
  highPaths,
674
725
  };
675
- }, { onWaivedAll });
726
+ }, { onWaivedAll, onAcceptedHits: (hits) => { acceptedHits = hits; } });
676
727
  // B96: `reviewsPassed` comes from the gate's own result, not from the onWaivedAll call. Each
677
728
  // extra check runs at most once per close (`closeBlock`'s one loop, src/state/block.mjs:562,
678
729
  // and `closeBlock` is called once below), so the gate's one pass is its final one.
@@ -748,6 +799,8 @@ export async function runBlock(args, deps = {}) {
748
799
  };
749
800
  /** @type {Array<() => Promise<{ok: boolean, reason?: string}>>} */
750
801
  const extraChecks = [reviews, report];
802
+ extraChecks.unshift(async () => agentWaiversAcked(record, rows, id, await loadKey(runId))
803
+ ? { ok: true } : { ok: false, reason: 'agent-waivers-not-acked' });
751
804
  // B63b: `context.require_coder_tests` in the run's config snapshot (never the live config)
752
805
  // needs the coder's green test run — waived reviews do not lift it. It runs FIRST: it only
753
806
  // reads the report, so a refusal here leaves no row (not even the human waiver row the review
@@ -772,6 +825,8 @@ export async function runBlock(args, deps = {}) {
772
825
  });
773
826
  }
774
827
  const result = await closeBlock({ runId, id, rows, writeRow, livePid: intFlag(flags['worker-pid'], 'worker-pid'), probe, extraChecks, closeRows });
828
+ const agentRows = agentWaivers(rows, id, await loadKey(runId));
829
+ const reopenedRows = agentReopens(rows.filter((r) => r.run === runId), id, await loadKey(runId));
775
830
  // B64b: the block ended (closed, or stopped by a worker pin event or ledger.tamper) — its rows
776
831
  // are written and the run lock released; the done file is written before the tree is removed
777
832
  // B96: the tool's reviewed lines of a block that closed (also kept in its done file)
@@ -795,6 +850,9 @@ export async function runBlock(args, deps = {}) {
795
850
  if (f.answered?.length) parts.push(`answered ${f.answered.join(', ')}`);
796
851
  say(`block ${id}: ${f.file} reviewed_by_waiver (${parts.join('; ')})\n`);
797
852
  }
853
+ for (const w of agentRows) say(`block ${id}: agent waiver ${w.file} ${w.finding} reason: ${w.reason}; verdict: ${w.verdict}\n`);
854
+ for (const r of reopenedRows) say(`agent reopen ${r.file}: ${r.reason} (verdict: ${r.verdict?.verdict})\n`);
855
+ for (const hit of acceptedHits) say(`accepted rule_break ${hit.rule} transcript line ${hit.line}: ${hit.reason} (by: human)\n`);
798
856
  }
799
857
  (result.ok ? say : err)(`block ${id} ${result.status}${result.event ? ` (${result.event})` : ''}${result.reason ? `: ${result.reason}` : ''}\n`);
800
858
  // B64a: a refused close also names itself on stdout under --json (its stderr is unchanged)
@@ -810,6 +868,9 @@ export async function runBlock(args, deps = {}) {
810
868
  ...(result.ok && waivedAll.length > 0 ? { reviewed_by_waiver: waivedAll.map((f) => ({ file: f.file, content_hash: f.content_hash, status: f.status, findings: f.findings, by: f.by, ...(f.answered?.length ? { answered: f.answered } : {}) })) } : {}),
811
869
  // B96: only when the tool gave reviewed lines (a waived or external close is unchanged)
812
870
  ...(reviewedJson.length > 0 ? { reviewed: reviewedJson } : {}),
871
+ ...(agentRows.length > 0 ? { agent_waivers: agentRows } : {}),
872
+ ...(reopenedRows.length > 0 ? { agent_reopens: reopenedRows } : {}),
873
+ ...(acceptedHits.length > 0 ? { accepted_hits: acceptedHits } : {}),
813
874
  };
814
875
  if (!result.ok || !removeTree) {
815
876
  if (json && result.ok) emit(closedJson);
@@ -898,9 +959,12 @@ export const NO_REVIEWS_NO_TTY = '--no-require-reviews needs the human at a term
898
959
 
899
960
  /**
900
961
  * B80: the refusal when `block waive` or `block reopen` has no terminal to ask the human at.
901
- * @param {'waive' | 'reopen'} verb @returns {string}
962
+ * @param {'waive' | 'reopen' | 'accept-hit' | 'unwaive' | 'ack-waivers'} verb @returns {string}
902
963
  */
903
- export const humanVerbNoTTY = (verb) => `block ${verb} needs the human at a terminal to confirm; nothing was recorded (there is no --yes)`;
964
+ export const humanVerbNoTTY = (verb) => verb === 'waive'
965
+ ? 'block waive needs the human at a terminal to confirm; nothing was recorded (--yes works only for a recorded warning or nit finding, with evidence)'
966
+ : verb === 'reopen' ? 'block reopen needs the human at a terminal to confirm; nothing was recorded (--yes works only with evidence and agreement from a fresh judge)'
967
+ : `block ${verb} needs the human at a terminal to confirm; nothing was recorded (there is no --yes)`;
904
968
 
905
969
  /**
906
970
  * @typedef {{isTTY?: boolean, stdinIsTTY?: boolean, stdoutIsTTY?: boolean, ui?: {confirm: (o: {message: string, initialValue?: boolean}) => Promise<unknown>, isCancel: (v: unknown) => boolean}, now?: () => Date}} ConfirmDeps
@@ -955,18 +1019,26 @@ async function confirmNoReviews(runId, id, deps) {
955
1019
  * autopilot grant changes nothing (autopilot waives through its own path, `by: autopilot`). The
956
1020
  * pre-checks (`reopenPrecheck`, `waivePrecheck`) run first, at a terminal, so the human is never
957
1021
  * asked about a waive or reopen the verb would refuse that way.
958
- * @param {'waive' | 'reopen'} verb @param {string} runId @param {string} id
959
- * @param {{finding?: string, file: string}} what @param {Record<string, any>} record @param {ConfirmDeps} deps
1022
+ * @param {'waive' | 'reopen' | 'accept-hit' | 'unwaive' | 'ack-waivers'} verb @param {string} runId @param {string} id
1023
+ * @param {{finding?: string, file: string, rule?: string, line?: number}} what @param {Record<string, any>} record @param {ConfirmDeps} deps
1024
+ * @param {() => void} [before]
960
1025
  * @returns {Promise<'no-tty' | 'confirmed' | 'cancelled'>}
961
1026
  */
962
- function confirmHumanVerb(verb, runId, id, what, record, deps) {
1027
+ function confirmHumanVerb(verb, runId, id, what, record, deps, before) {
963
1028
  const message =
964
1029
  verb === 'waive'
965
1030
  ? `Waive finding ${what.finding} in ${what.file} of block ${id} (run ${runId})? Only the owner may say yes.`
966
- : `Reopen the review of ${what.file} in block ${id} (run ${runId})? Only the owner may say yes.`;
1031
+ : verb === 'unwaive'
1032
+ ? `Undo agent waivers for finding ${what.finding} in ${what.file} of block ${id} (run ${runId})? Only the owner may say yes.`
1033
+ : verb === 'ack-waivers'
1034
+ ? `Acknowledge the listed agent waivers of block ${id} (run ${runId})? Only the owner may say yes.`
1035
+ : verb === 'accept-hit'
1036
+ ? `Accept false transcript hit ${what.rule} at line ${what.line} in ${what.file} of block ${id} (run ${runId})? Only the owner may say yes.`
1037
+ : `Reopen the review of ${what.file} in block ${id} (run ${runId})? Only the owner may say yes.`;
967
1038
  return confirmAtTerminal(deps, message, async () => {
968
1039
  if (verb === 'reopen') await reopenPrecheck(record, runId, id, what.file);
969
- else await waivePrecheck(record, runId, id, /** @type {string} */ (what.finding), what.file);
1040
+ else if (verb === 'waive') await waivePrecheck(record, runId, id, /** @type {string} */ (what.finding), what.file);
1041
+ before?.();
970
1042
  });
971
1043
  }
972
1044
 
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * `code-forge report [--slug <slug>] [--json] [--export <dir>]` (plan §6.2, R4). Terminal output
3
- * only, plus an optional `--export <dir>` that writes the same 14 sections as JSON files. The
3
+ * only, plus an optional `--export <dir>` that writes the same 15 sections as JSON files. The
4
4
  * terminal output ends with the spend per run (B33) and the mode of each run (B59: brief or plan);
5
5
  * both are text only, never in `--json` or `--export`. The 14th section, `outside_review` (B66), is
6
6
  * printed as text lines under its header (`outside review: none`, or one line per PR).
@@ -75,7 +75,7 @@ export async function runReport(args, deps = {}) {
75
75
  }
76
76
 
77
77
  /**
78
- * B33: the running spend per run (open blocks included), after the 14 sections. Unknown-price
78
+ * B33: the running spend per run (open blocks included), after the 15 sections. Unknown-price
79
79
  * sessions are named, never priced.
80
80
  * @param {ReturnType<typeof buildSpend>} spend @returns {string}
81
81
  */
@@ -163,21 +163,15 @@ const FRESH_ATTEMPTS = 3;
163
163
  /** B75: the ways out a counted result names (human-only; a coder stops and reports). */
164
164
  export const FRESH_EXITS = Object.freeze(['block reopen', 'block waive']);
165
165
 
166
+ /** The automatic retry allow-list excludes refused results, even those --fresh can rerun. */
167
+ const RETRY_STATUSES = new Set(['stale', 'unavailable', 'split_required']);
168
+
166
169
  /**
167
- * B75: `review-file <path> --block <id> --fresh`. No done marker: a normal enqueue. The marker is
168
- * read and classified IN PLACE; only a cached result that never counted is then claimed by
169
- * `dropDoneIf` (rename, then a byte compare with what was classified) and the ticket queued again.
170
- * A marker that changed under the claim is restored (link only) and classified again, at most
171
- * {@link FRESH_ATTEMPTS} times. The queue has no lock: the rename claim is the exclusion, and a
172
- * crash between the drop and the enqueue leaves only a re-runnable pending ticket. Anything else is refused and the marker kept byte-for-byte:
173
- * `fresh-needs-reopen` (names `block reopen` and `block waive`) or `fresh-tampered` (names only
174
- * the owner's `block close --no-require-reviews`; reopen refuses a tampered state).
175
- * B110: a plain cached round-1 `split_required` uses the same claim, with `retry: true`.
176
- * @param {{repoRoot: string, run: string, block: string, file: string, where: {tree?: string}, say: (obj: Record<string, any>) => unknown, dropDone?: typeof dropDoneIf, retry?: boolean}} opts
177
- * @returns {number} the exit code.
170
+ * Classify in place and use the same byte-checked claim for fresh and automatic retries.
171
+ * @param {{repoRoot: string, ticket: string, file: string, dropDone?: typeof dropDoneIf, once?: boolean, statuses?: Set<string>}} opts
172
+ * @returns {{verdict: 'rerun' | 'tampered' | 'counted', outcome: 'dropped' | 'gone' | 'kept'}}
178
173
  */
179
- function freshTicket({ repoRoot, run, block, file, where, say, dropDone = dropDoneIf, retry = false }) {
180
- const { ticket } = resolveTicket({ repoRoot, run, block, file, tree: where.tree ?? null });
174
+ function dropFreshDone({ repoRoot, ticket, file, dropDone = dropDoneIf, once = false, statuses }) {
181
175
  const markerPath = path.join(queueDir(repoRoot), `${ticket}.done`);
182
176
  /** @type {'rerun' | 'tampered' | 'counted'} */
183
177
  let verdict = 'counted';
@@ -191,18 +185,17 @@ function freshTicket({ repoRoot, run, block, file, where, say, dropDone = dropDo
191
185
  // no marker: a normal enqueue; a marker that exists but cannot be read is never dropped
192
186
  bytes = /** @type {{code?: unknown}} */ (err)?.code === 'ENOENT' ? null : Buffer.alloc(0);
193
187
  }
194
- if (bytes === null) {
195
- say(enqueue({ repoRoot, run, block, file, ...where }));
196
- return 0;
197
- }
188
+ if (bytes === null) return { verdict: 'counted', outcome: 'gone' };
198
189
  // classified IN PLACE: a refusal never moves, renames or touches the marker
199
190
  const text = bytes.toString('utf8');
200
- verdict = freshVerdict(text, resultOfMarker(repoRoot, text));
191
+ const result = resultOfMarker(repoRoot, text);
192
+ verdict = freshVerdict(text, result);
193
+ if (statuses && !statuses.has(result?.status)) verdict = 'counted';
201
194
  if (verdict !== 'rerun') break;
202
195
  /** @type {unknown} */
203
196
  let outcome;
204
197
  try {
205
- outcome = dropDone(repoRoot, ticket, bytes);
198
+ outcome = dropDone(repoRoot, ticket, bytes, once ? { once: true } : {});
206
199
  } catch (err) {
207
200
  // a named refusal passes through; any other throw is named here, never echoed
208
201
  if (err instanceof WorkerError) throw err;
@@ -212,10 +205,31 @@ function freshTicket({ repoRoot, run, block, file, where, say, dropDone = dropDo
212
205
  verdict = 'counted';
213
206
  continue;
214
207
  }
208
+ if (outcome === 'kept') break;
215
209
  // an allow-list of outcomes: anything else is refused and nothing is queued
216
210
  if (outcome !== 'dropped' && outcome !== 'gone') throw new WorkerError('fresh-claim-unknown', `the claim of the cached result of ${file} gave an unknown outcome; nothing was queued`);
217
- // dropped: queue it again now (a crash right here leaves a pending ticket with no marker, which
218
- // the next review-file queues anyway); gone: another caller took the marker, a normal enqueue
211
+ return { verdict, outcome };
212
+ }
213
+ return { verdict, outcome: 'kept' };
214
+ }
215
+
216
+ /**
217
+ * B75: `review-file <path> --block <id> --fresh`. No done marker: a normal enqueue. The marker is
218
+ * read and classified IN PLACE; only a cached result that never counted is then claimed by
219
+ * `dropDoneIf` (rename, then a byte compare with what was classified) and the ticket queued again.
220
+ * A marker that changed under the claim is restored (link only) and classified again, at most
221
+ * {@link FRESH_ATTEMPTS} times. The queue has no lock: the rename claim is the exclusion, and a
222
+ * crash between the drop and the enqueue leaves only a re-runnable pending ticket. Anything else is refused and the marker kept byte-for-byte:
223
+ * `fresh-needs-reopen` (names `block reopen` and `block waive`) or `fresh-tampered` (names only
224
+ * the owner's `block close --no-require-reviews`; reopen refuses a tampered state).
225
+ * B110: a plain cached round-1 `split_required` uses the same claim, with `retry: true`.
226
+ * @param {{repoRoot: string, run: string, block: string, file: string, where: {tree?: string}, say: (obj: Record<string, any>) => unknown, dropDone?: typeof dropDoneIf, retry?: boolean}} opts
227
+ * @returns {number} the exit code.
228
+ */
229
+ function freshTicket({ repoRoot, run, block, file, where, say, dropDone = dropDoneIf, retry = false }) {
230
+ const { ticket } = resolveTicket({ repoRoot, run, block, file, tree: where.tree ?? null });
231
+ const { verdict, outcome } = dropFreshDone({ repoRoot, ticket, file, dropDone });
232
+ if (outcome === 'dropped' || outcome === 'gone') {
219
233
  const queued = enqueue({ repoRoot, run, block, file, ...where });
220
234
  say(outcome === 'dropped' ? { ...queued, ...(retry ? { retry: true } : { fresh: true }) } : queued);
221
235
  return 0;
@@ -390,7 +404,7 @@ async function waitBlockScope(repoRoot, cwd, run, block) {
390
404
  throw new WorkerError('block-scope-unreadable', 'the block scope cannot be read');
391
405
  }
392
406
 
393
- /** A block wait reads only the marker, never review results or the signing key.
407
+ /** Read a completion marker without the signing key.
394
408
  * @param {string} repoRoot @param {string} ticket @returns {Record<string, any> | null}
395
409
  */
396
410
  function blockTicketMarker(repoRoot, ticket) {
@@ -404,12 +418,12 @@ function blockTicketMarker(repoRoot, ticket) {
404
418
  }
405
419
 
406
420
  /**
407
- * A block wait reads only completion/delivery markers, never review results or the signing key.
421
+ * A block wait uses unsigned results only to classify retries; it never reads the signing key.
408
422
  * Re-scan current content on every poll. Deliver unseen findings of the newest earlier ticket
409
423
  * before rechecking a blind edit; the next join queues the fix. Stopped files stay finished.
410
- * @param {{repoRoot: string, cwd: string, block: string, run: string, maxMs: number, pollMs: number, say: (obj: Record<string, any>) => unknown}} opts
424
+ * @param {{repoRoot: string, cwd: string, block: string, run: string, maxMs: number, pollMs: number, say: (obj: Record<string, any>) => unknown, dropDone?: typeof dropDoneIf}} opts
411
425
  */
412
- async function waitBlock({ repoRoot, cwd, block, run, maxMs, pollMs, say }) {
426
+ async function waitBlock({ repoRoot, cwd, block, run, maxMs, pollMs, say, dropDone = dropDoneIf }) {
413
427
  const { entry, tree } = await waitBlockScope(repoRoot, cwd, run, block);
414
428
  const where = realpathSync(tree) === realpathSync(repoRoot) ? {} : { tree: realpathSync(tree) };
415
429
  const deadline = Date.now() + maxMs;
@@ -441,7 +455,12 @@ async function waitBlock({ repoRoot, cwd, block, run, maxMs, pollMs, say }) {
441
455
  enqueue(opts);
442
456
  present = { ...previous[previous.length - 1], ticket };
443
457
  }
444
- return { file, ticket: present ? ticket : null, status: !present ? 'not_queued' : blockTicketMarker(repoRoot, ticket) ? 'done' : 'pending' };
458
+ let marker = present ? blockTicketMarker(repoRoot, ticket) : null;
459
+ if (live && marker && RETRY_STATUSES.has(marker.status)) {
460
+ dropFreshDone({ repoRoot, ticket, file, dropDone, once: true, statuses: RETRY_STATUSES });
461
+ marker = blockTicketMarker(repoRoot, ticket);
462
+ }
463
+ return { file, ticket: present ? ticket : null, status: !present ? 'not_queued' : marker ? 'done' : 'pending' };
445
464
  });
446
465
  if (downPolls >= 2) {
447
466
  say({ block, files, done: false, status: 'worker_down' });
@@ -478,7 +497,7 @@ export async function runReviewFile(args, deps = {}) {
478
497
  if (positionals.length > 0 || flags.block !== undefined || flags.wait !== undefined || flags.answers !== undefined || flags.of !== undefined || flags.fresh === true || typeof flags.run !== 'string') {
479
498
  throw new WorkerError('usage', '--wait-block takes a block, --run and --max only');
480
499
  }
481
- return await waitBlock({ repoRoot, cwd, block: assertBlockId(flags['wait-block']), run: assertRunId(flags.run), maxMs: flags.max === undefined ? DEFAULT_WAIT_MS : parseMax(flags.max), pollMs: deps.pollMs ?? POLL_MS, say });
500
+ return await waitBlock({ repoRoot, cwd, block: assertBlockId(flags['wait-block']), run: assertRunId(flags.run), maxMs: flags.max === undefined ? DEFAULT_WAIT_MS : parseMax(flags.max), pollMs: deps.pollMs ?? POLL_MS, say, dropDone: deps.dropDone });
482
501
  }
483
502
 
484
503
  if (typeof flags.wait === 'string') {
@@ -524,16 +543,9 @@ export async function runReviewFile(args, deps = {}) {
524
543
  const where = realpathSync(tree) === realpathSync(repoRoot) ? {} : { tree };
525
544
  if (flags.fresh === true) return freshTicket({ repoRoot, run: worker.run, block, file, where, say, ...(deps.dropDone ? { dropDone: deps.dropDone } : {}) });
526
545
  const queued = enqueue({ repoRoot, run: worker.run, block, file, ...where });
527
- if (queued.status === 'done' && doneResult(repoRoot, queued.ticket)?.status === 'split_required') {
546
+ if (queued.status === 'done' && ['unavailable', 'split_required'].includes(doneResult(repoRoot, queued.ticket)?.status)) {
528
547
  return freshTicket({ repoRoot, run: worker.run, block, file, where, say, retry: true, ...(deps.dropDone ? { dropDone: deps.dropDone } : {}) });
529
548
  }
530
- if (queued.status === 'done' && doneResult(repoRoot, queued.ticket)?.status === 'unavailable') {
531
- // `unavailable` is never approval AND never final: drop the done marker so the worker runs
532
- // the ticket again (the fix loop re-runs its pending round); the signed result is replaced.
533
- rmSync(path.join(queueDir(repoRoot), `${queued.ticket}.done`), { force: true });
534
- say({ ...enqueue({ repoRoot, run: worker.run, block, file, ...where }), retry: true });
535
- return 0;
536
- }
537
549
  say(queued);
538
550
  return 0;
539
551
  } catch (err) {
@@ -1,5 +1,5 @@
1
1
  /**
2
- * `report --export <dir>` — writes one JSON file per report section (plan §6.2; 14 sections since B66).
2
+ * `report --export <dir>` — writes one JSON file per report section (plan §6.2; 15 sections since B124b).
3
3
  */
4
4
 
5
5
  import { mkdir, writeFile } from 'node:fs/promises';
@@ -1,5 +1,5 @@
1
1
  /**
2
- * `code-forge report` (plan §6.2, R4): builds the 14 report sections from a run's ledger rows.
2
+ * `code-forge report` (plan §6.2, R4): builds the 15 report sections from a run's ledger rows.
3
3
  * Pure data, no I/O — terminal rendering + `--export` live in `src/cli/report.mjs`.
4
4
  *
5
5
  * Shadow rows (`row.source === 'shadow'`) are excluded everywhere here (plan §6.3: shadow-sampled
@@ -262,8 +262,8 @@ const runStopCounts = (rows) => ({
262
262
  noEngineStops: rows.filter((r) => r.event === 'run.stop' && r.reason === 'no_engine').length,
263
263
  });
264
264
 
265
- /** The exact, ordered set of section keys `buildReport` produces (plan §6.2; B66 appended the
266
- * 14th, `outside_review` — append new sections, never reorder) —
265
+ /** The exact, ordered set of section keys `buildReport` produces (plan §6.2; B66 appended
266
+ * `outside_review`, then B124b appended `agent_waivers` — append new sections, never reorder) —
267
267
  * exported so tests (and `--export`) can assert against ONE source of truth instead of a copy. */
268
268
  export const SECTION_NAMES = Object.freeze([
269
269
  'cost_per_block',
@@ -280,6 +280,7 @@ export const SECTION_NAMES = Object.freeze([
280
280
  'blocks_stopped_at_l3',
281
281
  'run_stop_counts',
282
282
  'outside_review',
283
+ 'agent_waivers',
283
284
  ]);
284
285
 
285
286
  /**
@@ -307,6 +308,11 @@ export function buildReport(rows) {
307
308
  // B66: per PR, the outside review rounds next to the cost of its blocks — the SAME
308
309
  // `costPerBlock` entries the `cost_per_block` section shows
309
310
  outside_review: (/** @type {any[]} */ r) => buildOutsideReview(r, costs),
311
+ agent_waivers: (/** @type {any[]} */ r) => r.flatMap((row, i) => row.event === 'review.waived' && row.by === 'agent'
312
+ ? [{ block: row.block, file: row.file, finding: row.finding, severity: row.severity, reason: row.reason, verdict: row.verdict,
313
+ unwaived: r.slice(i + 1).some((undo) => undo.event === 'review.unwaived' && undo.by === 'human'
314
+ && undo.run === row.run && undo.block === row.block && undo.file === row.file && undo.finding === row.finding) }]
315
+ : []),
310
316
  };
311
317
  /** @type {Record<string, any>} */
312
318
  const sections = {};
@@ -0,0 +1,78 @@
1
+ /** Human acceptance of one false transcript hit, bound to its rule and exact line bytes. */
2
+ import { createHash } from 'node:crypto';
3
+ import { readFile } from 'node:fs/promises';
4
+ import { StateError } from '../state/paths.mjs';
5
+ import { readRun, writeSigned } from '../state/run.mjs';
6
+ import { verifyRow } from '../state/signer.mjs';
7
+ import { mergeForbidden, scanTranscript } from '../util/forbidden.mjs';
8
+
9
+ /** @typedef {{rule: string, line: number, reason: string}} AcceptedHit */
10
+
11
+ /** @param {string} text */
12
+ const lineHash = (text) => createHash('sha256').update(text).digest('hex');
13
+
14
+ /** One refusal for every well-formed request that does not name an actual hit. */
15
+ function noSuchHit() {
16
+ return new StateError('accept-hit-no-such-hit', 'accept-hit-no-such-hit: the named transcript hit was not found; nothing was recorded');
17
+ }
18
+
19
+ /**
20
+ * Validate forms before reading state, then find the exact pair before the terminal question.
21
+ * The close and this pre-check use the same merged forbidden list.
22
+ * @param {{runId: string, id: string, flags: Record<string, any>, positionals: string[], extraTokens?: string[]}} opts
23
+ */
24
+ export async function prepareAcceptHit({ runId, id, flags, positionals, extraTokens = [] }) {
25
+ if (positionals.length !== 1) throw new StateError('usage', 'block accept-hit takes one block id');
26
+ for (const [name, value] of Object.entries({ block: id, run: runId, rule: flags.rule, line: flags.line, transcript: flags.transcript, reason: flags.reason })) {
27
+ // eslint-disable-next-line no-control-regex
28
+ if (typeof value === 'string' && /[\x00-\x1f\x7f]/.test(value)) throw new StateError('usage', `${name === 'block' ? 'block id' : `--${name}`} must not contain control characters`);
29
+ }
30
+ for (const name of ['rule', 'transcript', 'reason']) {
31
+ if (typeof flags[name] !== 'string' || flags[name].trim().length === 0) throw new StateError('usage', `--${name} is required`);
32
+ }
33
+ if (typeof flags.line !== 'string' || !/^-?\d+$/.test(flags.line) || !Number.isSafeInteger(Number(flags.line))) throw new StateError('usage', '--line must be an integer');
34
+ const line = Number(flags.line);
35
+ let record;
36
+ let text;
37
+ try {
38
+ record = await readRun(runId);
39
+ if (!record.blocks?.[id]) throw noSuchHit();
40
+ text = await readFile(flags.transcript, 'utf8');
41
+ } catch {
42
+ throw noSuchHit();
43
+ }
44
+ const lines = text.split('\n');
45
+ if (line < 1 || line > lines.length || !scanTranscript(text, mergeForbidden(extraTokens)).some((hit) => hit.id === flags.rule && hit.line === line)) throw noSuchHit();
46
+ return { record, row: { block: id, rule: /** @type {string} */ (flags.rule), line, line_sha256: lineHash(lines[line - 1]), reason: /** @type {string} */ (flags.reason.trim()), by: 'human' } };
47
+ }
48
+
49
+ /**
50
+ * Called only after terminal confirmation; exactly one signed row, through the waiver's writer.
51
+ * @param {{runId: string, row: Record<string, any>, writeRow: (row: Record<string, any>) => Promise<unknown>}} opts
52
+ */
53
+ export async function writeAcceptedHit({ runId, row, writeRow }) {
54
+ await writeSigned(runId, writeRow, { event: 'rule_break.accepted', ...row });
55
+ }
56
+
57
+ /**
58
+ * Ledger rule_break rows are deliberately outside this transcript-only filter.
59
+ * Line numbers are for reporting; only rule and exact content bind the acceptance.
60
+ * @param {{block: string, runId: string, rows: Record<string, any>[], key: Buffer, transcript: string | null, extraTokens?: string[]}} opts
61
+ * @returns {{remaining: {id: string, line: number}[], accepted: AcceptedHit[]}}
62
+ */
63
+ export function acceptedTranscriptHits({ block, runId, rows, key, transcript, extraTokens = [] }) {
64
+ if (typeof transcript !== 'string' || transcript.length === 0) return { remaining: [], accepted: [] };
65
+ const approvals = rows.filter((row) => row.event === 'rule_break.accepted' && row.run === runId && row.block === block && row.by === 'human' && typeof row.reason === 'string' && row.reason.trim().length > 0 && verifyRow(row, key).ok);
66
+ const lines = transcript.split('\n');
67
+ /** @type {{id: string, line: number}[]} */
68
+ const remaining = [];
69
+ /** @type {AcceptedHit[]} */
70
+ const accepted = [];
71
+ for (const hit of scanTranscript(transcript, mergeForbidden(extraTokens))) {
72
+ const hash = lineHash(lines[hit.line - 1]);
73
+ const approval = approvals.find((row) => row.rule === hit.id && row.line_sha256 === hash);
74
+ if (approval) accepted.push({ rule: hit.id, line: hit.line, reason: approval.reason });
75
+ else remaining.push(hit);
76
+ }
77
+ return { remaining, accepted };
78
+ }