tickmarkr 2.5.8 → 2.6.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.
Files changed (63) hide show
  1. package/dist/adapters/claude-code.js +19 -6
  2. package/dist/adapters/prompt.js +1 -1
  3. package/dist/adapters/qwen.js +30 -3
  4. package/dist/adapters/types.d.ts +3 -0
  5. package/dist/cli/commands/approve.js +15 -3
  6. package/dist/cli/commands/beat.d.ts +2 -0
  7. package/dist/cli/commands/beat.js +28 -29
  8. package/dist/cli/commands/compile.js +11 -0
  9. package/dist/cli/commands/fleet.js +61 -53
  10. package/dist/cli/commands/init.js +1 -1
  11. package/dist/cli/commands/report.js +11 -1
  12. package/dist/cli/commands/verify.d.ts +4 -1
  13. package/dist/cli/commands/verify.js +18 -5
  14. package/dist/cli/help.d.ts +2 -0
  15. package/dist/cli/help.js +6 -4
  16. package/dist/compile/native.js +39 -4
  17. package/dist/config/config.d.ts +41 -3
  18. package/dist/config/config.js +48 -17
  19. package/dist/config/fleet-overlay.js +47 -62
  20. package/dist/drivers/orca.d.ts +1 -1
  21. package/dist/drivers/orca.js +21 -4
  22. package/dist/gates/baseline.d.ts +67 -0
  23. package/dist/gates/baseline.js +172 -10
  24. package/dist/gates/cache.d.ts +8 -0
  25. package/dist/gates/cache.js +26 -13
  26. package/dist/gates/review.d.ts +8 -7
  27. package/dist/gates/review.js +83 -45
  28. package/dist/gates/run-gates.d.ts +4 -1
  29. package/dist/gates/run-gates.js +32 -12
  30. package/dist/gates/test-manifest.d.ts +16 -1
  31. package/dist/gates/test-manifest.js +127 -27
  32. package/dist/gates/test-reporter.js +4 -0
  33. package/dist/graph/graph.d.ts +1 -1
  34. package/dist/graph/graph.js +6 -1
  35. package/dist/graph/schema.d.ts +2 -0
  36. package/dist/graph/schema.js +2 -0
  37. package/dist/route/preference.d.ts +1 -1
  38. package/dist/route/preference.js +10 -39
  39. package/dist/run/daemon.d.ts +7 -1
  40. package/dist/run/daemon.js +237 -53
  41. package/dist/run/git.d.ts +10 -0
  42. package/dist/run/git.js +49 -1
  43. package/dist/run/journal.d.ts +18 -3
  44. package/dist/run/journal.js +141 -17
  45. package/dist/run/merge.d.ts +13 -2
  46. package/dist/run/merge.js +98 -12
  47. package/dist/run/protocol.d.ts +82 -0
  48. package/dist/run/protocol.js +35 -0
  49. package/dist/run/receipt-resolver.d.ts +18 -0
  50. package/dist/run/receipt-resolver.js +132 -0
  51. package/dist/run/supervision.d.ts +14 -1
  52. package/dist/run/supervision.js +122 -24
  53. package/dist/tui/cockpit/evidence-view.d.ts +10 -1
  54. package/dist/tui/cockpit/evidence-view.js +37 -5
  55. package/dist/tui/cockpit/live-runtime.d.ts +4 -0
  56. package/dist/tui/cockpit/live-runtime.js +37 -3
  57. package/dist/tui/cockpit/live-store.d.ts +2 -0
  58. package/dist/tui/ink/fleet-app.d.ts +12 -22
  59. package/dist/tui/ink/fleet-app.js +520 -131
  60. package/package.json +1 -1
  61. package/schema/rungraph.schema.json +7 -0
  62. package/skills/tickmarkr-overseer/SKILL.md +91 -41
  63. package/skills/tickmarkr-overseer/scripts/watch-context.sh +14 -2
@@ -1,9 +1,140 @@
1
- import { existsSync, readFileSync } from "node:fs";
2
- import { availableParallelism, loadavg } from "node:os";
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { execFileSync } from "node:child_process";
3
+ import { existsSync, readFileSync, mkdirSync, readdirSync, statSync, unlinkSync, writeFileSync, rmdirSync } from "node:fs";
4
+ import { availableParallelism, loadavg, tmpdir } from "node:os";
3
5
  import { join } from "node:path";
4
- import { DEFAULT_SHELL_TIMEOUT_MS, describeCapacity, sameCapacity, sh } from "../run/git.js";
6
+ import { dependencyLinkRefusal, DEFAULT_SHELL_TIMEOUT_MS, describeCapacity, sameCapacity, sh } from "../run/git.js";
5
7
  import { executionSignal } from "../run/execution-budget.js";
6
8
  import { isVitestTestCommand, manifestFileCount } from "./test-manifest.js";
