@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/LICENSE +21 -0
- package/README.md +246 -0
- package/bin/jury.js +1152 -0
- package/jury.config.example.json +43 -0
- package/lib/agents.js +295 -0
- package/lib/config.js +157 -0
- package/lib/findings.js +112 -0
- package/lib/loop.js +400 -0
- package/lib/prompt.js +67 -0
- package/lib/reply.js +134 -0
- package/lib/repository.js +185 -0
- package/lib/server.js +224 -0
- package/lib/store.js +316 -0
- package/lib/style.js +90 -0
- package/lib/triage.js +144 -0
- package/package.json +44 -0
- package/prompts/feedback.md +54 -0
- package/prompts/review-round.md +52 -0
- package/web/index.html +1699 -0
|
@@ -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
|
+
}
|
package/lib/findings.js
ADDED
|
@@ -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
|
+
}
|