@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.
- package/CHANGELOG.md +25 -0
- package/README.md +1 -1
- package/docs/reference/blocks.json +9 -1
- package/docs/reference/config.md +11 -1
- package/native/README.md +10 -0
- package/native/bin/darwin-arm64/code-forge-native +0 -0
- package/native/bin/darwin-x64/code-forge-native +0 -0
- package/native/bin/linux-arm64/code-forge-native +0 -0
- package/native/bin/linux-x64/code-forge-native +0 -0
- package/package.json +4 -1
- package/schema/code-forge.schema.json +26 -1
- package/skill/references/autopilot.md +6 -1
- package/skill/references/code.md +37 -0
- package/skill/references/plan.md +5 -1
- package/skill/references/review.md +46 -8
- package/skill/scripts/forge +1 -1
- package/src/cli/block.mjs +284 -11
- package/src/cli/doctor.mjs +32 -1
- package/src/cli/help.mjs +1 -0
- package/src/cli/native-status.mjs +13 -0
- package/src/cli/review-file.mjs +121 -14
- package/src/cli/status.mjs +43 -9
- package/src/doctor/index.mjs +11 -0
- package/src/doctor/worker-probe.mjs +1 -1
- package/src/review/agent-waiver.mjs +95 -8
- package/src/review/gate-check.mjs +13 -9
- package/src/review/model-stats.mjs +125 -0
- package/src/review/packet.mjs +54 -3
- package/src/review/reopen.mjs +38 -13
- package/src/review/route.mjs +58 -4
- package/src/session/facts.mjs +19 -7
- package/src/session/plan-check.mjs +4 -1
- package/src/state/block.mjs +39 -16
- package/src/util/native.mjs +131 -0
- package/src/worker/loop.mjs +144 -16
- package/src/worker/queue.mjs +1 -1
- package/src/worker/watch.mjs +16 -5
package/src/cli/review-file.mjs
CHANGED
|
@@ -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 {
|
|
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
|
-
*
|
|
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.
|
|
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) =>
|
|
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
|
-
|
|
608
|
-
|
|
609
|
-
|
|
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') {
|
package/src/cli/status.mjs
CHANGED
|
@@ -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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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 -
|
|
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)
|
|
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`);
|
|
237
|
+
out(`${JSON.stringify({ runs: shown })}\n`);
|
|
204
238
|
return 0;
|
|
205
239
|
}
|
|
206
240
|
if (shown.length === 0) {
|
package/src/doctor/index.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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)
|
|
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 (
|
|
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 (
|
|
30
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|