@agentsdance/codejury 0.1.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.
@@ -0,0 +1,43 @@
1
+ {
2
+ "_comment": [
3
+ "Copy to jury.config.json to override or add agents. That file is per-machine",
4
+ "and gitignored: which reviewers you have installed is a property of your box,",
5
+ "not of the project.",
6
+ "",
7
+ "Built-in defaults already cover claude (main), codex and droid, so this file is",
8
+ "only needed to add an agent or disable one.",
9
+ "",
10
+ "Placeholders: {{worktree}} {{promptFile}} {{promptText}} {{sha}}"
11
+ ],
12
+ "agents": [
13
+ { "name": "droid", "enabled": false },
14
+
15
+ {
16
+ "name": "agy",
17
+ "enabled": true,
18
+ "role": "reviewer",
19
+ "promptDelivery": "argv",
20
+ "cwd": "worktree",
21
+ "argv": [
22
+ "agy",
23
+ "--dangerously-skip-permissions",
24
+ "--add-dir",
25
+ "{{worktree}}",
26
+ "--print-timeout",
27
+ "20m",
28
+ "--print",
29
+ "{{promptText}}"
30
+ ],
31
+ "resume": { "supported": false, "reason": "print mode starts a fresh session per run" },
32
+ "report": "whole",
33
+ "expectSeconds": 300,
34
+ "_notes": [
35
+ "Two quirks, each of which cost a wasted run:",
36
+ " 1. --dangerously-skip-permissions must precede --print, or every file",
37
+ " read is auto-denied and the reviewer returns an empty report.",
38
+ " 2. agy picks its own working directory, so the worktree needs --add-dir",
39
+ " AND a mention in the prompt text; process cwd alone is ignored."
40
+ ]
41
+ }
42
+ ]
43
+ }
package/lib/agents.js ADDED
@@ -0,0 +1,295 @@
1
+ // Spawning reviewers and reading what they said back.
2
+ import { spawn } from "node:child_process";
3
+ import { randomUUID } from "node:crypto";
4
+ import { mkdtemp, writeFile, rm } from "node:fs/promises";
5
+ import { tmpdir } from "node:os";
6
+ import path from "node:path";
7
+
8
+ function subst(argv, vars) {
9
+ return argv.map((a) => a.replace(/\{\{(\w+)\}\}/g, (_, k) => vars[k] ?? ""));
10
+ }
11
+
12
+ /**
13
+ * Run one reviewer against a worktree. Never throws for a failing agent — a
14
+ * dead reviewer is a result, not a crash, and the round should still report the
15
+ * others.
16
+ */
17
+ export async function runAgent(agent, { worktree, prompt, stopToken, dryRun, onLog, onChunk, timeoutSeconds }) {
18
+ const started = Date.now();
19
+
20
+ if (dryRun) {
21
+ await new Promise((r) => setTimeout(r, 120));
22
+ return {
23
+ agent: agent.name,
24
+ ok: true,
25
+ seconds: 0.1,
26
+ verdict: "clean",
27
+ findings: [],
28
+ report: `${stopToken}\n\n(dry run — ${agent.name} was not executed)`,
29
+ raw: "",
30
+ };
31
+ }
32
+
33
+ let promptFile = null;
34
+ let tmp = null;
35
+ if (agent.promptDelivery === "file") {
36
+ tmp = await mkdtemp(path.join(tmpdir(), "jury-"));
37
+ promptFile = path.join(tmp, "prompt.md");
38
+ await writeFile(promptFile, prompt, "utf8");
39
+ }
40
+
41
+ // An agent that takes its session id as input gets one generated here, so the
42
+ // reply can name the exact conversation instead of asking for "the last one"
43
+ // and hoping nothing else ran in between.
44
+ const assigned = agent.newSession ? randomUUID() : null;
45
+ const vars = {
46
+ worktree, promptFile: promptFile ?? "", promptText: prompt,
47
+ sessionId: assigned ?? "",
48
+ };
49
+ const [cmd, ...args] = subst(agent.argv, vars);
50
+ const cwd = agent.cwd === "worktree" ? worktree : process.cwd();
51
+
52
+ // The caller already prints the agent's name at the head of this line, so
53
+ // repeating it here read as "codex: codex (in worktree)".
54
+ onLog?.(`${cmd} (${agent.cwd === "worktree" ? "in worktree" : "via flag"})`);
55
+
56
+ const limit = (timeoutSeconds ?? Math.max(600, (agent.expectSeconds ?? 600) * 3)) * 1000;
57
+
58
+ const out = await new Promise((resolve) => {
59
+ let stdout = "";
60
+ let stderr = "";
61
+ let child;
62
+ try {
63
+ // stdin must be closed, not an open pipe. A pipe that never delivers and
64
+ // never ends leaves an agent waiting on input forever: codex sat at 0% CPU
65
+ // for over an hour before this was fixed.
66
+ child = spawn(cmd, args, { cwd, env: process.env, stdio: ["ignore", "pipe", "pipe"] });
67
+ } catch (err) {
68
+ resolve({ code: -1, stdout: "", stderr: String(err) });
69
+ return;
70
+ }
71
+
72
+ // And a hard stop, so one stuck reviewer cannot hold the whole round.
73
+ const timer = setTimeout(() => {
74
+ onLog?.(`${agent.name}: no result after ${Math.round(limit / 1000)}s — terminating`);
75
+ child.kill("SIGTERM");
76
+ setTimeout(() => child.kill("SIGKILL"), 5000);
77
+ resolve({ code: -1, stdout, stderr: stderr + `\ntimed out after ${Math.round(limit / 1000)}s`, timedOut: true });
78
+ }, limit);
79
+
80
+ // Accumulate for the final parse AND hand each chunk on as it lands. A
81
+ // reviewer that takes twenty minutes is otherwise a black box for all
82
+ // twenty: nothing reaches disk until close, so "still thinking" and
83
+ // "wedged" look identical to anyone watching.
84
+ child.stdout.on("data", (d) => { stdout += d; onChunk?.(String(d)); });
85
+ child.stderr.on("data", (d) => (stderr += d));
86
+ child.on("error", (err) => { clearTimeout(timer); resolve({ code: -1, stdout, stderr: stderr + String(err) }); });
87
+ child.on("close", (code) => { clearTimeout(timer); resolve({ code, stdout, stderr }); });
88
+ });
89
+
90
+ if (tmp) await rm(tmp, { recursive: true, force: true });
91
+
92
+ const seconds = +((Date.now() - started) / 1000).toFixed(1);
93
+ if (out.code !== 0 && !out.stdout.trim()) {
94
+ return {
95
+ agent: agent.name,
96
+ ok: false,
97
+ seconds,
98
+ verdict: "error",
99
+ findings: [],
100
+ report: (out.stderr || `exited ${out.code}`).trim().slice(0, 2000),
101
+ raw: out.stdout,
102
+ };
103
+ }
104
+
105
+ // A resumable agent that prints a session id gets it captured here. Without
106
+ // it a reply can only say "resume the last session", which is the review only
107
+ // if nothing else ran in the meantime.
108
+ let sessionId = assigned;
109
+ if (!sessionId && agent.resume?.idFrom) {
110
+ try {
111
+ const re = agent.resume.idFrom instanceof RegExp
112
+ ? agent.resume.idFrom
113
+ : new RegExp(agent.resume.idFrom, "i");
114
+ sessionId = out.stdout.match(re)?.[1] ?? null;
115
+ } catch { /* a bad pattern must not fail the review */ }
116
+ }
117
+
118
+ const report = extractReport(out.stdout, agent.report);
119
+ const findings = parseFindings(report);
120
+ // The stop token plus header-formatted findings is a contradiction, and the
121
+ // findings win. This catches only what carries a FINDING header — a reviewer
122
+ // that signs off and then describes a bug in plain prose still reads as
123
+ // clean, because nothing here can tell that prose from a cosmetic note. The
124
+ // triage gate is what covers that; this is not a substitute for reading the
125
+ // report.
126
+ const said = hasStopToken(report, stopToken);
127
+ return {
128
+ agent: agent.name,
129
+ ok: true,
130
+ seconds,
131
+ verdict: said && !findings.length ? "clean" : "found",
132
+ contradicted: said && findings.length > 0,
133
+ findings,
134
+ report,
135
+ sessionId,
136
+ raw: out.stdout,
137
+ };
138
+ }
139
+
140
+ /**
141
+ * A verbose agent prints a transcript that includes the prompt it was given, so
142
+ * the stop token appears in the instruction as well as the answer. Take the tail
143
+ * for those; the whole stream for agents that print only their report.
144
+ */
145
+ export function extractReport(stdout, mode = "whole") {
146
+ const text = stdout.replace(/\r/g, "");
147
+ if (mode !== "tail") return text.trim();
148
+ const lines = text.split("\n");
149
+ // The last agent turn starts at the final bare "codex" marker line.
150
+ for (let i = lines.length - 1; i >= 0; i--) {
151
+ if (lines[i].trim() === "codex") return lines.slice(i + 1).join("\n").trim();
152
+ }
153
+ return lines.slice(-80).join("\n").trim();
154
+ }
155
+
156
+ // Agents write markdown even when asked for plain text, so strip the decoration
157
+ // before matching rather than spelling every variant out in the pattern.
158
+ // Headings and list numbering count: "### FINDING:" and "1. FINDING:" are both
159
+ // ordinary agent formatting.
160
+ const bare = (s) => s
161
+ .replace(/\*\*|`/g, "")
162
+ .replace(/^\s*#{1,6}\s+/, "")
163
+ .replace(/^\s*(?:[-*>]\s*)+/, "")
164
+ .replace(/^\s*\d+[.)]\s+/, "")
165
+ .trim();
166
+
167
+ /**
168
+ * The token must stand on its own line — not merely appear in the prose. It is
169
+ * compared bare, because a reviewer that bolds its output would otherwise never
170
+ * be able to end the loop, at roughly twenty minutes per wasted round.
171
+ */
172
+ export function hasStopToken(report, stopToken) {
173
+ return report.split("\n").some((l) => bare(l) === stopToken);
174
+ }
175
+
176
+ /**
177
+ * Which lines are the agent's own words rather than quoted material. The prompt
178
+ * shows the FINDING/WHERE format, so a reviewer that echoes the instructions
179
+ * back — or quotes a diff — would otherwise have the placeholder filed as a
180
+ * real finding, and a clean round would be reported as not converged.
181
+ */
182
+ function speaking(lines) {
183
+ let fenced = false;
184
+ const open = lines.map((l) => {
185
+ if (/^\s*(?:```|~~~)/.test(l)) { fenced = !fenced; return false; }
186
+ return !fenced;
187
+ });
188
+
189
+ // The prompt shows the header format inside a fence, so agents reasonably
190
+ // answer in the same shape: a fenced block containing only FINDING/WHERE is
191
+ // the reviewer's own finding, not quoted material. Reading it as a quote lost
192
+ // every finding one reviewer raised — five real bugs, none tracked.
193
+ // A fence holding anything else (a diff, an error, sample code) still is not
194
+ // speech, so the protection this rule exists for is unaffected.
195
+ for (let i = 0; i < lines.length; i++) {
196
+ if (open[i] || !/^\s*(?:```|~~~)/.test(lines[i])) continue;
197
+ let j = i + 1;
198
+ const body = [];
199
+ while (j < lines.length && !/^\s*(?:```|~~~)/.test(lines[j])) body.push(j++);
200
+ // A fence that OPENS with FINDING: is the reviewer answering in the shape
201
+ // the prompt showed. Requiring every line to be a header lost the whole
202
+ // finding the moment a reviewer added its explanation inside the same
203
+ // fence — and reviewers do that constantly. A fence starting with anything
204
+ // else (a diff, an error, sample code) is still quoted material.
205
+ // The reviewer is speaking when the fence opens with FINDING: and carries
206
+ // no code. Requiring EVERY line to be a header lost the whole finding as
207
+ // soon as a reviewer put its explanation in the same fence — which they do
208
+ // constantly. Accepting any fence that merely starts with FINDING: went too
209
+ // far the other way and swallowed quoted diffs. Prose after the headers is
210
+ // fine; a diff or code line means it is quoted material.
211
+ const said = body.map((k) => bare(lines[k])).filter(Boolean);
212
+ const code = (l) => /^[+-]\s|^[+-]{1,2}[^-]|[;{}]\s*$|^\s*(?:function|const|let|var|import|def|class)\b/.test(l);
213
+ if (said.length && /^FINDING\s*:/i.test(said[0]) && !said.some(code)) {
214
+ for (const k of body) open[k] = true;
215
+ }
216
+ i = j;
217
+ }
218
+ return open;
219
+ }
220
+
221
+ /**
222
+ * Pull the FINDING/WHERE headers out of a report so each claim can be tracked
223
+ * across rounds. Prose without a header is left alone — the full report is
224
+ * recorded regardless, this only decides what becomes a trackable finding.
225
+ */
226
+ export function parseFindings(report) {
227
+ const lines = report.replace(/\r/g, "").split("\n");
228
+ const own = speaking(lines);
229
+
230
+ const heads = [];
231
+ for (let i = 0; i < lines.length; i++) {
232
+ if (!own[i]) continue;
233
+ const claim = bare(lines[i]).match(/^FINDING\s*:\s*(.+)$/i);
234
+ // "<the claim, one line>" is the template from the prompt, not a claim. An
235
+ // agent that restates the format it was given must not have the
236
+ // placeholder filed against it as a real finding.
237
+ if (claim && !/^<.*>$/.test(claim[1].trim())) heads.push({ i, claim: claim[1] });
238
+ }
239
+
240
+ return heads.map((h, n) => {
241
+ // A finding runs until the next one starts, so nothing below can reach into
242
+ // the following finding's text. When the next header sits inside a fence,
243
+ // that fence's opening line — and any heading introducing it — are already
244
+ // the next finding, so stop there rather than swallowing them.
245
+ let end = n + 1 < heads.length ? heads[n + 1].i : lines.length;
246
+ if (n + 1 < heads.length) {
247
+ let k = end - 1;
248
+ if (k > h.i && /^\s*(?:```|~~~)/.test(lines[k])) k--; // its opening fence
249
+ while (k > h.i && !lines[k].trim()) k--; // blank space before it
250
+ if (k > h.i && /^\s*#{1,6}\s+/.test(lines[k])) k--; // the heading above it
251
+ end = k + 1;
252
+ }
253
+
254
+ let where = -1;
255
+ let loc = "";
256
+ for (let j = h.i + 1; j < end; j++) {
257
+ const w = own[j] ? bare(lines[j]).match(/^WHERE\s*:\s*(.+)$/i) : null;
258
+ if (w) { where = j; loc = w[1]; break; }
259
+ }
260
+
261
+ // A claim asked for on one line often wraps onto a second. Whatever sits
262
+ // between the header and the WHERE is the rest of the claim — the
263
+ // explanation comes after the WHERE. More than two lines is prose, not a
264
+ // wrap, so it is left in the body rather than glued into the claim.
265
+ const gap = where > h.i + 1
266
+ ? lines.slice(h.i + 1, where).map(bare).filter(Boolean)
267
+ : [];
268
+ const wrapped = gap.length <= 2 ? gap : [];
269
+
270
+ let bodyFrom = where >= 0 && wrapped.length === gap.length ? where + 1 : h.i + 1;
271
+ // When the header pair was fenced, the fence that closes it is not prose.
272
+ if (bodyFrom < end && /^\s*(?:```|~~~)\s*$/.test(lines[bodyFrom])) bodyFrom++;
273
+ const body = lines.slice(bodyFrom, end).join("\n").replace(/^\s*[-—]{3,}\s*$/gm, "").trim();
274
+ return { claim: [h.claim, ...wrapped].join(" "), loc, body };
275
+ });
276
+ }
277
+
278
+ export async function probe(agent) {
279
+ const bin = agent.argv?.[0];
280
+ // An agent with no argv is in-process — the session driving the loop, not
281
+ // something spawned. There is no binary to look for, and reporting it missing
282
+ // would fail `jury agents` for an agent that is by definition present.
283
+ if (!bin) return { name: agent.name, bin: "", path: "(in-process)", ok: true, inProcess: true };
284
+ // No shell: passing args through one is both a deprecation warning and an
285
+ // injection surface, and the agent name comes from a config file.
286
+ const lookup = process.platform === "win32" ? "where" : "which";
287
+ const found = await new Promise((resolve) => {
288
+ const c = spawn(lookup, [bin]);
289
+ let out = "";
290
+ c.stdout.on("data", (d) => (out += d));
291
+ c.on("close", (code) => resolve(code === 0 ? out.trim().split("\n")[0] : null));
292
+ c.on("error", () => resolve(null));
293
+ });
294
+ return { name: agent.name, bin, path: found, ok: Boolean(found) };
295
+ }
package/lib/config.js ADDED
@@ -0,0 +1,157 @@
1
+ // Agent registry. Built-in defaults cover codex and droid, so nothing has to be
2
+ // configured for the common case; jury.config.json overrides or adds agents.
3
+ //
4
+ // Only three things vary between agents, and all three bit during the reference
5
+ // run on !1158:
6
+ // promptDelivery argv (codex) vs a file flag (droid -f)
7
+ // cwd process cwd (codex) vs an explicit --cwd flag (droid)
8
+ // resume codex keeps a session; droid's exec output carries no id
9
+ import { readFile } from "node:fs/promises";
10
+ import path from "node:path";
11
+
12
+ export const DEFAULTS = {
13
+ stopToken: "NO NEW FINDINGS",
14
+ agents: [
15
+ {
16
+ name: "claude",
17
+ product: "Anthropic Claude Code",
18
+ role: "main",
19
+ // The main agent used to carry empty argv, on the theory that it was the
20
+ // session driving the loop rather than something spawned. That made the
21
+ // loop unable to run without a person in the chair: nothing triaged, so
22
+ // nothing was ever fixed and every round re-read the same commit. It is
23
+ // spawned like any other agent now — the difference is that it is the
24
+ // only one permitted to touch the tree.
25
+ promptDelivery: "argv",
26
+ cwd: "worktree",
27
+ argv: [
28
+ "claude", "-p", "{{promptText}}",
29
+ "--permission-mode", "acceptEdits",
30
+ "--add-dir", "{{worktree}}",
31
+ ],
32
+ resume: { supported: false, reason: "each finding is judged on its own merits" },
33
+ report: "whole",
34
+ // Triage is the longest step in a round: read, reproduce, fix, run the
35
+ // suite. It was 0 back when this agent was never spawned, which made its
36
+ // timeout 0 and killed it the instant it started.
37
+ expectSeconds: 900,
38
+ notes:
39
+ "Owns the working tree and the commit. Reproduces a finding before fixing it, proves the " +
40
+ "regression test fails without the fix, and replies to every finding including rejections.",
41
+ },
42
+ {
43
+ name: "codex",
44
+ product: "OpenAI Codex",
45
+ role: "reviewer",
46
+ promptDelivery: "argv",
47
+ cwd: "worktree",
48
+ argv: ["codex", "exec", "--skip-git-repo-check", "{{promptText}}"],
49
+ // `--last` means "whatever session ran most recently in this worktree",
50
+ // which is only the review being answered if nothing else ran in between.
51
+ // Any codex session started between the review and the reply steals the
52
+ // reply: the verdict is delivered into an unrelated conversation, which
53
+ // breaks both continuity and the isolation the whole design rests on.
54
+ // {{sessionId}} is substituted when the review recorded one; replyArgv
55
+ // falls back to a fresh session rather than resuming the wrong thread.
56
+ resume: {
57
+ supported: true,
58
+ argv: ["codex", "exec", "resume", "{{sessionId}}", "{{promptText}}"],
59
+ idFrom: /session[ _-]?id[:=]?\s*([0-9a-f-]{8,})/i,
60
+ },
61
+ // stdout is a full transcript that contains the prompt, so the stop token
62
+ // appears in the instruction as well as the answer. Read the tail only.
63
+ report: "tail",
64
+ expectSeconds: 1100,
65
+ },
66
+ {
67
+ name: "grok",
68
+ product: "xAI Grok",
69
+ role: "reviewer",
70
+ promptDelivery: "argv",
71
+ cwd: "flag",
72
+ // grok assigns its own session UUID up front rather than making us guess
73
+ // which one was ours afterwards, so resume names an exact conversation.
74
+ // {{sessionId}} is generated per review by runAgent and handed back here.
75
+ argv: [
76
+ "grok", "--cwd", "{{worktree}}", "--always-approve",
77
+ "--session-id", "{{sessionId}}", "-p", "{{promptText}}",
78
+ ],
79
+ newSession: true,
80
+ resume: {
81
+ supported: true,
82
+ argv: ["grok", "--cwd", "{{worktree}}", "--always-approve", "--resume", "{{sessionId}}", "-p", "{{promptText}}"],
83
+ },
84
+ report: "whole",
85
+ expectSeconds: 240,
86
+ },
87
+ {
88
+ name: "droid",
89
+ product: "Factory Droid",
90
+ role: "reviewer",
91
+ promptDelivery: "file",
92
+ cwd: "flag",
93
+ argv: ["droid", "exec", "--cwd", "{{worktree}}", "--auto", "medium", "-f", "{{promptFile}}"],
94
+ resume: { supported: false, reason: "exec output carries no session id" },
95
+ report: "whole",
96
+ expectSeconds: 130,
97
+ },
98
+ ],
99
+ };
100
+
101
+ /**
102
+ * Config file names, newest first.
103
+ *
104
+ * The tool was previously called `cr`, and `macr` before that. Both old names
105
+ * remain readable across the rename — a config that
106
+ * silently stops being found is worse than a deprecated filename, because the
107
+ * failure looks like "every agent is suddenly missing".
108
+ */
109
+ export const CONFIG_NAMES = ["jury.config.json", "cr.config.json", "macr.config.json"];
110
+
111
+ export async function loadConfig(dir = process.cwd()) {
112
+ let user = {};
113
+ let found = CONFIG_NAMES[0];
114
+ for (const name of CONFIG_NAMES) {
115
+ try {
116
+ user = JSON.parse(await readFile(path.join(dir, name), "utf8"));
117
+ found = name;
118
+ break;
119
+ } catch (err) {
120
+ if (err.code === "ENOENT") continue;
121
+ throw new Error(`${name} is not valid JSON: ${err.message}`);
122
+ }
123
+ }
124
+
125
+ const byName = new Map(DEFAULTS.agents.map((a) => [a.name, { ...a }]));
126
+ for (const a of user.agents ?? []) {
127
+ if (!a.name) throw new Error(`each agent in ${found} needs a name`);
128
+ byName.set(a.name, { ...(byName.get(a.name) ?? {}), ...a });
129
+ }
130
+
131
+ const agents = [...byName.values()].filter((a) => a.enabled !== false);
132
+
133
+ // Exactly one writer. Two agents both believing they own the commit is the
134
+ // one misconfiguration that corrupts a run rather than merely failing it.
135
+ const mains = agents.filter((a) => a.role === "main");
136
+ if (mains.length > 1) {
137
+ throw new Error(
138
+ `only one agent may have role "main"; found ${mains.map((a) => a.name).join(", ")}`,
139
+ );
140
+ }
141
+
142
+ return {
143
+ stopToken: user.stopToken ?? DEFAULTS.stopToken,
144
+ agents,
145
+ main: mains[0] ?? null,
146
+ configFile: path.join(dir, found),
147
+ };
148
+ }
149
+
150
+ /** The agent that owns the working tree and the commit. Never spawned. */
151
+ export function mainAgent(cfg) {
152
+ return cfg.main ?? null;
153
+ }
154
+
155
+ export function reviewers(cfg) {
156
+ return cfg.agents.filter((a) => (a.role ?? "reviewer") === "reviewer");
157
+ }
@@ -0,0 +1,112 @@
1
+ // The triage gate.
2
+ //
3
+ // DESIGN.md: "A skill can be skipped. That check cannot." The judgement half —
4
+ // does this finding actually reproduce — stays with the calling agent. The
5
+ // mechanical half is enforced here: an accepted finding must carry a recorded
6
+ // reproduction, and a fix must have a test that was observed failing without it.
7
+ import { readEvents } from "./store.js";
8
+
9
+ export const VERDICTS = ["accepted", "deferred", "rejected", "superseded"];
10
+
11
+ /** Fold the log down to one record per finding id. */
12
+ export async function findingsIn(dir) {
13
+ const byId = new Map();
14
+ for (const e of await readEvents(dir)) {
15
+ switch (e.t) {
16
+ case "finding.raised":
17
+ byId.set(e.id, {
18
+ id: e.id, round: e.round, agent: e.agent, claim: e.claim,
19
+ // The reviewer's own prose. Needed verbatim when replying to an agent
20
+ // whose session cannot resume — it has to be shown what it said.
21
+ loc: e.loc ?? "", body: e.body ?? "",
22
+ status: "open", reproduced: null, test: null, reason: "",
23
+ // Folded from finding.turn events, never assigned by a caller.
24
+ contested: false, turns: 0,
25
+ });
26
+ break;
27
+ case "finding.reproduced": {
28
+ const f = byId.get(e.id);
29
+ if (f) { f.reproduced = e.evidence ?? true; f.test = e.test ?? f.test; }
30
+ break;
31
+ }
32
+ case "finding.resolved": {
33
+ const f = byId.get(e.id);
34
+ // e.test carried too: resolve --test is the proof the regression test
35
+ // failed without the fix, and dropping it here silently stripped that
36
+ // proof from the reply and from a later re-resolve.
37
+ if (f) {
38
+ f.status = e.verdict;
39
+ f.reason = e.reason ?? "";
40
+ f.test = e.test ?? f.test;
41
+ // Answering a re-raise settles it again. Without this a finding
42
+ // contested once stays contested forever and the turn limit is the
43
+ // only thing that ever ends it.
44
+ f.contested = false;
45
+ }
46
+ break;
47
+ }
48
+ // A reviewer re-arguing a finding we already answered. This has to be
49
+ // folded from the log rather than set by whoever happens to hold the
50
+ // Map: `outstanding` reads `contested` to decide what the next round must
51
+ // answer, and a field nothing ever writes makes the turn limit — and the
52
+ // whole disagreement path — dead code.
53
+ case "finding.turn": {
54
+ const f = byId.get(e.id);
55
+ if (f) {
56
+ f.turns = (f.turns ?? 0) + 1;
57
+ // Only a reviewer pushing back re-opens the argument; our own turn in
58
+ // the thread is not a rebuttal against ourselves.
59
+ if (e.who && e.who !== "claude") f.contested = true;
60
+ }
61
+ break;
62
+ }
63
+ }
64
+ }
65
+ return byId;
66
+ }
67
+
68
+ /**
69
+ * Why a resolve must be refused, or null when it may proceed.
70
+ *
71
+ * Only `accepted` is gated. Rejecting or deferring a finding is a judgement the
72
+ * operator is entitled to make without having reproduced anything — the whole
73
+ * point of the loop is that a third of suggestions do not survive contact, and
74
+ * demanding a reproduction before you may say "no" would invert that.
75
+ */
76
+ export function gate(finding, { verdict, test }) {
77
+ if (!finding) return "no such finding";
78
+ if (!VERDICTS.includes(verdict)) {
79
+ return `verdict must be one of ${VERDICTS.join(", ")}`;
80
+ }
81
+ if (verdict !== "accepted") return null;
82
+ if (!finding.reproduced) {
83
+ return `no finding.reproduced event for ${finding.id} — reproduce it before accepting it`;
84
+ }
85
+ // A fix whose test passes with the fix reverted is decoration, so the
86
+ // observation that it failed is what is recorded, not merely a test name.
87
+ const proof = test ?? finding.test;
88
+ if (!proof) {
89
+ return `accepting ${finding.id} needs --test <what failed without the fix>`;
90
+ }
91
+ return null;
92
+ }
93
+
94
+ /**
95
+ * The settled list, regenerated from the log rather than hand-maintained.
96
+ *
97
+ * This is what makes the loop terminate: without it every fresh reviewer
98
+ * rediscovers the same deferred issues, round after round, forever. Deferred
99
+ * and rejected entries carry their reasoning so a reviewer can argue with the
100
+ * reasoning instead of re-proposing a fix that was already considered.
101
+ */
102
+ export function settledList(findings) {
103
+ const lines = [];
104
+ let n = 0;
105
+ for (const f of findings.values()) {
106
+ if (f.status === "open") continue;
107
+ const why = f.reason ? ` — ${f.reason}` : "";
108
+ const label = { accepted: "fixed", deferred: "deferred", rejected: "NOT valid", superseded: "superseded" }[f.status];
109
+ lines.push(`${++n}. [round ${f.round}, ${f.agent}] ${f.claim}${f.loc ? ` (${f.loc})` : ""} — ${label}${why}`);
110
+ }
111
+ return lines.join("\n");
112
+ }