@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.
package/lib/loop.js ADDED
@@ -0,0 +1,400 @@
1
+ // The autonomous loop: reviewers argue with the main agent until they stop.
2
+ //
3
+ // Everything here exists because the original design left three jobs to a human
4
+ // sitting between rounds — deciding whether a finding reproduces, fixing what
5
+ // does, and pushing so the next round has new code to read. Without someone in
6
+ // that seat `--max-rounds 10` re-reviews the same SHA ten times and calls it a
7
+ // loop. This module fills the seat.
8
+ //
9
+ // The shape is a conversation, not a pipeline. A reviewer makes a claim, the
10
+ // main agent answers it — reproduced and fixed, or disputed and why — and the
11
+ // reviewer answers back. That exchange is the product; the commits are a side
12
+ // effect of agreeing.
13
+ import path from "node:path";
14
+ import { runAgent } from "./agents.js";
15
+ import { buildReply, threadFor, replyArgv } from "./reply.js";
16
+ import { findingsIn, gate, settledList } from "./findings.js";
17
+ import { appendEvent, writeArtifact } from "./store.js";
18
+
19
+ // How many times one finding may be argued before it is set down. A reviewer
20
+ // convinced of a false positive will re-assert it indefinitely, and each
21
+ // re-assertion costs a full round; the run has to terminate whether or not
22
+ // anyone was persuaded. Both positions stay in the log, so "we disagreed" is
23
+ // recorded rather than resolved by fiat.
24
+ export const MAX_TURNS = 3;
25
+
26
+ /**
27
+ * One finding's state across the whole run, which is what decides whether the
28
+ * loop may stop. `turns` counts round-trips on this specific claim, not rounds.
29
+ */
30
+ export function turnsFor(events, id) {
31
+ return events.filter((e) => e.t === "finding.turn" && e.id === id).length;
32
+ }
33
+
34
+ /**
35
+ * Findings this round must answer: everything still open, plus anything the
36
+ * reviewer re-raised after we answered it. Re-raising is the disagreement
37
+ * signal — a reviewer that accepted our reasoning simply stops mentioning it.
38
+ */
39
+ export function outstanding(findings, events) {
40
+ const out = [];
41
+ for (const f of findings.values()) {
42
+ if (f.status === "open") { out.push(f); continue; }
43
+ // Re-raised after resolution and not yet talked out.
44
+ if (f.contested && turnsFor(events, f.id) < MAX_TURNS) out.push(f);
45
+ }
46
+ return out;
47
+ }
48
+
49
+ /**
50
+ * Deadlock rule. After MAX_TURNS exchanges on one claim the main agent stops
51
+ * arguing and defers it: both positions are already in the log, and the choice
52
+ * is between a terminating run that records a disagreement and a loop that
53
+ * cannot end. Deferred is deliberately not "accepted" — fixing something nobody
54
+ * demonstrated is how a stubborn false positive gets code written for it.
55
+ */
56
+ export function deadlocked(events, id) {
57
+ return turnsFor(events, id) >= MAX_TURNS;
58
+ }
59
+
60
+ /**
61
+ * The conversation as the console renders it: one thread per reviewer, each a
62
+ * list of turns with a speaker. Derived from the log rather than stored, so a
63
+ * run can be replayed and the view cannot drift from what happened.
64
+ */
65
+ export function conversation(events) {
66
+ const threads = new Map();
67
+ const thread = (agent) => {
68
+ let t = threads.get(agent);
69
+ if (!t) threads.set(agent, (t = { agent, turns: [] }));
70
+ return t;
71
+ };
72
+ const claims = new Map();
73
+
74
+ for (const e of events) {
75
+ switch (e.t) {
76
+ case "finding.raised":
77
+ // Deliberately NOT a turn of its own. The reviewer already said this in
78
+ // its report, verbatim and in its own shape; a parsed copy underneath
79
+ // is the same words twice. The claim is kept so a verdict further down
80
+ // can name what it is answering.
81
+ claims.set(e.id, { claim: e.claim, agent: e.agent });
82
+ // The reviewer still gets a thread even if this is the only thing it
83
+ // ever emitted: in production a report always precedes its findings,
84
+ // but a console that hides a reviewer entirely when it does not is
85
+ // worse than one that shows an empty column.
86
+ thread(e.agent);
87
+ break;
88
+ case "finding.reproduced":
89
+ thread(claims.get(e.id)?.agent ?? "?").turns.push({
90
+ who: "claude", kind: "reproduced", id: e.id,
91
+ claim: claims.get(e.id)?.claim ?? "",
92
+ text: typeof e.evidence === "string" ? e.evidence : "reproduced", ts: e.ts,
93
+ });
94
+ break;
95
+ case "finding.resolved":
96
+ thread(claims.get(e.id)?.agent ?? "?").turns.push({
97
+ who: "claude", kind: "verdict", id: e.id,
98
+ claim: claims.get(e.id)?.claim ?? "",
99
+ verdict: e.verdict, text: e.reason ?? "", test: e.test ?? "", ts: e.ts,
100
+ });
101
+ break;
102
+ case "finding.turn":
103
+ // A reviewer's rebuttal is an excerpt cut from the reply it arrived in,
104
+ // and that reply is already rendered whole as its `answer` turn — so the
105
+ // text is deliberately dropped, exactly as a raised finding's is. What
106
+ // survives is the linkage: which claim is being re-argued, and by whom.
107
+ // The event itself still matters even when it draws nothing, because
108
+ // `findingsIn` folds it into `contested`/`turns`, and that is what drives
109
+ // `outstanding` and the turn limit.
110
+ //
111
+ // Claude's own turn is the exception. It is not an excerpt of anything
112
+ // shown elsewhere, so dropping its text would lose it entirely.
113
+ {
114
+ const who = e.who ?? "?";
115
+ const turn = {
116
+ who, kind: "rebuttal", id: e.id,
117
+ claim: claims.get(e.id)?.claim ?? "", ts: e.ts,
118
+ };
119
+ if (who === "claude") turn.text = e.text ?? "";
120
+ thread(claims.get(e.id)?.agent ?? e.agent ?? "?").turns.push(turn);
121
+ }
122
+ break;
123
+ // A reviewer that does not stream. Collapsed into one turn per agent per
124
+ // round and replaced by the report, exactly like a streaming turn — the
125
+ // difference is only what it can say while waiting.
126
+ case "agent.alive": {
127
+ // `forAgent` names the reviewer whose finding is being judged, so the
128
+ // main agent's heartbeat lands inside that reviewer's conversation
129
+ // rather than opening a thread of its own.
130
+ const t = thread(e.forAgent ?? e.agent);
131
+ // Found by round, not by position. Two rounds running concurrently
132
+ // interleave their heartbeats, so "is it the last turn?" failed on
133
+ // every alternation and a nine-minute round grew 81 bubbles instead of
134
+ // one.
135
+ const w = t.turns.find(
136
+ (x) => x.kind === "waiting" && x.round === e.round && x.who === e.agent,
137
+ );
138
+ if (w) { w.seconds = e.seconds; w.quietSeconds = e.quietSeconds ?? null; break; }
139
+ // Real output already supersedes it; do not regress to a placeholder.
140
+ // Per speaker, not per thread: the reviewer's report ends the reviewer's
141
+ // turn, but the main agent triaging afterwards is a new speaker on the
142
+ // same thread — keying this on the thread dropped every triage
143
+ // heartbeat, which is the longest silence in the round and the reason
144
+ // the heartbeat exists.
145
+ if (t.turns.some((x) => (x.kind === "streaming" || x.kind === "report") && x.round === e.round && x.who === e.agent)) break;
146
+ t.turns.push({ who: e.agent, kind: "waiting", round: e.round, seconds: e.seconds, quietSeconds: e.quietSeconds ?? null, ts: e.ts });
147
+ break;
148
+ }
149
+ case "agent.chunk":
150
+ // Live partial output — the reviewer mid-sentence. Collapsed into one
151
+ // streaming turn per agent per round so the view does not grow a turn
152
+ // per token.
153
+ {
154
+ const t = thread(e.agent);
155
+ const last = t.turns[t.turns.length - 1];
156
+ if (last?.kind === "streaming" && last.round === e.round) last.text += e.text;
157
+ else {
158
+ // Real output replaces the heartbeat rather than appearing beneath it.
159
+ const w = t.turns.findIndex((x) => x.kind === "waiting" && x.round === e.round);
160
+ const turn = { who: e.agent, kind: "streaming", round: e.round, text: e.text, ts: e.ts };
161
+ if (w >= 0) t.turns.splice(w, 1, turn); else t.turns.push(turn);
162
+ }
163
+ }
164
+ break;
165
+ case "agent.report":
166
+ {
167
+ const t = thread(e.agent);
168
+ // The finished report replaces the streaming placeholder it was
169
+ // being assembled into.
170
+ const i = t.turns.findIndex(
171
+ (x) => (x.kind === "streaming" || x.kind === "waiting") && x.round === e.round,
172
+ );
173
+ const turn = {
174
+ who: e.agent, kind: "report", round: e.round,
175
+ verdict: e.verdict, text: e.report ?? "", seconds: e.seconds, ts: e.ts,
176
+ };
177
+ if (i >= 0) t.turns.splice(i, 1, turn); else t.turns.push(turn);
178
+ }
179
+ break;
180
+ case "reply.sent":
181
+ thread(e.agent).turns.push({
182
+ who: "claude", kind: "reply", text: e.text ?? "", resumed: e.resumed, ts: e.ts,
183
+ });
184
+ break;
185
+ case "reply.answered":
186
+ thread(e.agent).turns.push({
187
+ who: e.agent, kind: "answer", text: e.report ?? "",
188
+ verdict: e.verdict, seconds: e.seconds, ts: e.ts,
189
+ });
190
+ break;
191
+ case "commit.pushed":
192
+ for (const t of threads.values()) {
193
+ t.turns.push({ who: "claude", kind: "commit", sha: e.sha, text: e.subject ?? "", ts: e.ts });
194
+ }
195
+ break;
196
+ }
197
+ }
198
+ return [...threads.values()];
199
+ }
200
+
201
+ /**
202
+ * Ask the main agent to triage one finding, and record what it decided.
203
+ *
204
+ * The main agent is not a subprocess of this loop in the interactive case — it
205
+ * is the session driving it — so `ask` is injected. Headless runs pass a
206
+ * spawner; a Claude Code session passes a function that answers in-process.
207
+ * Either way the answer must survive the same gate a human's would: this
208
+ * records the reproduction attempt *before* the verdict, so an "accepted" with
209
+ * nothing behind it is refused by findings.gate rather than believed.
210
+ */
211
+ export async function triage(finding, { ask, dir, worktree, round }) {
212
+ const verdict = await ask({ kind: "triage", finding, worktree, round });
213
+ // Order matters. gate() reads the folded log, so the reproduction has to be
214
+ // on disk before the resolve is checked against it.
215
+ if (verdict.reproduced) {
216
+ await appendEvent(dir, {
217
+ t: "finding.reproduced", id: finding.id,
218
+ evidence: verdict.reproduced, test: verdict.test ?? null,
219
+ });
220
+ }
221
+ return verdict;
222
+ }
223
+
224
+ /**
225
+ * Record a verdict, refusing anything the gate rejects.
226
+ *
227
+ * A refusal is not an error — it is the gate doing its job, and the loop
228
+ * downgrades rather than dying: an acceptance with no reproduction becomes an
229
+ * open finding again, which the next round will re-raise. Crashing here would
230
+ * throw away a whole round of reviewer time over one badly-formed answer.
231
+ */
232
+ export async function record(dir, findings, id, { verdict, reason, test }) {
233
+ const f = findings.get(id);
234
+ const why = gate(f, { verdict, test });
235
+ if (why) return { ok: false, why };
236
+ await appendEvent(dir, { t: "finding.resolved", id, verdict, reason: reason ?? "", test: test ?? null });
237
+ return { ok: true };
238
+ }
239
+
240
+ /**
241
+ * Findings still awaiting a verdict, folded from the log.
242
+ *
243
+ * A clean round is not convergence on its own. Triage can leave a finding open
244
+ * — the main agent failed, or the gate refused an unbacked acceptance — and a
245
+ * reply can raise a brand new one after the last triage of the round has
246
+ * already run. Neither reaches settled.md, because settled.md only lists what
247
+ * has a verdict, so the next round's reviewers are never shown it and can
248
+ * quite honestly all emit the stop token. Answering "did everybody sign off?"
249
+ * without also asking "is anything still open?" declares the run finished with
250
+ * work nobody ever looked at.
251
+ */
252
+ export async function openFindings(dir) {
253
+ const findings = await findingsIn(dir);
254
+ return [...findings.values()].filter((f) => f.status === "open");
255
+ }
256
+
257
+ /**
258
+ * The settled list, rewritten from the log before every round.
259
+ *
260
+ * This is the difference between ten rounds and one round run ten times.
261
+ * `buildPrompt` reads settled.md; nothing regenerated it between rounds, so
262
+ * every round carried the same (usually empty) list and reviewers re-raised
263
+ * what had already been deferred — exactly the non-termination the list exists
264
+ * to prevent.
265
+ */
266
+ export async function refreshSettled(dir) {
267
+ const findings = await findingsIn(dir);
268
+ const text = settledList(findings);
269
+ await writeArtifact(dir, "settled.md", text ? text + "\n" : "");
270
+ return text;
271
+ }
272
+
273
+ /**
274
+ * One reply turn per reviewer, concurrently, about its own findings only.
275
+ *
276
+ * Returns whether each reviewer signed off. A reviewer that answers with the
277
+ * stop token has stopped arguing; one that comes back with more findings has
278
+ * not, and its rebuttal is recorded against the findings it concerns so the
279
+ * turn counter can eventually end the argument.
280
+ */
281
+ export async function replyRound({ dir, pool, cfg, worktree, sha, round, findings, sessions, dryRun, onLog, onChunk }) {
282
+ const names = pool.map((a) => a.name);
283
+ const out = await Promise.all(pool.map(async (a) => {
284
+ const thread = threadFor(a.name, findings);
285
+ const text = buildReply({
286
+ agent: a.name, thread, sha, worktree,
287
+ quotePrior: !a.resume?.supported,
288
+ stopToken: cfg.stopToken,
289
+ others: names.filter((n) => n !== a.name),
290
+ });
291
+ // Nothing answered is NOT a sign-off. Reporting `clean` here let a run with
292
+ // every finding still open satisfy `answers.every(a => a.clean)` and
293
+ // announce convergence — the reviewer was never asked anything, so it
294
+ // cannot have agreed to anything.
295
+ if (!text) return { agent: a.name, skipped: true, clean: false };
296
+
297
+ // The session recorded when this reviewer produced its review, so the reply
298
+ // lands in that conversation rather than whichever ran most recently.
299
+ const sessionId = sessions?.get(a.name) ?? null;
300
+ const { argv, resumed } = replyArgv(a, { promptText: text, worktree, sha, sessionId });
301
+ await appendEvent(dir, { t: "reply.sent", agent: a.name, resumed, sessionId, text });
302
+
303
+ const r = await runAgent({ ...a, argv }, {
304
+ worktree, prompt: text, stopToken: cfg.stopToken, dryRun,
305
+ // Which reviewer this line belongs to: replies run concurrently, so an
306
+ // unattributed line could have come from any of them.
307
+ onLog: (m) => onLog?.(m, a.name),
308
+ onChunk: (t) => onChunk?.(a.name, t),
309
+ });
310
+ await appendEvent(dir, {
311
+ t: "reply.answered", agent: a.name, verdict: r.verdict,
312
+ seconds: r.seconds, report: r.report,
313
+ });
314
+
315
+ // A reviewer that re-argues a finding we already answered is the
316
+ // disagreement signal. Recorded per finding, because the turn limit is per
317
+ // claim: one contested item must not spend the whole run's budget.
318
+ for (const f of thread) {
319
+ if (f.status === "open") continue;
320
+ if (mentions(r.report, f)) {
321
+ await appendEvent(dir, {
322
+ t: "finding.turn", id: f.id, agent: a.name, who: a.name,
323
+ text: excerpt(r.report, f),
324
+ });
325
+ }
326
+ }
327
+
328
+ // A reviewer checking our fix often finds something else while it is in
329
+ // there. Those were parsed and thrown away: only turns against KNOWN
330
+ // findings were recorded, so a genuinely new bug discovered during a reply
331
+ // was untrackable, and a later quiet round could call the run converged
332
+ // without it ever reaching triage.
333
+ const fresh = (r.findings ?? []).filter((nf) => !thread.some((f) => same(f, nf)));
334
+ for (const [n, nf] of fresh.entries()) {
335
+ await appendEvent(dir, {
336
+ t: "finding.raised", id: `${round}-${a.name}-reply-${n + 1}`, round,
337
+ agent: a.name, claim: nf.claim, loc: nf.loc, body: nf.body,
338
+ });
339
+ }
340
+
341
+ return {
342
+ agent: a.name,
343
+ // A reviewer that could not run has not agreed to anything. A quota wall,
344
+ // an auth failure or a timeout all arrive as verdict "error", and
345
+ // treating that as a sign-off would report agreement one of the reviewers
346
+ // never expressed — the exact thing running more than one is for.
347
+ failed: !r.ok || r.verdict === "error",
348
+ // New findings mean it is not signed off, whatever the stop token said.
349
+ clean: r.ok && r.verdict === "clean" && !fresh.length,
350
+ raised: fresh.length,
351
+ report: r.report,
352
+ seconds: r.seconds,
353
+ };
354
+ }));
355
+ return out;
356
+ }
357
+
358
+ /**
359
+ * Did this reply re-argue that finding? Deliberately loose — a false positive
360
+ * costs one extra turn, a false negative silently ends an argument the reviewer
361
+ * was still having.
362
+ */
363
+ /** Two claims are the same finding when they name the same place and read alike. */
364
+ function same(f, nf) {
365
+ if (f.loc && nf.loc && f.loc === nf.loc) return true;
366
+ const norm = (s) => (s ?? "").toLowerCase().replace(/\W+/g, " ").trim();
367
+ return norm(f.claim) === norm(nf.claim);
368
+ }
369
+
370
+ function mentions(report, f) {
371
+ if (!report) return false;
372
+ const hay = report.toLowerCase();
373
+ if (f.loc && hay.includes(f.loc.toLowerCase().split(":")[0])) return true;
374
+ const words = f.claim.toLowerCase().split(/\W+/).filter((w) => w.length > 5);
375
+ if (!words.length) return false;
376
+ const hits = words.filter((w) => hay.includes(w)).length;
377
+ return hits >= Math.max(2, Math.ceil(words.length / 3));
378
+ }
379
+
380
+ function excerpt(report, f) {
381
+ const lines = (report ?? "").split("\n");
382
+ const key = f.claim.toLowerCase().split(/\W+/).filter((w) => w.length > 5)[0] ?? "";
383
+ const i = lines.findIndex((l) => key && l.toLowerCase().includes(key));
384
+ return (i >= 0 ? lines.slice(Math.max(0, i - 1), i + 6) : lines.slice(0, 6)).join("\n").trim();
385
+ }
386
+
387
+ /**
388
+ * The session each reviewer last reported from, folded out of the log.
389
+ *
390
+ * Recorded at review time and read at reply time: a reply must resume the
391
+ * conversation that produced the findings it answers, not whichever session
392
+ * happens to be most recent in the worktree.
393
+ */
394
+ export function sessionsIn(events) {
395
+ const byAgent = new Map();
396
+ for (const e of events) {
397
+ if (e.t === "agent.report" && e.sessionId) byAgent.set(e.agent, e.sessionId);
398
+ }
399
+ return byAgent;
400
+ }
package/lib/prompt.js ADDED
@@ -0,0 +1,67 @@
1
+ // Building the round prompt.
2
+ //
3
+ // The settled list is what makes the loop terminate: without it every fresh
4
+ // reviewer rediscovers the same deferred issues, round after round.
5
+ import { readFile } from "node:fs/promises";
6
+
7
+ export async function buildPrompt({ target, trunk, settled = [], summary = "", stopToken, settledFile }) {
8
+ let carried = settled;
9
+ if (settledFile) {
10
+ try {
11
+ const raw = await readFile(settledFile, "utf8");
12
+ carried = raw.split("\n").filter((l) => /^\s*\d+\./.test(l)).map((l) => l.trim());
13
+ } catch (err) {
14
+ if (err.code !== "ENOENT") throw err;
15
+ }
16
+ }
17
+
18
+ const settledBlock = carried.length
19
+ ? carried.map((s, i) => (/^\d+\./.test(s) ? s : `${i + 1}. ${s}`)).join("\n")
20
+ : "(nothing settled yet — this is the first round)";
21
+
22
+ return `Review the current HEAD of this worktree.
23
+
24
+ Run \`git diff $(git merge-base HEAD origin/${trunk}) HEAD\` to see the change. Do NOT diff against
25
+ origin/${trunk}'s tip — the trunk has moved since the branch point and unrelated commits will appear
26
+ inverted as deletions in this branch.
27
+
28
+ TARGET
29
+
30
+ ${target.repo ?? "(repo)"} ${target.id ?? ""} — ${target.title ?? "(no title)"}
31
+ branch ${target.branch ?? "(unknown)"} → ${trunk}
32
+
33
+ WHAT IT DOES
34
+
35
+ ${summary || "(no summary supplied — read the diff)"}
36
+
37
+ ALREADY SETTLED — do NOT re-report these, they are known and decided:
38
+
39
+ ${settledBlock}
40
+
41
+ WHAT I WANT
42
+
43
+ Only NEW correctness problems in the code as it stands:
44
+
45
+ - races, deadlocks, leaks, incorrect accounting
46
+ - any way the change can produce a wrong result
47
+ - test correctness: would each assertion actually fail if the behaviour it guards regressed? Be
48
+ skeptical, read the assertions rather than the test names. Flag any test that passes for the wrong
49
+ reason or is timing-flaky.
50
+ - anything in the diff that is wrong regardless of the above
51
+
52
+ Rank by severity, cite file:line, and be concrete about the failing scenario.
53
+
54
+ Open each finding with these two lines, so they can be tracked across rounds:
55
+
56
+ \`\`\`
57
+ FINDING: <the claim, one line>
58
+ WHERE: <file:line>
59
+ \`\`\`
60
+
61
+ Then explain it in prose underneath. A finding without that header is still read, but it will not be
62
+ tracked, so use it for every one.
63
+
64
+ IMPORTANT: if you find no new correctness problems, say exactly "${stopToken}" on its own line, then
65
+ briefly note anything cosmetic. Do not restate the settled list. Do not edit any files.
66
+ `;
67
+ }
package/lib/reply.js ADDED
@@ -0,0 +1,134 @@
1
+ // Replying to a reviewer — one conversation per reviewer, never a broadcast.
2
+ //
3
+ // The main agent talks to codex about codex's findings and to agy about agy's.
4
+ // Neither sees the other's, which is the entire point of running more than one:
5
+ // two reviewers that read each other's reports stop being independent, and the
6
+ // agreement between them stops being evidence of anything.
7
+ //
8
+ // Delivery differs per agent and the difference is not cosmetic:
9
+ // resume.supported codex keeps the session, so the reply is a real turn in a
10
+ // conversation that already contains its own review
11
+ // otherwise a fresh process that has never seen the thread, so its own
12
+ // prior findings must be quoted back or the reply is
13
+ // addressed to an agent with no idea what it said
14
+
15
+ /** What one reviewer said, and what the main agent decided about each item. */
16
+ export function threadFor(agent, findings) {
17
+ return [...findings.values()].filter((f) => f.agent === agent);
18
+ }
19
+
20
+ const VERDICT_LABEL = {
21
+ accepted: "ACCEPTED, fixed",
22
+ deferred: "AGREE it is real, NOT fixing here",
23
+ rejected: "REJECTED",
24
+ superseded: "SUPERSEDED",
25
+ open: "still open",
26
+ };
27
+
28
+ /**
29
+ * The reply sent to one reviewer.
30
+ *
31
+ * `quotePrior` re-states the reviewer's own findings. Only needed when the
32
+ * session cannot resume — with resume, the agent is already looking at them,
33
+ * and repeating them back reads as though it had been misunderstood.
34
+ */
35
+ export function buildReply({ agent, thread, sha, worktree, quotePrior, stopToken, others = [] }) {
36
+ // Only findings actually answered. An item still marked open has no verdict
37
+ // to discuss, and padding the reply with "still open" invites the reviewer to
38
+ // re-argue something nobody responded to yet.
39
+ const answered = thread.filter((f) => f.status !== "open");
40
+ if (!answered.length) return null;
41
+
42
+ // Isolation is enforced here rather than trusted to whoever wrote the
43
+ // reason text. A verdict that says "agy raised this too" tells codex its
44
+ // finding was corroborated, which is exactly the cross-contamination that
45
+ // makes two reviewers agreeing stop counting as evidence.
46
+ // Ids first: replacing the bare name would leave "r1-another reviewer-4".
47
+ // A preceding article is absorbed too, so "the codex thread" does not become
48
+ // the ungrammatical "the another reviewer thread".
49
+ const scrub = (s) => others.reduce(
50
+ (acc, name) => acc
51
+ .replace(new RegExp(`\\br\\d+-${name}-\\d+\\b`, "gi"), "another reviewer's finding")
52
+ .replace(new RegExp(`\\b(?:the|a)\\s+${name}\\b`, "gi"), "another reviewer's")
53
+ .replace(new RegExp(`\\b${name}\\b`, "gi"), "another reviewer"),
54
+ s ?? "",
55
+ );
56
+
57
+ const items = answered.map((f, i) => {
58
+ const n = i + 1;
59
+ const label = VERDICT_LABEL[f.status] ?? f.status;
60
+ const parts = [`**${n}. ${f.claim}${f.loc ? ` (${f.loc})` : ""} — ${label}.**`];
61
+
62
+ // With no session to carry it, the reviewer needs its own words back before
63
+ // the verdict on them means anything.
64
+ if (quotePrior && f.body) {
65
+ parts.push(`\nYou wrote:\n\n> ${f.body.split("\n").join("\n> ")}`);
66
+ }
67
+
68
+ if (f.reason) parts.push(`\n${scrub(f.reason)}`);
69
+ if (f.reproduced && typeof f.reproduced === "string") {
70
+ parts.push(`\nReproduced first: ${scrub(f.reproduced)}`);
71
+ }
72
+ if (f.test) parts.push(`\nRegression test: ${scrub(f.test)} — verified failing with the fix reverted.`);
73
+
74
+ // A direct question per item is what makes this a conversation rather than
75
+ // a changelog. In the reference run it turned two "you should fix this"
76
+ // items into explicit agreement to defer.
77
+ parts.push(`\n${question(f)}`);
78
+ return parts.join("\n");
79
+ });
80
+
81
+ return `Thanks — I acted on your review of ${sha}. Here is what I did with each of your findings,
82
+ including where I disagree. The final state is at ${worktree} (detached at ${sha}).
83
+
84
+ These are your findings only. Other reviewers looked at this change independently; I am not
85
+ relaying their comments to you, and I am not asking you to agree with anyone but me.
86
+
87
+ ${items.join("\n\n")}
88
+
89
+ Please check the current state and answer the questions above. If you still disagree with any of my
90
+ positions, say so and why — I would rather be corrected now than ship it.
91
+
92
+ If you have no remaining correctness problems, say exactly "${stopToken}" on its own line.
93
+ Do not edit any files.
94
+ `;
95
+ }
96
+
97
+ function question(f) {
98
+ switch (f.status) {
99
+ case "accepted":
100
+ return "Question: does the fix actually close the case you had in mind, or did I fix a narrower version of it?";
101
+ case "deferred":
102
+ return "Question: do you agree it is (a) pre-existing and (b) not made materially more likely by this change?";
103
+ case "rejected":
104
+ return "Question: does that evidence settle it, or have I misread what you meant?";
105
+ default:
106
+ return "Question: is that an accurate reading of your finding?";
107
+ }
108
+ }
109
+
110
+ /** argv for a reply, honouring whether this agent can resume its session. */
111
+ export function replyArgv(agent, { promptText, promptFile, worktree, sha, sessionId }) {
112
+ // Resuming needs a session to resume INTO. A resume template that names
113
+ // {{sessionId}} without one would resolve to a blank argument — or, with
114
+ // `--last`, to whatever session happened to run most recently, delivering the
115
+ // verdict into an unrelated conversation. A fresh session addressed to an
116
+ // agent with no memory is the honest fallback, and quotePrior covers it.
117
+ const wantsId = agent.resume?.argv?.some((a) => a.includes("{{sessionId}}"));
118
+ const resumable = Boolean(
119
+ agent.resume?.supported && agent.resume?.argv?.length && (!wantsId || sessionId),
120
+ );
121
+ const argv = resumable ? agent.resume.argv : agent.argv;
122
+ const vars = { promptText, promptFile, worktree, sha, sessionId };
123
+ return {
124
+ // Only placeholders we actually have a value for. runAgent creates the
125
+ // temp prompt file itself and substitutes {{promptFile}} later, so blanking
126
+ // it here left a file-delivery reviewer spawned as `-f ""` with no way to
127
+ // recover the placeholder. An unknown key is left standing rather than
128
+ // erased, so a downstream substitution can still fill it.
129
+ argv: argv.map((a) =>
130
+ a.replace(/\{\{(\w+)\}\}/g, (m, k) => (vars[k] === undefined ? m : vars[k])),
131
+ ),
132
+ resumed: Boolean(resumable),
133
+ };
134
+ }