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.
- package/dist/adapters/claude-code.js +19 -6
- package/dist/adapters/prompt.js +1 -1
- package/dist/adapters/qwen.js +30 -3
- package/dist/adapters/types.d.ts +3 -0
- package/dist/cli/commands/approve.js +15 -3
- package/dist/cli/commands/beat.d.ts +2 -0
- package/dist/cli/commands/beat.js +28 -29
- package/dist/cli/commands/compile.js +11 -0
- package/dist/cli/commands/fleet.js +61 -53
- package/dist/cli/commands/init.js +1 -1
- package/dist/cli/commands/report.js +11 -1
- package/dist/cli/commands/verify.d.ts +4 -1
- package/dist/cli/commands/verify.js +18 -5
- package/dist/cli/help.d.ts +2 -0
- package/dist/cli/help.js +6 -4
- package/dist/compile/native.js +39 -4
- package/dist/config/config.d.ts +41 -3
- package/dist/config/config.js +48 -17
- package/dist/config/fleet-overlay.js +47 -62
- package/dist/drivers/orca.d.ts +1 -1
- package/dist/drivers/orca.js +21 -4
- package/dist/gates/baseline.d.ts +67 -0
- package/dist/gates/baseline.js +172 -10
- package/dist/gates/cache.d.ts +8 -0
- package/dist/gates/cache.js +26 -13
- package/dist/gates/review.d.ts +8 -7
- package/dist/gates/review.js +83 -45
- package/dist/gates/run-gates.d.ts +4 -1
- package/dist/gates/run-gates.js +32 -12
- package/dist/gates/test-manifest.d.ts +16 -1
- package/dist/gates/test-manifest.js +127 -27
- package/dist/gates/test-reporter.js +4 -0
- package/dist/graph/graph.d.ts +1 -1
- package/dist/graph/graph.js +6 -1
- package/dist/graph/schema.d.ts +2 -0
- package/dist/graph/schema.js +2 -0
- package/dist/route/preference.d.ts +1 -1
- package/dist/route/preference.js +10 -39
- package/dist/run/daemon.d.ts +7 -1
- package/dist/run/daemon.js +237 -53
- package/dist/run/git.d.ts +10 -0
- package/dist/run/git.js +49 -1
- package/dist/run/journal.d.ts +18 -3
- package/dist/run/journal.js +141 -17
- package/dist/run/merge.d.ts +13 -2
- package/dist/run/merge.js +98 -12
- package/dist/run/protocol.d.ts +82 -0
- package/dist/run/protocol.js +35 -0
- package/dist/run/receipt-resolver.d.ts +18 -0
- package/dist/run/receipt-resolver.js +132 -0
- package/dist/run/supervision.d.ts +14 -1
- package/dist/run/supervision.js +122 -24
- package/dist/tui/cockpit/evidence-view.d.ts +10 -1
- package/dist/tui/cockpit/evidence-view.js +37 -5
- package/dist/tui/cockpit/live-runtime.d.ts +4 -0
- package/dist/tui/cockpit/live-runtime.js +37 -3
- package/dist/tui/cockpit/live-store.d.ts +2 -0
- package/dist/tui/ink/fleet-app.d.ts +12 -22
- package/dist/tui/ink/fleet-app.js +520 -131
- package/package.json +1 -1
- package/schema/rungraph.schema.json +7 -0
- package/skills/tickmarkr-overseer/SKILL.md +91 -41
- package/skills/tickmarkr-overseer/scripts/watch-context.sh +14 -2
package/dist/gates/baseline.js
CHANGED
|
@@ -1,9 +1,140 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
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
|
|
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
|
|
638
|
-
|
|
639
|
-
|
|
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
|
}
|
package/dist/gates/cache.d.ts
CHANGED
|
@@ -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;
|
package/dist/gates/cache.js
CHANGED
|
@@ -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
|
-
|
|
332
|
+
renameSync(tmpPath, finalPath);
|
|
318
333
|
}
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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;
|
package/dist/gates/review.d.ts
CHANGED
|
@@ -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
|
|
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.
|
|
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>;
|
package/dist/gates/review.js
CHANGED
|
@@ -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
|
|
192
|
+
return reviewFingerprintMatches(candidate, target);
|
|
194
193
|
}
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
}
|
|
199
|
-
|
|
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 =
|
|
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
|
-
//
|
|
286
|
-
|
|
287
|
-
|
|
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 !==
|
|
301
|
-
&& modelProvider(c.model, c.vendor) !==
|
|
302
|
-
&& modelId(c.model) !== modelId(
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
${
|
|
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
|
-
//
|
|
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,
|
|
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
|
-
|
|
464
|
-
|
|
465
|
-
|
|
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 =
|
|
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:
|
|
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
|
|
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
|
-
...(
|
|
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
|
},
|