@335g/pi-herdr-fleet 0.0.1

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/review.ts ADDED
@@ -0,0 +1,515 @@
1
+ /**
2
+ * review: the one implementation behind the `fleet_review` tool and
3
+ * `/fleet review`.
4
+ *
5
+ * A fork branches the work *and* the context: the implementation session gets a
6
+ * task and nothing else. A review is the opposite trade. The reviewer can read
7
+ * the worktree, but it has no way to know what was asked, what the author
8
+ * believed it was doing, or which parts the author already knew were unfinished.
9
+ * So the review seed carries the diff, the task, and a bounded excerpt of the
10
+ * author's own session — the last of which is the only part no `git` command can
11
+ * produce.
12
+ *
13
+ * The review runs in a new pane *inside the implementation worktree*, because
14
+ * git refuses to check out one branch in two worktrees. The reviewer is told to
15
+ * be read-only; a review that edits the thing under review is not a review.
16
+ *
17
+ * The verdict is still text. 3c replaces this with a `fleet_verdict` tool call,
18
+ * which is why the seed fixes the shape now and why the reviewer is told to end
19
+ * with it.
20
+ */
21
+
22
+ import { closeSync, openSync, readFileSync, readSync, statSync } from "node:fs";
23
+ import { fileURLToPath } from "node:url";
24
+
25
+ import { Type } from "@earendil-works/pi-ai";
26
+ import type { ExtensionContext, ToolDefinition } from "@earendil-works/pi-coding-agent";
27
+
28
+ import { type HerdrClient, type Outcome, err, ok } from "./herdr-client.ts";
29
+ import { type RunRecord, readRun, writeRun } from "./runs.ts";
30
+ import { findScope } from "./scopes.ts";
31
+ import { type CommandRunner, agentName, mainCheckout, prepareWorktree, sendSeed, startAgent } from "./worktree.ts";
32
+
33
+ /**
34
+ * This extension's own entry point, taken from where this file is loaded from.
35
+ *
36
+ * The reviewer is started with `-e <this>` for two reasons: the installed path
37
+ * may point at an older build in the main checkout, and there may be no
38
+ * installed path at all. Either way the reviewer has to load the code that is
39
+ * running here, or `fleet_verdict` is not in its tool list.
40
+ */
41
+ const ENTRY = fileURLToPath(new URL("./index.ts", import.meta.url));
42
+
43
+ export interface ReviewRequest {
44
+ /** The directory the calling session is in: any checkout of the repository. */
45
+ cwd: string;
46
+ /** The branch the implementation session worked on. */
47
+ branch: string;
48
+ /** The task that session was given. A review is against the brief. */
49
+ task: string;
50
+ /** Ref the change is measured from. Defaults to the main checkout's HEAD. */
51
+ base?: string;
52
+ }
53
+
54
+ export interface ReviewedWorktree {
55
+ path: string;
56
+ branch: string;
57
+ workspaceId: string;
58
+ /** The pane the reviewer runs in, split off inside the author's worktree. */
59
+ paneId: string;
60
+ agent: string;
61
+ /** The ref the diff was taken against. */
62
+ base: string;
63
+ /** The author's pane, when its session was still running there. */
64
+ authorPaneId?: string;
65
+ authorSession?: string;
66
+ /** What the reviewer was actually given, so a truncated review is visible. */
67
+ diffChars: number;
68
+ diffTruncated: boolean;
69
+ authorChars: number;
70
+ authorMessages: number;
71
+ authorTruncated: boolean;
72
+ warnings: string[];
73
+ }
74
+
75
+ /**
76
+ * The caps on the author's session. A Pi session reaches megabytes, and the
77
+ * whole point of reading it is to hand over a bounded amount, so both a line and
78
+ * a character limit are needed: a session of one-line tool results and a session
79
+ * of long prose hit different ceilings.
80
+ *
81
+ * 300 lines and 20000 characters are roughly 5k tokens — small next to the diff,
82
+ * large enough to hold the reasoning around the last few commits. The newest
83
+ * text is what survives, because that is where the report is.
84
+ *
85
+ * The caps bound everything that is delivered, marker included: the omission
86
+ * notice is reserved out of the budget whether or not it is used. The separators
87
+ * between messages count as lines too — a session of 300 one-line messages is
88
+ * otherwise 1500 lines of seed.
89
+ */
90
+ const AUTHOR_MAX_LINES = 300;
91
+ const AUTHOR_MAX_CHARS = 20_000;
92
+ /** Never read a whole session into memory: only its tail can be used anyway. */
93
+ const AUTHOR_TAIL_BYTES = 4 * 1024 * 1024;
94
+ const OMITTED = "[earlier messages omitted]";
95
+ const OMITTED_LINES = 2;
96
+ const SEPARATOR = "\n\n---\n\n";
97
+ const SEPARATOR_LINES = SEPARATOR.split("\n").length - 1;
98
+
99
+ /**
100
+ * The diff goes into a seed sent through a pane, so a pathological one (a
101
+ * regenerated lockfile, a vendored tree) has to be cut somewhere. It is cut
102
+ * loudly: the reviewer is told, and can run `git diff` itself in the worktree.
103
+ */
104
+ const DIFF_MAX_CHARS = 60_000;
105
+
106
+ const GIT_TIMEOUT_MS = 30_000;
107
+
108
+ export interface AuthorExcerpt {
109
+ /** Assistant text, oldest first, with a marker when it was cut. */
110
+ text: string;
111
+ /** How many assistant messages survived. */
112
+ messages: number;
113
+ truncated: boolean;
114
+ }
115
+
116
+ /**
117
+ * Read a Pi session JSONL and take the assistant text out of it.
118
+ *
119
+ * Only the `text` parts: thinking is not what the author said, and tool calls
120
+ * and results are the diff by another route. The tail of the file is what is
121
+ * read, so a session that grew past `AUTHOR_TAIL_BYTES` still parses (the first,
122
+ * partial line is dropped) and an unreadable file is the caller's to report.
123
+ */
124
+ export function readAuthorSession(
125
+ path: string,
126
+ maxLines = AUTHOR_MAX_LINES,
127
+ maxChars = AUTHOR_MAX_CHARS,
128
+ ): AuthorExcerpt {
129
+ const messages: string[] = [];
130
+ for (const line of readTail(path, AUTHOR_TAIL_BYTES).split("\n")) {
131
+ const text = assistantText(line);
132
+ if (text !== "") messages.push(text);
133
+ }
134
+
135
+ // Walked from the end, because the report is the newest message and the caps
136
+ // are a budget rather than a per-message rule. The marker's room is taken out
137
+ // first, so what is delivered is inside the caps either way.
138
+ const kept: string[] = [];
139
+ const maxBodyLines = maxLines - OMITTED_LINES;
140
+ const maxBodyChars = maxChars - OMITTED.length - OMITTED_LINES;
141
+ let lines = 0;
142
+ let chars = 0;
143
+ let truncated = false;
144
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
145
+ const message = messages[index]!;
146
+ const cost = kept.length === 0 ? message.length : message.length + SEPARATOR.length;
147
+ const height = message.split("\n").length + (kept.length === 0 ? 0 : SEPARATOR_LINES);
148
+ if (lines + height > maxBodyLines || chars + cost > maxBodyChars) {
149
+ // The newest message can be longer than the whole budget on its own.
150
+ if (kept.length === 0) kept.push(tailWithin(message, maxBodyLines, maxBodyChars));
151
+ truncated = true;
152
+ break;
153
+ }
154
+ kept.unshift(message);
155
+ chars += cost;
156
+ lines += height;
157
+ }
158
+
159
+ return {
160
+ text: truncated ? `${OMITTED}\n\n${kept.join(SEPARATOR)}` : kept.join(SEPARATOR),
161
+ messages: kept.length,
162
+ truncated,
163
+ };
164
+ }
165
+
166
+ /** The last `maxChars` characters, then the last `maxLines` of those. */
167
+ function tailWithin(text: string, maxLines: number, maxChars: number): string {
168
+ let kept = text.length > maxChars ? text.slice(text.length - maxChars) : text;
169
+ const lines = kept.split("\n");
170
+ if (lines.length > maxLines) kept = lines.slice(lines.length - maxLines).join("\n");
171
+ return kept;
172
+ }
173
+
174
+ /** One JSONL line, when it is an assistant message with text in it. */
175
+ function assistantText(line: string): string {
176
+ let parsed: any;
177
+ try {
178
+ parsed = JSON.parse(line);
179
+ } catch {
180
+ return "";
181
+ }
182
+ if (parsed?.type !== "message" || parsed.message?.role !== "assistant") return "";
183
+ const content = parsed.message.content;
184
+ if (!Array.isArray(content)) return "";
185
+ return content
186
+ .filter((part) => part?.type === "text" && typeof part.text === "string")
187
+ .map((part: { text: string }) => part.text.trim())
188
+ .filter((text: string) => text !== "")
189
+ .join("\n");
190
+ }
191
+
192
+ function readTail(path: string, maxBytes: number): string {
193
+ if (statSync(path).size <= maxBytes) return readFileSync(path, "utf8");
194
+ const fd = openSync(path, "r");
195
+ try {
196
+ const buffer = Buffer.allocUnsafe(maxBytes);
197
+ const read = readSync(fd, buffer, 0, maxBytes, statSync(path).size - maxBytes);
198
+ const text = buffer.subarray(0, read).toString("utf8");
199
+ // The cut lands mid-line and possibly mid-character; the partial line goes.
200
+ const newline = text.indexOf("\n");
201
+ return newline < 0 ? "" : text.slice(newline + 1);
202
+ } finally {
203
+ closeSync(fd);
204
+ }
205
+ }
206
+
207
+ // ------------------------------------------------------------------ review
208
+
209
+ export async function reviewWorktree(
210
+ client: HerdrClient,
211
+ run: CommandRunner,
212
+ request: ReviewRequest,
213
+ ): Promise<Outcome<ReviewedWorktree>> {
214
+ const branch = request.branch.trim();
215
+ const task = request.task.trim();
216
+ // The tool's caller is an agent, so an argument is usually missing as an empty
217
+ // string rather than as an absent field.
218
+ if (branch === "" || task === "") return err("review: branch and task are both required");
219
+ const scope = findScope("review");
220
+ if (!scope) return err("review: this build has no review scope");
221
+
222
+ // The run record is where the fork point was written down, so a review of a
223
+ // forked branch diffs against the fork's own base by default. Without it, a
224
+ // nested fork would drag the parent branch's changes into the diff.
225
+ const main = await mainCheckout(run, request.cwd);
226
+ const record = main ? readRun(main, branch) : undefined;
227
+
228
+ const located = await locate(client, run, { cwd: request.cwd, branch, base: request.base ?? record?.base });
229
+ if (!located.ok) return located;
230
+ const { authorPaneId, authorSession, base, path, targetPaneId, workspaceId } = located.value;
231
+ const warnings = [...located.value.warnings];
232
+ // Without a pane inside the author's workspace, `pane.split` would split whatever
233
+ // is focused and the reviewer would end up somewhere else entirely.
234
+ if (!targetPaneId) {
235
+ return err(`review: no pane was found in workspace ${workspaceId}, so the reviewer has nowhere to run`);
236
+ }
237
+
238
+ // Gathered before the pane exists: a missing worktree or an unreadable git
239
+ // should not leave a half-started review session behind.
240
+ const diff = await git(run, ["diff", `${base}...HEAD`], path);
241
+ if (diff.code !== 0) return err(`review: git diff ${base}...HEAD failed: ${firstLine(diff.stderr) ?? `exit ${diff.code}`}`);
242
+ const truncatedDiff = diff.stdout.length > DIFF_MAX_CHARS;
243
+ const diffText = truncatedDiff
244
+ ? `${diff.stdout.slice(0, DIFF_MAX_CHARS)}\n\n[diff truncated at ${DIFF_MAX_CHARS} of ${diff.stdout.length} characters: run git diff yourself for the rest]`
245
+ : diff.stdout;
246
+
247
+ let author: AuthorExcerpt | undefined;
248
+ if (authorSession) {
249
+ try {
250
+ author = readAuthorSession(authorSession);
251
+ } catch (error) {
252
+ warnings.push(`the author's session could not be read: ${describe(error)}`);
253
+ }
254
+ } else {
255
+ warnings.push(`no Pi session was found in ${path}, so the author's own account is not part of the review`);
256
+ }
257
+
258
+ const prepared = await prepareWorktree(client, {
259
+ path,
260
+ workspaceId,
261
+ rootPaneId: targetPaneId,
262
+ // Dependency installation is the implementation's problem, not the review's.
263
+ install: false,
264
+ });
265
+ if (!prepared.ok) return err(`review: ${prepared.error} (the review is of what is already at ${path})`);
266
+
267
+ // A review of a branch that was sent back is a second review of the same
268
+ // branch, and an agent name is taken once. The name has to be free before
269
+ // `agent.start`, or herdr refuses it and the pane is left behind.
270
+ const agent = await freeAgentName(client, branch, "review");
271
+ const started = await startAgent(client, { paneId: prepared.value.paneId, name: agent, args: ["-e", ENTRY] });
272
+ if (!started.ok) {
273
+ // The pane is this call's own: a failed start must not leave it on screen.
274
+ const closed = await client.request("pane.close", { pane_id: prepared.value.paneId });
275
+ const where = closed.ok
276
+ ? `the pane ${prepared.value.paneId} was closed`
277
+ : `the pane ${prepared.value.paneId} could not be closed: ${closed.error}`;
278
+ return err(`review: ${started.error} (${where})`);
279
+ }
280
+
281
+ const sent = await sendSeed(
282
+ client,
283
+ prepared.value.paneId,
284
+ scope.seed({ task, path, branch, base, diff: diffText, author: author?.text, authorSession }),
285
+ );
286
+ if (!sent.ok) return err(`review: ${sent.error} (the reviewer's pane is ${prepared.value.paneId})`);
287
+
288
+ // The record is what `fleet_verdict` checks the calling pane against, so the
289
+ // reviewer has to be in it before it can answer.
290
+ if (main) {
291
+ const sessionPath = await sessionOf(client, prepared.value.paneId);
292
+ const next: RunRecord = {
293
+ ...(record ?? {}),
294
+ branch,
295
+ base,
296
+ path,
297
+ workspaceId,
298
+ scope: record?.scope ?? "implementation",
299
+ task: record?.task ?? task,
300
+ createdAt: record?.createdAt ?? new Date().toISOString(),
301
+ reviewer: { paneId: prepared.value.paneId, agentName: agent, ...(sessionPath ? { sessionPath } : {}) },
302
+ };
303
+ try {
304
+ writeRun(main, next);
305
+ } catch (error) {
306
+ warnings.push(`the run record could not be written: ${describe(error)}`);
307
+ }
308
+ }
309
+
310
+ return ok({
311
+ path,
312
+ branch,
313
+ workspaceId,
314
+ paneId: prepared.value.paneId,
315
+ agent,
316
+ base,
317
+ authorPaneId,
318
+ authorSession,
319
+ diffChars: diffText.length,
320
+ diffTruncated: truncatedDiff,
321
+ authorChars: author?.text.length ?? 0,
322
+ authorMessages: author?.messages ?? 0,
323
+ authorTruncated: author?.truncated ?? false,
324
+ warnings,
325
+ });
326
+ }
327
+
328
+ interface LocatedWorktree {
329
+ path: string;
330
+ workspaceId: string;
331
+ /** Where the review pane is split from: a pane that is already in the worktree. */
332
+ targetPaneId?: string;
333
+ authorPaneId?: string;
334
+ authorSession?: string;
335
+ base: string;
336
+ warnings: string[];
337
+ }
338
+
339
+ /**
340
+ * Where the branch is checked out, who wrote it, and what to diff against.
341
+ *
342
+ * herdr is asked which worktree holds the branch, because the answer has to
343
+ * include the workspace the review pane goes in. The author is found in the
344
+ * snapshot, by name first: a second review of the same branch would otherwise
345
+ * find the first reviewer's session before the author's.
346
+ */
347
+ async function locate(
348
+ client: HerdrClient,
349
+ run: CommandRunner,
350
+ request: { cwd: string; branch: string; base?: string },
351
+ ): Promise<Outcome<LocatedWorktree>> {
352
+ const listed = await client.request("worktree.list", { cwd: request.cwd });
353
+ if (!listed.ok) return listed;
354
+ const worktrees: any[] = Array.isArray(listed.value?.worktrees) ? listed.value.worktrees : [];
355
+ const worktree = worktrees.find((candidate) => candidate?.branch === request.branch);
356
+ if (!worktree) return err(`review: no worktree is checked out on ${request.branch}`);
357
+ const workspaceId = worktree.open_workspace_id;
358
+ if (typeof workspaceId !== "string") {
359
+ return err(`review: the worktree for ${request.branch} is not open in a workspace, so there is nowhere to review it`);
360
+ }
361
+
362
+ const warnings: string[] = [];
363
+ const author = await authorAgent(client, workspaceId, request.branch, warnings);
364
+ const source = listed.value?.source?.source_checkout_path;
365
+ const base = request.base?.trim() || (source ? await headOf(run, source) : undefined);
366
+ if (!base) return err("review: no base to diff against: pass one, or run this from a checkout of the branch's repository");
367
+
368
+ return ok({
369
+ path: worktree.path,
370
+ workspaceId,
371
+ targetPaneId: author?.pane_id ?? (await anyPaneIn(client, workspaceId)),
372
+ authorPaneId: author?.pane_id,
373
+ authorSession: typeof author?.agent_session?.value === "string" ? author.agent_session.value : undefined,
374
+ base,
375
+ warnings,
376
+ });
377
+ }
378
+
379
+ async function authorAgent(
380
+ client: HerdrClient,
381
+ workspaceId: string,
382
+ branch: string,
383
+ warnings: string[],
384
+ ): Promise<{ pane_id: string; agent_session?: { value?: string | null } | null } | undefined> {
385
+ const snapshot = await client.snapshot();
386
+ if (!snapshot.ok) {
387
+ warnings.push(`the snapshot could not be read: ${snapshot.error}`);
388
+ return undefined;
389
+ }
390
+ const inWorkspace = snapshot.value.agents.filter((agent) => agent.workspace_id === workspaceId && agent.agent === "pi");
391
+ return inWorkspace.find((agent) => agent.name === agentName(branch)) ?? inWorkspace[0];
392
+ }
393
+
394
+ /** A pane to split: the worktree's own shell when the author's Pi is gone. */
395
+ async function anyPaneIn(client: HerdrClient, workspaceId: string): Promise<string | undefined> {
396
+ const snapshot = await client.snapshot();
397
+ if (!snapshot.ok) return undefined;
398
+ return snapshot.value.panes.find((pane) => pane.workspace_id === workspaceId)?.pane_id;
399
+ }
400
+
401
+ /** The session herdr knows for a pane, when it knows one yet. */
402
+ async function sessionOf(client: HerdrClient, paneId: string): Promise<string | undefined> {
403
+ const snapshot = await client.snapshot();
404
+ if (!snapshot.ok) return undefined;
405
+ const value = snapshot.value.agents.find((agent) => agent.pane_id === paneId)?.agent_session?.value;
406
+ return typeof value === "string" ? value : undefined;
407
+ }
408
+
409
+ /** How many `-2`, `-3`, ... suffixes to try before letting herdr report the clash. */
410
+ const AGENT_NAME_ATTEMPTS = 50;
411
+
412
+ /**
413
+ * The reviewer's agent name, unique for this review.
414
+ *
415
+ * `<branch>-review` is the name the first review of a branch takes, and herdr
416
+ * refuses a second `agent.start` with a name that is already used — which is
417
+ * exactly what a send-back followed by a re-review does. The live agents are
418
+ * herdr's own answer to "is this name taken", so the suffix is chosen from the
419
+ * snapshot: `-review`, `-review-2`, `-review-3`, ...
420
+ *
421
+ * The check is not a lock. Two reviews started at the same instant could still
422
+ * pick the same name; the loser gets `agent_name_taken` and closes its pane.
423
+ */
424
+ export async function freeAgentName(client: HerdrClient, branch: string, suffix: string): Promise<string> {
425
+ const taken = await takenAgentNames(client);
426
+ const first = agentName(branch, suffix);
427
+ if (!taken.has(first)) return first;
428
+ for (let index = 2; index <= AGENT_NAME_ATTEMPTS; index += 1) {
429
+ const candidate = agentName(branch, `${suffix}-${index}`);
430
+ if (!taken.has(candidate)) return candidate;
431
+ }
432
+ // Every suffix is taken: let `agent.start` say so rather than inventing one.
433
+ return first;
434
+ }
435
+
436
+ /** The names herdr currently reports, or none when the snapshot cannot be read. */
437
+ async function takenAgentNames(client: HerdrClient): Promise<Set<string>> {
438
+ const snapshot = await client.snapshot();
439
+ if (!snapshot.ok) return new Set();
440
+ return new Set(snapshot.value.agents.flatMap((agent) => (typeof agent.name === "string" ? [agent.name] : [])));
441
+ }
442
+
443
+ async function headOf(run: CommandRunner, cwd: string): Promise<string | undefined> {
444
+ const head = await git(run, ["rev-parse", "HEAD"], cwd);
445
+ return head.code === 0 ? head.stdout.trim() : undefined;
446
+ }
447
+
448
+ function git(run: CommandRunner, args: string[], cwd: string): Promise<{ stdout: string; stderr: string; code: number }> {
449
+ return run("git", args, { cwd, timeout: GIT_TIMEOUT_MS });
450
+ }
451
+
452
+ function firstLine(text: string): string | undefined {
453
+ return text.split("\n").find((line) => line.trim() !== "")?.trim();
454
+ }
455
+
456
+ function describe(error: unknown): string {
457
+ return error instanceof Error ? error.message : String(error);
458
+ }
459
+
460
+ // ------------------------------------------------------------------ the tool
461
+
462
+ const REVIEW_PARAMETERS = Type.Object({
463
+ branch: Type.String({ description: "The branch the implementation session worked on." }),
464
+ task: Type.String({
465
+ description: "The task that session was given, in full. The review is against the brief, and the reviewer cannot see this conversation.",
466
+ }),
467
+ base: Type.Optional(
468
+ Type.String({ description: "Ref the change is measured from. Defaults to the main checkout's HEAD, which is where a fork branches from." }),
469
+ ),
470
+ });
471
+
472
+ /** The tool the agent calls. Registered in a TUI session only, like the command. */
473
+ export function fleetReviewTool(client: HerdrClient, run: CommandRunner): ToolDefinition<typeof REVIEW_PARAMETERS> {
474
+ return {
475
+ name: "fleet_review",
476
+ label: "Fleet review",
477
+ description:
478
+ "Start a read-only Pi session in the worktree of a branch that was forked, and give it the diff, the task and the author's own session to judge. The reviewer answers with a verdict; it does not change the worktree. Returns the reviewer's pane and agent.",
479
+ promptSnippet: "Send a reviewer into a forked worktree, with the diff and the author's session",
480
+ promptGuidelines: [
481
+ "Use fleet_review after fleet_fork once the implementation session has committed, so the work is reviewed before it is merged.",
482
+ "Pass the same task that fleet_fork was given: the reviewer judges the change against that brief.",
483
+ ],
484
+ parameters: REVIEW_PARAMETERS,
485
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx: ExtensionContext) {
486
+ if (ctx.mode !== "tui") throw new Error("fleet_review only works in an interactive Pi session");
487
+ const reviewed = await reviewWorktree(client, run, {
488
+ cwd: ctx.cwd,
489
+ branch: params.branch,
490
+ task: params.task,
491
+ base: params.base,
492
+ });
493
+ if (!reviewed.ok) throw new Error(reviewed.error);
494
+ return { content: [{ type: "text" as const, text: report(reviewed.value) }], details: reviewed.value };
495
+ },
496
+ };
497
+ }
498
+
499
+ /** One conversation entry, so it stays short: what was reviewed and by whom. */
500
+ function report(reviewed: ReviewedWorktree): string {
501
+ const lines = [
502
+ `reviewing ${reviewed.branch} against ${reviewed.base}`,
503
+ `worktree: ${reviewed.path}`,
504
+ `pane: ${reviewed.paneId}`,
505
+ `agent: ${reviewed.agent}`,
506
+ `diff: ${reviewed.diffChars} characters${reviewed.diffTruncated ? " (truncated)" : ""}`,
507
+ `author session: ${reviewed.authorMessages} assistant messages, ${reviewed.authorChars} characters${
508
+ reviewed.authorTruncated ? " (truncated)" : ""
509
+ }`,
510
+ ];
511
+ if (!reviewed.authorSession) lines.push("no author session was found");
512
+ for (const warning of reviewed.warnings) lines.push(`warning: ${warning}`);
513
+ lines.push("the reviewer records its verdict with the fleet_verdict tool; it does not modify the worktree");
514
+ return lines.join("\n");
515
+ }