9
+ export const EVIDENCE_TAIL_BYTES = 16 * 1024;
10
+ export const EVIDENCE_RUN_QUOTA_BYTES = 8 * 1024 * 1024;
11
+ export function redactGateOutput(text, env) {
12
+ // One pass over the original bytes: replacing an environment value must not break a token
13
+ // recognizer, and a short environment value must not rewrite the redaction marker itself.
14
+ // Short operational values (0, true, vi, test) occur throughout ordinary diagnostics.
15
+ // Only secret-named keys justify redacting short values.
16
+ const values = [...new Set(Object.entries(env)
17
+ .filter(([key, value]) => !!value && (value.length >= 8 || /KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL|AUTH/i.test(key)))
18
+ .map(([, value]) => value))]
19
+ .sort((a, b) => b.length - a.length).map(v => v.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
20
+ const tokens = String.raw `\b(?:sk-[A-Za-z0-9_-]{12,}|gh[pousr]_[A-Za-z0-9_]{16,}|github_pat_[A-Za-z0-9_]{16,}|AKIA[A-Z0-9]{16}|eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+)\b`;
21
+ const assignments = String.raw `(?:authorization\s*:\s*bearer|(?:api[_-]?key|token|password|secret)\s*[:=])\s*[^\s"']+`;
22
+ return text.replace(new RegExp([tokens, assignments, ...values].join("|"), "gi"), "[REDACTED]");
23
+ }
24
+ /** Resolve a snapshot reference against actual bytes: eviction never fabricates a live artifact. */
25
+ export function resolveEvidenceArtifact(root, ref) {
26
+ if (ref.availability !== "available")
27
+ return ref;
28
+ if (ref.path.startsWith("/") || ref.path.includes("\\") || ref.path.split("/").includes(".."))
29
+ return { ...ref, availability: "missing" };
30
+ try {
31
+ const bytes = readFileSync(join(root, ref.path));
32
+ return bytes.length === ref.retainedBytes && createHash("sha256").update(bytes).digest("hex") === ref.sha256
33
+ ? ref : { ...ref, availability: "missing" };
34
+ }
35
+ catch {
36
+ return { ...ref, availability: "missing" };
37
+ }
38
+ }
39
+ export function beginGateEvidence(cwd, gate, command, opts = {}, nonce) {
40
+ let invocationId = nonce ?? randomUUID();
41
+ let artifactId = /^[a-zA-Z0-9-]+$/.test(invocationId) ? invocationId : createHash("sha256").update(invocationId).digest("hex");
42
+ let subjectCommit = opts.subjectCommit ?? null;
43
+ if (!subjectCommit) {
44
+ try {
45
+ subjectCommit = execFileSync("git", ["rev-parse", "HEAD"], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
46
+ }
47
+ catch { /* unknown subject */ }
48
+ }
49
+ const root = opts.artifactDir ?? join(tmpdir(), "tickmarkr-gate-evidence", createHash("sha256").update(cwd).digest("hex"));
50
+ const env = { ...process.env, ...opts.env };
51
+ for (const match of command.matchAll(/(?:^|\s)[A-Za-z_][A-Za-z_0-9]*=(?:'([^']*)'|"([^"$]*)"|([^\s;&|]+))/g)) {
52
+ env[`assignment_${match.index}`] = match[1] ?? match[2] ?? match[3];
53
+ }
54
+ let observed;
55
+ let pendingId;
56
+ const history = [];
57
+ return {
58
+ root, history,
59
+ observe(receipt) {
60
+ if (pendingId) {
61
+ history.push(this.finish());
62
+ invocationId = pendingId;
63
+ artifactId = pendingId;
64
+ pendingId = undefined;
65
+ }
66
+ observed = receipt;
67
+ // A shell-internal pre-spawn retry receives an id before it executes too. Keep the failed
68
+ // attempt current until a later observation proves that the shell actually tried again.
69
+ if (receipt.outcome === "spawn-failed")
70
+ pendingId = randomUUID();
71
+ },
72
+ finish(stdout = "", stderr = "") {
73
+ const termination = {
74
+ kind: !observed ? "unknown" : !observed.confirmedStart ? "not-started" : observed.outcome === "timed-out" ? "timeout" : observed.signal ? "signal" : observed.exitCode != null ? "exit" : "unknown",
75
+ exitCode: observed?.exitCode ?? null, signal: observed?.signal ?? null,
76
+ timedOut: observed ? observed.outcome === "timed-out" : null,
77
+ };
78
+ const clean = [redactGateOutput(stdout, env), redactGateOutput(stderr, env)];
79
+ const refs = clean.map((text, i) => {
80
+ const bytes = Buffer.from(text);
81
+ const tail = bytes.subarray(-EVIDENCE_TAIL_BYTES);
82
+ return { path: `gate-evidence/${artifactId}-${i === 0 ? "stdout" : "stderr"}.log`,
83
+ availability: termination.kind === "not-started" ? "not-started" : "available",
84
+ sha256: termination.kind === "not-started" ? null : createHash("sha256").update(tail).digest("hex"), retainedBytes: tail.length,
85
+ droppedBytes: bytes.length - tail.length, truncated: bytes.length > tail.length };
86
+ });
87
+ let availability = termination.kind === "not-started" ? "not-started" : "available";
88
+ if (availability === "available") {
89
+ let lock;
90
+ try {
91
+ const dir = join(root, "gate-evidence");
92
+ mkdirSync(dir, { recursive: true });
93
+ mkdirSync(join(dir, ".write-lock"));
94
+ lock = join(dir, ".write-lock");
95
+ const quota = opts.quotaBytes !== undefined && Number.isFinite(opts.quotaBytes)
96
+ ? Math.max(0, Math.floor(opts.quotaBytes)) : EVIDENCE_RUN_QUOTA_BYTES;
97
+ const files = readdirSync(dir).filter(f => /^[a-zA-Z0-9-]+-(stdout|stderr)\.log$/.test(f)).map(f => ({ path: join(dir, f), stat: statSync(join(dir, f)) }))
98
+ .sort((a, b) => a.stat.mtimeMs - b.stat.mtimeMs || a.path.localeCompare(b.path));
99
+ let total = files.reduce((sum, f) => sum + f.stat.size, 0);
100
+ const incoming = refs.reduce((sum, ref) => sum + ref.retainedBytes, 0);
101
+ if (incoming > quota)
102
+ throw new Error("evidence quota cannot retain this invocation");
103
+ while (files.length && total + incoming > quota) {
104
+ const oldest = files.shift();
105
+ unlinkSync(oldest.path);
106
+ total -= oldest.stat.size;
107
+ }
108
+ for (const [i, ref] of refs.entries()) {
109
+ (opts.write ?? writeFileSync)(join(root, ref.path), Buffer.from(clean[i]).subarray(-EVIDENCE_TAIL_BYTES));
110
+ }
111
+ }
112
+ catch {
113
+ availability = "capture-failed";
114
+ for (const ref of refs) {
115
+ ref.availability = "capture-failed";
116
+ ref.sha256 = null;
117
+ try {
118
+ unlinkSync(join(root, ref.path));
119
+ }
120
+ catch { /* best effort cleanup */ }
121
+ }
122
+ }
123
+ finally {
124
+ if (lock) {
125
+ try {
126
+ rmdirSync(lock);
127
+ }
128
+ catch { /* observational cleanup */ }
129
+ }
130
+ }
131
+ }
132
+ return { invocationId, ...(nonce ? { nonce } : {}),
133
+ subject: { runId: opts.runId ?? "standalone", taskId: opts.taskId ?? null, attempt: opts.attempt ?? null, gate, subjectCommit },
134
+ termination, availability, redaction: { material: clean[0] !== stdout || clean[1] !== stderr }, stdout: refs[0], stderr: refs[1] };
135
+ },
136
+ };
137
+ }
7
138
  // incident #2 (run-20260709-104447): a vitest ✓ PASS line with "error" in the test NAME, wrapped in ANSI
8
139
  // codes that varied between baseline and worktree runs, was reported as a "new failure". Strip ANSI first;
9
140
  // a pass-marker line is never a failure. [\d;#] covers raw ANSI and digit-normalized ANSI ("\x1b[#m") from
@@ -489,11 +620,21 @@ export function staleFileCountCommands(baseline, commands, cwd) {
489
620
  }
490
621
  export async function captureBaseline(cwd, commands, opts = {}) {
491
622
  const base = { commands: {} };
623
+ const refusal = dependencyLinkRefusal(cwd);
624
+ if (refusal) {
625
+ base.refusal = refusal;
626
+ for (const name of Object.keys(commands)) {
627
+ base.commands[name] = { infra: true, invalidCause: "infra", fingerprints: [], invalidatingLines: [refusal] };
628
+ }
629
+ return base;
630
+ }
492
631
  for (const [name, cmd] of Object.entries(commands)) {
493
632
  if (name === "tipTest" && commands.test !== undefined && cmd === commands.test) {
494
633
  continue;
495
634
  }
496
- const r = await sh(cmd, cwd, CAPTURE_CEILING_MS, { onReceipt: opts.onReceipt });
635
+ const evidence = beginGateEvidence(cwd, name, cmd, opts.evidence);
636
+ const r = await sh(cmd, cwd, CAPTURE_CEILING_MS, { env: opts.evidence?.env, onReceipt: receipt => { evidence.observe(receipt); opts.onReceipt?.(receipt); } });
637
+ (base.evidenceReceipts ??= {})[name] = evidence.finish(r.stdout, r.stderr);
497
638
  // ponytail: strip the executing cwd so repo-root capture and worktree compare fingerprint identically; /private-vs-/tmp symlink variance is out of scope
498
639
  // ponytail: a capture that was itself killed records the ceiling as its "measurement", which
499
640
  // scales the next ceiling up — the right direction for a suite that never finished once.
@@ -634,15 +775,36 @@ export async function compareToBaseline(cwd, commands, baseline, enabled, opts =
634
775
  }
635
776
  const entry = baseline.commands[name];
636
777
  const ceilingMs = effectiveCeilingMs(entry);
637
- const r = await sh(cmd, cwd, ceilingMs, {
638
- onReceipt: opts.onReceipt,
639
- ...(name === "build" ? { receiptAttribution: opts.taskBuildAttribution } : {}),
640
- });
778
+ const evidence = beginGateEvidence(cwd, name, cmd, opts.evidence);
779
+ let r;
780
+ try {
781
+ r = await sh(cmd, cwd, ceilingMs, {
782
+ env: opts.evidence?.env,
783
+ onReceipt: receipt => { evidence.observe(receipt); opts.onReceipt?.(receipt); },
784
+ ...(name === "build" ? { receiptAttribution: opts.taskBuildAttribution } : {}),
785
+ });
786
+ }
787
+ catch (error) {
788
+ const evidenceReceipt = evidence.finish();
789
+ // A launch refusal is infrastructure; cancellation and other thrown control flow still unwind.
790
+ if (evidenceReceipt.termination.kind !== "not-started" || executionSignal()?.aborted)
791
+ throw error;
792
+ results.push({ gate: name, pass: false, details: `infra; command failed to launch: ${String(error)}`,
793
+ meta: { classification: "infra", infra: true }, evidenceReceipt, evidenceReceipts: [...evidence.history, evidenceReceipt] });
794
+ continue;
795
+ }
796
+ const evidenceReceipt = evidence.finish(r.stdout, r.stderr);
797
+ if (evidenceReceipt.termination.kind === "not-started") {
798
+ results.push({ gate: name, pass: false, details: `infra; command failed to launch: ${r.stderr}`,
799
+ meta: { classification: "infra", infra: true }, evidenceReceipt, evidenceReceipts: [...evidence.history, evidenceReceipt] });
800
+ continue;
801
+ }
641
802
  // T7: every verdict below carries the capacity ITS OWN command ran under, taken off the shell
642
803
  // result rather than re-derived after the fact. The skip row above ran no command and therefore
643
804
  // states no capacity — a row that never divided the machine must not claim that it did.
644
805
  let recoveryBlocked;
645
806
  const record = (g) => {
807
+ g = { ...g, evidenceReceipt, evidenceReceipts: [...evidence.history, evidenceReceipt] };
646
808
  if (recoveryBlocked)
647
809
  g = { ...g, meta: { ...g.meta, recoveryBlocked } };
648
810
  const withReap = r.reapedGroup ? { ...g, meta: { ...g.meta, reapedGroup: true } } : g;
@@ -735,7 +897,7 @@ export async function compareToBaseline(cwd, commands, baseline, enabled, opts =
735
897
  }
736
898
  else {
737
899
  const provenance = { durationMs: r.durationMs ?? 0, referenceMs: entry?.durationMs ?? 0, waitedMs };
738
- results.push(...await compareToBaseline(cwd, { [name]: cmd }, baseline, [name], { ...opts, infraRerun: provenance }));
900
+ results.push(...(await compareToBaseline(cwd, { [name]: cmd }, baseline, [name], { ...opts, infraRerun: provenance })).map(row => ({ ...row, evidenceReceipts: [...evidence.history, evidenceReceipt, ...(row.evidenceReceipts ?? [])] })));
739
901
  continue;
740
902
  }
741
903
  }
@@ -754,7 +916,7 @@ export async function compareToBaseline(cwd, commands, baseline, enabled, opts =
754
916
  // Compatibility with OBS-896, not an infrastructure reclassification: a persistent
755
917
  // timeout remains the ordinary conservative verdict after the single bounded remeasure.
756
918
  const provenance = { durationMs: r.durationMs ?? 0, referenceMs: entry?.durationMs ?? 0, waitedMs };
757
- results.push(...await compareToBaseline(cwd, { [name]: cmd }, baseline, [name], { ...opts, rerunOf: provenance }));
919
+ results.push(...(await compareToBaseline(cwd, { [name]: cmd }, baseline, [name], { ...opts, rerunOf: provenance })).map(row => ({ ...row, evidenceReceipts: [...evidence.history, evidenceReceipt, ...(row.evidenceReceipts ?? [])] })));
758
920
  continue;
759
921
  }
760
922
  }
@@ -59,7 +59,15 @@ export declare function computeVerificationIdentity(params: {
59
59
  }): Promise<VerificationIdentity | undefined>;
60
60
  export declare function verificationIdentityKey(id: VerificationIdentity): string;
61
61
  export declare function formatReusedDetails(originalDetails: string, id: VerificationIdentity): string;
62
+ declare module "./types.js" {
63
+ interface GateResult {
64
+ originRunRoot?: string;
65
+ }
66
+ }
62
67
  export interface CachedVerdict {
68
+ evidenceReceipt?: GateResult["evidenceReceipt"];
69
+ evidenceReceipts?: GateResult["evidenceReceipts"];
70
+ originRunRoot?: string;
63
71
  gate: string;
64
72
  pass: boolean;
65
73
  details: string;
@@ -1,9 +1,9 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { execSync } from "node:child_process";
3
- import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
4
- import { dirname, join, resolve } from "node:path";
3
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
4
+ import { dirname, join, relative, resolve } from "node:path";
5
5
  import { tmpdir } from "node:os";
6
- import { describeCapacity, resolvedCapacity, shGit, verificationProtocol } from "../run/git.js";
6
+ import { describeCapacity, inventoryDependencyLinks, resolvedCapacity, shGit, verificationProtocol } from "../run/git.js";
7
7
  import { shq } from "../adapters/types.js";
8
8
  export const DEFAULT_VERDICT_CACHE_BOUND = 128;
9
9
  let testCacheBound;
@@ -84,6 +84,13 @@ export function environmentFingerprint(env) {
84
84
  const capacity = { forkCap: cap.forkCap, cores: cap.cores };
85
85
  const selectedSet = env.selectedSet ? [...env.selectedSet].sort() : undefined;
86
86
  const verification = env.verification ?? verificationProtocol(process.env, env.worktree ?? process.cwd());
87
+ // Admitted links still affect resolution. Normalize against the classified root so relocating
88
+ // an otherwise identical checkout (including its dependency store) preserves the identity.
89
+ const resolution = env.worktree ? inventoryDependencyLinks(env.worktree).map(({ link, target, classification }) => ({
90
+ link,
91
+ classification,
92
+ target: classification === "outside" ? target : relative(realpathSync(classification === "worktree" ? env.worktree : join(env.worktree, "node_modules")), target),
93
+ })).sort((a, b) => a.link < b.link ? -1 : a.link > b.link ? 1 : 0) : [];
87
94
  // R41: the protocol and the EFFECTIVE lifecycle are IN the hashed payload, so every entry written
88
95
  // before this stamp — green or red — keys differently and is never answered; no store surgery is
89
96
  // needed. `source` is provenance (kept in parts, printed on the row) and never enters the key: an
@@ -92,6 +99,7 @@ export function environmentFingerprint(env) {
92
99
  nodeRuntime,
93
100
  lockfile,
94
101
  capacity,
102
+ resolution,
95
103
  selectedSet: selectedSet ?? null,
96
104
  scope: env.scope ?? "battery",
97
105
  verification: { protocol: verification.protocol, lifecycle: verification.lifecycle },
@@ -155,6 +163,9 @@ export function formatReusedRow(cached, id) {
155
163
  pass: cached.pass,
156
164
  details: cached.pass ? reusedDetails : cached.details,
157
165
  capacity: cached.capacity,
166
+ ...(cached.evidenceReceipt ? { evidenceReceipt: cached.evidenceReceipt } : {}),
167
+ ...(cached.evidenceReceipts ? { evidenceReceipts: cached.evidenceReceipts } : {}),
168
+ ...(cached.originRunRoot ? { originRunRoot: cached.originRunRoot } : {}),
158
169
  meta: {
159
170
  ...cached.meta,
160
171
  reused: true,
@@ -306,6 +317,10 @@ export class VerdictStore {
306
317
  ...("exitCode" in verdict ? { exitCode: verdict.exitCode } : {}),
307
318
  ...(verdict.capacity ? { capacity: verdict.capacity } : {}),
308
319
  ...(verdict.meta ? { meta: verdict.meta } : {}),
320
+ ...(verdict.evidenceReceipt ? { evidenceReceipt: verdict.evidenceReceipt } : {}),
321
+ ...(verdict.evidenceReceipts ? { evidenceReceipts: verdict.evidenceReceipts } : {}),
322
+ ...(verdict.originRunRoot ? { originRunRoot: verdict.originRunRoot }
323
+ : typeof verdict.meta?.runDir === "string" ? { originRunRoot: resolve(verdict.meta.runDir) } : {}),
309
324
  },
310
325
  timestamp: Date.now(),
311
326
  sequence: ++globalStoreSequence,
@@ -314,17 +329,15 @@ export class VerdictStore {
314
329
  const tmpPath = join(this.dir, `verdict-${key}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`);
315
330
  writeFileSync(tmpPath, JSON.stringify(record, null, 2) + "\n");
316
331
  try {
317
- unlinkSync(finalPath);
332
+ renameSync(tmpPath, finalPath);
318
333
  }
319
- catch {
320
- // ignore
321
- }
322
- writeFileSync(finalPath, readFileSync(tmpPath));
323
- try {
324
- unlinkSync(tmpPath);
325
- }
326
- catch {
327
- // ignore
334
+ finally {
335
+ try {
336
+ unlinkSync(tmpPath);
337
+ }
338
+ catch {
339
+ // Already renamed, or best-effort cleanup after a failed replacement.
340
+ }
328
341
  }
329
342
  this.evictOldest(bound);
330
343
  return true;
@@ -14,6 +14,8 @@ export interface ReviewFinding {
14
14
  severity: ReviewSeverity;
15
15
  defer?: boolean;
16
16
  rationale?: string;
17
+ /** Copy an id from the top-level reraised list to identify this restatement. */
18
+ reraised?: string;
17
19
  }
18
20
  export interface ReviewVerdict {
19
21
  approve?: boolean;
@@ -58,19 +60,19 @@ export { modelProvider };
58
60
  * comparing both sides with every whitespace run removed (`s.replace(/\s+/g, "")`).
59
61
  */
60
62
  export declare function matchClosureId(candidate: unknown, fingerprint: string): boolean;
61
- export declare function matchClosureId(candidate: unknown, fingerprints: Iterable<string>): string | undefined;
63
+ export declare function matchClosureId(candidate: unknown, fingerprints: Iterable<string | StructuredFinding>): string | undefined;
62
64
  /**
63
65
  * Validates closure ids in a review verdict: membership, duplication, and coverage of every prior id
64
66
  * all route through matchClosureId.
65
67
  */
66
- export declare function isReviewClosureInvalid(v: Pick<ReviewVerdict, "resolved" | "reraised"> | null | undefined, priorIds: ReadonlySet<string> | readonly string[]): boolean;
68
+ export declare function isReviewClosureInvalid(v: Pick<ReviewVerdict, "resolved" | "reraised"> | null | undefined, priorIds: ReadonlySet<string> | readonly (string | StructuredFinding)[]): boolean;
67
69
  /**
68
70
  * OBS-1013 add.3: the reviewer ECHOED closure ids and at least one matches no carried fingerprint —
69
71
  * it answered about the materials and missed the id (a retyped, truncated or paraphrased fingerprint).
70
72
  * That is a no-verdict about the carried work (re-route), not a parse defect. A verdict that omits a
71
73
  * list, carries a non-string or duplicates an id stays malformed: its shape, not its ids, is wrong.
72
74
  */
73
- export declare function isReviewClosureMismatch(v: Pick<ReviewVerdict, "resolved" | "reraised"> | null | undefined, priorIds: ReadonlySet<string> | readonly string[]): boolean;
75
+ export declare function isReviewClosureMismatch(v: Pick<ReviewVerdict, "resolved" | "reraised"> | null | undefined, priorIds: ReadonlySet<string> | readonly (string | StructuredFinding)[]): boolean;
74
76
  export type ReviewerFloorCause = "author-tier" | "task-floor" | "config" | "prior-reviewer";
75
77
  /**
76
78
  * RF-1 (OBS-922 add.2/3): the tier a reviewer must meet is the maximum of the author's tier, the
@@ -108,7 +110,7 @@ export declare function pickReviewer(author: Assignment, channels: BillingChanne
108
110
  prefer?: string[], // v1.53 T2: review.prefer — reorders eligible channels, never changes eligibility
109
111
  floor?: Tier, // task/config/prior floor from the caller; the author's own tier is ALWAYS applied here (RF-1)
110
112
  history?: string[], // run-scoped picks, oldest to newest; empty preserves the established ranking
111
- onSeat?: (seat: number, count: number) => void, demoted?: ReadonlySet<string>, excludeVendors?: ReadonlySet<string>): BillingChannel | null;
113
+ onSeat?: (seat: number, count: number) => void, demoted?: ReadonlySet<string>, excludeVendors?: ReadonlySet<string>, authors?: readonly string[]): BillingChannel | null;
112
114
  export type ReviewUnparseableCause = VerdictUnparseableCause | "launch-never-started" | "truncated" | "silent" | "closure-mismatch";
113
115
  /**
114
116
  * This shows the reviewer what the task DECLARED, never what the diff may actually reach. The diff
@@ -118,8 +120,7 @@ export type ReviewUnparseableCause = VerdictUnparseableCause | "launch-never-sta
118
120
  export declare function renderDeclaredWriteScope(files: ReadonlyArray<string>): string;
119
121
  /**
120
122
  * OBS-1033: the vendors of the seats that authored commits inside the accumulated diff. A seat is
121
- * never handed its own earlier work to approve. A prior author not resolvable in the pool excludes
122
- * its adapter's vendors instead (fail closed: the seat is known, its vendor is whatever it bills as).
123
+ * never handed its own earlier work to approve. Unresolved channels are refused by reviewGate.
123
124
  */
124
125
  export declare function carriedAuthorVendors(channels: BillingChannel[], carriedAuthors?: readonly string[]): Set<string>;
125
126
  /**
@@ -134,4 +135,4 @@ export declare function renderGoalSection(goal: string, repoRoot?: string): stri
134
135
  * closure on a typo and that read as malformed — the block is what a closure list is copied from.
135
136
  */
136
137
  export declare function renderPriorMaterials(priorMaterials: readonly StructuredFinding[]): string;
137
- export declare function reviewGate(task: Task, worktree: string, baseRef: string, author: Assignment, channels: BillingChannel[], adapters: WorkerAdapter[], cfg: TickmarkrConfig, via?: GateVia, excludeReviewers?: string[], artifactDir?: string, reviewHistory?: string[], demotedReviewers?: ReadonlySet<string>, carriedFindings?: readonly StructuredFinding[], priorReviewers?: readonly PriorReviewer[], carriedAuthors?: readonly string[]): Promise<GateResult>;
138
+ export declare function reviewGate(task: Task, worktree: string, baseRef: string, author: Assignment, channels: BillingChannel[], adapters: WorkerAdapter[], cfg: TickmarkrConfig, via?: GateVia, excludeReviewers?: string[], artifactDir?: string, reviewHistory?: string[], demotedReviewers?: ReadonlySet<string>, carriedFindings?: readonly StructuredFinding[], priorReviewers?: readonly PriorReviewer[], carriedAuthors?: readonly string[], operatorContext?: string): Promise<GateResult>;
@@ -6,12 +6,12 @@ import { filesGlob } from "../graph/files-glob.js";
6
6
  import { renderAcceptanceItem, TIERS } from "../graph/schema.js";
7
7
  import { getAdapter } from "../adapters/registry.js";
8
8
  import { shOk } from "../run/git.js";
9
- import { structuredFindings } from "../run/journal.js";
9
+ import { carryReviewFindings, observedReviewFingerprints, reviewFingerprintMatches, structuredFindings, UNIDENTIFIED } from "../run/journal.js";
10
10
  import { redactSecrets } from "../run/redact.js";
11
11
  import { marginalCostRank } from "../route/router.js";
12
12
  import { modelProvider } from "../route/preference.js";
13
13
  import { resolveStateDir } from "./cache.js";
14
- import { appendAnchoredReview, COMPLETION_FAKING_CHECKLIST, extractVerdictJson, generateVerdictNonce, runLlmDetailed, verdictNonceLine } from "./llm.js";
14
+ import { appendAnchoredReview, COMPLETION_FAKING_CHECKLIST, extractVerdictJson, generateVerdictNonce, parseAnchoredComments, runLlmDetailed, verdictNonceLine } from "./llm.js";
15
15
  import { classifyVerdictCause } from "./verdict-cause.js";
16
16
  import { captureDiffCapFor, measureArtifactDiff, reviewableLogicDiff, } from "./artifact-manifest.js";
17
17
  export { isProtectedEvidence, PROTECTED_EVIDENCE_PREFIXES, REGENERABLE_CAPTURE_PATHS, setAsideReceiptPath, setAsideRegenerableCaptures, } from "./artifact-manifest.js";
@@ -188,15 +188,17 @@ export function matchClosureId(candidate, target) {
188
188
  // OBS-1068: the brief's copy block printed each id as `Fingerprint: <id>` and told the seat to copy
189
189
  // it EXACTLY — three faithful approvals in one run were discarded as closure-mismatch. A leading
190
190
  // `Fingerprint:` label is the brief's own wording, never the reviewer's id; strip it before comparing.
191
- const normCandidate = candidate.replace(/^\s*fingerprint\s*:\s*/i, "").replace(/\s+/g, "");
192
191
  if (typeof target === "string") {
193
- return normCandidate === target.replace(/\s+/g, "");
192
+ return reviewFingerprintMatches(candidate, target);
194
193
  }
195
- for (const fp of target) {
196
- if (typeof fp === "string" && normCandidate === fp.replace(/\s+/g, ""))
197
- return fp;
198
- }
199
- return undefined;
194
+ const matches = [...target].filter((finding) => {
195
+ const ids = typeof finding === "string" ? [finding] : observedReviewFingerprints(finding);
196
+ return ids.some((id) => reviewFingerprintMatches(candidate, id));
197
+ });
198
+ if (matches.length !== 1)
199
+ return undefined;
200
+ const match = matches[0];
201
+ return typeof match === "string" ? match : match.fingerprint;
200
202
  }
201
203
  /**
202
204
  * Validates closure ids in a review verdict: membership, duplication, and coverage of every prior id
@@ -205,10 +207,10 @@ export function matchClosureId(candidate, target) {
205
207
  export function isReviewClosureInvalid(v, priorIds) {
206
208
  const priors = priorIds instanceof Set ? priorIds : new Set(priorIds);
207
209
  const closureLists = [v?.resolved, v?.reraised];
208
- const allCandidateIds = [...(v?.resolved ?? []), ...(v?.reraised ?? [])];
210
+ const allCandidateIds = closureLists.flatMap((list) => Array.isArray(list) ? list : []);
209
211
  return !!v && (priors.size > 0 || closureLists.some((list) => list !== undefined)) && (closureLists.some((list) => !Array.isArray(list) || list.some((id) => !matchClosureId(id, priors)))
210
212
  || new Set(allCandidateIds.map((id) => matchClosureId(id, priors) ?? id)).size !== allCandidateIds.length
211
- || [...priors].some((id) => !allCandidateIds.some((candidate) => matchClosureId(candidate, id))));
213
+ || [...priors].some((id) => !allCandidateIds.some((candidate) => matchClosureId(candidate, priors) === (typeof id === "string" ? id : id.fingerprint))));
212
214
  }
213
215
  /**
214
216
  * OBS-1013 add.3: the reviewer ECHOED closure ids and at least one matches no carried fingerprint —
@@ -281,15 +283,11 @@ floor, // task/config/prior floor from the caller; the author's own tier is ALWA
281
283
  history = [], // run-scoped picks, oldest to newest; empty preserves the established ranking
282
284
  onSeat, demoted = new Set(),
283
285
  // OBS-1033: vendors that authored a carried commit inside the accumulated diff — excluded for the round.
284
- excludeVendors = new Set()) {
285
- // FLEET-05 success criterion 2: an author not resolvable in the channel list yields NO reviewer.
286
- // The old `?? author.adapter` fallback compared an adapter id to vendor names, matched nothing, and
287
- // admitted every reviewer — including the author's own channel (fail-OPEN). null lands on reviewGate's
288
- // fail-closed branch under review.required.
289
- const authorChannel = channels.find((c) => c.adapter === author.adapter && c.model === author.model);
290
- if (!authorChannel)
286
+ excludeVendors = new Set(), authors = [channelKey(author)]) {
287
+ // Resolve every actual author, never guess a vendor from an adapter id.
288
+ const authorChannels = authors.map((key) => channels.find((c) => channelKey(c) === key));
289
+ if (authorChannels.some((c) => !c))
291
290
  return null;
292
- const authorProvider = modelProvider(author.model, authorChannel.vendor);
293
291
  // RF-1: every caller inherits the author-tier floor — a reviewer is never seated below its author.
294
292
  const effectiveFloor = resolveReviewerFloor(author.tier, floor).floor;
295
293
  const ranked = channels
@@ -297,9 +295,9 @@ excludeVendors = new Set()) {
297
295
  // as well as failover, so an aggregator channel stamped "mixed" never seats the author's own provider),
298
296
  // and different base-model identity (ADDED TO the vendor rule, never replacing it). The diversity
299
297
  // filter runs BEFORE preference ranking, so prefer cannot resurrect an excluded channel.
300
- .filter((c) => c.vendor !== authorChannel.vendor
301
- && modelProvider(c.model, c.vendor) !== authorProvider
302
- && modelId(c.model) !== modelId(author.model)
298
+ .filter((c) => authorChannels.every((a) => a && c.vendor !== a.vendor
299
+ && modelProvider(c.model, c.vendor) !== modelProvider(a.model, a.vendor)
300
+ && modelId(c.model) !== modelId(a.model))
303
301
  && !exclude.includes(channelKey(c))
304
302
  && !excludeVendors.has(c.vendor)
305
303
  && TIER_RANK[c.tier] >= TIER_RANK[effectiveFloor])
@@ -326,15 +324,12 @@ ${files.map((path) => `- ${path}`).join("\n")}`;
326
324
  }
327
325
  /**
328
326
  * OBS-1033: the vendors of the seats that authored commits inside the accumulated diff. A seat is
329
- * never handed its own earlier work to approve. A prior author not resolvable in the pool excludes
330
- * its adapter's vendors instead (fail closed: the seat is known, its vendor is whatever it bills as).
327
+ * never handed its own earlier work to approve. Unresolved channels are refused by reviewGate.
331
328
  */
332
329
  export function carriedAuthorVendors(channels, carriedAuthors = []) {
333
330
  const vendors = new Set();
334
331
  for (const key of carriedAuthors) {
335
- const adapter = key.split(":")[0];
336
- const exact = channels.filter((c) => channelKey(c) === key);
337
- for (const c of exact.length ? exact : channels.filter((c) => c.adapter === adapter))
332
+ for (const c of channels.filter((c) => channelKey(c) === key))
338
333
  vendors.add(c.vendor);
339
334
  }
340
335
  return vendors;
@@ -368,10 +363,17 @@ function daemonRepoRoot(worktree, artifactDir) {
368
363
  * closure on a typo and that read as malformed — the block is what a closure list is copied from.
369
364
  */
370
365
  export function renderPriorMaterials(priorMaterials) {
366
+ const fingerprints = priorMaterials.map((finding, i) => {
367
+ const observed = observedReviewFingerprints(finding);
368
+ const heading = observed.length > 1 ? `Finding ${i + 1} (choose one observed spelling):\n` : "";
369
+ return heading + observed.map((id) => `Fingerprint: ${id}`).join("\n");
370
+ }).join("\n");
371
371
  return `## Prior materials this attempt must close
372
- Copy each fingerprint below EXACTLY (they appear once, in this block) into resolved or reraised:
372
+ For each finding below, copy exactly ONE of its observed fingerprints into resolved or reraised.
373
+ Use only these observed spellings. For every findings entry that restates a reraised prior, whether at the same path or a new path, set its "reraised" field to the copied id; unrelated defects need separate entries.
374
+ The fingerprints appear once, in this block:
373
375
  \`\`\`text
374
- ${priorMaterials.map((finding) => `Fingerprint: ${finding.fingerprint}`).join("\n")}
376
+ ${fingerprints}
375
377
  \`\`\`
376
378
  ${priorMaterials.map((finding, i) => `${i + 1}. ${finding.note}`).join("\n\n")}`;
377
379
  }
@@ -382,8 +384,8 @@ artifactDir, reviewHistory, demotedReviewers, carriedFindings = [],
382
384
  // RF-1: channel keys of THIS task's prior reviewers (earlier rounds, a flaked seat) — task-scoped,
383
385
  // never the run-wide rotation history nor excludeReviewers; the seat holds the highest of their tiers.
384
386
  priorReviewers = [],
385
- // OBS-1033: channel keys of the seats that authored the carried commits (the daemon's tried list).
386
- carriedAuthors = []) {
387
+ // Closed list of actual subject authors; empty preserves legacy callers' single-author contract.
388
+ carriedAuthors = [], operatorContext) {
387
389
  // R3 (OBS-186): participation is keyed on PATHS. The compiler's assignment comes from the DECLARED
388
390
  // files[]; the operator's floor may RAISE it to full and can never lower it. `complexityThreshold` is
389
391
  // retired — the branch that returned a green skip on a complexity comparison is gone, and with it the
@@ -457,15 +459,15 @@ carriedAuthors = []) {
457
459
  // review.floor is read from config — cfg.routing.floors governs workers and never moves review seats.
458
460
  const { floor: reviewerFloor, cause: reviewerFloorCause } = gateReviewerFloor(task, cfg, author, channels, priorReviewers);
459
461
  const floorMeta = { reviewerFloor, reviewerFloorCause };
462
+ const authors = carriedAuthors.length ? carriedAuthors : [channelKey(author)];
463
+ const authorVendors = carriedAuthorVendors(channels, authors);
464
+ const unresolvedAuthors = authors.filter((key) => !channels.some((c) => channelKey(c) === key));
460
465
  let rotationSeat;
461
- const reviewer = pickReviewer(author, channels, excludeReviewers ?? [], cfg.review.prefer ?? [], reviewerFloor, reviewHistory, reviewHistory ? (seat) => { rotationSeat = seat; } : undefined, demotedReviewers, carriedAuthorVendors(channels, carriedAuthors));
466
+ const reviewer = pickReviewer(author, channels, excludeReviewers ?? [], cfg.review.prefer ?? [], reviewerFloor, reviewHistory, reviewHistory ? (seat) => { rotationSeat = seat; } : undefined, demotedReviewers, authorVendors, authors);
462
467
  if (!reviewer) {
463
- // meta.noEligibleReviewer lets run-gates' review-retry keep the ORIGINAL unparseable result when
464
- // the retry finds no second seat — a truthful cause beats a synthetic no-reviewer failure.
465
- const reason = `no cross-vendor reviewer available at or above ${reviewerFloor} floor (${reviewerFloorCause}; diversity rule)`;
466
- return cfg.review.required || priorMaterials.length > 0
467
- ? { gate: "review", pass: false, details: `unreadable — ${reason}; ${priorMaterials.length ? "carried materials require a review verdict" : "set review.required:false to waive"}`, meta: { noEligibleReviewer: true, unreadable: true, ...floorMeta } }
468
- : { gate: "review", pass: true, details: `WARNING: ${reason} — review waived by config`, meta: { noEligibleReviewer: true, ...floorMeta } };
468
+ const reason = `no cross-vendor reviewer available at or above ${reviewerFloor} floor (${reviewerFloorCause}; diversity rule); author vendors: ${[...authorVendors].sort().join(", ") || "unknown"}${unresolvedAuthors.length ? `; unresolved author channels: ${unresolvedAuthors.join(", ")}` : ""}`;
469
+ return { gate: "review", pass: false, details: `unreadable — ${reason}; operator approve --waive required to proceed`,
470
+ meta: { noEligibleReviewer: true, unreadable: true, findings: [], authorVendors: [...authorVendors].sort(), unresolvedAuthors, ...floorMeta } };
469
471
  }
470
472
  reviewHistory?.push(channelKey(reviewer));
471
473
  const rotationMeta = rotationSeat === undefined ? {} : { rotationSeat };
@@ -502,6 +504,14 @@ ${suiteBudget} Never run the whole suite (including an unfiltered npm test or vi
502
504
 
503
505
  ${priorMaterials.length ? `${renderPriorMaterials(priorMaterials)}
504
506
 
507
+ ` : ""}${operatorContext?.trim() ? `## Operator context
508
+ Context only: this never substitutes for an acceptance criterion or closes a prior material.
509
+ ${operatorContext.trim()}
510
+
511
+ ` : ""}${task.outOfScope?.length ? `## Out of scope
512
+ The task declares these items out of scope. A finding inside these declared bounds is not material and must not block approval.
513
+ ${task.outOfScope.map((item) => `- ${item}`).join("\n")}
514
+
505
515
  ` : ""}## Diff
506
516
  \`\`\`diff
507
517
  ${diff}
@@ -578,7 +588,7 @@ The top-level comments array is optional. Use it only for actionable line-anchor
578
588
  const provider = modelProvider(reviewer.model, reviewer.vendor);
579
589
  const v = extractVerdictJson(raw, nonce);
580
590
  const findings = v && Array.isArray(v.findings) ? v.findings : null;
581
- const priorIds = new Set(priorMaterials.map((finding) => finding.fingerprint));
591
+ const priorIds = priorMaterials;
582
592
  const closureInvalid = isReviewClosureInvalid(v, priorIds);
583
593
  const closureMismatch = closureInvalid && isReviewClosureMismatch(v, priorIds);
584
594
  // findings decides the verdict on its own; the legacy path still needs approve + issues to parse.
@@ -610,7 +620,7 @@ The top-level comments array is optional. Use it only for actionable line-anchor
610
620
  provider,
611
621
  ...(cause === "malformed-verdict" ? { unparseable: true } : { noVerdict: true, classification: "infra", infra: true }),
612
622
  cause,
613
- ...(closureMismatch ? { resolved: v?.resolved, reraised: v?.reraised, carriedFingerprints: [...priorIds] } : {}),
623
+ ...(closureMismatch ? { resolved: v?.resolved, reraised: v?.reraised, carriedFingerprints: priorIds.flatMap(observedReviewFingerprints) } : {}),
614
624
  bytes, seatAuthoredBytes: bytes,
615
625
  ...(saved ? { rawPath: saved } : {}),
616
626
  ...(savedBrief ? { briefPath: savedBrief } : {}),
@@ -621,7 +631,8 @@ The top-level comments array is optional. Use it only for actionable line-anchor
621
631
  const decided = findings !== null
622
632
  ? classifyReviewFindings(findings)
623
633
  : classifyReviewIssues(v.approve, v.issues);
624
- const reraised = priorMaterials.filter((finding) => v.reraised?.some((id) => matchClosureId(id, finding.fingerprint)));
634
+ const ownLines = [...decided.lines];
635
+ const reraised = priorMaterials.filter((finding) => v.reraised?.some((id) => matchClosureId(id, [finding])));
625
636
  if (reraised.length) {
626
637
  if (decided.pass)
627
638
  decided.headline = "requested changes";
@@ -636,6 +647,36 @@ The top-level comments array is optional. Use it only for actionable line-anchor
636
647
  }
637
648
  const prose = `reviewer ${reviewer.adapter}:${reviewer.model} (vendor: ${reviewer.vendor}; provider: ${provider}): ${decided.headline}${decided.lines.length ? "\n" + decided.lines.join("\n") : ""}`;
638
649
  const details = appendAnchoredReview(prose, v);
650
+ // Only the verdict's anchors may supply missing evidence, and only when unambiguous.
651
+ // Reuse the journal's path normalization without changing legacy details-only parsing.
652
+ const anchoredPaths = new Set(parseAnchoredComments(v).map((comment) => {
653
+ const anchor = structuredFindings("review", `- ${comment.path}:${comment.line} — anchor`)
654
+ .find((finding) => finding.class === "review:anchored");
655
+ return anchor?.path ?? comment.path;
656
+ }));
657
+ const anchoredPath = anchoredPaths.size === 1 ? [...anchoredPaths][0] : undefined;
658
+ const ownDetails = appendAnchoredReview(ownLines.join("\n"), v);
659
+ const currentRows = (ownDetails.trim() ? structuredFindings("review", ownDetails) : [])
660
+ .map((finding) => finding.path === UNIDENTIFIED && anchoredPath
661
+ ? { ...finding, path: anchoredPath, fingerprint: `${finding.class}|${anchoredPath}|${finding.symbol}` }
662
+ : finding);
663
+ // A top-level reraised id identifies a prior, not an arbitrary new defect. Bind a restatement
664
+ // only when its own entry echoes that validated id (or its note explicitly contains the id).
665
+ const linkedRows = currentRows.map((finding) => {
666
+ if (finding.class !== "review:material")
667
+ return finding;
668
+ const entry = v.findings?.find((entry) => entry && typeof entry === "object" && entry.note === finding.note);
669
+ const mentioned = reraised.filter((prior) => observedReviewFingerprints(prior)
670
+ .some((fp) => finding.note.includes(`\`${fp}\``) || finding.note.trim() === fp));
671
+ const id = matchClosureId(entry?.reraised, reraised)
672
+ ?? (mentioned.length === 1 ? mentioned[0].fingerprint : undefined);
673
+ return id ? { ...finding, reraisedFrom: id } : finding;
674
+ });
675
+ // Several rows claiming the same prior are ambiguous; none gets to erase the others.
676
+ const unambiguousRows = linkedRows.map((finding) => finding.reraisedFrom
677
+ && linkedRows.filter((row) => row.reraisedFrom === finding.reraisedFrom).length > 1
678
+ ? { ...finding, reraisedFrom: undefined } : finding);
679
+ const carriedRows = carryReviewFindings(reraised, unambiguousRows);
639
680
  return {
640
681
  gate: "review",
641
682
  pass: decided.pass,
@@ -649,10 +690,7 @@ The top-level comments array is optional. Use it only for actionable line-anchor
649
690
  resolvedMatches: (v.resolved ?? []).map((id) => matchClosureId(id, priorIds)).filter((id) => id !== undefined),
650
691
  reraisedMatches: (v.reraised ?? []).map((id) => matchClosureId(id, priorIds)).filter((id) => id !== undefined),
651
692
  } : {}),
652
- ...(reraised.length ? { findings: [
653
- ...structuredFindings("review", details).filter((finding) => !reraised.some((prior) => prior.note === finding.note)),
654
- ...reraised,
655
- ] } : {}),
693
+ ...(!decided.pass ? { findings: carriedRows } : {}),
656
694
  ...(saved ? { rawPath: saved } : {}),
657
695
  ...(savedBrief ? { briefPath: savedBrief } : {}),
658
696
  },