@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/runs.ts ADDED
@@ -0,0 +1,437 @@
1
+ /**
2
+ * runs: the record of a fork, its review and its verdict, and the gate that
3
+ * reads it.
4
+ *
5
+ * Up to 3b everything about a run lived in live panes. That is not enough for a
6
+ * gate: a verdict kept only in the reviewer's session disappears the moment the
7
+ * pane is closed, and the gate would silently open. So the record is a file in
8
+ * the main checkout — `<main checkout>/.pi/herdr-fleet/runs/<branch>.json`, with
9
+ * `/` replaced by `-` in the branch — written by a fork, updated by a review and
10
+ * by `fleet_verdict`.
11
+ *
12
+ * The verdict itself is a tool call, not a line of text. The reviewer's reply is
13
+ * prose for the human reading it; the tool call is the machine-readable one, and
14
+ * the merge gate reads only that.
15
+ *
16
+ * `fleet_verdict` executes inside the reviewer's own session, which is why it
17
+ * checks the calling pane against the run it recorded: the extension is loaded
18
+ * in every Pi session, so without that check any session could write a verdict.
19
+ *
20
+ * `fleet_status` and `fleet_merge` are the other two calls that close the loop:
21
+ * a tool is what an agent can drive, and a command alone would put a human in
22
+ * the middle of every round. `/fleet status` and `/fleet merge` are thin
23
+ * wrappers over `statusRuns` and `mergeRun`, the same functions the tools call.
24
+ */
25
+
26
+ import { mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
27
+ import { join } from "node:path";
28
+
29
+ import { StringEnum, Type } from "@earendil-works/pi-ai";
30
+ import type { ExtensionAPI, ExtensionContext, ToolDefinition } from "@earendil-works/pi-coding-agent";
31
+
32
+ import { type HerdrClient, type Outcome, err, ok } from "./herdr-client.ts";
33
+ import { type CommandRunner, mainCheckout } from "./worktree.ts";
34
+
35
+ export type VerdictKind = "approve" | "request-changes";
36
+
37
+ export interface Finding {
38
+ path: string;
39
+ line?: number;
40
+ note: string;
41
+ }
42
+
43
+ export interface RunRecord {
44
+ branch: string;
45
+ /** The ref the fork branched from, so a review can default to it. */
46
+ base?: string;
47
+ path: string;
48
+ workspaceId: string;
49
+ /** The implementation session, when the fork started one. */
50
+ paneId?: string;
51
+ agentName?: string;
52
+ scope: string;
53
+ task: string;
54
+ createdAt: string;
55
+ reviewer?: { paneId: string; agentName: string; sessionPath?: string };
56
+ verdict?: { verdict: VerdictKind; findings: Finding[]; at: string };
57
+ /** Set by `/fleet merge`, so a status line can say it without asking git. */
58
+ mergedAt?: string;
59
+ /**
60
+ * Set by `fleet_clean`. The record is never deleted — it is the audit trail —
61
+ * so this field is the only trace that the worktree, the branch and the panes
62
+ * are gone.
63
+ */
64
+ cleanedAt?: string;
65
+ /**
66
+ * Set by `fleet_clean` when it could not finish: the reason, and the stages it
67
+ * did complete. `cleanedAt` stays unset, so a record with this field is a
68
+ * partially cleaned run, not an untouched one. A later clean that finishes
69
+ * clears it.
70
+ */
71
+ cleanError?: string;
72
+ }
73
+
74
+ /** What `/fleet status` reports, derived from the record and one git question. */
75
+ export type RunState = "working" | "unreviewed" | "approve" | "request-changes" | "merged" | "cleaned";
76
+
77
+ const GIT_TIMEOUT_MS = 30_000;
78
+ const MERGE_TIMEOUT_MS = 10 * 60_000;
79
+
80
+ // ------------------------------------------------------------------ storage
81
+
82
+ export const RUNS_DIR = join(".pi", "herdr-fleet", "runs");
83
+
84
+ export function runFileName(branch: string): string {
85
+ return `${branch.replaceAll("/", "-")}.json`;
86
+ }
87
+
88
+ export function runsDir(main: string): string {
89
+ return join(main, RUNS_DIR);
90
+ }
91
+
92
+ export function readRun(main: string, branch: string): RunRecord | undefined {
93
+ try {
94
+ return JSON.parse(readFileSync(join(runsDir(main), runFileName(branch)), "utf8")) as RunRecord;
95
+ } catch {
96
+ return undefined;
97
+ }
98
+ }
99
+
100
+ export function writeRun(main: string, record: RunRecord): void {
101
+ const dir = runsDir(main);
102
+ mkdirSync(dir, { recursive: true });
103
+ writeFileSync(join(dir, runFileName(record.branch)), `${JSON.stringify(record, null, 2)}\n`);
104
+ }
105
+
106
+ export function updateRun(main: string, branch: string, patch: Partial<RunRecord>): RunRecord | undefined {
107
+ const existing = readRun(main, branch);
108
+ if (!existing) return undefined;
109
+ const next = { ...existing, ...patch };
110
+ writeRun(main, next);
111
+ return next;
112
+ }
113
+
114
+ /** Every record, sorted by branch name. An unreadable directory is no records. */
115
+ export function listRuns(main: string): RunRecord[] {
116
+ try {
117
+ const dir = runsDir(main);
118
+ return readdirSync(dir)
119
+ .filter((name) => name.endsWith(".json"))
120
+ .sort()
121
+ .flatMap((name) => {
122
+ try {
123
+ return [JSON.parse(readFileSync(join(dir, name), "utf8")) as RunRecord];
124
+ } catch {
125
+ return [];
126
+ }
127
+ });
128
+ } catch {
129
+ return [];
130
+ }
131
+ }
132
+
133
+ // ------------------------------------------------------------------- status
134
+
135
+ export function runState(record: RunRecord, merged: boolean): RunState {
136
+ // `cleanedAt` comes first: after `fleet_clean` the branch is gone, so git cannot
137
+ // answer, and a force-cleaned run was never merged at all. Without this a cleaned
138
+ // run falls back to its verdict and reads as `approve`, which invites a merge of a
139
+ // branch that no longer exists.
140
+ if (record.cleanedAt !== undefined) return "cleaned";
141
+ // `mergedAt` is the same kind of evidence when the branch ref is gone but the run
142
+ // was not cleaned: `fleet_merge` writes it only after a successful merge.
143
+ if (merged || record.mergedAt !== undefined) return "merged";
144
+ if (record.verdict) return record.verdict.verdict;
145
+ // A reviewer was started but never answered: the run is waiting on it.
146
+ if (record.reviewer) return "unreviewed";
147
+ return "working";
148
+ }
149
+
150
+ /** Whether the branch is already in the main checkout's history. */
151
+ export async function isMerged(run: CommandRunner, main: string, branch: string): Promise<boolean> {
152
+ const result = await run("git", ["merge-base", "--is-ancestor", branch, "HEAD"], { cwd: main, timeout: GIT_TIMEOUT_MS });
153
+ return result.code === 0;
154
+ }
155
+
156
+ /** One row of the status list, before either caller gives it its own words. */
157
+ export interface RunStatus {
158
+ branch: string;
159
+ scope: string;
160
+ state: RunState;
161
+ /** The verdict name, or `-` when there is none. */
162
+ verdict: string;
163
+ }
164
+
165
+ /**
166
+ * Every recorded run with its state. One implementation behind `fleet_status`
167
+ * and `/fleet status`: the list is the loop's own view of itself, and two
168
+ * versions of it would disagree about what is mergeable.
169
+ *
170
+ * One git call per run — `merged` is the main checkout's history, not a field
171
+ * in the record, so a branch merged by hand still reads as merged. The record's
172
+ * own `mergedAt` and `cleanedAt` are the fallback when the branch ref is gone:
173
+ * a cleaned branch cannot be asked about at all.
174
+ */
175
+ export async function statusRuns(run: CommandRunner, main: string): Promise<RunStatus[]> {
176
+ const rows: RunStatus[] = [];
177
+ for (const record of listRuns(main)) {
178
+ const merged = await isMerged(run, main, record.branch);
179
+ rows.push({
180
+ branch: record.branch,
181
+ scope: record.scope,
182
+ state: runState(record, merged),
183
+ verdict: record.verdict?.verdict ?? "-",
184
+ });
185
+ }
186
+ return rows;
187
+ }
188
+
189
+ // -------------------------------------------------------------- the verdict
190
+
191
+ const VERDICT_PARAMETERS = Type.Object({
192
+ verdict: StringEnum(["approve", "request-changes"] as const, {
193
+ description: "approve when the change is ready to merge; request-changes when the author has to act on a finding.",
194
+ }),
195
+ findings: Type.Array(
196
+ Type.Object({
197
+ path: Type.String({ description: "The file the finding is about, relative to the worktree." }),
198
+ line: Type.Optional(Type.Number({ description: "The line in that file, when the finding points at one." })),
199
+ note: Type.String({ description: "What is wrong, in one actionable sentence." }),
200
+ }),
201
+ { description: "One entry per problem. Empty is allowed for approve; request-changes without findings is not useful." },
202
+ ),
203
+ });
204
+
205
+ /**
206
+ * The tool the reviewer calls. It writes the run's verdict and, on
207
+ * `request-changes`, sends the findings back to the implementation session when
208
+ * that session is still alive (3c does not start a replacement: see DESIGN.md).
209
+ */
210
+ export function fleetVerdictTool(
211
+ client: HerdrClient,
212
+ run: CommandRunner,
213
+ pi: ExtensionAPI,
214
+ ): ToolDefinition<typeof VERDICT_PARAMETERS> {
215
+ return {
216
+ name: "fleet_verdict",
217
+ label: "Fleet verdict",
218
+ description:
219
+ "Record the verdict of a review you are running: approve or request-changes, with one finding per problem. Only the pane this run recorded as the reviewer may call it. On request-changes the findings are sent back to the author's session if it is still running.",
220
+ promptSnippet: "Record this review's verdict so the branch can be merged or sent back",
221
+ promptGuidelines: [
222
+ "Call fleet_verdict once, when the review is finished: approve only when nothing is worth changing.",
223
+ "The findings are what the author has to act on, so name the file, the line and the problem.",
224
+ ],
225
+ parameters: VERDICT_PARAMETERS,
226
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx: ExtensionContext) {
227
+ if (ctx.mode !== "tui") throw new Error("fleet_verdict only works in an interactive Pi session");
228
+ const main = await mainCheckout(run, ctx.cwd);
229
+ if (!main) throw new Error(`fleet_verdict: ${ctx.cwd} is not inside a git checkout, so no run can be recorded`);
230
+
231
+ const paneId = client.selfPaneId();
232
+ const record = listRuns(main).find((candidate) => candidate.reviewer?.paneId === paneId);
233
+ if (!record) {
234
+ throw new Error(
235
+ `fleet_verdict: this pane (${paneId}) is not the reviewer of any run in ${runsDir(main)}; only the reviewer recorded by fleet_review may give a verdict`,
236
+ );
237
+ }
238
+
239
+ const findings: Finding[] = params.findings.map((finding) => ({
240
+ path: finding.path,
241
+ ...(finding.line === undefined ? {} : { line: finding.line }),
242
+ note: finding.note,
243
+ }));
244
+ const at = new Date().toISOString();
245
+ updateRun(main, record.branch, { verdict: { verdict: params.verdict as VerdictKind, findings, at } });
246
+ // The file is the gate; the entry is what keeps the verdict in the
247
+ // conversation tree it was given in.
248
+ pi.appendEntry("fleet-verdict", { branch: record.branch, verdict: params.verdict, findings, at });
249
+
250
+ const notes: string[] = [];
251
+ if (params.verdict === "request-changes") {
252
+ notes.push(...(await deliverFindings(client, { ...record, verdict: { verdict: params.verdict as VerdictKind, findings, at } }, findings)));
253
+ }
254
+
255
+ const lines = [
256
+ `recorded ${params.verdict} for ${record.branch}`,
257
+ `findings: ${findings.length}`,
258
+ `record: ${join(runsDir(main), runFileName(record.branch))}`,
259
+ ...notes,
260
+ ];
261
+ return { content: [{ type: "text" as const, text: lines.join("\n") }], details: { branch: record.branch, verdict: params.verdict, findings } };
262
+ },
263
+ };
264
+ }
265
+
266
+ /**
267
+ * `request-changes` is only useful if the author hears about it. The
268
+ * implementation session is the one the fork started, so it is reached through
269
+ * its pane — the same surface §2 settled on. When that session is gone nothing
270
+ * is started in its place; the caller is told, and a fork is the human's move.
271
+ */
272
+ async function deliverFindings(client: HerdrClient, record: RunRecord, findings: Finding[]): Promise<string[]> {
273
+ if (!record.paneId) return ["the run recorded no implementation pane, so the findings were not sent anywhere"];
274
+ const snapshot = await client.snapshot();
275
+ if (!snapshot.ok) return [`the implementation session could not be checked: ${snapshot.error}`];
276
+ if (!snapshot.value.panes.some((pane) => pane.pane_id === record.paneId)) {
277
+ return [`the implementation session (${record.paneId}) is gone; the findings were not sent — fork a new session for the rework`];
278
+ }
279
+ const sent = await client.paneSendInput(record.paneId, findingsText(record.branch, findings));
280
+ return sent.ok ? [`the findings were sent to the implementation session (${record.paneId})`] : [`the findings could not be sent: ${sent.error}`];
281
+ }
282
+
283
+ /** One message back to the author: the verdict, and every finding. */
284
+ export function findingsText(branch: string, findings: Finding[]): string {
285
+ const lines = [`A review of ${branch} requested changes. Act on the findings, then commit again.`, "", "# Findings", ""];
286
+ if (findings.length === 0) lines.push("- (the reviewer gave no findings; ask what it wants changed)");
287
+ for (const finding of findings) lines.push(`- ${finding.path}${finding.line === undefined ? "" : `:${finding.line}`} ${finding.note}`);
288
+ return lines.join("\n");
289
+ }
290
+
291
+ // ------------------------------------------------------------- the gate
292
+
293
+ export interface MergeRequest {
294
+ /** Any directory in the repository; the merge itself runs in the main checkout. */
295
+ cwd: string;
296
+ branch: string;
297
+ /** Merge even without an approve verdict. */
298
+ force?: boolean;
299
+ }
300
+
301
+ export interface MergeResult {
302
+ branch: string;
303
+ main: string;
304
+ /** The verdict the gate saw, when there was one. */
305
+ verdict?: VerdictKind;
306
+ output: string;
307
+ }
308
+
309
+ /**
310
+ * `/fleet merge`: the approve verdict, then a clean main checkout, then git.
311
+ *
312
+ * The worktree is deliberately left in place — its branch is now in main, but
313
+ * the checkout is also where the reviewer ran and where the author's session may
314
+ * still be. Cleanup is its own operation, not a side effect of merging.
315
+ */
316
+ export async function mergeRun(run: CommandRunner, request: MergeRequest): Promise<Outcome<MergeResult>> {
317
+ const branch = request.branch.trim();
318
+ if (branch === "") return err("merge: a branch is required");
319
+ const main = await mainCheckout(run, request.cwd);
320
+ if (!main) return err(`merge: ${request.cwd} is not inside a git checkout`);
321
+ const record = readRun(main, branch);
322
+
323
+ const verdict = record?.verdict?.verdict;
324
+ if (verdict !== "approve" && request.force !== true) {
325
+ const why = record === undefined ? "no run was recorded for it" : `its verdict is ${verdict ?? "missing"}`;
326
+ return err(`merge: ${branch} has no approve verdict (${why}); review it first, or pass --force`);
327
+ }
328
+
329
+ // Only tracked changes count. The run records live under `.pi/` inside this
330
+ // checkout, so treating an untracked `.pi/` as a dirty tree would make the
331
+ // gate refuse forever. git still refuses a merge that would clobber an
332
+ // untracked file, and reports that itself.
333
+ const dirty = await run("git", ["status", "--porcelain", "--untracked-files=no"], { cwd: main, timeout: GIT_TIMEOUT_MS });
334
+ if (dirty.code !== 0) return err(`merge: git status failed in ${main}: ${firstLine(dirty.stderr) ?? `exit ${dirty.code}`}`);
335
+ if (dirty.stdout.trim() !== "") return err(`merge: ${main} has uncommitted changes; commit or stash them first`);
336
+
337
+ const merged = await run("git", ["merge", "--no-edit", branch], { cwd: main, timeout: MERGE_TIMEOUT_MS });
338
+ if (merged.code !== 0) {
339
+ return err(`merge: git merge ${branch} failed: ${firstLine(merged.stderr) ?? firstLine(merged.stdout) ?? `exit ${merged.code}`}`);
340
+ }
341
+ if (record) updateRun(main, branch, { mergedAt: new Date().toISOString() });
342
+ return ok({ branch, main, verdict, output: merged.stdout.trim() });
343
+ }
344
+
345
+ function firstLine(text: string): string | undefined {
346
+ return text.split("\n").find((line) => line.trim() !== "")?.trim();
347
+ }
348
+
349
+ // ------------------------------------------------------------- the tools
350
+
351
+ /** Every state, in the tool's language. `stateLabel` is the command's. */
352
+ const STATE_TEXT: Record<RunState, string> = {
353
+ working: "working",
354
+ unreviewed: "unreviewed",
355
+ approve: "approve",
356
+ "request-changes": "request-changes",
357
+ merged: "merged",
358
+ cleaned: "cleaned",
359
+ };
360
+
361
+ /** No arguments: the list is the main checkout's own record. */
362
+ const STATUS_PARAMETERS = Type.Object({});
363
+
364
+ /**
365
+ * The tool an agent calls to see the loop's state. Same rows as `/fleet status`,
366
+ * but the result is a conversation entry, which is what lets a driving agent
367
+ * decide what to review or merge without a human reading a toast.
368
+ */
369
+ export function fleetStatusTool(run: CommandRunner): ToolDefinition<typeof STATUS_PARAMETERS> {
370
+ return {
371
+ name: "fleet_status",
372
+ label: "Fleet status",
373
+ description:
374
+ "List every recorded fleet run, one per branch that fleet_fork created, with its branch, scope, state and verdict. A state is working, unreviewed, approve, request-changes, merged when the branch is already in the main checkout's history, or cleaned once fleet_clean has removed its worktree, branch and panes. Read-only, and takes no arguments.",
375
+ promptSnippet: "List every recorded run with its branch, scope, state and verdict",
376
+ promptGuidelines: [
377
+ "Call fleet_status to see which branches are still working, waiting on a review, approved, already merged, or already cleaned.",
378
+ "Only a run whose state is approve may be merged; fleet_merge refuses the rest unless force is set.",
379
+ ],
380
+ parameters: STATUS_PARAMETERS,
381
+ async execute(_toolCallId, _params, _signal, _onUpdate, ctx: ExtensionContext) {
382
+ if (ctx.mode !== "tui") throw new Error("fleet_status only works in an interactive Pi session");
383
+ const main = await mainCheckout(run, ctx.cwd);
384
+ if (!main) throw new Error(`fleet_status: ${ctx.cwd} is not inside a git checkout, so no run can be listed`);
385
+ const rows = await statusRuns(run, main);
386
+ const lines = rows.length === 0 ? [`no run has been recorded in ${main}`] : rows.map((row) => statusLine(row));
387
+ return { content: [{ type: "text" as const, text: lines.join("\n") }], details: { main, runs: rows } };
388
+ },
389
+ };
390
+ }
391
+
392
+ /** `branch · scope · state · verdict`, the same shape the command prints. */
393
+ function statusLine(row: RunStatus): string {
394
+ return `${row.branch} · ${row.scope} · ${STATE_TEXT[row.state]} · ${row.verdict}`;
395
+ }
396
+
397
+ const MERGE_PARAMETERS = Type.Object({
398
+ branch: Type.String({ description: "The branch to merge. It must have a run whose recorded verdict is approve, unless force is set." }),
399
+ force: Type.Optional(
400
+ Type.Boolean({
401
+ description:
402
+ "Merge even though the recorded verdict is not approve. Defaults to false. It does not bypass a dirty main checkout.",
403
+ }),
404
+ ),
405
+ });
406
+
407
+ /**
408
+ * The tool that closes the loop. `mergeRun` is the gate and git call; the tool
409
+ * only turns its refusal into a thrown error, because a returned value never
410
+ * sets the error flag and the agent has to know the merge did not happen.
411
+ */
412
+ export function fleetMergeTool(run: CommandRunner): ToolDefinition<typeof MERGE_PARAMETERS> {
413
+ return {
414
+ name: "fleet_merge",
415
+ label: "Fleet merge",
416
+ description:
417
+ "Merge a reviewed branch into the main checkout with `git merge --no-edit`. It presumes the run's recorded verdict is approve and refuses otherwise; pass force to merge anyway. Either way it refuses while the main checkout has uncommitted tracked changes. The branch's worktree is left in place: merging is not cleanup.",
418
+ promptSnippet: "Merge a branch whose review recorded approve into the main checkout",
419
+ promptGuidelines: [
420
+ "Call fleet_merge once a review has recorded approve; set force only when the human asks for it, and say why.",
421
+ "fleet_merge leaves the worktree in place, so a merged branch is still cleaned up separately.",
422
+ ],
423
+ parameters: MERGE_PARAMETERS,
424
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx: ExtensionContext) {
425
+ if (ctx.mode !== "tui") throw new Error("fleet_merge only works in an interactive Pi session");
426
+ const merged = await mergeRun(run, { cwd: ctx.cwd, branch: params.branch, force: params.force });
427
+ if (!merged.ok) throw new Error(merged.error);
428
+ const lines = [
429
+ `merged ${merged.value.branch} into ${merged.value.main}`,
430
+ `verdict: ${merged.value.verdict ?? "none (forced)"}`,
431
+ ];
432
+ if (merged.value.output) lines.push(merged.value.output);
433
+ lines.push("the worktree was left in place");
434
+ return { content: [{ type: "text" as const, text: lines.join("\n") }], details: merged.value };
435
+ },
436
+ };
437
+ }
package/scopes.ts ADDED
@@ -0,0 +1,134 @@
1
+ /**
2
+ * scopes: what a forked session is told.
3
+ *
4
+ * A fork branches the repository *and* the context. What crosses over is not the
5
+ * main session's history but one prompt built from the scope: the task, where to
6
+ * do it, and what "done" means. A discussion is not a brief — the decisions it
7
+ * produced have to be written into the task, and anything it left open has to be
8
+ * asked again rather than reconstructed.
9
+ *
10
+ * `review` is the other direction: there the context *is* the material, because
11
+ * nobody can review a change they cannot see.
12
+ */
13
+
14
+ export interface ForkInput {
15
+ /** The task text as the caller typed it. */
16
+ task: string;
17
+ /** The worktree the session works in. */
18
+ path: string;
19
+ branch: string;
20
+ /** The ref the branch was cut from, when the caller named one. */
21
+ base?: string;
22
+ /** `review` only: the change under review, from `git diff <base>...HEAD`. */
23
+ diff?: string;
24
+ /** `review` only: what the author's own session said, taken from its JSONL. */
25
+ author?: string;
26
+ /** `review` only: the file that text came from, so the reviewer can read more. */
27
+ authorSession?: string;
28
+ }
29
+
30
+ export interface Scope {
31
+ id: string;
32
+ /** One line, for errors and help. */
33
+ purpose: string;
34
+ /**
35
+ * Whether `fleet_fork` may use it. A fork can only supply a task; the review
36
+ * scope needs material gathered from an existing worktree, so it is reachable
37
+ * through `fleet_review` instead of through the fork's scope argument.
38
+ */
39
+ forkable: boolean;
40
+ /** The single message a forked session receives. */
41
+ seed(input: ForkInput): string;
42
+ deliverable: string;
43
+ }
44
+
45
+ const IMPLEMENT_DELIVERABLE = "a commit on the branch, and a short report back";
46
+ const REVIEW_DELIVERABLE = "a verdict (approve or request-changes) with findings";
47
+
48
+ const implementation: Scope = {
49
+ id: "implementation",
50
+ purpose: "Implement the task in the worktree and report back",
51
+ forkable: true,
52
+ deliverable: IMPLEMENT_DELIVERABLE,
53
+ seed: (input) =>
54
+ [
55
+ "You are working in a git worktree forked from another Pi session. The task below is the whole brief: the session that forked you kept its history to itself.",
56
+ "",
57
+ `worktree: ${input.path}`,
58
+ `branch: ${input.branch}`,
59
+ ...(input.base ? [`base: ${input.base}`] : []),
60
+ "",
61
+ "# Task",
62
+ "",
63
+ input.task,
64
+ "",
65
+ "# Constraints",
66
+ "",
67
+ "- Work only inside this worktree. Do not touch other checkouts.",
68
+ "- Commit on this branch. Do not create worktrees, start agents, or push.",
69
+ "- If the task does not decide something, ask instead of guessing. The decision was made in the session that forked you, so it cannot be recovered from here.",
70
+ "",
71
+ "# Done",
72
+ "",
73
+ `- The deliverable is ${IMPLEMENT_DELIVERABLE}.`,
74
+ "- Commit the work on this branch.",
75
+ "- Reply with a short report: what changed, the commit, and what is still open. Do not paste diffs.",
76
+ ].join("\n"),
77
+ };
78
+
79
+ const review: Scope = {
80
+ id: "review",
81
+ purpose: "Review another session's change in its worktree and return a verdict",
82
+ forkable: false,
83
+ deliverable: REVIEW_DELIVERABLE,
84
+ seed: (input) =>
85
+ [
86
+ "You are reviewing work that another Pi session did in this git worktree. You are the reviewer, not the author: read and judge, and change nothing. The task below is what the author was asked to do; the diff and the author's own session are the evidence.",
87
+ "",
88
+ `worktree: ${input.path}`,
89
+ `branch: ${input.branch}`,
90
+ ...(input.base ? [`base: ${input.base} (the change under review is \`git diff ${input.base}...HEAD\`)`] : []),
91
+ ...(input.authorSession ? [`author session: ${input.authorSession} (assistant text only, truncated)`] : []),
92
+ "",
93
+ "# Task the author was given",
94
+ "",
95
+ input.task,
96
+ "",
97
+ "# The change",
98
+ "",
99
+ input.diff?.trim() ? input.diff : "(the diff is empty)",
100
+ "",
101
+ "# The author's session",
102
+ "",
103
+ input.author?.trim() ? input.author : "(no session text was available)",
104
+ "",
105
+ "# How to review",
106
+ "",
107
+ "- Read the files the diff touches, and anything else you need. You are in the author's worktree, so the code under review is right here.",
108
+ "- Judge the change against the task, not against what you would have written instead.",
109
+ "- Report what is wrong or missing, with the file and the line. A finding the author cannot act on is noise.",
110
+ "- Do not modify, commit, revert, or run anything that changes this worktree, and do not create worktrees or agents.",
111
+ "",
112
+ "# Done",
113
+ "",
114
+ `- The deliverable is ${REVIEW_DELIVERABLE}.`,
115
+ "- Finish by calling the `fleet_verdict` tool: `verdict` is `approve` or `request-changes`, and `findings` is one entry per problem (`path`, optional `line`, `note`). The merge gate reads that call and nothing else, so the review is not finished until it has been made.",
116
+ "- Use `approve` only when you found nothing worth changing. An empty diff is not a pass: say so and request changes.",
117
+ ].join("\n"),
118
+ };
119
+
120
+ /** Every scope this build knows. */
121
+ export const SCOPES: Scope[] = [implementation, review];
122
+
123
+ /** The ids `fleet_fork` accepts, so the schema and the registry cannot drift. */
124
+ export function forkScopeIds(): string[] {
125
+ return SCOPES.filter((scope) => scope.forkable).map((scope) => scope.id);
126
+ }
127
+
128
+ export function findScope(id: string): Scope | undefined {
129
+ return SCOPES.find((scope) => scope.id === id);
130
+ }
131
+
132
+ export function scopeIds(): string {
133
+ return SCOPES.map((scope) => scope.id).join(", ");
134
+ }