@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
package/bin/jury.js
ADDED
|
@@ -0,0 +1,1152 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// jury — run a pull request past several independent AI reviewers.
|
|
3
|
+
//
|
|
4
|
+
// The CLI owns the mechanics: worktrees, spawning agents, capturing what they
|
|
5
|
+
// said, recording it, serving the console. It deliberately does NOT triage —
|
|
6
|
+
// deciding whether a finding reproduces is the calling agent's job, and roughly
|
|
7
|
+
// a third of suggestions do not survive that step.
|
|
8
|
+
import { parseArgs } from "node:util";
|
|
9
|
+
import { execFile } from "node:child_process";
|
|
10
|
+
import { readFileSync } from "node:fs";
|
|
11
|
+
import { promisify } from "node:util";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
import { loadConfig, reviewers, mainAgent } from "../lib/config.js";
|
|
14
|
+
import { runAgent, probe } from "../lib/agents.js";
|
|
15
|
+
import { threadFor, buildReply, replyArgv } from "../lib/reply.js";
|
|
16
|
+
import { buildPrompt } from "../lib/prompt.js";
|
|
17
|
+
import { serve } from "../lib/server.js";
|
|
18
|
+
import { appendEvent, writeRun, readEvents, foldEvents, slugFor, attemptStamp, runsDir, writeArtifact, openArtifact } from "../lib/store.js";
|
|
19
|
+
import { findingsIn, gate, settledList, VERDICTS } from "../lib/findings.js";
|
|
20
|
+
import { MAX_TURNS, turnsFor, outstanding, deadlocked, refreshSettled, replyRound, record, sessionsIn, openFindings } from "../lib/loop.js";
|
|
21
|
+
import { triageOne } from "../lib/triage.js";
|
|
22
|
+
import { assertPrCheckout, resolvePrCheckout } from "../lib/repository.js";
|
|
23
|
+
import * as st from "../lib/style.js";
|
|
24
|
+
|
|
25
|
+
const run = promisify(execFile);
|
|
26
|
+
// Read from the manifest rather than restated here, where it drifted: the CLI
|
|
27
|
+
// reported 0.1.0 while package.json said something else.
|
|
28
|
+
const VERSION = JSON.parse(
|
|
29
|
+
readFileSync(new URL("../package.json", import.meta.url), "utf8"),
|
|
30
|
+
).version;
|
|
31
|
+
|
|
32
|
+
// Set when `review --web` boots the console in-process. Read once, after the
|
|
33
|
+
// loop returns, to decide whether the process may exit.
|
|
34
|
+
let liveConsole = null;
|
|
35
|
+
let resolvedCheckoutCleanup = null;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* What a person types, and nothing else.
|
|
39
|
+
*
|
|
40
|
+
* The full reference lists eight commands and thirty flags, most of which exist
|
|
41
|
+
* for the skill driving triage rather than for anyone at a prompt. Showing all
|
|
42
|
+
* of it by default buried the one command that matters in a wall of options —
|
|
43
|
+
* `jury help --all` still prints everything.
|
|
44
|
+
*/
|
|
45
|
+
const USAGE = `jury — run a pull request past several AI reviewers until they agree
|
|
46
|
+
|
|
47
|
+
jury <pr-url> review a pull request
|
|
48
|
+
jury --rounds 3 review the current branch, no PR
|
|
49
|
+
jury --web <pr-url> …and watch it in the browser
|
|
50
|
+
|
|
51
|
+
Common flags
|
|
52
|
+
|
|
53
|
+
--rounds <n> stop after n rounds (default 10)
|
|
54
|
+
--agents codex,grok only these reviewers (default: all installed)
|
|
55
|
+
--no-push fix locally, do not push
|
|
56
|
+
--dry-run exercise the pipeline, spawn nothing
|
|
57
|
+
|
|
58
|
+
Other commands
|
|
59
|
+
|
|
60
|
+
jury agents which reviewers are installed
|
|
61
|
+
jury runs every PR under review
|
|
62
|
+
jury web the console, on its own
|
|
63
|
+
jury version
|
|
64
|
+
|
|
65
|
+
jury help --all every command and flag
|
|
66
|
+
`;
|
|
67
|
+
|
|
68
|
+
const USAGE_FULL = `jury — multi-agent code review
|
|
69
|
+
|
|
70
|
+
jury <pr-url> review a PR: rounds until convergence, one conversation per reviewer
|
|
71
|
+
jury review-once [flags] a single round, no triage or reply
|
|
72
|
+
jury web [flags] serve the console (default http://127.0.0.1:3080)
|
|
73
|
+
jury finding <cmd> list | reproduce | resolve | settled — appends events, enforces the gate
|
|
74
|
+
jury reply [flags] send each reviewer your verdicts on ITS findings, one conversation each
|
|
75
|
+
jury runs list every PR under review, with its slug for --run
|
|
76
|
+
jury agents check which configured agents are installed
|
|
77
|
+
jury version
|
|
78
|
+
|
|
79
|
+
review (drives itself; no operator between rounds)
|
|
80
|
+
jury https://github.com/owner/repo/pull/1
|
|
81
|
+
jury --rounds 3 the current branch, no PR
|
|
82
|
+
|
|
83
|
+
--dir <path> repo/worktree (default .)
|
|
84
|
+
--pr <url> same as the positional argument
|
|
85
|
+
--trunk <branch> diff base branch (default: the remote's own HEAD)
|
|
86
|
+
--title <text> what the change does (default: read from the PR)
|
|
87
|
+
--summary <text> intent, passed to reviewers (default: the PR description)
|
|
88
|
+
--rounds <n> maximum rounds (default 10)
|
|
89
|
+
--agents a,b only these reviewers (default: all installed)
|
|
90
|
+
--resume <slug> continue an existing run instead of starting a new one
|
|
91
|
+
--web open the console on this run (stays up when it ends)
|
|
92
|
+
--port <n> console port, with --web (default 3080)
|
|
93
|
+
--no-push commit fixes to the worktree without pushing
|
|
94
|
+
--dry-run exercise the pipeline, spawn nothing
|
|
95
|
+
|
|
96
|
+
review-once flags
|
|
97
|
+
--dir <path> repo/worktree the reviewers read (default .)
|
|
98
|
+
--pr <url> pull request URL, recorded on the run
|
|
99
|
+
--title <text> what the change does, shown in the console
|
|
100
|
+
--summary <text> a few lines of intent, passed to reviewers
|
|
101
|
+
--trunk <branch> diff base branch (default master)
|
|
102
|
+
--round <n> round number (default: next)
|
|
103
|
+
--agents a,b only these reviewers (default: all enabled)
|
|
104
|
+
--max-rounds <n> keep going until convergence, at most n (default 1)
|
|
105
|
+
--dry-run do not spawn anything; exercise the pipeline
|
|
106
|
+
|
|
107
|
+
web flags
|
|
108
|
+
--port <n> default 3080, walks forward if busy
|
|
109
|
+
--open open a browser
|
|
110
|
+
|
|
111
|
+
finding commands (--run <slug> picks the run)
|
|
112
|
+
jury finding list
|
|
113
|
+
jury finding reproduce <id> --evidence <text> [--test <text>]
|
|
114
|
+
jury finding resolve <id> --verdict <${VERDICTS.join("|")}> [--reason <text>] [--test <text>]
|
|
115
|
+
jury finding settled print the regenerated settled list
|
|
116
|
+
`;
|
|
117
|
+
|
|
118
|
+
let [, , cmd, ...rest] = process.argv;
|
|
119
|
+
|
|
120
|
+
// Reviewing is the only thing this tool does; `jury review <url>` on a program
|
|
121
|
+
// named for multi-agent code review is a tautology. A bare URL — or a bare
|
|
122
|
+
// flag, with the target implied by the working directory — means review.
|
|
123
|
+
// Nothing else here takes a URL, so there is nothing to disambiguate.
|
|
124
|
+
if (cmd && (/^https?:\/\//.test(cmd) || cmd.startsWith("-")) && cmd !== "-h" && cmd !== "--help"
|
|
125
|
+
&& cmd !== "-v" && cmd !== "--version") {
|
|
126
|
+
rest = [cmd, ...rest];
|
|
127
|
+
cmd = "review";
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
try {
|
|
131
|
+
switch (cmd) {
|
|
132
|
+
// `agent` collided with both --agents and `jury agents`, three different
|
|
133
|
+
// things reading the same. Reviewing is what this tool does, so `review` is
|
|
134
|
+
// the loop; the old single-round behaviour is --rounds 1, which it already
|
|
135
|
+
// supported. `agent` stays as a hidden alias.
|
|
136
|
+
case "review": case "agent":
|
|
137
|
+
await cmdAgent(rest);
|
|
138
|
+
await cleanupResolvedCheckout();
|
|
139
|
+
// The loop is done; the console it started is not. Exiting here would
|
|
140
|
+
// close the page at the exact moment there is a finished run to read.
|
|
141
|
+
if (liveConsole) {
|
|
142
|
+
console.log(`\nconsole still up at ${liveConsole} — ctrl-c to stop`);
|
|
143
|
+
await new Promise(() => {});
|
|
144
|
+
}
|
|
145
|
+
break;
|
|
146
|
+
case "review-once": await cmdReview(rest); break;
|
|
147
|
+
case "web": await cmdWeb(rest); break;
|
|
148
|
+
case "finding": case "findings": await cmdFinding(rest); break;
|
|
149
|
+
case "reply": await cmdReply(rest); break;
|
|
150
|
+
case "runs": await cmdRuns(); break;
|
|
151
|
+
case "agents": await cmdAgents(); break;
|
|
152
|
+
case "version": case "-v": case "--version": console.log(`jury ${VERSION}`); break;
|
|
153
|
+
case "help": case "-h": case "--help": case undefined:
|
|
154
|
+
// --all, -a, or the bare word: every command and flag. Anything else
|
|
155
|
+
// gets the short form, which is what a person at a prompt wants.
|
|
156
|
+
process.stdout.write(
|
|
157
|
+
rest.some((a) => a === "--all" || a === "-a" || a === "all") ? USAGE_FULL : USAGE,
|
|
158
|
+
);
|
|
159
|
+
break;
|
|
160
|
+
default:
|
|
161
|
+
console.error(`jury: unknown command "${cmd}"\n`);
|
|
162
|
+
process.stdout.write(USAGE);
|
|
163
|
+
process.exit(2);
|
|
164
|
+
}
|
|
165
|
+
} catch (err) {
|
|
166
|
+
await cleanupResolvedCheckout();
|
|
167
|
+
console.error(`jury: ${err.message}`);
|
|
168
|
+
process.exit(1);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function cmdReview(argv) {
|
|
172
|
+
const { values } = parseArgs({
|
|
173
|
+
args: argv, allowPositionals: false,
|
|
174
|
+
options: {
|
|
175
|
+
dir: { type: "string", default: "." },
|
|
176
|
+
pr: { type: "string" },
|
|
177
|
+
title: { type: "string" },
|
|
178
|
+
summary: { type: "string", default: "" },
|
|
179
|
+
trunk: { type: "string" },
|
|
180
|
+
round: { type: "string" },
|
|
181
|
+
agents: { type: "string" },
|
|
182
|
+
"max-rounds": { type: "string", default: "1" },
|
|
183
|
+
"dry-run": { type: "boolean", default: false },
|
|
184
|
+
},
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
const maxRounds = Number(values["max-rounds"]);
|
|
188
|
+
if (!Number.isInteger(maxRounds) || maxRounds < 1) {
|
|
189
|
+
throw new Error("--max-rounds must be a positive integer");
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const worktree = path.resolve(values.dir);
|
|
193
|
+
const cfg = await loadConfig(worktree);
|
|
194
|
+
let pool = reviewers(cfg);
|
|
195
|
+
if (values.agents) {
|
|
196
|
+
const want = new Set(values.agents.split(",").map((s) => s.trim()));
|
|
197
|
+
pool = pool.filter((a) => want.has(a.name));
|
|
198
|
+
if (!pool.length) throw new Error(`no configured reviewer matches "${values.agents}"`);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const git = await describe(worktree, values.trunk);
|
|
202
|
+
if (values.pr) assertPrCheckout(values.pr, git.remote, worktree);
|
|
203
|
+
const target = {
|
|
204
|
+
repo: values.pr ? repoFromUrl(values.pr) : git.repo,
|
|
205
|
+
id: values.pr ? idFromUrl(values.pr) : git.branch,
|
|
206
|
+
url: values.pr ?? "",
|
|
207
|
+
title: values.title ?? git.subject,
|
|
208
|
+
branch: git.branch,
|
|
209
|
+
trunk: values.trunk,
|
|
210
|
+
state: "review",
|
|
211
|
+
// Always set, never left to inherit. Target events are merged key by key,
|
|
212
|
+
// so omitting this would carry the previous round's note onto the new
|
|
213
|
+
// state — a live round labelled "converged".
|
|
214
|
+
stateNote: "",
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
const dir = path.join(runsDir(), slugFor(target));
|
|
218
|
+
const prior = await readEvents(dir);
|
|
219
|
+
const firstRound = values.round
|
|
220
|
+
? Number(values.round)
|
|
221
|
+
: Math.max(0, ...prior.filter((e) => e.t === "round.start").map((e) => e.n)) + 1;
|
|
222
|
+
|
|
223
|
+
console.log(`target ${target.repo} ${target.id}`);
|
|
224
|
+
console.log(`worktree ${worktree} @ ${git.sha}`);
|
|
225
|
+
console.log(`agents ${pool.map((a) => a.name).join(", ")}${values["dry-run"] ? " (dry run)" : ""}`);
|
|
226
|
+
if (maxRounds > 1) console.log(`rounds ${firstRound}..${firstRound + maxRounds - 1} (until convergence)`);
|
|
227
|
+
console.log("");
|
|
228
|
+
|
|
229
|
+
for (let i = 0; i < maxRounds; i++) {
|
|
230
|
+
const round = firstRound + i;
|
|
231
|
+
// Re-read HEAD each round: between rounds the operator fixes what
|
|
232
|
+
// reproduced and pushes, so a later round must review the new commit, not
|
|
233
|
+
// the one the loop started on.
|
|
234
|
+
const head = await describe(worktree, values.trunk);
|
|
235
|
+
const converged = await runRound({
|
|
236
|
+
dir, round, pool, cfg, target, worktree, values, sha: head.sha,
|
|
237
|
+
});
|
|
238
|
+
if (converged) break;
|
|
239
|
+
if (i + 1 < maxRounds) {
|
|
240
|
+
console.log(`\nfix what reproduces and push, then round ${round + 1} reviews the new HEAD.\n`);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** One round: prompt, launch every reviewer, record what they said. */
|
|
246
|
+
async function runRound({ dir, round, pool, cfg, target, worktree, values, sha }) {
|
|
247
|
+
const git = { sha };
|
|
248
|
+
console.log(st.info(st.bold(`round ${round}`)));
|
|
249
|
+
|
|
250
|
+
// Built before the round opens so the exact text handed to the reviewers is
|
|
251
|
+
// part of the record. A verdict is only readable next to the question asked.
|
|
252
|
+
const prompt = await buildPrompt({
|
|
253
|
+
target, trunk: values.trunk, summary: values.summary,
|
|
254
|
+
stopToken: cfg.stopToken, settledFile: path.join(dir, "settled.md"),
|
|
255
|
+
});
|
|
256
|
+
const promptFile = await writeArtifact(dir, `round-${round}.prompt.md`, prompt);
|
|
257
|
+
|
|
258
|
+
await appendEvent(dir, {
|
|
259
|
+
t: "target",
|
|
260
|
+
target: { ...target, stateNote: `round ${round} running — ${pool.length} reviewer(s)` },
|
|
261
|
+
});
|
|
262
|
+
await appendEvent(dir, { t: "round.start", n: round, sha: git.sha, prompt, promptFile });
|
|
263
|
+
// Publish immediately: a review that takes 18 minutes should be visible for
|
|
264
|
+
// all 18, not appear only once it is over.
|
|
265
|
+
const publish = async () => writeRun(dir, foldEvents(await readEvents(dir), { target }));
|
|
266
|
+
await publish();
|
|
267
|
+
|
|
268
|
+
// Concurrently: the slowest reviewer sets the round's wall clock, and running
|
|
269
|
+
// an 18-minute agent behind a 2-minute one wastes most of it.
|
|
270
|
+
const started = Date.now();
|
|
271
|
+
const results = await Promise.all(
|
|
272
|
+
pool.map(async (a) => {
|
|
273
|
+
await appendEvent(dir, { t: "agent.launch", agent: a.name, round });
|
|
274
|
+
|
|
275
|
+
// Stream to the artifact as the reviewer talks, rather than only once it
|
|
276
|
+
// exits. Without this a twenty-minute agent is a black box for all twenty
|
|
277
|
+
// minutes and "still thinking" is indistinguishable from "wedged".
|
|
278
|
+
const rawName = `round-${round}.${a.name}.stdout.txt`;
|
|
279
|
+
const sink = await openArtifact(dir, rawName);
|
|
280
|
+
// Not every agent streams. codex writes nothing until it exits — an
|
|
281
|
+
// 8.9-second run produced one 7-byte chunk at 8.5s — because it
|
|
282
|
+
// block-buffers when it is piped rather than attached to a terminal. For
|
|
283
|
+
// those, an empty column for nine minutes is indistinguishable from a
|
|
284
|
+
// wedged process, so a heartbeat says what is knowable: it is alive, and
|
|
285
|
+
// how long it has been going. A streaming agent never needs one.
|
|
286
|
+
const heartStart = Date.now();
|
|
287
|
+
let lastSpoke = 0;
|
|
288
|
+
const heart = setInterval(() => {
|
|
289
|
+
// Silence is what needs reporting, not never-having-spoken. Suppressing
|
|
290
|
+
// the beat permanently once an agent streamed left a gap with no signal
|
|
291
|
+
// at all: grok streamed for three minutes, went quiet for four while
|
|
292
|
+
// waiting on the model, and the console could not tell that from
|
|
293
|
+
// wedged. Resume after 15s of nothing.
|
|
294
|
+
if (lastSpoke && Date.now() - lastSpoke < 15000) return;
|
|
295
|
+
appendEvent(dir, {
|
|
296
|
+
t: "agent.alive", agent: a.name, round,
|
|
297
|
+
seconds: Math.round((Date.now() - heartStart) / 1000),
|
|
298
|
+
// So the console can say "quiet for 2m" rather than implying it never
|
|
299
|
+
// said anything.
|
|
300
|
+
quietSeconds: lastSpoke ? Math.round((Date.now() - lastSpoke) / 1000) : null,
|
|
301
|
+
}).catch(() => {});
|
|
302
|
+
}, 5000);
|
|
303
|
+
|
|
304
|
+
// Chunks reach the console through the event log as well as the
|
|
305
|
+
// artifact. Batched: an agent emits stdout a few bytes at a time, and one
|
|
306
|
+
// event per write would bloat the log by orders of magnitude for no gain
|
|
307
|
+
// a reader could perceive.
|
|
308
|
+
let buf = "";
|
|
309
|
+
let flushing = null;
|
|
310
|
+
// Appends are chained rather than fired independently. Clearing `flushing`
|
|
311
|
+
// before awaiting let a second flush start while the first append was
|
|
312
|
+
// still in flight, so a later chunk — or the final report — could reach
|
|
313
|
+
// the log ahead of earlier output, leaving the conversation with a
|
|
314
|
+
// permanent "streaming" turn full of stale text.
|
|
315
|
+
let writes = Promise.resolve();
|
|
316
|
+
const flush = () => {
|
|
317
|
+
flushing = null;
|
|
318
|
+
if (!buf) return writes;
|
|
319
|
+
const text = buf;
|
|
320
|
+
buf = "";
|
|
321
|
+
writes = writes.then(() =>
|
|
322
|
+
appendEvent(dir, { t: "agent.chunk", agent: a.name, round, text }),
|
|
323
|
+
);
|
|
324
|
+
return writes;
|
|
325
|
+
};
|
|
326
|
+
let r;
|
|
327
|
+
try {
|
|
328
|
+
r = await runAgent(a, {
|
|
329
|
+
worktree, prompt, stopToken: cfg.stopToken,
|
|
330
|
+
dryRun: values["dry-run"],
|
|
331
|
+
onLog: (m) => console.log(` ${st.agent(a.name)} ${st.muted(m)}`),
|
|
332
|
+
onChunk: (text) => {
|
|
333
|
+
lastSpoke = Date.now();
|
|
334
|
+
sink.write(text);
|
|
335
|
+
buf += text;
|
|
336
|
+
if (!flushing) flushing = setTimeout(flush, 700);
|
|
337
|
+
},
|
|
338
|
+
});
|
|
339
|
+
} finally {
|
|
340
|
+
// In finally, not merely after the await: runAgent can throw, and a
|
|
341
|
+
// leaked interval keeps appending heartbeats for a round that is over.
|
|
342
|
+
// Rounds then interleave in the log, and the console grows a bubble per
|
|
343
|
+
// beat rather than one per round — 81 of them for a nine-minute round.
|
|
344
|
+
clearInterval(heart);
|
|
345
|
+
}
|
|
346
|
+
if (flushing) clearTimeout(flushing);
|
|
347
|
+
// Await the chain, not just this flush: an append queued earlier must
|
|
348
|
+
// land before agent.report is written after it.
|
|
349
|
+
await flush();
|
|
350
|
+
await writes;
|
|
351
|
+
// Rewritten whole at the end: the streamed copy can be short if the agent
|
|
352
|
+
// was killed mid-write, and r.raw is the authoritative buffer.
|
|
353
|
+
await sink.close();
|
|
354
|
+
const rawFile = await writeArtifact(dir, rawName, r.raw ?? "");
|
|
355
|
+
await appendEvent(dir, {
|
|
356
|
+
t: "agent.report", agent: a.name, round,
|
|
357
|
+
verdict: r.verdict, seconds: r.seconds, summary: firstLine(r.report), report: r.report,
|
|
358
|
+
// The session this review happened in, so the reply resumes THIS
|
|
359
|
+
// conversation rather than whichever ran most recently.
|
|
360
|
+
sessionId: r.sessionId ?? null,
|
|
361
|
+
rawFile, rawBytes: (r.raw ?? "").length,
|
|
362
|
+
});
|
|
363
|
+
if (r.contradicted) {
|
|
364
|
+
console.log(` ${a.name}: said the stop token but also listed findings — treating as findings`);
|
|
365
|
+
}
|
|
366
|
+
// Each claim becomes its own tracked item. They open unresolved on
|
|
367
|
+
// purpose: the CLI records what was said, the calling agent decides
|
|
368
|
+
// whether it reproduces.
|
|
369
|
+
for (const [n, f] of (r.findings ?? []).entries()) {
|
|
370
|
+
await appendEvent(dir, {
|
|
371
|
+
t: "finding.raised", id: `r${round}-${a.name}-${n + 1}`, round,
|
|
372
|
+
agent: a.name, claim: f.claim, loc: f.loc, body: f.body,
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
await publish(); // each reviewer lands as it finishes
|
|
376
|
+
return r;
|
|
377
|
+
})
|
|
378
|
+
);
|
|
379
|
+
|
|
380
|
+
const clean = results.filter((r) => r.ok).every((r) => r.verdict === "clean");
|
|
381
|
+
const errored = results.filter((r) => !r.ok);
|
|
382
|
+
const raised = results.reduce((n, r) => n + (r.findings?.length ?? 0), 0);
|
|
383
|
+
|
|
384
|
+
// The state the run was launched with says "review", which stops being true
|
|
385
|
+
// the moment the round ends. Restate it, or the console keeps asserting a
|
|
386
|
+
// review is running after it converged.
|
|
387
|
+
const state = errored.length ? "human" : clean ? "converged" : "review";
|
|
388
|
+
const stateNote = errored.length
|
|
389
|
+
? `${errored.length} reviewer(s) failed to run`
|
|
390
|
+
: clean
|
|
391
|
+
? `converged on ${git.sha}`
|
|
392
|
+
: `round ${round} — ${raised || "unparsed"} finding(s) to triage`;
|
|
393
|
+
await appendEvent(dir, { t: "target", target: { ...target, state, stateNote } });
|
|
394
|
+
await appendEvent(dir, { t: "round.end", n: round });
|
|
395
|
+
await publish();
|
|
396
|
+
|
|
397
|
+
// Once, not three times. The elapsed time, the per-reviewer table and the
|
|
398
|
+
// verdict each used to restate the round's result in their own words.
|
|
399
|
+
const took = ((Date.now() - started) / 1000).toFixed(1);
|
|
400
|
+
console.log(`\n${st.rule(`round ${round} · ${took}s`)}\n`);
|
|
401
|
+
for (const r of results) {
|
|
402
|
+
const paint = r.verdict === "clean" ? st.ok : r.verdict === "error" ? st.bad : st.warn;
|
|
403
|
+
const mark = r.verdict === "clean" ? "clean" : r.verdict === "error" ? "failed" : "findings";
|
|
404
|
+
const n = r.findings?.length ? st.muted(` ${st.count(r.findings.length, "finding")}`) : "";
|
|
405
|
+
console.log(` ${st.agent(r.agent.padEnd(8))} ${st.muted(String(r.seconds).padStart(5) + "s")} ${paint(mark)}${n}`);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
console.log("");
|
|
409
|
+
if (errored.length) {
|
|
410
|
+
console.log(st.bad(`${st.count(errored.length, "reviewer")} failed to run — see the reports below.`));
|
|
411
|
+
}
|
|
412
|
+
console.log(clean && !errored.length
|
|
413
|
+
? st.ok(`CONVERGED — every reviewer emitted the stop token on ${git.sha}.`)
|
|
414
|
+
: st.warn(`NOT converged. Triage, fix what reproduces, push, then round ${round + 1}.`));
|
|
415
|
+
console.log(st.muted(`recorded: ${path.relative(process.cwd(), dir)}`));
|
|
416
|
+
|
|
417
|
+
// Every reviewer's report, including the clean ones: a sign-off still says
|
|
418
|
+
// what was checked, and reading only the complaints hides that. Indented and
|
|
419
|
+
// ruled at both ends, because otherwise a report runs straight into whatever
|
|
420
|
+
// the loop prints next and the two read as one voice.
|
|
421
|
+
for (const r of results) {
|
|
422
|
+
const head = `${r.agent}${r.verdict === "clean" ? " · clean" : ""}`;
|
|
423
|
+
console.log(`\n${st.rule(head)}`);
|
|
424
|
+
console.log(st.indent(r.report ?? ""));
|
|
425
|
+
console.log(st.rule(""));
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// A failed reviewer is not convergence. Stopping the loop here would report
|
|
429
|
+
// agreement that one of the reviewers never actually expressed.
|
|
430
|
+
return clean && !errored.length;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* The autonomous loop.
|
|
435
|
+
*
|
|
436
|
+
* `review --max-rounds n` was never a loop: it re-read HEAD each round assuming
|
|
437
|
+
* an operator had fixed and pushed in between, and when nobody did, it reviewed
|
|
438
|
+
* the same commit n times. This command fills that seat — triage, fix, commit,
|
|
439
|
+
* reply, repeat — so the run converges or exhausts its rounds on its own.
|
|
440
|
+
*
|
|
441
|
+
* Three things make it terminate rather than argue forever:
|
|
442
|
+
* the settled list regenerated every round, so a deferred finding is not
|
|
443
|
+
* re-raised by the next reviewer that reads the diff
|
|
444
|
+
* the turn limit MAX_TURNS exchanges per claim, then it is set down as
|
|
445
|
+
* deferred with both positions in the log
|
|
446
|
+
* the round cap --rounds, default 10
|
|
447
|
+
*/
|
|
448
|
+
async function cmdAgent(argv) {
|
|
449
|
+
const { values, positionals } = parseArgs({
|
|
450
|
+
args: argv, allowPositionals: true,
|
|
451
|
+
options: {
|
|
452
|
+
dir: { type: "string", default: "." },
|
|
453
|
+
pr: { type: "string" },
|
|
454
|
+
title: { type: "string" },
|
|
455
|
+
summary: { type: "string" },
|
|
456
|
+
trunk: { type: "string" },
|
|
457
|
+
rounds: { type: "string", default: "10" },
|
|
458
|
+
agents: { type: "string" },
|
|
459
|
+
push: { type: "boolean", default: true },
|
|
460
|
+
"no-push": { type: "boolean", default: false },
|
|
461
|
+
resume: { type: "string" },
|
|
462
|
+
"dry-run": { type: "boolean", default: false },
|
|
463
|
+
web: { type: "boolean", default: false },
|
|
464
|
+
port: { type: "string", default: "3080" },
|
|
465
|
+
},
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
// The PR link is the argument. `--pr` still works, but a flag for the one
|
|
469
|
+
// thing every invocation names is ceremony: `jury agent <url>` is what
|
|
470
|
+
// someone reaches for, and refusing it teaches nothing.
|
|
471
|
+
const url = positionals.find((a) => /^https?:\/\//.test(a));
|
|
472
|
+
if (url) values.pr = values.pr ?? url;
|
|
473
|
+
const stray = positionals.filter((a) => a !== url);
|
|
474
|
+
if (stray.length) {
|
|
475
|
+
throw new Error(`unexpected argument "${stray[0]}" — pass the pull request URL, or use --pr`);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
const maxRounds = Number(values.rounds);
|
|
479
|
+
if (!Number.isInteger(maxRounds) || maxRounds < 1) {
|
|
480
|
+
throw new Error("--rounds must be a positive integer");
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
values.push = values.push && !values["no-push"];
|
|
484
|
+
const requestedWorktree = path.resolve(values.dir);
|
|
485
|
+
const resolved = values.pr
|
|
486
|
+
? await resolvePrCheckout(values.pr, { allowPush: values.push })
|
|
487
|
+
: null;
|
|
488
|
+
const worktree = resolved?.worktree ?? requestedWorktree;
|
|
489
|
+
resolvedCheckoutCleanup = resolved?.cleanup ?? null;
|
|
490
|
+
|
|
491
|
+
// A local ignored config belongs to the requested checkout. An automatic
|
|
492
|
+
// clone intentionally starts from the repository's committed/default config.
|
|
493
|
+
const cfg = await loadConfig(resolved ? worktree : requestedWorktree);
|
|
494
|
+
const main = mainAgent(cfg);
|
|
495
|
+
if (!main) {
|
|
496
|
+
// A run with no main agent has no one to triage or fix — every finding
|
|
497
|
+
// would stay open and every round would re-raise it. Better to say so than
|
|
498
|
+
// to burn ten rounds discovering it.
|
|
499
|
+
throw new Error('no agent has role "main" — the loop has nobody to triage or fix');
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
let pool = reviewers(cfg);
|
|
503
|
+
if (values.agents) {
|
|
504
|
+
const want = new Set(values.agents.split(",").map((s) => s.trim()));
|
|
505
|
+
pool = pool.filter((a) => want.has(a.name));
|
|
506
|
+
}
|
|
507
|
+
if (!pool.length) throw new Error("no reviewers configured — nothing would review anything");
|
|
508
|
+
|
|
509
|
+
const git = await describe(worktree);
|
|
510
|
+
|
|
511
|
+
// Both asked for rather than assumed: the trunk from the remote's own HEAD,
|
|
512
|
+
// the title and intent from the PR itself. Every one of these was a flag you
|
|
513
|
+
// had to get right, and getting the trunk wrong is silent — the diff is taken
|
|
514
|
+
// against a branch that does not exist.
|
|
515
|
+
const trunkGiven = Boolean(values.trunk);
|
|
516
|
+
const trunk = values.trunk ?? resolved?.trunk ?? (await defaultTrunk(worktree));
|
|
517
|
+
values.trunk = trunk;
|
|
518
|
+
const pr = resolved ?? await prDetails(values.pr, worktree);
|
|
519
|
+
|
|
520
|
+
// The next round has to review NEW code or it is not a loop, so pushing is
|
|
521
|
+
// the default. It still refuses trunk outright: a loop that can push to
|
|
522
|
+
// master is one bad triage away from a bad afternoon.
|
|
523
|
+
const pushTarget = values.push
|
|
524
|
+
? resolved?.pushTarget ?? { remote: "origin", branch: git.branch }
|
|
525
|
+
: null;
|
|
526
|
+
if (values.push) {
|
|
527
|
+
if (!git.branch || git.branch === "HEAD") {
|
|
528
|
+
throw new Error("--push needs a checked-out branch; this worktree is detached");
|
|
529
|
+
}
|
|
530
|
+
if (!resolved && git.branch === values.trunk) {
|
|
531
|
+
throw new Error(`refusing --push onto ${values.trunk}: that is the trunk, not a PR branch`);
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
const target = {
|
|
536
|
+
repo: values.pr ? repoFromUrl(values.pr) : git.repo,
|
|
537
|
+
id: values.pr ? idFromUrl(values.pr) : git.branch,
|
|
538
|
+
url: values.pr ?? "",
|
|
539
|
+
// An explicit flag still wins; the PR is only consulted when you did not say.
|
|
540
|
+
title: values.title ?? pr.title ?? git.subject,
|
|
541
|
+
branch: resolved?.branch ?? git.branch,
|
|
542
|
+
trunk,
|
|
543
|
+
state: "review",
|
|
544
|
+
stateNote: "",
|
|
545
|
+
};
|
|
546
|
+
values.summary = values.summary ?? pr.summary ?? "";
|
|
547
|
+
|
|
548
|
+
// Each invocation is its own run unless you explicitly resume one. Appending
|
|
549
|
+
// to whatever ran before meant three separate reviews of the same PR merged
|
|
550
|
+
// into rounds 1-5 of a single run, and a killed run's open rounds interleaved
|
|
551
|
+
// with the next one's.
|
|
552
|
+
target.attempt = values.resume ? "" : attemptStamp();
|
|
553
|
+
const dir = values.resume
|
|
554
|
+
? path.join(runsDir(), path.basename(values.resume))
|
|
555
|
+
: path.join(runsDir(), slugFor(target));
|
|
556
|
+
const prior = await readEvents(dir);
|
|
557
|
+
if (values.resume && !prior.length) {
|
|
558
|
+
throw new Error(`no run at ${path.relative(process.cwd(), dir)} — check \`jury runs\``);
|
|
559
|
+
}
|
|
560
|
+
const first = Math.max(0, ...prior.filter((e) => e.t === "round.start").map((e) => e.n)) + 1;
|
|
561
|
+
|
|
562
|
+
console.log(st.field("target", `${target.repo} ${st.bold(target.id)}`));
|
|
563
|
+
console.log(st.field("worktree", st.muted(`${worktree} @ ${git.sha} (${resolved?.branch ?? git.branch})`)));
|
|
564
|
+
console.log(st.field("trunk", `${trunk}${trunkGiven ? st.muted(" (detected)") : ""}`));
|
|
565
|
+
if (target.title) console.log(st.field("title", target.title.slice(0, 72)));
|
|
566
|
+
console.log(st.field("main", st.agent(main.name)));
|
|
567
|
+
console.log(st.field("agents", pool.map((a) => st.agent(a.name)).join(", ")
|
|
568
|
+
+ (values["dry-run"] ? st.warn(" (dry run)") : "")));
|
|
569
|
+
console.log(st.field("rounds", st.muted(`${first}..${first + maxRounds - 1}, ${MAX_TURNS} turns per finding`)));
|
|
570
|
+
console.log(st.field("fixes", st.muted(values.push
|
|
571
|
+
? `committed and pushed to ${pushTarget.branch}` : "committed to the worktree only")));
|
|
572
|
+
console.log(st.field("run", st.muted(path.basename(dir) + (values.resume ? " (resumed)" : ""))));
|
|
573
|
+
// The console, in this same process. The loop and the server share the run
|
|
574
|
+
// directory and nothing else: the server re-reads it per request and tails
|
|
575
|
+
// the event log, so it sees each round land without the loop telling it.
|
|
576
|
+
if (values.web) {
|
|
577
|
+
const { url } = await serve({ port: Number(values.port), onLog: (m) => console.log(st.field("console", st.muted(m))) });
|
|
578
|
+
liveConsole = url;
|
|
579
|
+
console.log(st.field("console", `${url}${st.muted(" → the conversation streams live")}`));
|
|
580
|
+
openBrowser(url);
|
|
581
|
+
console.log("");
|
|
582
|
+
} else {
|
|
583
|
+
console.log(st.field("watch", st.muted("jury web → the conversation streams live")) + "\n");
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
let pushFailed = false;
|
|
587
|
+
for (let i = 0; i < maxRounds; i++) {
|
|
588
|
+
const round = first + i;
|
|
589
|
+
|
|
590
|
+
// Before the round, not after: buildPrompt reads settled.md, so a list
|
|
591
|
+
// written afterwards would first take effect one round too late.
|
|
592
|
+
await refreshSettled(dir);
|
|
593
|
+
|
|
594
|
+
const head = await describe(worktree, values.trunk);
|
|
595
|
+
const converged = await runRound({
|
|
596
|
+
dir, round, pool, cfg, target, worktree, values, sha: head.sha,
|
|
597
|
+
});
|
|
598
|
+
if (converged) {
|
|
599
|
+
// Clean is not enough on its own. A finding left open by the previous
|
|
600
|
+
// round — triage failed, the gate refused the verdict, or a reply raised
|
|
601
|
+
// it after the last triage had already run — is invisible to this round's
|
|
602
|
+
// reviewers, because settled.md lists only what has a verdict. They can
|
|
603
|
+
// all sign off in good faith while it sits unanswered. Fall through and
|
|
604
|
+
// triage it; the round-end check below is the one that may declare
|
|
605
|
+
// convergence.
|
|
606
|
+
const open = await openFindings(dir);
|
|
607
|
+
if (!open.length) {
|
|
608
|
+
console.log(st.ok(`\nCONVERGED after ${st.count(round - first + 1, "round")}.`));
|
|
609
|
+
return;
|
|
610
|
+
}
|
|
611
|
+
console.log(st.warn(`\nevery reviewer is clean, but ${st.count(open.length, "finding")} from earlier ${open.length === 1 ? "is" : "are"} still open.`));
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
let findings = await findingsIn(dir);
|
|
615
|
+
const events = await readEvents(dir);
|
|
616
|
+
const todo = outstanding(findings, events);
|
|
617
|
+
|
|
618
|
+
// Nothing to answer and nobody signed off: the reviewers produced prose
|
|
619
|
+
// this parser could not track. Another identical round will not fix that.
|
|
620
|
+
if (!todo.length) {
|
|
621
|
+
console.log(st.warn("\nno trackable findings to triage — stopping rather than repeating the round."));
|
|
622
|
+
await publishRun(dir, {
|
|
623
|
+
...target, state: "human", stateNote: "findings raised but none parsed as trackable",
|
|
624
|
+
});
|
|
625
|
+
return;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
console.log(`\n${st.rule(`triage · ${st.count(todo.length, "finding")} → ${main.name}`)}`);
|
|
629
|
+
let fixed = 0;
|
|
630
|
+
for (const f of todo) {
|
|
631
|
+
// The turn limit ends an argument the loop cannot win. Both positions are
|
|
632
|
+
// already recorded; deferring is not agreeing, and it is not fixing
|
|
633
|
+
// something nobody demonstrated either.
|
|
634
|
+
if (deadlocked(events, f.id)) {
|
|
635
|
+
const r = await record(dir, findings, f.id, {
|
|
636
|
+
verdict: "deferred",
|
|
637
|
+
reason: `argued ${MAX_TURNS} turns without agreement; both positions are in the log`,
|
|
638
|
+
});
|
|
639
|
+
console.log(` ${st.muted(f.id)} ${st.verdict("deferred")} ${st.muted(`(turn limit)${r.ok ? "" : ` — ${r.why}`}`)}`);
|
|
640
|
+
continue;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
console.log(` ${st.muted(f.id)} ${st.agent(f.agent)} ${f.claim.slice(0, 62)}${f.claim.length > 62 ? "…" : ""}`);
|
|
644
|
+
|
|
645
|
+
// One finding at a time, deliberately. Judging them concurrently would
|
|
646
|
+
// have several agents editing the same tree at once, and the second fix
|
|
647
|
+
// would land on top of the first without having seen it.
|
|
648
|
+
// Tagged with the reviewer whose finding this is: threads are per
|
|
649
|
+
// reviewer, and a heartbeat filed under "claude" opened a claude thread
|
|
650
|
+
// of its own, splitting one conversation in two.
|
|
651
|
+
const heart = beat(dir, main.name, round, f.agent);
|
|
652
|
+
let v;
|
|
653
|
+
try {
|
|
654
|
+
v = await triageOne(main, f, {
|
|
655
|
+
worktree, trunk, stopToken: cfg.stopToken, dryRun: values["dry-run"],
|
|
656
|
+
onLog: (m) => console.log(` ${st.muted(m)}`),
|
|
657
|
+
});
|
|
658
|
+
} finally {
|
|
659
|
+
clearInterval(heart);
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
if (!v.verdict) {
|
|
663
|
+
// Left open on purpose: an unjudged finding must be raised again rather
|
|
664
|
+
// than silently disappearing into a round that reports convergence.
|
|
665
|
+
console.log(` ${st.bad(v.failed ? "main agent failed" : "no verdict parsed")}${st.muted(" — left open")}`);
|
|
666
|
+
continue;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
// The reproduction is recorded before the verdict, because gate() reads
|
|
670
|
+
// the folded log: an acceptance is checked against what is already on
|
|
671
|
+
// disk, not against what this function happens to know.
|
|
672
|
+
if (v.reproduced) {
|
|
673
|
+
await appendEvent(dir, {
|
|
674
|
+
t: "finding.reproduced", id: f.id, evidence: v.reproduced, test: v.test ?? null,
|
|
675
|
+
});
|
|
676
|
+
}
|
|
677
|
+
// Refold BEFORE recording, not after. gate() checks the finding it is
|
|
678
|
+
// handed, and the map was folded before the reproduction above was
|
|
679
|
+
// appended — so every accepted finding was refused for having no
|
|
680
|
+
// reproduction, moments after one was written for it. The comment above
|
|
681
|
+
// described the right order; the code did not implement it.
|
|
682
|
+
findings = await findingsIn(dir);
|
|
683
|
+
const res = await record(dir, findings, f.id, {
|
|
684
|
+
verdict: v.verdict, reason: v.reason, test: v.test,
|
|
685
|
+
});
|
|
686
|
+
if (!res.ok) {
|
|
687
|
+
// The gate refusing is the gate working. Downgrade rather than crash:
|
|
688
|
+
// an acceptance with nothing behind it becomes an open finding again.
|
|
689
|
+
console.log(` ${st.bad("refused")}${st.muted(`: ${res.why} — left open`)}`);
|
|
690
|
+
continue;
|
|
691
|
+
}
|
|
692
|
+
console.log(` ${st.verdict(v.verdict)}${v.reason ? st.muted(` — ${v.reason.slice(0, 60)}`) : ""}`);
|
|
693
|
+
if (v.verdict === "accepted") fixed++;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
if (fixed && !values["dry-run"]) {
|
|
697
|
+
const c = await commitFixes(worktree, round, pushTarget);
|
|
698
|
+
if (c?.sha) {
|
|
699
|
+
await appendEvent(dir, {
|
|
700
|
+
t: "commit.pushed", sha: c.sha, pushed: c.pushed, subject: `round ${round} fixes`,
|
|
701
|
+
});
|
|
702
|
+
console.log(`commit ${c.sha}${
|
|
703
|
+
c.pushed === null ? "" : c.pushed ? ` → pushed to ${pushTarget.branch}` : " (NOT pushed)"}`);
|
|
704
|
+
// A run whose fixes never reached the branch cannot converge: the
|
|
705
|
+
// reviewers would be signing off on code the PR does not contain.
|
|
706
|
+
if (c.pushed === false) pushFailed = true;
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
// Every finding answered gets a reply, including the rejections — that is
|
|
711
|
+
// the conversation, and it is what a reviewer needs in order to either
|
|
712
|
+
// concede or push back.
|
|
713
|
+
const after = await findingsIn(dir);
|
|
714
|
+
const answers = await replyRound({
|
|
715
|
+
dir, pool, cfg, worktree, sha: head.sha, round, findings: after,
|
|
716
|
+
sessions: sessionsIn(await readEvents(dir)),
|
|
717
|
+
dryRun: values["dry-run"],
|
|
718
|
+
// Prefixed like the launch line: without a name this printed a bare
|
|
719
|
+
// "node (in worktree)" with nothing saying whose reply it was.
|
|
720
|
+
onLog: (m, who) => console.log(` ${who ? st.agent(who) + " " : ""}${st.muted(m)}`),
|
|
721
|
+
});
|
|
722
|
+
for (const a of answers) {
|
|
723
|
+
const what = a.failed
|
|
724
|
+
? "COULD NOT RUN — not counted as agreement"
|
|
725
|
+
: a.skipped
|
|
726
|
+
? "nothing to answer"
|
|
727
|
+
: a.raised
|
|
728
|
+
? `raised ${a.raised} new finding(s)`
|
|
729
|
+
: a.clean ? "signed off" : "still disagrees";
|
|
730
|
+
console.log(`${st.dim("reply")} ${st.agent(a.agent.padEnd(8))} ${a.failed ? st.bad(what) : a.clean ? st.ok(what) : st.warn(what)}`);
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
// Convergence is three things, and the first one was missing: every
|
|
734
|
+
// reviewer signed off, no reviewer failed to run, AND nothing is still
|
|
735
|
+
// open. Without the last, a round that raised findings nobody triaged
|
|
736
|
+
// reported success — every thread had nothing to answer, "nothing to
|
|
737
|
+
// answer" counted as clean, and the run exited having addressed none of it.
|
|
738
|
+
const settled = await findingsIn(dir);
|
|
739
|
+
const stillOpen = [...settled.values()].filter((f) => f.status === "open");
|
|
740
|
+
const broke = answers.filter((a) => a.failed);
|
|
741
|
+
if (answers.every((a) => a.clean) && !stillOpen.length && !broke.length && !pushFailed) {
|
|
742
|
+
console.log(st.ok(st.bold(`\nCONVERGED — every reviewer signed off after round ${round}.`)));
|
|
743
|
+
await publishRun(dir, {
|
|
744
|
+
...target, state: "converged", stateNote: `converged after round ${round}`,
|
|
745
|
+
});
|
|
746
|
+
return;
|
|
747
|
+
}
|
|
748
|
+
if (stillOpen.length) {
|
|
749
|
+
console.log(st.warn(`\n${st.count(stillOpen.length, "finding")} still open — not convergence.`));
|
|
750
|
+
}
|
|
751
|
+
if (broke.length) {
|
|
752
|
+
console.log(st.bad(`${broke.map((a) => a.agent).join(", ")} could not run — this run cannot converge.`));
|
|
753
|
+
}
|
|
754
|
+
if (pushFailed) {
|
|
755
|
+
console.log(st.bad("a push failed — the reviewers are reading code the PR does not have."));
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
console.log(st.warn(`\nstopped at the ${maxRounds}-round limit without full agreement.`));
|
|
760
|
+
await publishRun(dir, { ...target, state: "human", stateNote: `hit the ${maxRounds}-round limit` });
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
/**
|
|
764
|
+
* A heartbeat for the main agent while it triages.
|
|
765
|
+
*
|
|
766
|
+
* Triage is the longest single step in a round — read, reproduce, fix, run the
|
|
767
|
+
* suite — and without this the console shows nothing between the last report
|
|
768
|
+
* and the reply, which is when the interesting work happens.
|
|
769
|
+
*/
|
|
770
|
+
function beat(dir, agent, round, forAgent = null) {
|
|
771
|
+
const started = Date.now();
|
|
772
|
+
return setInterval(() => {
|
|
773
|
+
appendEvent(dir, {
|
|
774
|
+
t: "agent.alive", agent, round, forAgent,
|
|
775
|
+
seconds: Math.round((Date.now() - started) / 1000),
|
|
776
|
+
}).catch(() => {});
|
|
777
|
+
}, 5000);
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
/**
|
|
781
|
+
* Append a target state AND fold it into run.json.
|
|
782
|
+
*
|
|
783
|
+
* The console reads run.json, not the event log, so a terminal state that was
|
|
784
|
+
* only appended left the page asserting a review was still running after the
|
|
785
|
+
* loop had already converged or given up.
|
|
786
|
+
*/
|
|
787
|
+
async function publishRun(dir, target) {
|
|
788
|
+
await appendEvent(dir, { t: "target", target });
|
|
789
|
+
await writeRun(dir, foldEvents(await readEvents(dir), { target }));
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
/**
|
|
793
|
+
* Commit whatever the main agent changed, and push only when asked.
|
|
794
|
+
*
|
|
795
|
+
* Fast-forward only and never forced: this loop appends to someone's branch, it
|
|
796
|
+
* does not rewrite what is already there. A rejected push is reported, not
|
|
797
|
+
* retried harder.
|
|
798
|
+
*/
|
|
799
|
+
async function commitFixes(worktree, round, pushTarget) {
|
|
800
|
+
const status = await run("git", ["status", "--porcelain"], { cwd: worktree });
|
|
801
|
+
if (!status.stdout.trim()) return null;
|
|
802
|
+
await run("git", ["add", "-A"], { cwd: worktree });
|
|
803
|
+
await run("git", ["commit", "-m", `fix: round ${round} review findings`], { cwd: worktree });
|
|
804
|
+
const { stdout } = await run("git", ["rev-parse", "--short", "HEAD"], { cwd: worktree });
|
|
805
|
+
const sha = stdout.trim();
|
|
806
|
+
let pushed = false;
|
|
807
|
+
if (pushTarget) {
|
|
808
|
+
try {
|
|
809
|
+
await run("git", ["push", pushTarget.remote, `HEAD:${pushTarget.branch}`], { cwd: worktree });
|
|
810
|
+
pushed = true;
|
|
811
|
+
} catch (err) {
|
|
812
|
+
// Swallowing this reported a local commit as pushed, so later rounds
|
|
813
|
+
// reviewed code the PR never received — and could sign off on it.
|
|
814
|
+
console.error(`push FAILED (${err.message.split("\n")[0]}) — the commit is local only`);
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
return { sha, pushed: pushTarget ? pushed : null };
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
async function cleanupResolvedCheckout() {
|
|
821
|
+
const cleanup = resolvedCheckoutCleanup;
|
|
822
|
+
resolvedCheckoutCleanup = null;
|
|
823
|
+
if (cleanup) await cleanup();
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
async function cmdWeb(argv) {
|
|
827
|
+
const { values } = parseArgs({
|
|
828
|
+
args: argv, allowPositionals: false,
|
|
829
|
+
options: { port: { type: "string", default: "3080" }, open: { type: "boolean", default: false } },
|
|
830
|
+
});
|
|
831
|
+
const { url, port } = await serve({ port: Number(values.port) });
|
|
832
|
+
console.log(`console: ${url}`);
|
|
833
|
+
console.log(`runs: ${path.relative(process.cwd(), runsDir()) || "runs"}/`);
|
|
834
|
+
if (values.open) openBrowser(url);
|
|
835
|
+
process.on("SIGINT", () => { console.log("\nstopped"); process.exit(0); });
|
|
836
|
+
return new Promise(() => {}); // serve until interrupted
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
// Best effort, and deliberately unawaited: no browser (a CI box, a bare ssh
|
|
840
|
+
// session) is not a reason to fail a review that is otherwise about to run.
|
|
841
|
+
function openBrowser(url) {
|
|
842
|
+
const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
|
|
843
|
+
execFile(cmd, [url], () => {});
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
/**
|
|
847
|
+
* The triage gate as a command. The CLI does not decide whether a finding
|
|
848
|
+
* reproduces — that is the calling agent's judgement — but it does refuse to
|
|
849
|
+
* record an acceptance that never demonstrated one.
|
|
850
|
+
*/
|
|
851
|
+
async function cmdFinding(argv) {
|
|
852
|
+
const [sub, ...rest] = argv;
|
|
853
|
+
const { values, positionals } = parseArgs({
|
|
854
|
+
args: rest, allowPositionals: true,
|
|
855
|
+
options: {
|
|
856
|
+
run: { type: "string" },
|
|
857
|
+
evidence: { type: "string" },
|
|
858
|
+
test: { type: "string" },
|
|
859
|
+
verdict: { type: "string" },
|
|
860
|
+
reason: { type: "string" },
|
|
861
|
+
},
|
|
862
|
+
});
|
|
863
|
+
|
|
864
|
+
const dir = await resolveRun(values.run);
|
|
865
|
+
const findings = await findingsIn(dir);
|
|
866
|
+
const id = positionals[0];
|
|
867
|
+
|
|
868
|
+
switch (sub) {
|
|
869
|
+
case "list": {
|
|
870
|
+
if (!findings.size) { console.log("no findings recorded yet"); return; }
|
|
871
|
+
for (const f of findings.values()) {
|
|
872
|
+
const repro = f.reproduced ? "reproduced" : "unverified";
|
|
873
|
+
console.log(`${f.id.padEnd(18)} ${f.status.padEnd(10)} ${repro.padEnd(11)} ${f.agent.padEnd(7)} ${f.claim}`);
|
|
874
|
+
if (f.loc) console.log(`${" ".repeat(18)} ${f.loc}`);
|
|
875
|
+
}
|
|
876
|
+
return;
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
case "reproduce": {
|
|
880
|
+
if (!id) throw new Error("usage: jury finding reproduce <id> --evidence <text>");
|
|
881
|
+
if (!findings.has(id)) throw new Error(`no such finding: ${id}`);
|
|
882
|
+
if (!values.evidence) throw new Error("--evidence is required: what demonstrated the finding");
|
|
883
|
+
await appendEvent(dir, {
|
|
884
|
+
t: "finding.reproduced", id, evidence: values.evidence, test: values.test,
|
|
885
|
+
});
|
|
886
|
+
await republish(dir);
|
|
887
|
+
console.log(`${id}: reproduction recorded`);
|
|
888
|
+
return;
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
case "resolve": {
|
|
892
|
+
if (!id) throw new Error(`usage: jury finding resolve <id> --verdict <${VERDICTS.join("|")}>`);
|
|
893
|
+
const why = gate(findings.get(id), { verdict: values.verdict, test: values.test });
|
|
894
|
+
// Refused, not merely warned: this is the half of the discipline that
|
|
895
|
+
// survives the operator deciding to skip it.
|
|
896
|
+
if (why) throw new Error(`refused: ${why}`);
|
|
897
|
+
await appendEvent(dir, {
|
|
898
|
+
t: "finding.resolved", id, verdict: values.verdict, reason: values.reason ?? "", test: values.test,
|
|
899
|
+
});
|
|
900
|
+
await republish(dir);
|
|
901
|
+
console.log(`${id}: ${values.verdict}`);
|
|
902
|
+
return;
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
case "settled": {
|
|
906
|
+
const list = settledList(findings);
|
|
907
|
+
// Written where buildPrompt already looks for it, so the next round
|
|
908
|
+
// carries it without anyone maintaining a list by hand.
|
|
909
|
+
await writeArtifact(dir, "settled.md", list ? list + "\n" : "");
|
|
910
|
+
console.log(list || "(nothing settled yet)");
|
|
911
|
+
return;
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
default:
|
|
915
|
+
throw new Error(`unknown finding command "${sub ?? ""}" — try list, reproduce, resolve, settled`);
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
/**
|
|
920
|
+
* Reply to each reviewer about its own findings — separate conversations, run
|
|
921
|
+
* concurrently. A reviewer never sees another reviewer's findings or verdicts:
|
|
922
|
+
* two reviewers that read each other stop being independent, and the agreement
|
|
923
|
+
* between them stops being evidence.
|
|
924
|
+
*/
|
|
925
|
+
async function cmdReply(argv) {
|
|
926
|
+
const { values } = parseArgs({
|
|
927
|
+
args: argv, allowPositionals: false,
|
|
928
|
+
options: {
|
|
929
|
+
run: { type: "string" },
|
|
930
|
+
dir: { type: "string", default: "." },
|
|
931
|
+
agents: { type: "string" },
|
|
932
|
+
"dry-run": { type: "boolean", default: false },
|
|
933
|
+
},
|
|
934
|
+
});
|
|
935
|
+
|
|
936
|
+
const cfg = await loadConfig();
|
|
937
|
+
const dir = await resolveRun(values.run);
|
|
938
|
+
const findings = await findingsIn(dir);
|
|
939
|
+
const worktree = path.resolve(values.dir);
|
|
940
|
+
const { sha } = await describe(worktree, "master");
|
|
941
|
+
|
|
942
|
+
let pool = reviewers(cfg);
|
|
943
|
+
if (values.agents) {
|
|
944
|
+
const want = new Set(values.agents.split(",").map((s) => s.trim()));
|
|
945
|
+
pool = pool.filter((a) => want.has(a.name));
|
|
946
|
+
}
|
|
947
|
+
// Only reviewers that actually said something, and only once you have
|
|
948
|
+
// answered them: a reply that says "still open" for every item is noise.
|
|
949
|
+
pool = pool.filter((a) => {
|
|
950
|
+
const t = threadFor(a.name, findings);
|
|
951
|
+
return t.length && t.some((f) => f.status !== "open");
|
|
952
|
+
});
|
|
953
|
+
if (!pool.length) throw new Error("nothing to reply about — resolve some findings first");
|
|
954
|
+
|
|
955
|
+
const main = mainAgent(cfg);
|
|
956
|
+
console.log(`main ${main?.name ?? "(unset)"}`);
|
|
957
|
+
console.log(`replying ${pool.map((a) => a.name).join(", ")} (separate conversations)`);
|
|
958
|
+
console.log("");
|
|
959
|
+
|
|
960
|
+
await Promise.all(pool.map(async (a) => {
|
|
961
|
+
const thread = threadFor(a.name, findings);
|
|
962
|
+
const resumable = Boolean(a.resume?.supported && a.resume?.argv?.length);
|
|
963
|
+
const text = buildReply({
|
|
964
|
+
agent: a.name, thread, sha, worktree,
|
|
965
|
+
// Without a session there is nothing carrying its review, so its own
|
|
966
|
+
// findings have to travel with the reply.
|
|
967
|
+
quotePrior: !resumable, stopToken: cfg.stopToken,
|
|
968
|
+
// Every other agent in the registry, so a verdict that names one gets
|
|
969
|
+
// redacted before it reaches this reviewer.
|
|
970
|
+
others: cfg.agents.map((x) => x.name).filter((n) => n !== a.name),
|
|
971
|
+
});
|
|
972
|
+
if (!text) { console.log(` ${a.name}: nothing answered yet — skipped`); return; }
|
|
973
|
+
const file = await writeArtifact(dir, `reply.${a.name}.md`, text);
|
|
974
|
+
const answered = thread.filter((f) => f.status !== "open").length;
|
|
975
|
+
console.log(` ${a.name}: ${answered} verdict(s), ${resumable ? "resumed session" : "fresh run, findings quoted"} → ${file}`);
|
|
976
|
+
|
|
977
|
+
if (values["dry-run"]) return;
|
|
978
|
+
|
|
979
|
+
await appendEvent(dir, { t: "reply.sent", agent: a.name, sha, resumed: resumable, promptFile: file });
|
|
980
|
+
const spec = { ...a, argv: replyArgv(a, { promptText: text, worktree }).argv };
|
|
981
|
+
const replyRaw = `reply.${a.name}.stdout.txt`;
|
|
982
|
+
const sink = await openArtifact(dir, replyRaw);
|
|
983
|
+
const r = await runAgent(spec, {
|
|
984
|
+
worktree, prompt: text, stopToken: cfg.stopToken, onLog: (m) => console.log(` ${m}`),
|
|
985
|
+
onChunk: (chunk) => sink.write(chunk),
|
|
986
|
+
});
|
|
987
|
+
await sink.close();
|
|
988
|
+
await writeArtifact(dir, replyRaw, r.raw ?? "");
|
|
989
|
+
await appendEvent(dir, {
|
|
990
|
+
t: "reply.answered", agent: a.name, verdict: r.verdict, seconds: r.seconds, report: r.report,
|
|
991
|
+
});
|
|
992
|
+
console.log(`\n${"─".repeat(64)}\n${a.name} answered (${r.seconds}s)\n${"─".repeat(64)}\n${r.report}`);
|
|
993
|
+
}));
|
|
994
|
+
|
|
995
|
+
await republish(dir);
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
/** Which run a finding command applies to; unambiguous by default. */
|
|
999
|
+
async function resolveRun(slug) {
|
|
1000
|
+
const base = runsDir();
|
|
1001
|
+
if (slug) return path.join(base, slug);
|
|
1002
|
+
const { readdir } = await import("node:fs/promises");
|
|
1003
|
+
let dirs = [];
|
|
1004
|
+
try {
|
|
1005
|
+
dirs = (await readdir(base, { withFileTypes: true })).filter((e) => e.isDirectory()).map((e) => e.name);
|
|
1006
|
+
} catch { /* handled below */ }
|
|
1007
|
+
if (!dirs.length) throw new Error("no runs yet — run `jury review` first");
|
|
1008
|
+
if (dirs.length > 1) throw new Error(`several runs; pick one with --run <${dirs.join("|")}>`);
|
|
1009
|
+
return path.join(base, dirs[0]);
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
async function republish(dir) {
|
|
1013
|
+
const events = await readEvents(dir);
|
|
1014
|
+
const seed = events.filter((e) => e.t === "target").at(-1)?.target ?? {};
|
|
1015
|
+
await writeRun(dir, foldEvents(events, { target: seed }));
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
/**
|
|
1019
|
+
* Every PR under review. With more than one run, `--run <slug>` stops being
|
|
1020
|
+
* optional, so there has to be a way to see the slugs that does not require
|
|
1021
|
+
* triggering the ambiguity error to find them out.
|
|
1022
|
+
*/
|
|
1023
|
+
async function cmdRuns() {
|
|
1024
|
+
const { listRuns } = await import("../lib/store.js");
|
|
1025
|
+
const { runs, skipped } = await listRuns(runsDir());
|
|
1026
|
+
if (!runs.length) {
|
|
1027
|
+
// Report the unreadable ones even when nothing succeeded: "no runs yet"
|
|
1028
|
+
// over a directory full of broken runs is a lie that reads as "nothing was
|
|
1029
|
+
// ever reviewed".
|
|
1030
|
+
for (const s of skipped) console.log(`SKIPPED ${s.dir}: ${s.reason}`);
|
|
1031
|
+
console.log(skipped.length ? "no readable runs" : "no runs yet — run `jury review` first");
|
|
1032
|
+
return;
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
for (const r of runs) {
|
|
1036
|
+
const open = (r.exchanges ?? []).filter((f) => f.res === "open").length;
|
|
1037
|
+
const total = (r.exchanges ?? []).length;
|
|
1038
|
+
const rounds = (r.rounds ?? []).length;
|
|
1039
|
+
console.log(
|
|
1040
|
+
`${r.slug.padEnd(24)} ${String(r.target?.id ?? "").padEnd(5)} ` +
|
|
1041
|
+
`${String(r.target?.state ?? "").padEnd(10)} ${rounds} round(s) ` +
|
|
1042
|
+
`${total} finding(s)${open ? `, ${open} open` : ""}`,
|
|
1043
|
+
);
|
|
1044
|
+
if (r.target?.title) console.log(`${" ".repeat(24)} ${r.target.title}`);
|
|
1045
|
+
}
|
|
1046
|
+
// A run that cannot be read is reported, never silently omitted: an absent
|
|
1047
|
+
// row otherwise reads as "that PR was never reviewed".
|
|
1048
|
+
for (const s of skipped) console.log(`SKIPPED ${s.dir}: ${s.reason}`);
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
async function cmdAgents() {
|
|
1052
|
+
const cfg = await loadConfig();
|
|
1053
|
+
const found = await Promise.all(cfg.agents.map(probe));
|
|
1054
|
+
const roleOf = new Map(cfg.agents.map((a) => [a.name, a.role ?? "reviewer"]));
|
|
1055
|
+
for (const p of found) {
|
|
1056
|
+
const role = roleOf.get(p.name);
|
|
1057
|
+
console.log(
|
|
1058
|
+
`${p.ok ? "ok " : "MISSING"} ${p.name.padEnd(8)} ${role.padEnd(8)} ${p.path ?? p.bin}`,
|
|
1059
|
+
);
|
|
1060
|
+
}
|
|
1061
|
+
const missing = found.filter((p) => !p.ok);
|
|
1062
|
+
if (missing.length) {
|
|
1063
|
+
console.log(`\n${missing.length} agent(s) not installed. Install them, or disable in jury.config.json.`);
|
|
1064
|
+
process.exitCode = 1;
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
/**
|
|
1069
|
+
* The repository's default branch, asked of the remote rather than assumed.
|
|
1070
|
+
*
|
|
1071
|
+
* "master" was a guess that is wrong on most repositories made in the last few
|
|
1072
|
+
* years, and getting it wrong is not a visible failure: the diff is taken
|
|
1073
|
+
* against a branch that does not exist, so reviewers read the wrong change.
|
|
1074
|
+
* The remote's own HEAD is the authority; a repo with no remote falls back to
|
|
1075
|
+
* whichever local branch exists.
|
|
1076
|
+
*/
|
|
1077
|
+
async function defaultTrunk(dir) {
|
|
1078
|
+
const g = async (...a) => (await run("git", ["-C", dir, ...a])).stdout.trim();
|
|
1079
|
+
try {
|
|
1080
|
+
// origin/HEAD -> origin/main
|
|
1081
|
+
const ref = await g("symbolic-ref", "--short", "refs/remotes/origin/HEAD");
|
|
1082
|
+
const name = ref.replace(/^origin\//, "");
|
|
1083
|
+
if (name) return name;
|
|
1084
|
+
} catch { /* not set locally; ask the remote */ }
|
|
1085
|
+
try {
|
|
1086
|
+
const out = await g("ls-remote", "--symref", "origin", "HEAD");
|
|
1087
|
+
const m = out.match(/^ref:\s+refs\/heads\/(\S+)\s+HEAD/m);
|
|
1088
|
+
if (m) return m[1];
|
|
1089
|
+
} catch { /* offline or no remote */ }
|
|
1090
|
+
for (const name of ["main", "master"]) {
|
|
1091
|
+
try {
|
|
1092
|
+
await g("rev-parse", "--verify", `refs/heads/${name}`);
|
|
1093
|
+
return name;
|
|
1094
|
+
} catch { /* try the next */ }
|
|
1095
|
+
}
|
|
1096
|
+
return "master";
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
/**
|
|
1100
|
+
* The PR's own title and body, asked of GitHub rather than retyped.
|
|
1101
|
+
*
|
|
1102
|
+
* These reach the reviewers: the summary is what tells them what the change is
|
|
1103
|
+
* for, and a reviewer given "(no summary supplied — read the diff)" reviews
|
|
1104
|
+
* mechanics without intent. Retyping it on the command line is both work and a
|
|
1105
|
+
* chance to describe something other than what the PR says. Best-effort: no gh,
|
|
1106
|
+
* no auth, or a URL that is not a PR all fall back silently.
|
|
1107
|
+
*/
|
|
1108
|
+
async function prDetails(url, dir) {
|
|
1109
|
+
if (!url) return {};
|
|
1110
|
+
try {
|
|
1111
|
+
const { stdout } = await run("gh", ["pr", "view", url, "--json", "title,body"], { cwd: dir });
|
|
1112
|
+
const { title, body } = JSON.parse(stdout);
|
|
1113
|
+
return {
|
|
1114
|
+
title: title || undefined,
|
|
1115
|
+
// A PR body can be enormous; reviewers need the intent, not the checklist.
|
|
1116
|
+
summary: body ? body.replace(/\r/g, "").trim().slice(0, 4000) : undefined,
|
|
1117
|
+
};
|
|
1118
|
+
} catch {
|
|
1119
|
+
return {};
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
async function describe(dir, trunk) {
|
|
1124
|
+
const g = async (...args) => (await run("git", ["-C", dir, ...args])).stdout.trim();
|
|
1125
|
+
const [sha, branch, subject] = await Promise.all([
|
|
1126
|
+
g("rev-parse", "--short", "HEAD"),
|
|
1127
|
+
g("rev-parse", "--abbrev-ref", "HEAD").catch(() => "HEAD"),
|
|
1128
|
+
g("log", "-1", "--format=%s").catch(() => ""),
|
|
1129
|
+
]);
|
|
1130
|
+
let repo = path.basename(dir);
|
|
1131
|
+
let remote = "";
|
|
1132
|
+
try {
|
|
1133
|
+
remote = await g("remote", "get-url", "origin");
|
|
1134
|
+
repo = remote.replace(/\.git$/, "").split(/[:/]/).slice(-2).join("/");
|
|
1135
|
+
} catch { /* no remote is fine */ }
|
|
1136
|
+
return { sha, branch, subject, repo, remote, trunk };
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
// Declarations, not const arrows: the dispatch switch above runs at module
|
|
1140
|
+
// top level, so anything it reaches must be hoisted.
|
|
1141
|
+
function repoFromUrl(u) {
|
|
1142
|
+
return u.match(/\/([^/]+\/[^/]+)\/(?:merge_requests|pull)\//)?.[1] ?? "";
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
function idFromUrl(u) {
|
|
1146
|
+
const m = u.match(/\/merge_requests\/(\d+)|\/pull\/(\d+)/);
|
|
1147
|
+
return m ? (m[1] ? `!${m[1]}` : `#${m[2]}`) : "";
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
function firstLine(s) {
|
|
1151
|
+
return (s ?? "").split("\n").find((l) => l.trim()) ?? "";
|
|
1152
|
+
}
|