@difflab/pi 0.1.0 → 0.2.0-rc.202609170958.44c1e9f

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.
Files changed (70) hide show
  1. package/README.md +36 -1
  2. package/agents/diffpi-autonomous.md +15 -0
  3. package/agents/diffpi-copilot.md +22 -0
  4. package/agents/diffpi-orchestrator.md +23 -0
  5. package/agents/diffpi-planner.md +15 -0
  6. package/agents/diffpi-reviewer.md +27 -0
  7. package/agents/diffpi-tutor.md +20 -0
  8. package/agents/diffpi-worker.md +19 -0
  9. package/dist/assets.d.ts +4 -0
  10. package/dist/assets.d.ts.map +1 -0
  11. package/dist/config.d.ts +28 -0
  12. package/dist/config.d.ts.map +1 -0
  13. package/dist/environment.d.ts +40 -0
  14. package/dist/environment.d.ts.map +1 -0
  15. package/dist/extensions/index.js +2919 -183
  16. package/dist/forge.d.ts +48 -0
  17. package/dist/forge.d.ts.map +1 -0
  18. package/dist/fsx.d.ts +6 -0
  19. package/dist/fsx.d.ts.map +1 -0
  20. package/dist/gates.d.ts +12 -0
  21. package/dist/gates.d.ts.map +1 -0
  22. package/dist/index.d.ts +26 -1
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +2162 -162
  25. package/dist/modes.d.ts +59 -0
  26. package/dist/modes.d.ts.map +1 -0
  27. package/dist/pi.d.ts +21 -5
  28. package/dist/pi.d.ts.map +1 -1
  29. package/dist/process.d.ts +2 -0
  30. package/dist/process.d.ts.map +1 -1
  31. package/dist/review-backend.d.ts +15 -0
  32. package/dist/review-backend.d.ts.map +1 -0
  33. package/dist/review-publication.d.ts +17 -0
  34. package/dist/review-publication.d.ts.map +1 -0
  35. package/dist/review-types.d.ts +49 -0
  36. package/dist/review-types.d.ts.map +1 -0
  37. package/dist/review.d.ts +62 -0
  38. package/dist/review.d.ts.map +1 -0
  39. package/dist/setup.d.ts +11 -0
  40. package/dist/setup.d.ts.map +1 -1
  41. package/dist/store.d.ts +15 -0
  42. package/dist/store.d.ts.map +1 -0
  43. package/dist/templates.d.ts +14 -0
  44. package/dist/templates.d.ts.map +1 -0
  45. package/dist/tools/index.d.ts +6 -2
  46. package/dist/tools/index.d.ts.map +1 -1
  47. package/dist/tools/index.js +2631 -172
  48. package/dist/tools/modes.d.ts +4 -0
  49. package/dist/tools/modes.d.ts.map +1 -0
  50. package/dist/tools/review.d.ts +7 -0
  51. package/dist/tools/review.d.ts.map +1 -0
  52. package/dist/tools/setup.d.ts.map +1 -1
  53. package/dist/tools/templates.d.ts +3 -0
  54. package/dist/tools/templates.d.ts.map +1 -0
  55. package/dist/tuicr.d.ts +55 -0
  56. package/dist/tuicr.d.ts.map +1 -0
  57. package/dist/zed.d.ts +11 -0
  58. package/dist/zed.d.ts.map +1 -0
  59. package/package.json +4 -2
  60. package/skills/diffpi-setup/SKILL.md +15 -1
  61. package/skills/mode/SKILL.md +38 -0
  62. package/skills/review/SKILL.md +15 -0
  63. package/skills/review/references/workflows/address.md +10 -0
  64. package/skills/review/references/workflows/edit.md +9 -0
  65. package/skills/review/references/workflows/help.md +12 -0
  66. package/skills/review/references/workflows/merge.md +6 -0
  67. package/skills/review/references/workflows/new.md +9 -0
  68. package/skills/review/references/workflows/open.md +7 -0
  69. package/skills/review/references/workflows/publish.md +8 -0
  70. package/templates/review/draft-pr.md +18 -0
@@ -25,21 +25,2386 @@ function createDiffpiReloadTool(pi) {
25
25
  });
26
26
  }
27
27
 
28
+ // src/tools/modes.ts
29
+ import { defineTool as defineTool2 } from "@earendil-works/pi-coding-agent";
30
+ import { z as z2 } from "zod";
31
+ var emptyParametersSchema = z2.object({});
32
+ var emptyParameters = z2.toJSONSchema(emptyParametersSchema, { io: "input" });
33
+ var listParametersSchema = z2.object({
34
+ includeSkills: z2.boolean().optional().describe("Include skill-owned agents using skill:agent ids.")
35
+ });
36
+ var listParameters = z2.toJSONSchema(listParametersSchema, { io: "input" });
37
+ var setParametersSchema = z2.object({
38
+ agent: z2.string().trim().min(1).describe("Inline agent id from diffpi_modes_list.")
39
+ });
40
+ var setParameters = z2.toJSONSchema(setParametersSchema, { io: "input" });
41
+ function createModeTools(controller) {
42
+ return [
43
+ defineTool2({
44
+ name: "diffpi_modes_list",
45
+ label: "diffpi modes list",
46
+ description: "List inline agents shared with the subagent plugin, optionally including skill-owned agents.",
47
+ promptSnippet: "List inline agents before selecting one when the requested agent is unclear",
48
+ promptGuidelines: [
49
+ "Call diffpi_modes_list when the user asks which inline agents are available.",
50
+ "Set includeSkills to true only when the user asks for skill agents or runs /skill:mode --include-skills.",
51
+ "Inline mode applies the profile prompt, first available preferred model, thinking level, and available tools."
52
+ ],
53
+ parameters: listParameters,
54
+ executionMode: "parallel",
55
+ async execute(_toolCallId, input, _signal, _onUpdate, ctx) {
56
+ const params = listParametersSchema.parse(input);
57
+ const catalog = await controller.list(ctx, { includeSkills: params.includeSkills });
58
+ return {
59
+ content: [{ type: "text", text: formatCatalog(catalog, controller.getActive()?.id) }],
60
+ details: { active: controller.getActive()?.id, catalog }
61
+ };
62
+ }
63
+ }),
64
+ defineTool2({
65
+ name: "diffpi_modes_set",
66
+ label: "diffpi modes set",
67
+ description: "Set a validated available agent as the inline behavioral agent for subsequent chat turns.",
68
+ promptSnippet: "Set the inline behavioral agent only after the user chooses one",
69
+ promptGuidelines: [
70
+ "Call diffpi_modes_set only after the user explicitly selects an agent.",
71
+ "Use the exact skill:agent id for a skill-owned agent.",
72
+ "The selected prompt takes effect on the next model turn."
73
+ ],
74
+ parameters: setParameters,
75
+ executionMode: "sequential",
76
+ async execute(_toolCallId, input, _signal, _onUpdate, ctx) {
77
+ const params = setParametersSchema.parse(input);
78
+ const result = await controller.set(params.agent, ctx);
79
+ if (!result.ok)
80
+ throw new Error(result.message);
81
+ return {
82
+ content: [{ type: "text", text: `${result.message} The prompt takes effect on the next turn.` }],
83
+ details: { active: result.active }
84
+ };
85
+ }
86
+ }),
87
+ defineTool2({
88
+ name: "diffpi_modes_unset",
89
+ label: "diffpi modes unset",
90
+ description: "Clear the inline behavioral agent and restore default Pi prompting for subsequent turns.",
91
+ promptSnippet: "Clear the inline agent when the user asks for default behavior",
92
+ promptGuidelines: [
93
+ "Call diffpi_modes_unset only when the user explicitly asks to clear the active inline agent."
94
+ ],
95
+ parameters: emptyParameters,
96
+ executionMode: "sequential",
97
+ async execute(_toolCallId, input, _signal, _onUpdate, ctx) {
98
+ emptyParametersSchema.parse(input);
99
+ const result = await controller.unset(ctx);
100
+ return {
101
+ content: [{ type: "text", text: result.message }],
102
+ details: { active: controller.getActive()?.id }
103
+ };
104
+ }
105
+ })
106
+ ];
107
+ }
108
+ function formatCatalog(catalog, active) {
109
+ const lines = [`Active inline agent: ${active ?? "default"}.`, "", "Available inline agents:"];
110
+ for (const mode of catalog.modes) {
111
+ const runtime = [mode.modelPreferences[0], mode.thinkingLevel].filter(Boolean).join(", ");
112
+ lines.push(`- ${mode.id} [${mode.promptStrategy}${runtime ? `; ${runtime}` : ""}] — ${sanitize(mode.description)} (${mode.source})`);
113
+ }
114
+ if (catalog.diagnostics.length > 0) {
115
+ lines.push("", "Skipped agent files:");
116
+ for (const diagnostic of catalog.diagnostics)
117
+ lines.push(`- ${sanitize(diagnostic)}`);
118
+ }
119
+ lines.push("", "Inline mode applies the profile prompt, preferred available model, thinking level, and tool set.");
120
+ return lines.join(`
121
+ `);
122
+ }
123
+ function sanitize(value) {
124
+ return value.replace(/[\r\n\t]+/g, " ").replace(/\s+/g, " ").trim();
125
+ }
126
+
127
+ // src/tools/review.ts
128
+ import { existsSync as existsSync2 } from "node:fs";
129
+ import { mkdir as mkdir3, readdir, readFile as readFile6, stat, writeFile as writeFile4 } from "node:fs/promises";
130
+ import { join as join7 } from "node:path";
131
+ import { defineTool as defineTool3 } from "@earendil-works/pi-coding-agent";
132
+ import { z as z5 } from "zod";
133
+
134
+ // src/environment.ts
135
+ import { basename } from "node:path";
136
+
137
+ // src/process.ts
138
+ import { constants } from "node:fs";
139
+ import { access } from "node:fs/promises";
140
+ import { delimiter, join } from "node:path";
141
+ import { spawn } from "node:child_process";
142
+ var MAX_CAPTURED_OUTPUT_LENGTH = 65536;
143
+ async function findExecutable(name) {
144
+ if (name.includes("/")) {
145
+ try {
146
+ await access(name, constants.X_OK);
147
+ return name;
148
+ } catch {
149
+ return;
150
+ }
151
+ }
152
+ for (const directory of (process.env.PATH ?? "").split(delimiter)) {
153
+ if (!directory)
154
+ continue;
155
+ const candidate = join(directory, name);
156
+ try {
157
+ await access(candidate, constants.X_OK);
158
+ return candidate;
159
+ } catch {}
160
+ }
161
+ return;
162
+ }
163
+ function run(command, args, options = {}) {
164
+ return new Promise((resolve, reject) => {
165
+ const child = spawn(command, args, {
166
+ cwd: options.cwd,
167
+ env: options.env ?? process.env,
168
+ stdio: [options.input === undefined ? "ignore" : "pipe", "pipe", "pipe"]
169
+ });
170
+ let stdout = "";
171
+ let stderr = "";
172
+ const stdoutChunks = [];
173
+ const stderrChunks = [];
174
+ const unbounded = options.capture === "unbounded";
175
+ child.stdout?.on("data", (chunk) => {
176
+ const text = chunk.toString();
177
+ if (unbounded)
178
+ stdoutChunks.push(text);
179
+ else
180
+ stdout = appendBounded(stdout, text);
181
+ });
182
+ child.stderr?.on("data", (chunk) => {
183
+ const text = chunk.toString();
184
+ if (unbounded)
185
+ stderrChunks.push(text);
186
+ else
187
+ stderr = appendBounded(stderr, text);
188
+ });
189
+ child.on("error", reject);
190
+ child.on("close", (code) => resolve({
191
+ code: code ?? 1,
192
+ stdout: unbounded ? stdoutChunks.join("") : stdout,
193
+ stderr: unbounded ? stderrChunks.join("") : stderr
194
+ }));
195
+ if (options.input !== undefined && child.stdin)
196
+ child.stdin.end(options.input);
197
+ });
198
+ }
199
+ async function runChecked(command, args, options = {}) {
200
+ const result = await run(command, args, options);
201
+ if (result.code === 0)
202
+ return result;
203
+ const detail = result.stderr.trim() || result.stdout.trim() || `exit code ${result.code}`;
204
+ throw new Error(`${command} ${args.join(" ")} failed: ${detail}`);
205
+ }
206
+ function appendBounded(current, next) {
207
+ const combined = current + next;
208
+ return combined.length <= MAX_CAPTURED_OUTPUT_LENGTH ? combined : combined.slice(-MAX_CAPTURED_OUTPUT_LENGTH);
209
+ }
210
+
211
+ // src/zed.ts
212
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
213
+ import { homedir } from "node:os";
214
+ import { dirname, join as join2 } from "node:path";
215
+ var ZED_REVIEW_TASK_NAME = "diffpi: tuicr review";
216
+ var REVIEW_KEYBINDING = "cmd-alt-r";
217
+ var REVIEW_TASK = {
218
+ label: ZED_REVIEW_TASK_NAME,
219
+ command: "tuicr",
220
+ args: ["-w"],
221
+ cwd: "$ZED_WORKTREE_ROOT",
222
+ use_new_terminal: true,
223
+ reveal: "always"
224
+ };
225
+ function zedTasksPath(homeDir = homedir()) {
226
+ return join2(homeDir, ".config", "zed", "tasks.json");
227
+ }
228
+ function zedKeymapPath(homeDir = homedir()) {
229
+ return join2(homeDir, ".config", "zed", "keymap.json");
230
+ }
231
+ async function ensureZedReviewTask(homeDir = homedir()) {
232
+ const path = zedTasksPath(homeDir);
233
+ const currentText = await readOptional(path);
234
+ const tasks = parseJsonArray(currentText, path);
235
+ const index = tasks.findIndex((task) => task.label === ZED_REVIEW_TASK_NAME);
236
+ const next = [...tasks];
237
+ if (index >= 0)
238
+ next[index] = { ...tasks[index], ...REVIEW_TASK };
239
+ else
240
+ next.push(REVIEW_TASK);
241
+ const changed = JSON.stringify(tasks) !== JSON.stringify(next);
242
+ if (changed)
243
+ await writeJson(path, next);
244
+ return { path, changed, existed: currentText !== undefined };
245
+ }
246
+ async function ensureZedReviewKeybinding(homeDir = homedir()) {
247
+ const path = zedKeymapPath(homeDir);
248
+ const currentText = await readOptional(path);
249
+ const entries = parseJsonArray(currentText, path);
250
+ const alreadyBound = entries.some((entry) => Object.values(entry.bindings ?? {}).some((action) => Array.isArray(action) && action[0] === "task::Spawn" && bindsReviewTask(action[1])));
251
+ if (alreadyBound)
252
+ return { path, changed: false, existed: currentText !== undefined };
253
+ const next = [
254
+ ...entries,
255
+ { context: "Workspace", bindings: { [REVIEW_KEYBINDING]: ["task::Spawn", { task_name: ZED_REVIEW_TASK_NAME }] } }
256
+ ];
257
+ await writeJson(path, next);
258
+ return { path, changed: true, existed: currentText !== undefined };
259
+ }
260
+ function bindsReviewTask(payload) {
261
+ return typeof payload === "object" && payload !== null && payload.task_name === ZED_REVIEW_TASK_NAME;
262
+ }
263
+ async function readOptional(path) {
264
+ try {
265
+ return await readFile(path, "utf8");
266
+ } catch (error) {
267
+ if (error instanceof Error && "code" in error && error.code === "ENOENT")
268
+ return;
269
+ throw error;
270
+ }
271
+ }
272
+ function parseJsonArray(content, path) {
273
+ if (!content?.trim())
274
+ return [];
275
+ let value;
276
+ try {
277
+ value = JSON.parse(content);
278
+ } catch {
279
+ throw new Error(`Cannot safely edit ${path}: not strict JSON (it may contain JSONC comments).`);
280
+ }
281
+ if (!Array.isArray(value))
282
+ throw new Error(`Expected a JSON array in ${path}.`);
283
+ return value;
284
+ }
285
+ async function writeJson(path, value) {
286
+ await mkdir(dirname(path), { recursive: true });
287
+ await writeFile(path, `${JSON.stringify(value, null, 2)}
288
+ `, "utf8");
289
+ }
290
+
291
+ // src/environment.ts
292
+ function detectIde(env = process.env) {
293
+ const program = (env.TERM_PROGRAM ?? "").toLowerCase();
294
+ if (env.ZED_TERM === "true" || program === "zed")
295
+ return "zed";
296
+ if (env.CURSOR_TRACE_ID || program === "cursor")
297
+ return "cursor";
298
+ if (env.WINDSURF_ENV || program === "windsurf")
299
+ return "windsurf";
300
+ if (env.TERMINAL_EMULATOR?.toLowerCase().includes("jetbrains"))
301
+ return "jetbrains";
302
+ if (env.VSCODE_PID || env.VSCODE_GIT_IPC_HANDLE || program === "vscode")
303
+ return "vscode";
304
+ return "unknown";
305
+ }
306
+ function detectMux(env = process.env) {
307
+ if (env.ZELLIJ || env.ZELLIJ_SESSION_NAME)
308
+ return "zellij";
309
+ if (env.TMUX)
310
+ return "tmux";
311
+ if (env.STY)
312
+ return "screen";
313
+ return "none";
314
+ }
315
+ function detectShell(env = process.env) {
316
+ return env.SHELL ? basename(env.SHELL) : "unknown";
317
+ }
318
+ async function detectVcs(cwd) {
319
+ const root = (await run("git", ["-C", cwd, "rev-parse", "--show-toplevel"])).stdout.trim() || cwd;
320
+ const branch = (await run("git", ["-C", root, "rev-parse", "--abbrev-ref", "HEAD"])).stdout.trim();
321
+ const remote = (await run("git", ["-C", root, "remote", "get-url", "origin"])).stdout.trim();
322
+ return { ...parseRemote(remote), branch, root };
323
+ }
324
+ function parseRemote(remote) {
325
+ const empty = { provider: "none", host: "", owner: "", repo: "" };
326
+ if (!remote)
327
+ return empty;
328
+ const scp = remote.match(/^[^@]+@([^:]+):(.+?)(?:\.git)?$/);
329
+ const url = remote.match(/^[a-z]+:\/\/(?:[^@]+@)?([^/]+)\/(.+?)(?:\.git)?$/i);
330
+ const match = scp ?? url;
331
+ if (!match)
332
+ return empty;
333
+ const host = match[1];
334
+ const segments = match[2].split("/").filter(Boolean);
335
+ if (segments.length < 2)
336
+ return { ...empty, host };
337
+ const repo = segments.at(-1) ?? "";
338
+ const owner = segments.slice(0, -1).join("/");
339
+ const provider = /github/i.test(host) ? "github" : /gitlab/i.test(host) ? "gitlab" : "none";
340
+ return { provider, host, owner, repo };
341
+ }
342
+ async function openInNewTab(command, opts) {
343
+ const env = opts.env ?? process.env;
344
+ const name = opts.name ?? "review";
345
+ const printable = command.join(" ");
346
+ const mux = detectMux(env);
347
+ if (mux !== "none") {
348
+ const opened = await openMuxTab(mux, command, opts.cwd, name, printable);
349
+ if (opened)
350
+ return opened;
351
+ }
352
+ if (detectIde(env) === "zed") {
353
+ try {
354
+ await ensureZedReviewTask(opts.homeDir);
355
+ return {
356
+ launched: false,
357
+ configured: true,
358
+ via: "zed-task",
359
+ command: printable,
360
+ taskName: ZED_REVIEW_TASK_NAME,
361
+ instruction: `Run the Zed task "${ZED_REVIEW_TASK_NAME}".`
362
+ };
363
+ } catch {}
364
+ }
365
+ return { launched: false, via: "print", command: printable };
366
+ }
367
+ async function openMuxTab(mux, command, cwd, name, printable) {
368
+ if (mux === "zellij" && await findExecutable("zellij")) {
369
+ const result = await run("zellij", ["action", "new-tab", "--cwd", cwd, "--name", name, "--", ...command]);
370
+ if (result.code === 0)
371
+ return { launched: true, via: "zellij", command: printable };
372
+ const fallback = await run("zellij", ["run", "--cwd", cwd, "--name", name, "--", ...command]);
373
+ if (fallback.code === 0)
374
+ return { launched: true, via: "zellij-run", command: printable };
375
+ }
376
+ if (mux === "tmux" && await findExecutable("tmux")) {
377
+ const result = await run("tmux", ["new-window", "-c", cwd, "-n", name, printable]);
378
+ if (result.code === 0)
379
+ return { launched: true, via: "tmux", command: printable };
380
+ }
381
+ if (mux === "screen" && await findExecutable("screen")) {
382
+ const result = await run("screen", screenWindowArgs(command, cwd, name));
383
+ if (result.code === 0)
384
+ return { launched: true, via: "screen", command: printable };
385
+ }
386
+ return;
387
+ }
388
+ function screenWindowArgs(command, cwd, name) {
389
+ return ["-X", "screen", "-t", name, "sh", "-lc", 'cd -- "$1" && shift && exec "$@"', "sh", cwd, ...command];
390
+ }
391
+
392
+ // src/forge.ts
393
+ function createForge(vcs) {
394
+ if (vcs.provider === "github")
395
+ return new GithubForge(vcs);
396
+ if (vcs.provider === "gitlab")
397
+ return new GitlabForge(vcs);
398
+ throw new Error("No forge detected from the git remote. Use --local for an offline review.");
399
+ }
400
+
401
+ class GithubForge {
402
+ vcs;
403
+ provider = "github";
404
+ constructor(vcs) {
405
+ this.vcs = vcs;
406
+ }
407
+ repoFlag() {
408
+ return ["--repo", `${this.vcs.owner}/${this.vcs.repo}`];
409
+ }
410
+ async createDraftPr(options) {
411
+ const args = [
412
+ "pr",
413
+ "create",
414
+ ...this.repoFlag(),
415
+ "--title",
416
+ options.title,
417
+ "--body",
418
+ options.body,
419
+ "--base",
420
+ options.base,
421
+ "--head",
422
+ options.head
423
+ ];
424
+ if (options.draft !== false)
425
+ args.push("--draft");
426
+ await runChecked("gh", args);
427
+ const ref = await this.viewPr(options.head);
428
+ if (!ref)
429
+ throw new Error("Draft PR created but could not be resolved.");
430
+ return ref;
431
+ }
432
+ async viewPr(idOrBranch) {
433
+ const args = [
434
+ "pr",
435
+ "view",
436
+ idOrBranch,
437
+ ...this.repoFlag(),
438
+ "--json",
439
+ "number,title,url,isDraft,baseRefName,headRefName,headRefOid"
440
+ ];
441
+ const result = await run("gh", args);
442
+ if (result.code !== 0) {
443
+ if (isConfirmedMissingChange("github", result.stderr || result.stdout))
444
+ return;
445
+ throw commandFailure("gh", args, result);
446
+ }
447
+ if (!result.stdout.trim())
448
+ throw new Error("GitHub returned an empty pull request response.");
449
+ let data;
450
+ try {
451
+ data = JSON.parse(result.stdout);
452
+ } catch {
453
+ throw new Error("Cannot parse the GitHub pull request response as JSON.");
454
+ }
455
+ if (typeof data.number !== "number" || typeof data.title !== "string" || typeof data.url !== "string" || typeof data.isDraft !== "boolean" || typeof data.baseRefName !== "string" || typeof data.headRefName !== "string") {
456
+ throw new Error("GitHub returned an invalid pull request response.");
457
+ }
458
+ return {
459
+ number: data.number,
460
+ title: data.title,
461
+ url: data.url,
462
+ isDraft: data.isDraft,
463
+ baseRef: data.baseRefName,
464
+ headRef: data.headRefName,
465
+ headSha: data.headRefOid
466
+ };
467
+ }
468
+ async defaultBranch() {
469
+ const result = await runChecked("gh", [
470
+ "repo",
471
+ "view",
472
+ `${this.vcs.owner}/${this.vcs.repo}`,
473
+ "--json",
474
+ "defaultBranchRef",
475
+ "--jq",
476
+ ".defaultBranchRef.name"
477
+ ]);
478
+ return requireBranchName(result.stdout, "GitHub");
479
+ }
480
+ async prDiff(id) {
481
+ const result = await runChecked("gh", ["pr", "diff", String(id), ...this.repoFlag()], { capture: "unbounded" });
482
+ return result.stdout;
483
+ }
484
+ async prChecks(id) {
485
+ const result = await run("gh", ["pr", "checks", String(id), ...this.repoFlag()]);
486
+ return result.stdout;
487
+ }
488
+ async markReady(id) {
489
+ await runChecked("gh", ["pr", "ready", String(id), ...this.repoFlag()]);
490
+ }
491
+ async closePr(id, comment) {
492
+ const args = ["pr", "close", String(id), ...this.repoFlag()];
493
+ if (comment)
494
+ args.push("--comment", comment);
495
+ await runChecked("gh", args);
496
+ }
497
+ async mergePr(id, subject) {
498
+ const readiness = await runChecked("gh", [
499
+ "pr",
500
+ "view",
501
+ String(id),
502
+ ...this.repoFlag(),
503
+ "--json",
504
+ "isDraft,state,reviewDecision,mergeStateStatus,statusCheckRollup"
505
+ ]);
506
+ assertGitHubMergeReady(readiness.stdout);
507
+ await runChecked("gh", ["pr", "merge", String(id), ...this.repoFlag(), "--squash", "--subject", subject]);
508
+ }
509
+ }
510
+
511
+ class GitlabForge {
512
+ vcs;
513
+ provider = "gitlab";
514
+ constructor(vcs) {
515
+ this.vcs = vcs;
516
+ }
517
+ project() {
518
+ return `${this.vcs.owner}/${this.vcs.repo}`;
519
+ }
520
+ async createDraftPr(options) {
521
+ await runChecked("glab", [
522
+ "mr",
523
+ "create",
524
+ "--repo",
525
+ this.project(),
526
+ "--title",
527
+ `Draft: ${options.title}`,
528
+ "--description",
529
+ options.body,
530
+ "--target-branch",
531
+ options.base,
532
+ "--source-branch",
533
+ options.head,
534
+ "--yes"
535
+ ]);
536
+ const ref = await this.viewPr(options.head);
537
+ if (!ref)
538
+ throw new Error("Draft MR created but could not be resolved.");
539
+ return ref;
540
+ }
541
+ async viewPr(idOrBranch) {
542
+ const args = ["mr", "view", idOrBranch, "--repo", this.project(), "--output", "json"];
543
+ const result = await run("glab", args);
544
+ if (result.code !== 0) {
545
+ if (isConfirmedMissingChange("gitlab", result.stderr || result.stdout))
546
+ return;
547
+ throw commandFailure("glab", args, result);
548
+ }
549
+ if (!result.stdout.trim())
550
+ throw new Error("GitLab returned an empty merge request response.");
551
+ let data;
552
+ try {
553
+ data = JSON.parse(result.stdout);
554
+ } catch {
555
+ throw new Error("Cannot parse the GitLab merge request response as JSON.");
556
+ }
557
+ if (typeof data.iid !== "number" || typeof data.title !== "string" || typeof data.web_url !== "string" || typeof data.target_branch !== "string" || typeof data.source_branch !== "string") {
558
+ throw new Error("GitLab returned an invalid merge request response.");
559
+ }
560
+ return {
561
+ number: data.iid,
562
+ title: data.title,
563
+ url: data.web_url,
564
+ isDraft: Boolean(data.draft ?? data.work_in_progress),
565
+ baseRef: data.target_branch,
566
+ headRef: data.source_branch,
567
+ headSha: data.sha
568
+ };
569
+ }
570
+ async defaultBranch() {
571
+ const result = await runChecked("glab", [
572
+ "api",
573
+ `projects/${encodeURIComponent(this.project())}`,
574
+ "--jq",
575
+ ".default_branch"
576
+ ]);
577
+ return requireBranchName(result.stdout, "GitLab");
578
+ }
579
+ async prDiff(id) {
580
+ return (await runChecked("glab", ["mr", "diff", String(id), "--repo", this.project()], { capture: "unbounded" })).stdout;
581
+ }
582
+ async prChecks(id) {
583
+ return (await runChecked("glab", [
584
+ "api",
585
+ `projects/${encodeURIComponent(this.project())}/merge_requests/${id}/pipelines?per_page=100`
586
+ ])).stdout;
587
+ }
588
+ async markReady(id) {
589
+ await runChecked("glab", ["mr", "update", String(id), "--repo", this.project(), "--ready"]);
590
+ }
591
+ async closePr(id, comment) {
592
+ if (comment)
593
+ await runChecked("glab", ["mr", "note", String(id), "--repo", this.project(), "--message", comment]);
594
+ await runChecked("glab", ["mr", "close", String(id), "--repo", this.project()]);
595
+ }
596
+ async mergePr() {
597
+ throw new Error("Merge is not supported by the GitLab forge adapter.");
598
+ }
599
+ }
600
+ function assertGitHubMergeReady(input) {
601
+ let data;
602
+ try {
603
+ data = JSON.parse(input);
604
+ } catch {
605
+ throw new Error("Merge blocked: GitHub readiness response was not valid JSON.");
606
+ }
607
+ const blockers = [];
608
+ if (data.state !== "OPEN")
609
+ blockers.push(`pull request state is ${data.state ?? "unknown"}`);
610
+ if (data.isDraft)
611
+ blockers.push("pull request is still a draft");
612
+ if (data.reviewDecision !== "APPROVED")
613
+ blockers.push(`review decision is ${data.reviewDecision || "not approved"}`);
614
+ if (data.mergeStateStatus !== "CLEAN")
615
+ blockers.push(`merge state is ${data.mergeStateStatus ?? "unknown"}`);
616
+ for (const check of data.statusCheckRollup ?? []) {
617
+ const name = check.name ?? check.context ?? "unnamed check";
618
+ if (check.__typename === "CheckRun") {
619
+ if (check.status !== "COMPLETED")
620
+ blockers.push(`${name} is ${check.status?.toLowerCase() ?? "pending"}`);
621
+ else if (!["SUCCESS", "SKIPPED", "NEUTRAL"].includes(check.conclusion ?? "")) {
622
+ blockers.push(`${name} concluded ${(check.conclusion ?? "unknown").toLowerCase()}`);
623
+ }
624
+ } else if (check.state !== "SUCCESS")
625
+ blockers.push(`${name} is ${(check.state ?? "pending").toLowerCase()}`);
626
+ }
627
+ if (blockers.length > 0)
628
+ throw new Error(`Merge blocked: ${blockers.join("; ")}.`);
629
+ }
630
+ function isConfirmedMissingChange(provider, output) {
631
+ const message = output.toLowerCase();
632
+ if (provider === "github") {
633
+ return message.includes("no pull requests found for branch") || message.includes("could not find pull request") || message.includes("could not resolve to a pullrequest");
634
+ }
635
+ if (provider === "gitlab") {
636
+ return message.includes("no open merge request") || /failed to get open merge request/.test(message) && /404(?: not found)?/.test(message);
637
+ }
638
+ return false;
639
+ }
640
+ function commandFailure(command, args, result) {
641
+ const detail = result.stderr.trim() || result.stdout.trim() || `exit code ${result.code}`;
642
+ return new Error(`${command} ${args.join(" ")} failed: ${detail}`);
643
+ }
644
+ function requireBranchName(output, provider) {
645
+ const branch = output.trim();
646
+ if (!branch || branch === "null")
647
+ throw new Error(`${provider} did not return a default branch.`);
648
+ return branch;
649
+ }
650
+
651
+ // src/gates.ts
652
+ var CONVENTIONAL_COMMIT = /^(feat|fix|perf|refactor|docs|chore|test|build|ci|style|revert)(\([^)]+\))?!?: .+/;
653
+ var MISE_GATES = ["format:check", "lint", "test"];
654
+ function checkConventionalSubject(subject) {
655
+ const trimmed = subject.trim();
656
+ const ok = CONVENTIONAL_COMMIT.test(trimmed);
657
+ return {
658
+ name: "conventional-subject",
659
+ status: ok ? "pass" : "warn",
660
+ detail: ok ? trimmed : `not a conventional-commit subject: "${trimmed}"`
661
+ };
662
+ }
663
+ async function runMiseGates(cwd) {
664
+ const tasks = await discoverMiseTasks(cwd);
665
+ const results = [];
666
+ for (const gate of MISE_GATES) {
667
+ const targets = tasks.get(gate) ?? [];
668
+ if (targets.length === 0) {
669
+ results.push({ name: gate, status: "skip", detail: "no mise recipe" });
670
+ continue;
671
+ }
672
+ const invocations = targets.flatMap((target, index) => index === 0 ? [target] : [":::", target]);
673
+ const result = await run("mise", ["run", ...invocations], { cwd });
674
+ results.push({
675
+ name: gate,
676
+ status: result.code === 0 ? "pass" : "fail",
677
+ detail: result.code === 0 ? "clean" : (result.stderr.trim() || result.stdout.trim()).slice(-400)
678
+ });
679
+ }
680
+ return results;
681
+ }
682
+ function ciGate(checksOutput) {
683
+ const text = checksOutput.toLowerCase();
684
+ if (!text.trim())
685
+ return { name: "ci", status: "skip", detail: "no CI output" };
686
+ if (/\bfail|error\b/.test(text))
687
+ return { name: "ci", status: "warn", detail: "CI failing" };
688
+ if (/\bpending|in progress|queued\b/.test(text))
689
+ return { name: "ci", status: "warn", detail: "CI pending" };
690
+ return { name: "ci", status: "pass", detail: "CI green" };
691
+ }
692
+ async function discoverMiseTasks(cwd) {
693
+ const result = await run("mise", ["tasks", "--json", "--all"], { cwd });
694
+ if (result.code !== 0)
695
+ return new Map;
696
+ return parseMiseTasks(result.stdout);
697
+ }
698
+ function parseMiseTasks(input) {
699
+ let tasks;
700
+ try {
701
+ tasks = JSON.parse(input);
702
+ } catch {
703
+ return new Map;
704
+ }
705
+ if (!Array.isArray(tasks))
706
+ return new Map;
707
+ const found = new Map;
708
+ for (const gate of MISE_GATES) {
709
+ const targets = tasks.flatMap((task) => {
710
+ if (typeof task.name !== "string")
711
+ return [];
712
+ return task.name === gate || task.name.endsWith(`:${gate}`) || task.aliases?.includes(gate) ? [task.name] : [];
713
+ });
714
+ if (targets.length > 0)
715
+ found.set(gate, [...new Set(targets)]);
716
+ }
717
+ return found;
718
+ }
719
+
720
+ // src/review-backend.ts
721
+ import { readFile as readFile3, writeFile as writeFile2 } from "node:fs/promises";
722
+
723
+ // src/review.ts
724
+ import { z as z3 } from "zod";
725
+ var severitySchema = z3.enum(["BLOCKING", "CONSIDER", "NOTE"]);
726
+ var findingSchema = z3.object({
727
+ file: z3.string().min(1),
728
+ line: z3.number().int().nonnegative(),
729
+ severity: severitySchema,
730
+ body: z3.string().min(1),
731
+ reference: z3.string().optional().default("")
732
+ });
733
+ var findingsSchema = z3.array(findingSchema);
734
+ var reviewThreadRecordSchema = z3.object({
735
+ id: z3.string().min(1),
736
+ file: z3.string().optional(),
737
+ line: z3.number().int().positive().optional(),
738
+ body: z3.string(),
739
+ author: z3.string().optional(),
740
+ resolved: z3.boolean(),
741
+ question: z3.boolean(),
742
+ replies: z3.array(z3.string()).optional()
743
+ });
744
+ function reviewSlug(input) {
745
+ return input.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 50);
746
+ }
747
+ function yymmdd(date = new Date) {
748
+ const yy = String(date.getFullYear() % 100).padStart(2, "0");
749
+ const mm = String(date.getMonth() + 1).padStart(2, "0");
750
+ const dd = String(date.getDate()).padStart(2, "0");
751
+ return `${yy}${mm}${dd}`;
752
+ }
753
+ function reviewRecordName(target, date = new Date) {
754
+ return `${yymmdd(date)}-${reviewSlug(target) || "local"}`;
755
+ }
756
+ function dedupeFindings(findings) {
757
+ const rank = { BLOCKING: 3, CONSIDER: 2, NOTE: 1 };
758
+ const byKey = new Map;
759
+ for (const finding of findings) {
760
+ const key = `${finding.file}:${finding.line}`;
761
+ const existing = byKey.get(key);
762
+ if (!existing || rank[finding.severity] > rank[existing.severity])
763
+ byKey.set(key, finding);
764
+ }
765
+ return [...byKey.values()].sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line || rank[b.severity] - rank[a.severity]);
766
+ }
767
+ function toReviewComments(findings, model) {
768
+ const comments = [];
769
+ for (const finding of findings) {
770
+ if (finding.line <= 0)
771
+ continue;
772
+ const body = renderCommentBody(finding);
773
+ comments.push({
774
+ file: finding.file,
775
+ line: finding.line,
776
+ side: "RIGHT",
777
+ body: model ? withRemoteProvenance(body, model) : body
778
+ });
779
+ }
780
+ return comments;
781
+ }
782
+ function withRemoteProvenance(body, model) {
783
+ const normalized = body.trimEnd();
784
+ if (/Generated review by Diffpi using `[^`]+`\.$/.test(normalized))
785
+ return body;
786
+ return `${normalized}
787
+
788
+ Generated review by Diffpi using \`${model}\`.`;
789
+ }
790
+ function localReviewAuthor(model) {
791
+ return `Agent: ${model}`;
792
+ }
793
+ function renderReviewDoc(input) {
794
+ const anchored = dedupeFindings(input.findings).filter((finding) => finding.line > 0);
795
+ const lines = [`# Review: ${input.title}`, "", "## Metadata"];
796
+ if (input.number !== undefined)
797
+ lines.push(`- **PR/MR**: #${input.number}${input.url ? ` — ${input.url}` : ""}`);
798
+ if (input.author)
799
+ lines.push(`- **Author**: ${input.author}`);
800
+ if (input.model)
801
+ lines.push(`- **Review agent**: ${input.model}`);
802
+ if (input.headRef && input.baseRef)
803
+ lines.push(`- **Branch**: ${input.headRef} → ${input.baseRef}`);
804
+ if (input.additions !== undefined)
805
+ lines.push(`- **Stats**: +${input.additions} -${input.deletions ?? 0} across ${input.changedFiles ?? 0} files`);
806
+ lines.push(`- **Reviewed**: ${input.timestamp ?? new Date().toISOString()}`, "");
807
+ if (input.overallIssues.length > 0) {
808
+ lines.push("## Overall issues", "");
809
+ for (const issue of input.overallIssues)
810
+ lines.push(`- ${issue}`);
811
+ lines.push("");
812
+ }
813
+ lines.push("## Verification", "");
814
+ for (const gate of input.gates)
815
+ lines.push(`- ${gate.name}: ${gate.status} — ${gate.detail}`);
816
+ lines.push("");
817
+ if (input.notVerified.length > 0) {
818
+ lines.push("## What was NOT verified", "");
819
+ for (const item of input.notVerified)
820
+ lines.push(`- ${item}`);
821
+ lines.push("");
822
+ }
823
+ lines.push("## Inline Comments", "");
824
+ for (const finding of anchored) {
825
+ lines.push(`### ${finding.file}:${finding.line} — ${finding.severity}`, "", finding.body, "");
826
+ if (finding.reference)
827
+ lines.push(`> **Reference:** ${finding.reference}`, "");
828
+ lines.push("---", "");
829
+ }
830
+ return `${lines.join(`
831
+ `).trimEnd()}
832
+ `;
833
+ }
834
+ function renderThreadArtifact(title, target, threads, options = {}) {
835
+ const records = threads.map((thread) => ({
836
+ id: thread.id,
837
+ file: thread.file,
838
+ line: thread.line,
839
+ body: thread.body,
840
+ author: thread.author,
841
+ resolved: thread.resolved,
842
+ question: thread.question,
843
+ replies: thread.replies
844
+ }));
845
+ const payload = Buffer.from(JSON.stringify(records), "utf8").toString("base64url");
846
+ const lines = [
847
+ `<!-- diffpi-threads:${payload} -->`,
848
+ `# Review threads: ${title}`,
849
+ "",
850
+ "## Metadata",
851
+ "",
852
+ `- Target: ${target}`,
853
+ `- Pulled: ${options.timestamp ?? new Date().toISOString()}`
854
+ ];
855
+ if (options.number !== undefined)
856
+ lines.push(`- PR/MR: #${options.number}${options.url ? ` — ${options.url}` : ""}`);
857
+ lines.push("", "## Replies", "");
858
+ for (const thread of threads) {
859
+ const id = Buffer.from(thread.id, "utf8").toString("base64url");
860
+ lines.push(`### ${thread.file ?? "review"}:${thread.line ?? "n/a"} (${thread.id})`, "", `<!-- diffpi-reply-start:${id} -->`, thread.reply ?? "", `<!-- diffpi-reply-end:${id} -->`, "");
861
+ }
862
+ lines.push("## Source comments", "");
863
+ for (const thread of threads) {
864
+ lines.push(`### ${thread.file ?? "review"}:${thread.line ?? "n/a"} — ${thread.author ?? "unknown"}`, "", `Thread: ${thread.id}`, "", thread.body, "", "---", "");
865
+ }
866
+ return `${lines.join(`
867
+ `).trimEnd()}
868
+ `;
869
+ }
870
+ function parseThreadArtifact(content) {
871
+ const payload = content.match(/^<!-- diffpi-threads:([A-Za-z0-9_-]+) -->$/m)?.[1];
872
+ if (!payload)
873
+ throw new Error("This file is not a Diffpi thread artifact.");
874
+ let threads;
875
+ try {
876
+ const decoded = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
877
+ threads = z3.array(reviewThreadRecordSchema).parse(decoded);
878
+ } catch {
879
+ throw new Error("Cannot parse the Diffpi thread artifact payload.");
880
+ }
881
+ const replies = content.split(/^## Source comments$/m, 1)[0] ?? "";
882
+ return threads.map((thread) => {
883
+ const id = Buffer.from(thread.id, "utf8").toString("base64url");
884
+ const startMarker = `<!-- diffpi-reply-start:${id} -->
885
+ `;
886
+ const endMarker = `
887
+ <!-- diffpi-reply-end:${id} -->`;
888
+ const start = replies.indexOf(startMarker);
889
+ const end = start < 0 ? -1 : replies.indexOf(endMarker, start + startMarker.length);
890
+ const reply = start >= 0 && end >= 0 ? replies.slice(start + startMarker.length, end).trim() : "";
891
+ return reply ? { ...thread, reply } : thread;
892
+ });
893
+ }
894
+ function upsertThreadReply(content, threadId, body, question) {
895
+ const threads = parseThreadArtifact(content);
896
+ const thread = threads.find((candidate) => candidate.id === threadId);
897
+ if (!thread)
898
+ throw new Error(`Review thread ${threadId} was not found in the local artifact.`);
899
+ thread.reply = body;
900
+ if (question !== undefined)
901
+ thread.question = question;
902
+ const title = content.match(/^# Review threads: (.+)$/m)?.[1] ?? "review";
903
+ const target = content.match(/^- Target: (.+)$/m)?.[1] ?? "local";
904
+ const timestamp = content.match(/^- Pulled: (.+)$/m)?.[1];
905
+ const pr = content.match(/^- PR\/MR: #(\d+)(?: — (.+))?$/m);
906
+ return renderThreadArtifact(title, target, threads, {
907
+ timestamp,
908
+ number: pr ? Number.parseInt(pr[1], 10) : undefined,
909
+ url: pr?.[2]
910
+ });
911
+ }
912
+ function renderCommentBody(finding) {
913
+ const prefix = finding.severity === "BLOCKING" ? "**BLOCKING** " : "";
914
+ const reference = finding.reference ? `
915
+
916
+ > **Reference:** ${finding.reference}` : "";
917
+ return `${prefix}${finding.body}${reference}`;
918
+ }
919
+
920
+ // src/tuicr.ts
921
+ import { readFile as readFile2, realpath as realpath2 } from "node:fs/promises";
922
+ import { resolve as resolve2 } from "node:path";
923
+
924
+ // src/store.ts
925
+ import { createHash } from "node:crypto";
926
+ import { lstat, mkdir as mkdir2, readlink, realpath, symlink, unlink } from "node:fs/promises";
927
+ import { homedir as homedir2 } from "node:os";
928
+ import { dirname as dirname2, isAbsolute, join as join3, resolve } from "node:path";
929
+ var STORE_LINK = ".diffpi";
930
+ var LEGACY_STORE_LINK = join3(".pi", "diffpi");
931
+ async function gitToplevel(cwd) {
932
+ const result = await run("git", ["-C", cwd, "rev-parse", "--show-toplevel"]);
933
+ const top = result.stdout.trim();
934
+ return result.code === 0 && top ? top : resolve(cwd);
935
+ }
936
+ async function computeProjectSlug(cwd) {
937
+ const root = await gitToplevel(cwd);
938
+ const remoteResult = await run("git", ["-C", root, "remote", "get-url", "origin"]);
939
+ const remote = remoteResult.code === 0 ? remoteResult.stdout.trim() : "";
940
+ const commonResult = await run("git", ["-C", root, "rev-parse", "--git-common-dir"]);
941
+ const common = commonResult.stdout.trim();
942
+ let commonPath = root;
943
+ if (commonResult.code === 0 && common) {
944
+ const resolvedCommon = isAbsolute(common) ? common : join3(root, common);
945
+ commonPath = resolve(resolvedCommon);
946
+ }
947
+ const canonicalCommon = await canonicalPath(commonPath);
948
+ const identity = remote ? `remote:${normalizeRemote(remote)}` : `git-common-dir:${canonicalCommon}`;
949
+ const name = remote ? repositoryName(remote) : basename2(resolve(canonicalCommon, "..")) || basename2(root);
950
+ const readable = name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "unnamed";
951
+ const digest = createHash("sha256").update(identity).digest("hex").slice(0, 12);
952
+ return `${readable}-${digest}`;
953
+ }
954
+ function storeGlobalRoot(homeDir = homedir2()) {
955
+ return join3(homeDir, ".difflab", "diffpi", "projects");
956
+ }
957
+ async function ensureStore(cwd, homeDir = homedir2()) {
958
+ const root = await gitToplevel(cwd);
959
+ const slug = await computeProjectSlug(root);
960
+ const dest = join3(storeGlobalRoot(homeDir), slug);
961
+ const link = join3(root, STORE_LINK);
962
+ await mkdir2(dest, { recursive: true });
963
+ try {
964
+ await assertStoreLink(link, dest);
965
+ } catch (error) {
966
+ if (error.code !== "ENOENT")
967
+ throw error;
968
+ await symlink(dest, link);
969
+ }
970
+ await removeLegacyStoreLink(join3(root, LEGACY_STORE_LINK), dest);
971
+ return { slug, root, dest, link, linked: true };
972
+ }
973
+ async function reviewsDir(cwd, homeDir = homedir2()) {
974
+ const store = await ensureStore(cwd, homeDir);
975
+ const dir = join3(store.link, "reviews");
976
+ await mkdir2(dir, { recursive: true });
977
+ return dir;
978
+ }
979
+ async function sessionsDir(cwd, homeDir = homedir2()) {
980
+ const store = await ensureStore(cwd, homeDir);
981
+ const dir = join3(store.link, "sessions");
982
+ await mkdir2(dir, { recursive: true });
983
+ return dir;
984
+ }
985
+ async function assertStoreLink(path, dest) {
986
+ const entry = await lstat(path);
987
+ if (!entry.isSymbolicLink())
988
+ throw new Error(`${path} exists and is not a symlink.`);
989
+ const target = await symlinkTarget(path);
990
+ if (target !== await canonicalPath(dest))
991
+ throw new Error(`${path} points to ${target}, not ${dest}.`);
992
+ }
993
+ async function removeLegacyStoreLink(path, dest) {
994
+ try {
995
+ const entry = await lstat(path);
996
+ if (!entry.isSymbolicLink())
997
+ return;
998
+ const target = await symlinkTarget(path);
999
+ if (target === await canonicalPath(dest))
1000
+ await unlink(path);
1001
+ } catch (error) {
1002
+ if (error.code !== "ENOENT")
1003
+ throw error;
1004
+ }
1005
+ }
1006
+ async function symlinkTarget(path) {
1007
+ const target = await readlink(path);
1008
+ return canonicalPath(isAbsolute(target) ? target : resolve(dirname2(path), target));
1009
+ }
1010
+ async function canonicalPath(path) {
1011
+ try {
1012
+ return await realpath(path);
1013
+ } catch {
1014
+ return resolve(path);
1015
+ }
1016
+ }
1017
+ function normalizeRemote(remote) {
1018
+ return remote.trim().replace(/\.git\/?$/i, "").replace(/\/+$/, "").toLowerCase();
1019
+ }
1020
+ function repositoryName(remote) {
1021
+ const normalized = remote.trim().replace(/\.git\/?$/i, "").replace(/\/+$/, "");
1022
+ return normalized.split(/[/\\:]/).filter(Boolean).at(-1) ?? "";
1023
+ }
1024
+ function basename2(path) {
1025
+ const parts = resolve(path).split(/[/\\]/).filter(Boolean);
1026
+ return parts.at(-1) ?? "";
1027
+ }
1028
+
1029
+ // src/tuicr.ts
1030
+ async function listSessions(repo = ".") {
1031
+ const result = await run("tuicr", ["review", "list", "--repo", repo]);
1032
+ if (result.code !== 0 || !result.stdout.trim())
1033
+ return [];
1034
+ let raw;
1035
+ try {
1036
+ raw = JSON.parse(result.stdout);
1037
+ } catch {
1038
+ return [];
1039
+ }
1040
+ return raw.map((entry) => ({
1041
+ slug: entry.slug,
1042
+ kind: entry.kind,
1043
+ path: entry.path,
1044
+ updatedAt: entry.updated_at,
1045
+ commentCount: entry.comment_count,
1046
+ anchor: entry.anchor,
1047
+ active: entry.active
1048
+ }));
1049
+ }
1050
+ async function resolveSession(cwd, branch) {
1051
+ const sessions = await listSessions(cwd);
1052
+ return findMatchingSession(sessions, cwd, branch);
1053
+ }
1054
+ async function resolveReviewSession(cwd, target) {
1055
+ if (!target.workingTree && target.owner && target.repo && target.number !== undefined) {
1056
+ return resolvePrSession(cwd, target.owner, target.repo, target.number);
1057
+ }
1058
+ return resolveSession(cwd, target.branch);
1059
+ }
1060
+ async function resolvePrSession(cwd, owner, repo, number) {
1061
+ const coordinate = `${owner}/${repo}`.toLowerCase();
1062
+ const sessions = await listSessions(cwd);
1063
+ return sessions.find((session) => {
1064
+ const slug = session.slug.toLowerCase();
1065
+ return session.kind === "pr" && slug.includes(coordinate) && (slug.endsWith(`/pr/${number}`) || slug.endsWith(`/mr/${number}`));
1066
+ });
1067
+ }
1068
+ async function findMatchingSession(sessions, cwd, branch) {
1069
+ const repository = await canonicalPath2(await gitToplevel(cwd));
1070
+ for (const session of sessions) {
1071
+ if (session.kind !== "local")
1072
+ continue;
1073
+ try {
1074
+ const data = await readSession(session.path);
1075
+ if (data.branch_name !== branch || !data.repo_path)
1076
+ continue;
1077
+ if (await canonicalPath2(data.repo_path) === repository)
1078
+ return session;
1079
+ } catch {}
1080
+ }
1081
+ return;
1082
+ }
1083
+ async function readSession(path) {
1084
+ const content = await readFile2(path, "utf8");
1085
+ try {
1086
+ return JSON.parse(content);
1087
+ } catch {
1088
+ throw new Error(`Cannot parse tuicr session JSON: ${path}`);
1089
+ }
1090
+ }
1091
+ async function addComment(session, body, opts = {}) {
1092
+ const args = ["review", "add", "--session", session, body];
1093
+ if (opts.targetFile)
1094
+ args.push("--target-file", opts.targetFile);
1095
+ if (opts.line !== undefined)
1096
+ args.push("--line", String(opts.line));
1097
+ if (opts.side)
1098
+ args.push("--side", opts.side);
1099
+ if (opts.username)
1100
+ args.push("--username", opts.username);
1101
+ await runChecked("tuicr", args);
1102
+ }
1103
+ async function launch(cwd, pr) {
1104
+ const command = pr === undefined ? ["tuicr", "-w"] : ["tuicr", "pr", String(pr)];
1105
+ return openInNewTab(command, { cwd, name: "tuicr" });
1106
+ }
1107
+ function toFindings(session, options = {}) {
1108
+ const comments = [];
1109
+ const include = (comment) => !options.agentOnly || commentAuthor(comment)?.startsWith("Agent: ");
1110
+ const bodyParts = (session.review_comments ?? []).flatMap((comment) => include(comment) ? [comment.content] : []);
1111
+ for (const [file, entry] of Object.entries(session.files ?? {})) {
1112
+ const fileComments = (entry.file_comments ?? []).flatMap((comment) => include(comment) ? [comment.content] : []);
1113
+ if (fileComments.length > 0)
1114
+ bodyParts.push(`File: ${file}
1115
+
1116
+ ${fileComments.join(`
1117
+
1118
+ `)}`);
1119
+ for (const [lineKey, lineComments] of Object.entries(entry.line_comments ?? {})) {
1120
+ const line = Number.parseInt(lineKey, 10);
1121
+ if (!Number.isFinite(line))
1122
+ continue;
1123
+ for (const lineComment of lineComments) {
1124
+ if (!include(lineComment))
1125
+ continue;
1126
+ const comment = {
1127
+ file,
1128
+ line,
1129
+ side: lineComment.side === "old" ? "LEFT" : "RIGHT",
1130
+ body: lineComment.content
1131
+ };
1132
+ const author = commentAuthor(lineComment);
1133
+ if (author)
1134
+ comment.author = author;
1135
+ comments.push(comment);
1136
+ }
1137
+ }
1138
+ }
1139
+ return {
1140
+ comments,
1141
+ body: bodyParts.join(`
1142
+
1143
+ `)
1144
+ };
1145
+ }
1146
+ function commentAuthor(comment) {
1147
+ return comment.username ?? comment.author;
1148
+ }
1149
+ async function canonicalPath2(path) {
1150
+ try {
1151
+ return await realpath2(path);
1152
+ } catch {
1153
+ return resolve2(path);
1154
+ }
1155
+ }
1156
+
1157
+ // src/review-backend.ts
1158
+ function createRemoteReviewBackend(vcs, number) {
1159
+ if (vcs.provider === "github")
1160
+ return new GithubReviewBackend(vcs, number);
1161
+ if (vcs.provider === "gitlab")
1162
+ return new GitlabReviewBackend(vcs, number);
1163
+ throw new Error("A remote review backend requires a GitHub or GitLab repository.");
1164
+ }
1165
+ function createLocalReviewBackend(options) {
1166
+ return new LocalReviewBackend(options);
1167
+ }
1168
+
1169
+ class LocalReviewBackend {
1170
+ options;
1171
+ kind = "local";
1172
+ constructor(options) {
1173
+ this.options = options;
1174
+ }
1175
+ async stage(draft) {
1176
+ for (const comment of draft.comments) {
1177
+ await addComment(this.options.session, comment.body, {
1178
+ targetFile: comment.file,
1179
+ line: comment.line,
1180
+ side: comment.side === "LEFT" ? "old" : "new",
1181
+ username: this.options.author
1182
+ });
1183
+ }
1184
+ if (draft.body.trim()) {
1185
+ await addComment(this.options.session, draft.body, { username: this.options.author });
1186
+ }
1187
+ }
1188
+ async readDraft() {
1189
+ return toFindings(await readSession(this.options.session), { agentOnly: true });
1190
+ }
1191
+ async listThreads() {
1192
+ try {
1193
+ return parseThreadArtifact(await readFile3(this.options.artifactPath, "utf8"));
1194
+ } catch (error) {
1195
+ if (error.code === "ENOENT")
1196
+ return [];
1197
+ throw error;
1198
+ }
1199
+ }
1200
+ async reply(input) {
1201
+ const content = await readFile3(this.options.artifactPath, "utf8");
1202
+ await writeFile2(this.options.artifactPath, upsertThreadReply(content, input.threadId, input.body, input.question), "utf8");
1203
+ }
1204
+ async publish() {
1205
+ throw new Error("Promote a local draft through a remote review backend before publishing it.");
1206
+ }
1207
+ }
1208
+
1209
+ class GithubReviewBackend {
1210
+ vcs;
1211
+ number;
1212
+ kind = "remote";
1213
+ constructor(vcs, number) {
1214
+ this.vcs = vcs;
1215
+ this.number = number;
1216
+ }
1217
+ async stage(draft) {
1218
+ const pending = await this.pendingReview();
1219
+ if (!pending) {
1220
+ const payload = {
1221
+ body: draft.body,
1222
+ comments: draft.comments.map((comment) => ({
1223
+ path: comment.file,
1224
+ line: comment.line,
1225
+ side: comment.side ?? "RIGHT",
1226
+ body: comment.body
1227
+ }))
1228
+ };
1229
+ await runChecked("gh", [
1230
+ "api",
1231
+ "--method",
1232
+ "POST",
1233
+ `/repos/${this.vcs.owner}/${this.vcs.repo}/pulls/${this.number}/reviews`,
1234
+ "--input",
1235
+ "-"
1236
+ ], { input: JSON.stringify(payload) });
1237
+ return;
1238
+ }
1239
+ if (draft.body.trim()) {
1240
+ await runChecked("gh", [
1241
+ "api",
1242
+ "--method",
1243
+ "PUT",
1244
+ `/repos/${this.vcs.owner}/${this.vcs.repo}/pulls/${this.number}/reviews/${pending.id}`,
1245
+ "-f",
1246
+ `body=${draft.body}`
1247
+ ]);
1248
+ }
1249
+ for (const comment of draft.comments) {
1250
+ await runChecked("gh", [
1251
+ "api",
1252
+ "graphql",
1253
+ "-f",
1254
+ `query=${GITHUB_ADD_THREAD_MUTATION}`,
1255
+ "-f",
1256
+ `reviewId=${pending.nodeId}`,
1257
+ "-f",
1258
+ `body=${comment.body}`,
1259
+ "-f",
1260
+ `path=${comment.file}`,
1261
+ "-F",
1262
+ `line=${comment.line}`,
1263
+ "-f",
1264
+ `side=${comment.side ?? "RIGHT"}`
1265
+ ]);
1266
+ }
1267
+ }
1268
+ async readDraft() {
1269
+ const pending = await this.pendingReview();
1270
+ if (!pending)
1271
+ return { comments: [], body: "" };
1272
+ const review = await runChecked("gh", [
1273
+ "api",
1274
+ `/repos/${this.vcs.owner}/${this.vcs.repo}/pulls/${this.number}/reviews/${pending.id}`
1275
+ ]);
1276
+ const comments = await runChecked("gh", [
1277
+ "api",
1278
+ `/repos/${this.vcs.owner}/${this.vcs.repo}/pulls/${this.number}/reviews/${pending.id}/comments`
1279
+ ]);
1280
+ let reviewData;
1281
+ let commentData;
1282
+ try {
1283
+ reviewData = JSON.parse(review.stdout);
1284
+ commentData = JSON.parse(comments.stdout);
1285
+ } catch {
1286
+ throw new Error("Cannot parse the pending GitHub review as JSON.");
1287
+ }
1288
+ return {
1289
+ body: reviewData.body ?? "",
1290
+ comments: commentData.map((comment) => ({
1291
+ file: comment.path,
1292
+ line: comment.line ?? comment.original_line ?? 1,
1293
+ side: comment.side,
1294
+ body: comment.body
1295
+ }))
1296
+ };
1297
+ }
1298
+ async listThreads() {
1299
+ const threads = [];
1300
+ let cursor;
1301
+ do {
1302
+ const args = [
1303
+ "api",
1304
+ "graphql",
1305
+ "-f",
1306
+ `query=${GITHUB_THREADS_QUERY}`,
1307
+ "-f",
1308
+ `owner=${this.vcs.owner}`,
1309
+ "-f",
1310
+ `repo=${this.vcs.repo}`,
1311
+ "-F",
1312
+ `number=${this.number}`
1313
+ ];
1314
+ if (cursor)
1315
+ args.push("-f", `after=${cursor}`);
1316
+ const result = await runChecked("gh", args);
1317
+ let data;
1318
+ try {
1319
+ data = JSON.parse(result.stdout);
1320
+ } catch {
1321
+ throw new Error("Cannot parse GitHub review threads as JSON.");
1322
+ }
1323
+ const connection = data.data?.repository?.pullRequest?.reviewThreads;
1324
+ threads.push(...connection?.nodes ?? []);
1325
+ cursor = connection?.pageInfo?.hasNextPage ? connection.pageInfo.endCursor : undefined;
1326
+ if (connection?.pageInfo?.hasNextPage && !cursor) {
1327
+ throw new Error("GitHub review thread pagination did not return an end cursor.");
1328
+ }
1329
+ } while (cursor);
1330
+ return threads.map((thread) => {
1331
+ const nodes = thread.comments?.nodes ?? [];
1332
+ const comment = nodes[0];
1333
+ const body = comment?.body ?? "";
1334
+ return {
1335
+ id: thread.id,
1336
+ file: thread.path,
1337
+ line: thread.line,
1338
+ body,
1339
+ author: comment?.author?.login,
1340
+ resolved: thread.isResolved,
1341
+ question: /\?\s*$/.test(body.trim()),
1342
+ replies: nodes.slice(1).map((node) => node.body)
1343
+ };
1344
+ });
1345
+ }
1346
+ async reply(input) {
1347
+ const threads = await this.listThreads();
1348
+ const existing = threads.find((thread) => thread.id === input.threadId);
1349
+ if (!existing?.replies?.includes(input.body)) {
1350
+ await runChecked("gh", [
1351
+ "api",
1352
+ "graphql",
1353
+ "-f",
1354
+ `query=${GITHUB_REPLY_MUTATION}`,
1355
+ "-f",
1356
+ `threadId=${input.threadId}`,
1357
+ "-f",
1358
+ `body=${input.body}`
1359
+ ]);
1360
+ }
1361
+ if (input.resolve) {
1362
+ await runChecked("gh", [
1363
+ "api",
1364
+ "graphql",
1365
+ "-f",
1366
+ `query=${GITHUB_RESOLVE_MUTATION}`,
1367
+ "-f",
1368
+ `threadId=${input.threadId}`
1369
+ ]);
1370
+ }
1371
+ }
1372
+ async publish(event) {
1373
+ const pending = await this.pendingReview();
1374
+ if (!pending && event === "COMMENT")
1375
+ return;
1376
+ if (!pending && event === "REQUEST_CHANGES") {
1377
+ throw new Error("GitHub requires pending comments before publishing a request-changes review without a body.");
1378
+ }
1379
+ const endpoint = githubReviewSubmissionEndpoint(this.vcs.owner, this.vcs.repo, this.number, pending?.id ?? "");
1380
+ await runChecked("gh", ["api", "--method", "POST", endpoint, "-f", `event=${event}`]);
1381
+ }
1382
+ async pendingReview() {
1383
+ const result = await runChecked("gh", [
1384
+ "api",
1385
+ `/repos/${this.vcs.owner}/${this.vcs.repo}/pulls/${this.number}/reviews`,
1386
+ "--jq",
1387
+ '[.[] | select(.state=="PENDING")] | last | {id: (.id | tostring), nodeId: .node_id}'
1388
+ ]);
1389
+ if (!result.stdout.trim())
1390
+ return;
1391
+ let pending;
1392
+ try {
1393
+ pending = JSON.parse(result.stdout);
1394
+ } catch {
1395
+ throw new Error("Cannot parse the pending GitHub review identifier as JSON.");
1396
+ }
1397
+ if (!pending.id || !pending.nodeId)
1398
+ return;
1399
+ return { id: pending.id, nodeId: pending.nodeId };
1400
+ }
1401
+ }
1402
+
1403
+ class GitlabReviewBackend {
1404
+ vcs;
1405
+ number;
1406
+ kind = "remote";
1407
+ constructor(vcs, number) {
1408
+ this.vcs = vcs;
1409
+ this.number = number;
1410
+ }
1411
+ async stage(draft) {
1412
+ const endpoint = `${this.mergeRequestEndpoint()}/draft_notes`;
1413
+ if (draft.body.trim()) {
1414
+ await runChecked("glab", ["api", "--method", "POST", endpoint, "--input", "-"], {
1415
+ input: JSON.stringify({ note: draft.body })
1416
+ });
1417
+ }
1418
+ if (draft.comments.length === 0)
1419
+ return;
1420
+ const response = await runChecked("glab", ["api", this.mergeRequestEndpoint()]);
1421
+ const diffRefs = parseGitlabDiffRefs(response.stdout);
1422
+ for (const comment of draft.comments) {
1423
+ const payload = {
1424
+ note: comment.body,
1425
+ position: {
1426
+ ...diffRefs,
1427
+ position_type: "text",
1428
+ new_path: comment.file,
1429
+ old_path: comment.file,
1430
+ new_line: comment.side === "LEFT" ? undefined : comment.line,
1431
+ old_line: comment.side === "LEFT" ? comment.line : undefined
1432
+ }
1433
+ };
1434
+ await runChecked("glab", ["api", "--method", "POST", endpoint, "--input", "-"], {
1435
+ input: JSON.stringify(payload)
1436
+ });
1437
+ }
1438
+ }
1439
+ async readDraft() {
1440
+ const result = await runChecked("glab", ["api", `${this.mergeRequestEndpoint()}/draft_notes`]);
1441
+ let notes;
1442
+ try {
1443
+ notes = JSON.parse(result.stdout);
1444
+ } catch {
1445
+ throw new Error("Cannot parse GitLab draft notes as JSON.");
1446
+ }
1447
+ const comments = [];
1448
+ const body = [];
1449
+ for (const note of notes) {
1450
+ if (!note.note)
1451
+ continue;
1452
+ const file = note.position?.new_path ?? note.position?.old_path;
1453
+ const line = note.position?.new_line ?? note.position?.old_line;
1454
+ if (file && line) {
1455
+ comments.push({
1456
+ file,
1457
+ line,
1458
+ side: note.position?.new_line ? "RIGHT" : "LEFT",
1459
+ body: note.note
1460
+ });
1461
+ } else {
1462
+ body.push(note.note);
1463
+ }
1464
+ }
1465
+ return { comments, body: body.join(`
1466
+
1467
+ `) };
1468
+ }
1469
+ async listThreads() {
1470
+ const discussions = [];
1471
+ for (let page = 1;; page += 1) {
1472
+ const result = await runChecked("glab", [
1473
+ "api",
1474
+ `${this.mergeRequestEndpoint()}/discussions?per_page=100&page=${page}`
1475
+ ]);
1476
+ let batch;
1477
+ try {
1478
+ batch = JSON.parse(result.stdout);
1479
+ } catch {
1480
+ throw new Error("Cannot parse GitLab review discussions as JSON.");
1481
+ }
1482
+ discussions.push(...batch);
1483
+ if (batch.length < 100)
1484
+ break;
1485
+ }
1486
+ return discussions.map((discussion) => {
1487
+ const notes = discussion.notes ?? [];
1488
+ const note = notes[0];
1489
+ const body = note?.body ?? "";
1490
+ return {
1491
+ id: discussion.id,
1492
+ file: note?.position?.new_path ?? note?.position?.old_path,
1493
+ line: note?.position?.new_line ?? note?.position?.old_line,
1494
+ body,
1495
+ author: note?.author?.username,
1496
+ resolved: Boolean(discussion.resolved),
1497
+ question: /\?\s*$/.test(body.trim()),
1498
+ replies: notes.slice(1).map((reply) => reply.body)
1499
+ };
1500
+ });
1501
+ }
1502
+ async reply(input) {
1503
+ const endpoint = `${this.mergeRequestEndpoint()}/discussions/${encodeURIComponent(input.threadId)}`;
1504
+ const threads = await this.listThreads();
1505
+ const existing = threads.find((thread) => thread.id === input.threadId);
1506
+ if (!existing?.replies?.includes(input.body)) {
1507
+ await runChecked("glab", ["api", "--method", "POST", `${endpoint}/notes`, "--input", "-"], {
1508
+ input: JSON.stringify({ body: input.body })
1509
+ });
1510
+ }
1511
+ if (input.resolve)
1512
+ await runChecked("glab", ["api", "--method", "PUT", `${endpoint}?resolved=true`]);
1513
+ }
1514
+ async publish(event) {
1515
+ assertReviewEventSupported(this.vcs.provider, event);
1516
+ const drafts = await runChecked("glab", ["api", `${this.mergeRequestEndpoint()}/draft_notes`]);
1517
+ if (hasGitlabDraftNotes(drafts.stdout)) {
1518
+ await runChecked("glab", ["api", "--method", "POST", `${this.mergeRequestEndpoint()}/draft_notes/bulk_publish`]);
1519
+ }
1520
+ if (event === "APPROVE") {
1521
+ await runChecked("glab", ["mr", "approve", String(this.number), "--repo", this.project()]);
1522
+ }
1523
+ }
1524
+ project() {
1525
+ return `${this.vcs.owner}/${this.vcs.repo}`;
1526
+ }
1527
+ mergeRequestEndpoint() {
1528
+ return `projects/${encodeURIComponent(this.project())}/merge_requests/${this.number}`;
1529
+ }
1530
+ }
1531
+ function assertReviewEventSupported(provider, event) {
1532
+ if (provider === "gitlab" && event === "REQUEST_CHANGES") {
1533
+ throw new Error("GitLab does not support REQUEST_CHANGES reviews; post a comment or reject the merge request manually.");
1534
+ }
1535
+ }
1536
+ function githubReviewSubmissionEndpoint(owner, repo, id, pendingReviewId) {
1537
+ return pendingReviewId ? `/repos/${owner}/${repo}/pulls/${id}/reviews/${pendingReviewId}/events` : `/repos/${owner}/${repo}/pulls/${id}/reviews`;
1538
+ }
1539
+ function parseGitlabDiffRefs(input) {
1540
+ let data;
1541
+ try {
1542
+ data = JSON.parse(input);
1543
+ } catch {
1544
+ throw new Error("Cannot create positioned GitLab draft notes: the merge request response was not valid JSON.");
1545
+ }
1546
+ const { base_sha, start_sha, head_sha } = data.diff_refs ?? {};
1547
+ if (!base_sha || !start_sha || !head_sha) {
1548
+ throw new Error("Cannot create positioned GitLab draft notes: merge request diff refs are unavailable.");
1549
+ }
1550
+ return { base_sha, start_sha, head_sha };
1551
+ }
1552
+ function hasGitlabDraftNotes(input) {
1553
+ try {
1554
+ const data = JSON.parse(input);
1555
+ return Array.isArray(data) && data.length > 0;
1556
+ } catch {
1557
+ throw new Error("Cannot publish the GitLab review: the draft notes response was not valid JSON.");
1558
+ }
1559
+ }
1560
+ var GITHUB_THREADS_QUERY = `query($owner:String!,$repo:String!,$number:Int!,$after:String){repository(owner:$owner,name:$repo){pullRequest(number:$number){reviewThreads(first:100,after:$after){nodes{id,isResolved,path,line,comments(first:100){nodes{body,author{login}}}}pageInfo{hasNextPage,endCursor}}}}}`;
1561
+ var GITHUB_ADD_THREAD_MUTATION = `mutation($reviewId:ID!,$body:String!,$path:String!,$line:Int!,$side:DiffSide!){addPullRequestReviewThread(input:{pullRequestReviewId:$reviewId,body:$body,path:$path,line:$line,side:$side}){thread{id}}}`;
1562
+ var GITHUB_REPLY_MUTATION = `mutation($threadId:ID!,$body:String!){addPullRequestReviewThreadReply(input:{pullRequestReviewThreadId:$threadId,body:$body}){comment{id}}}`;
1563
+ var GITHUB_RESOLVE_MUTATION = `mutation($threadId:ID!){resolveReviewThread(input:{threadId:$threadId}){thread{isResolved}}}`;
1564
+
1565
+ // src/review-publication.ts
1566
+ import { createHash as createHash2 } from "node:crypto";
1567
+ import { readFile as readFile4, rename, writeFile as writeFile3 } from "node:fs/promises";
1568
+ import { join as join4 } from "node:path";
1569
+ import { z as z4 } from "zod";
1570
+ var reviewPublicationStateSchema = z4.object({
1571
+ target: z4.string().optional(),
1572
+ comments: z4.array(z4.string()).default([]),
1573
+ replies: z4.array(z4.string()).default([]),
1574
+ overlayPath: z4.string().optional()
1575
+ });
1576
+ function reviewCommentFingerprint(comment) {
1577
+ return digest([comment.file, String(comment.line), comment.side ?? "RIGHT", comment.body].join("\x00"));
1578
+ }
1579
+ function reviewReplyFingerprint(threadId, body) {
1580
+ return digest(`${threadId}\x00${body}`);
1581
+ }
1582
+ function unpublishedReviewComments(comments, knownFingerprints) {
1583
+ return comments.filter((comment) => !knownFingerprints.has(reviewCommentFingerprint(comment)));
1584
+ }
1585
+ async function loadReviewPublicationState(cwd, vcs, number, homeDir) {
1586
+ const target = `${vcs.provider}:${vcs.owner}/${vcs.repo}#${number}`;
1587
+ const path = join4(await sessionsDir(cwd, homeDir), `review-publish-${digest(target).slice(0, 16)}.json`);
1588
+ try {
1589
+ const parsed = reviewPublicationStateSchema.parse(JSON.parse(await readFile4(path, "utf8")));
1590
+ return { path, state: { ...parsed, target } };
1591
+ } catch (error) {
1592
+ if (error.code === "ENOENT") {
1593
+ return { path, state: { target, comments: [], replies: [] } };
1594
+ }
1595
+ if (error instanceof SyntaxError || error instanceof z4.ZodError) {
1596
+ throw new Error(`Cannot parse review publication state: ${path}`);
1597
+ }
1598
+ throw error;
1599
+ }
1600
+ }
1601
+ async function saveReviewPublicationState(path, state) {
1602
+ const temp = `${path}.${process.pid}.tmp`;
1603
+ await writeFile3(temp, `${JSON.stringify(state, null, 2)}
1604
+ `, "utf8");
1605
+ await rename(temp, path);
1606
+ }
1607
+ function digest(value) {
1608
+ return createHash2("sha256").update(value).digest("hex");
1609
+ }
1610
+
1611
+ // src/templates.ts
1612
+ import { readFile as readFile5 } from "node:fs/promises";
1613
+ import { homedir as homedir3 } from "node:os";
1614
+ import { join as join6, normalize } from "node:path";
1615
+
1616
+ // src/assets.ts
1617
+ import { existsSync } from "node:fs";
1618
+ import { dirname as dirname3, join as join5 } from "node:path";
1619
+ import { fileURLToPath } from "node:url";
1620
+ function resolveBundledAssetDir(name, moduleUrl = import.meta.url) {
1621
+ const moduleDir = dirname3(fileURLToPath(moduleUrl));
1622
+ const candidates = [join5(moduleDir, name), join5(moduleDir, "..", name), join5(moduleDir, "..", "..", name)];
1623
+ return candidates.find((path) => existsSync(path)) ?? candidates[1];
1624
+ }
1625
+ function resolveBundledAgentsDir(moduleUrl = import.meta.url) {
1626
+ return resolveBundledAssetDir("agents", moduleUrl);
1627
+ }
1628
+ function resolveBundledTemplatesDir(moduleUrl = import.meta.url) {
1629
+ return resolveBundledAssetDir("templates", moduleUrl);
1630
+ }
1631
+
1632
+ // src/templates.ts
1633
+ async function loadTemplate(name, options = {}) {
1634
+ const relative = templateRelativePath(name);
1635
+ const userPath = join6(options.homeDir ?? homedir3(), ".difflab", "diffpi", "templates", relative);
1636
+ const bundledPath = join6(options.bundledDir ?? resolveBundledTemplatesDir(), relative);
1637
+ const user = await readOptionalFile(userPath);
1638
+ if (user !== undefined)
1639
+ return { name, path: userPath, source: "user", content: user };
1640
+ const bundled = await readOptionalFile(bundledPath);
1641
+ if (bundled !== undefined)
1642
+ return { name, path: bundledPath, source: "bundled", content: bundled };
1643
+ throw new Error(`Template "${name}" was not found at ${userPath} or ${bundledPath}.`);
1644
+ }
1645
+ function renderTemplate(content, variables) {
1646
+ return content.replace(/\{\{\s*([a-zA-Z0-9_.-]+)\s*\}\}/g, (match, key) => variables[key] ?? match);
1647
+ }
1648
+ function templateRelativePath(name) {
1649
+ const normalized = normalize(name.replaceAll("\\", "/")).replace(/^\.\//, "");
1650
+ if (!normalized || normalized.startsWith("../") || normalized.includes("/../") || normalized.startsWith("/")) {
1651
+ throw new Error(`Invalid template name: ${name}`);
1652
+ }
1653
+ return normalized.endsWith(".md") ? normalized : `${normalized}.md`;
1654
+ }
1655
+ async function readOptionalFile(path) {
1656
+ try {
1657
+ return await readFile5(path, "utf8");
1658
+ } catch (error) {
1659
+ if (error.code === "ENOENT")
1660
+ return;
1661
+ throw error;
1662
+ }
1663
+ }
1664
+
1665
+ // src/tools/review.ts
1666
+ var contextSchema = z5.object({
1667
+ cwd: z5.string().optional(),
1668
+ target: z5.string().optional(),
1669
+ workingTree: z5.boolean().optional()
1670
+ });
1671
+ var localSchema = contextSchema.extend({ local: z5.boolean().optional() });
1672
+ var openSchema = localSchema.extend({
1673
+ title: z5.string().optional(),
1674
+ intent: z5.string().optional(),
1675
+ base: z5.string().optional()
1676
+ });
1677
+ var submitSchema = localSchema.extend({
1678
+ findings: findingsSchema,
1679
+ overallIssues: z5.array(z5.string()).optional(),
1680
+ notVerified: z5.array(z5.string()).optional(),
1681
+ title: z5.string().optional()
1682
+ });
1683
+ var addCommentSchema = localSchema.extend({
1684
+ body: z5.string().min(1),
1685
+ file: z5.string().min(1),
1686
+ line: z5.number().int().positive(),
1687
+ side: z5.enum(["LEFT", "RIGHT"]).optional()
1688
+ });
1689
+ var respondSchema = localSchema.extend({
1690
+ threadId: z5.string().min(1),
1691
+ body: z5.string().min(1),
1692
+ question: z5.boolean().optional(),
1693
+ resolve: z5.boolean().optional()
1694
+ });
1695
+ var publishSchema = localSchema.extend({
1696
+ status: z5.enum(["COMMENT", "APPROVE", "REQUEST_CHANGES", "CLOSE"]).optional()
1697
+ });
1698
+ function parameters(schema) {
1699
+ return z5.toJSONSchema(schema, { io: "input" });
1700
+ }
1701
+ function cwdOf(params) {
1702
+ return params.cwd ?? process.cwd();
1703
+ }
1704
+ function result(text, details = {}) {
1705
+ return { content: [{ type: "text", text }], details };
1706
+ }
1707
+ function modelRoute(ctx) {
1708
+ if (!ctx.model)
1709
+ throw new Error("Cannot record review provenance because Pi did not provide an active model route.");
1710
+ return `${ctx.model.provider}/${ctx.model.id}`;
1711
+ }
1712
+ function conventionalMergeGuard(subject) {
1713
+ return checkConventionalSubject(subject);
1714
+ }
1715
+ async function workingTreeDiff(cwd) {
1716
+ const tracked = await run("git", ["-C", cwd, "diff", "HEAD"], { capture: "unbounded" });
1717
+ if (tracked.code !== 0)
1718
+ throw new Error(tracked.stderr || "Cannot read tracked working-tree changes.");
1719
+ const untracked = await runChecked("git", ["-C", cwd, "ls-files", "--others", "--exclude-standard", "-z"]);
1720
+ const patches = [tracked.stdout];
1721
+ for (const file of untracked.stdout.split("\x00").filter(Boolean)) {
1722
+ const patch = await run("git", ["-C", cwd, "diff", "--no-index", "--", "/dev/null", file], {
1723
+ capture: "unbounded"
1724
+ });
1725
+ if (patch.code > 1)
1726
+ throw new Error(patch.stderr || `Cannot read untracked file diff: ${file}`);
1727
+ patches.push(patch.stdout);
1728
+ }
1729
+ return patches.filter(Boolean).join(`
1730
+ `);
1731
+ }
1732
+ function createReviewTools() {
1733
+ return [
1734
+ defineTool3({
1735
+ name: "review_context",
1736
+ label: "review context",
1737
+ description: "Orient to the target, backend, forge, environment, shared store, PR/MR, and tuicr session.",
1738
+ promptSnippet: "Call review_context first",
1739
+ promptGuidelines: ["Call this before every review workflow."],
1740
+ parameters: parameters(localSchema),
1741
+ executionMode: "parallel",
1742
+ async execute(_id, input) {
1743
+ const params = localSchema.parse(input);
1744
+ const review = await resolveReviewContext(cwdOf(params), params.target);
1745
+ const store = await ensureStore(review.cwd);
1746
+ const env = { ide: detectIde(), mux: detectMux(), shell: detectShell() };
1747
+ const session = await resolveTuicrSession(review, params.workingTree);
1748
+ const baseRef = review.pr?.baseRef ?? (review.forge ? await review.forge.defaultBranch() : "local");
1749
+ const backend = params.local ? "tuicr" : review.forge ? review.vcs.provider : "tuicr";
1750
+ return result([
1751
+ `Backend: ${backend}`,
1752
+ `Forge: ${review.vcs.provider}${review.vcs.provider === "none" ? "" : ` (${review.vcs.owner}/${review.vcs.repo})`}`,
1753
+ `Branch: ${review.vcs.branch} → ${baseRef}`,
1754
+ `Env: ide=${env.ide} mux=${env.mux} shell=${env.shell}`,
1755
+ `Store: ${store.link} → ${store.dest}`,
1756
+ review.pr ? `PR/MR: #${review.pr.number} ${review.pr.url}` : "PR/MR: none",
1757
+ session ? `tuicr: ${session.slug} (${session.commentCount} comments)` : "tuicr: none"
1758
+ ].join(`
1759
+ `), { ...review, env, store, session, baseRef, backend });
1760
+ }
1761
+ }),
1762
+ defineTool3({
1763
+ name: "review_open",
1764
+ label: "review open",
1765
+ description: "Create a draft PR/MR from the template registry, or launch a local tuicr target.",
1766
+ promptSnippet: "Call review_open to start",
1767
+ promptGuidelines: ["Use local to select tuicr as the review backend."],
1768
+ parameters: parameters(openSchema),
1769
+ executionMode: "sequential",
1770
+ async execute(_id, input) {
1771
+ const params = openSchema.parse(input);
1772
+ const review = await resolveReviewContext(cwdOf(params), params.target);
1773
+ await ensureStore(review.cwd);
1774
+ if (params.local || !review.forge)
1775
+ return launchLocalReview(review, params.workingTree);
1776
+ const base = params.base ?? await review.forge.defaultBranch();
1777
+ const template = await loadTemplate("review/draft-pr");
1778
+ const body = renderTemplate(template.content, {
1779
+ intent: params.intent ?? "<!-- Describe why this change is needed. -->",
1780
+ head: review.vcs.branch,
1781
+ base
1782
+ });
1783
+ const pr = await review.forge.createDraftPr({
1784
+ title: params.title ?? deriveTitle(review.vcs.branch),
1785
+ body,
1786
+ base,
1787
+ head: review.vcs.branch
1788
+ });
1789
+ return result(`Draft PR/MR created from ${template.source} template: ${pr.url}`, { pr, template });
1790
+ }
1791
+ }),
1792
+ defineTool3({
1793
+ name: "review_edit",
1794
+ label: "review edit",
1795
+ description: "Switch to the requested PR branch when necessary and open its existing tuicr session without generating comments.",
1796
+ promptSnippet: "Call review_edit for the local-only edit workflow",
1797
+ promptGuidelines: ["This tool never generates review findings."],
1798
+ parameters: parameters(contextSchema),
1799
+ executionMode: "sequential",
1800
+ async execute(_id, input) {
1801
+ const params = contextSchema.parse(input);
1802
+ let review = await resolveReviewContext(cwdOf(params), params.target);
1803
+ if (review.pr && review.pr.headRef !== review.vcs.branch) {
1804
+ await switchBranch(review.cwd, review.pr.headRef);
1805
+ review = await resolveReviewContext(review.cwd, params.target);
1806
+ }
1807
+ await ensureStore(review.cwd);
1808
+ return launchLocalReview(review, params.workingTree);
1809
+ }
1810
+ }),
1811
+ defineTool3({
1812
+ name: "review_diff",
1813
+ label: "review diff",
1814
+ description: "Fetch the target PR/MR diff or auto-detected local working-tree diff.",
1815
+ promptSnippet: "Call review_diff for the code under review",
1816
+ promptGuidelines: ["Ground findings in this diff."],
1817
+ parameters: parameters(localSchema),
1818
+ executionMode: "parallel",
1819
+ async execute(_id, input) {
1820
+ const params = localSchema.parse(input);
1821
+ const review = await resolveReviewContext(cwdOf(params), params.target);
1822
+ if (params.workingTree || !review.pr && (params.local || !review.forge)) {
1823
+ const diff = await workingTreeDiff(review.cwd);
1824
+ return result(diff || "No working-tree changes.", { diff, target: "local" });
1825
+ }
1826
+ if (!review.pr || !review.forge)
1827
+ return result("No PR/MR matches this remote review target.");
1828
+ const diff = await review.forge.prDiff(review.pr.number);
1829
+ return result(diff || "Empty diff.", { diff, pr: review.pr });
1830
+ }
1831
+ }),
1832
+ defineTool3({
1833
+ name: "review_gates",
1834
+ label: "review gates",
1835
+ description: "Run format, lint, test, conventional-subject, and available CI checks.",
1836
+ promptSnippet: "Call review_gates before submitting findings",
1837
+ promptGuidelines: ["Report skipped gates as skipped."],
1838
+ parameters: parameters(localSchema),
1839
+ executionMode: "parallel",
1840
+ async execute(_id, input) {
1841
+ const params = localSchema.parse(input);
1842
+ const review = await resolveReviewContext(cwdOf(params), params.target);
1843
+ const gates = await runMiseGates(review.cwd);
1844
+ const commit = await run("git", ["-C", review.cwd, "log", "-1", "--format=%s"]);
1845
+ const subject = commit.stdout.trim();
1846
+ if (subject)
1847
+ gates.push(checkConventionalSubject(subject));
1848
+ if (!params.local && review.pr && review.forge)
1849
+ gates.push(ciGate(await review.forge.prChecks(review.pr.number)));
1850
+ return result(gates.map((gate) => `- ${gate.name}: ${gate.status} — ${gate.detail}`).join(`
1851
+ `), {
1852
+ results: gates
1853
+ });
1854
+ }
1855
+ }),
1856
+ defineTool3({
1857
+ name: "review_submit",
1858
+ label: "review submit",
1859
+ description: "Write the review artifact and stage comments in the selected local or remote backend.",
1860
+ promptSnippet: "Call review_submit with the findings JSON",
1861
+ promptGuidelines: ["Remote comments remain pending until review_publish."],
1862
+ parameters: parameters(submitSchema),
1863
+ executionMode: "sequential",
1864
+ async execute(_id, input, _signal, _onUpdate, ctx) {
1865
+ const params = submitSchema.parse(input);
1866
+ const review = await resolveReviewContext(cwdOf(params), params.target);
1867
+ const model = modelRoute(ctx);
1868
+ const findings = dedupeFindings(params.findings);
1869
+ const gates = await runMiseGates(review.cwd);
1870
+ const baseRef = review.pr?.baseRef ?? (review.forge ? await review.forge.defaultBranch() : "local");
1871
+ const artifact = await newReviewArtifactPath(review.cwd, params.workingTree || !review.pr ? "local" : await reviewTargetId(review));
1872
+ await writeFile4(artifact, renderReviewDoc({
1873
+ title: params.title ?? review.pr?.title ?? review.vcs.branch,
1874
+ number: review.pr?.number,
1875
+ url: review.pr?.url,
1876
+ model,
1877
+ headRef: review.vcs.branch,
1878
+ baseRef,
1879
+ findings,
1880
+ overallIssues: params.overallIssues ?? [],
1881
+ gates,
1882
+ notVerified: params.notVerified ?? []
1883
+ }), "utf8");
1884
+ const useLocalBackend = Boolean(params.local || !review.forge);
1885
+ const comments = toReviewComments(findings, useLocalBackend ? undefined : model);
1886
+ if (useLocalBackend) {
1887
+ const session = await resolveTuicrSession(review, params.workingTree);
1888
+ if (!session) {
1889
+ return result(`Review written: ${artifact}. Open tuicr, then call review_submit again to seed comments.`, {
1890
+ artifact,
1891
+ count: findings.length
1892
+ });
1893
+ }
1894
+ const backend = createLocalReviewBackend({
1895
+ session: session.path,
1896
+ artifactPath: artifact,
1897
+ author: localReviewAuthor(model)
1898
+ });
1899
+ await backend.stage({ comments, body: (params.overallIssues ?? []).join(`
1900
+ `) });
1901
+ return result(`Local review staged in tuicr: ${artifact}`, { artifact, count: findings.length, session });
1902
+ }
1903
+ if (!review.pr)
1904
+ return result(`Review written: ${artifact}. No PR/MR matches this remote target.`, { artifact });
1905
+ if (comments.length > 0) {
1906
+ await createRemoteReviewBackend(review.vcs, review.pr.number).stage({ comments, body: "" });
1907
+ }
1908
+ return result(`${comments.length > 0 ? "Pending review staged" : "Clean review recorded"} on #${review.pr.number}. Artifact: ${artifact}`, {
1909
+ artifact,
1910
+ pr: review.pr,
1911
+ count: findings.length
1912
+ });
1913
+ }
1914
+ }),
1915
+ defineTool3({
1916
+ name: "review_add_comment",
1917
+ label: "review add comment",
1918
+ description: "Add one provenance-marked comment through tuicr or the remote pending-review backend.",
1919
+ promptSnippet: "Call review_add_comment for incremental comments",
1920
+ promptGuidelines: ["Pass local=true when tuicr owns the draft."],
1921
+ parameters: parameters(addCommentSchema),
1922
+ executionMode: "sequential",
1923
+ async execute(_id, input, _signal, _onUpdate, ctx) {
1924
+ const params = addCommentSchema.parse(input);
1925
+ const review = await resolveReviewContext(cwdOf(params), params.target);
1926
+ const model = modelRoute(ctx);
1927
+ const useLocalBackend = Boolean(params.local || !review.forge);
1928
+ const comment = {
1929
+ file: params.file,
1930
+ line: params.line,
1931
+ side: params.side ?? "RIGHT",
1932
+ body: useLocalBackend ? params.body : withRemoteProvenance(params.body, model)
1933
+ };
1934
+ if (useLocalBackend) {
1935
+ const session = await resolveTuicrSession(review, params.workingTree);
1936
+ if (!session)
1937
+ return result("No matching tuicr session. Open review_edit or review_launch first.");
1938
+ const backend = createLocalReviewBackend({
1939
+ session: session.path,
1940
+ artifactPath: "",
1941
+ author: localReviewAuthor(model)
1942
+ });
1943
+ await backend.stage({ comments: [comment], body: "" });
1944
+ return result(`Comment added to tuicr session ${session.slug}.`, { session, comment });
1945
+ }
1946
+ if (!review.pr)
1947
+ return result("No PR/MR matches this remote review target.");
1948
+ await createRemoteReviewBackend(review.vcs, review.pr.number).stage({ comments: [comment], body: "" });
1949
+ return result(`Draft comment added to #${review.pr.number}.`, { pr: review.pr, comment });
1950
+ }
1951
+ }),
1952
+ defineTool3({
1953
+ name: "review_comments",
1954
+ label: "review comments",
1955
+ description: "Pull review threads or local tuicr comments and write a target-named artifact.",
1956
+ promptSnippet: "Call review_comments before addressing findings",
1957
+ promptGuidelines: ["Pass local=true to prepare the local reply overlay."],
1958
+ parameters: parameters(localSchema),
1959
+ executionMode: "sequential",
1960
+ async execute(_id, input) {
1961
+ const params = localSchema.parse(input);
1962
+ const review = await resolveReviewContext(cwdOf(params), params.target);
1963
+ let threads;
1964
+ if (!params.workingTree && review.pr && review.forge) {
1965
+ threads = await createRemoteReviewBackend(review.vcs, review.pr.number).listThreads();
1966
+ } else if (params.local || !review.forge) {
1967
+ const session = await resolveTuicrSession(review, params.workingTree);
1968
+ if (!session)
1969
+ return result("No matching tuicr session found.");
1970
+ const draft = toFindings(await readSession(session.path));
1971
+ threads = draft.comments.map((comment, index) => ({
1972
+ id: `local-${index + 1}`,
1973
+ file: comment.file,
1974
+ line: comment.line,
1975
+ body: comment.body,
1976
+ resolved: false,
1977
+ question: /\?\s*$/.test(comment.body.trim())
1978
+ }));
1979
+ } else {
1980
+ return result("No PR/MR matches this remote review target.");
1981
+ }
1982
+ const target = params.workingTree ? "local" : await reviewTargetId(review);
1983
+ const artifact = await newReviewArtifactPath(review.cwd, target);
1984
+ await writeFile4(artifact, renderThreadArtifact(review.pr?.title ?? review.vcs.branch, target, threads, {
1985
+ number: params.workingTree ? undefined : review.pr?.number,
1986
+ url: params.workingTree ? undefined : review.pr?.url
1987
+ }), "utf8");
1988
+ if (params.local && !params.workingTree && review.pr && review.forge) {
1989
+ const publication = await loadReviewPublicationState(review.cwd, review.vcs, review.pr.number);
1990
+ publication.state.overlayPath = artifact;
1991
+ await saveReviewPublicationState(publication.path, publication.state);
1992
+ }
1993
+ return result(threads.map((thread) => `${thread.id} ${thread.file ?? "review"}:${thread.line ?? "-"} — ${thread.body}`).join(`
1994
+ `) || "No comments.", { artifact, threads });
1995
+ }
1996
+ }),
1997
+ defineTool3({
1998
+ name: "review_respond",
1999
+ label: "review respond",
2000
+ description: "Record a local overlay reply or post a provenance-marked remote thread reply.",
2001
+ promptSnippet: "Call review_respond after addressing a comment",
2002
+ promptGuidelines: ["Question replies remain unresolved."],
2003
+ parameters: parameters(respondSchema),
2004
+ executionMode: "sequential",
2005
+ async execute(_id, input, _signal, _onUpdate, ctx) {
2006
+ const params = respondSchema.parse(input);
2007
+ const review = await resolveReviewContext(cwdOf(params), params.target);
2008
+ const model = modelRoute(ctx);
2009
+ if (params.local) {
2010
+ const target = params.workingTree ? "local" : await reviewTargetId(review);
2011
+ const publication = !params.workingTree && review.pr && review.forge ? await loadReviewPublicationState(review.cwd, review.vcs, review.pr.number) : undefined;
2012
+ const artifact = publication?.state.overlayPath ?? await latestThreadArtifact(review.cwd, review, target);
2013
+ if (!artifact)
2014
+ return result("No local review artifact. Run review_comments with local=true first.");
2015
+ const threads = await readThreadArtifact(artifact);
2016
+ const known = threads.find((thread) => thread.id === params.threadId);
2017
+ const question = known?.question === true || params.question === true || !known && params.question === undefined;
2018
+ const tuicrSession = await resolveTuicrSession(review, params.workingTree);
2019
+ const session = tuicrSession?.path ?? "";
2020
+ await createLocalReviewBackend({ session, artifactPath: artifact, author: localReviewAuthor(model) }).reply({
2021
+ threadId: params.threadId,
2022
+ body: withRemoteProvenance(params.body, model),
2023
+ resolve: false,
2024
+ question
2025
+ });
2026
+ return result(`Local reply recorded in ${artifact}.`, { artifact, question });
2027
+ }
2028
+ if (!review.pr || !review.forge)
2029
+ return result("No remote PR/MR for this reply.");
2030
+ const remote = createRemoteReviewBackend(review.vcs, review.pr.number);
2031
+ const remoteThreads = await remote.listThreads();
2032
+ const known = remoteThreads.find((thread) => thread.id === params.threadId);
2033
+ const question = known?.question === true || params.question === true || !known && params.question === undefined;
2034
+ const resolve = question ? false : params.resolve ?? true;
2035
+ await remote.reply({
2036
+ threadId: params.threadId,
2037
+ body: withRemoteProvenance(params.body, model),
2038
+ resolve,
2039
+ question
2040
+ });
2041
+ return result(`Replied to ${params.threadId}${resolve ? " and resolved it" : " and left it open"}.`, {
2042
+ pr: review.pr,
2043
+ resolve
2044
+ });
2045
+ }
2046
+ }),
2047
+ defineTool3({
2048
+ name: "review_publish",
2049
+ label: "review publish",
2050
+ description: "Promote local drafts when needed, publish pending review work, and apply the selected public status.",
2051
+ promptSnippet: "Call review_publish to make review work public",
2052
+ promptGuidelines: ["Statuses are COMMENT, APPROVE, REQUEST_CHANGES, or CLOSE."],
2053
+ parameters: parameters(publishSchema),
2054
+ executionMode: "sequential",
2055
+ async execute(_id, input, _signal, _onUpdate, ctx) {
2056
+ const params = publishSchema.parse(input);
2057
+ const review = await resolveReviewContext(cwdOf(params), params.target);
2058
+ if (!review.pr || !review.forge)
2059
+ return result("No remote PR/MR to publish.");
2060
+ return publishResolvedReview(review, params, modelRoute(ctx));
2061
+ }
2062
+ }),
2063
+ defineTool3({
2064
+ name: "review_merge",
2065
+ label: "review merge",
2066
+ description: "Squash-merge an approved GitHub PR after checking its conventional subject.",
2067
+ promptSnippet: "Call review_merge only after review_publish APPROVE",
2068
+ promptGuidelines: ["This is intentionally GitHub-only until GitLab merge support is added."],
2069
+ parameters: parameters(contextSchema.extend({ subject: z5.string().optional() })),
2070
+ executionMode: "sequential",
2071
+ async execute(_id, input) {
2072
+ const params = contextSchema.extend({ subject: z5.string().optional() }).parse(input);
2073
+ const review = await resolveReviewContext(cwdOf(params), params.target);
2074
+ if (review.vcs.provider !== "github" || !review.forge)
2075
+ return result("review_merge currently supports GitHub only.");
2076
+ if (!review.pr)
2077
+ return result("No open PR/MR for this branch.");
2078
+ const subject = params.subject ?? review.pr.title;
2079
+ const guard = conventionalMergeGuard(subject);
2080
+ if (guard.status !== "pass")
2081
+ return result(`Merge blocked: ${guard.detail}`, { pr: review.pr, guard });
2082
+ await review.forge.mergePr(review.pr.number, subject);
2083
+ return result(`Merged #${review.pr.number} with subject: ${subject}.`, { pr: review.pr, guard });
2084
+ }
2085
+ }),
2086
+ defineTool3({
2087
+ name: "review_launch",
2088
+ label: "review launch",
2089
+ description: "Open the auto-detected tuicr target in a mux tab, configure Zed, or print the command.",
2090
+ promptSnippet: "Call review_launch for the interactive tuicr TUI",
2091
+ promptGuidelines: ["Show the returned command when launch cannot open a tab."],
2092
+ parameters: parameters(contextSchema),
2093
+ executionMode: "sequential",
2094
+ async execute(_id, input) {
2095
+ const params = contextSchema.parse(input);
2096
+ const review = await resolveReviewContext(cwdOf(params), params.target);
2097
+ return launchLocalReview(review, params.workingTree);
2098
+ }
2099
+ })
2100
+ ];
2101
+ }
2102
+ async function publishResolvedReview(review, params, model) {
2103
+ const status = params.status ?? "COMMENT";
2104
+ const event = status === "CLOSE" ? "COMMENT" : status;
2105
+ assertReviewEventSupported(review.vcs.provider, event);
2106
+ const remote = createRemoteReviewBackend(review.vcs, review.pr.number);
2107
+ const promotion = params.local ? await promoteLocalReview(review, remote, model, Boolean(params.workingTree)) : undefined;
2108
+ if (status !== "CLOSE" && review.pr.isDraft)
2109
+ await review.forge.markReady(review.pr.number);
2110
+ await remote.publish(event);
2111
+ if (promotion) {
2112
+ promotion.publication.state.comments = [
2113
+ ...new Set([...promotion.publication.state.comments, ...promotion.commentFingerprints])
2114
+ ];
2115
+ await saveReviewPublicationState(promotion.publication.path, promotion.publication.state);
2116
+ }
2117
+ if (status === "CLOSE")
2118
+ await review.forge.closePr(review.pr.number);
2119
+ const finalPr = await review.forge.viewPr(String(review.pr.number));
2120
+ const promotedComments = promotion?.promotedComments ?? 0;
2121
+ const promotedReplies = promotion?.promotedReplies ?? 0;
2122
+ return result(`Published #${review.pr.number} (${status}); promoted ${promotedComments} comments and ${promotedReplies} replies.`, { pr: finalPr ?? review.pr, status, promotedComments, promotedReplies });
2123
+ }
2124
+ async function promoteLocalReview(review, remote, model, workingTree) {
2125
+ const publication = await loadReviewPublicationState(review.cwd, review.vcs, review.pr.number);
2126
+ const session = await resolveTuicrSession(review, workingTree);
2127
+ if (!session)
2128
+ throw new Error("No matching tuicr session to publish.");
2129
+ const local = createLocalReviewBackend({
2130
+ session: session.path,
2131
+ artifactPath: "",
2132
+ author: localReviewAuthor(model)
2133
+ });
2134
+ const draft = await local.readDraft();
2135
+ const comments = draft.comments.map((comment) => ({
2136
+ ...comment,
2137
+ body: withRemoteProvenance(comment.body, comment.author?.replace(/^Agent:\s*/, "") || model)
2138
+ }));
2139
+ const commentFingerprints = comments.map(reviewCommentFingerprint);
2140
+ const remoteDraft = await remote.readDraft();
2141
+ const known = new Set([...publication.state.comments, ...remoteDraft.comments.map(reviewCommentFingerprint)]);
2142
+ const unpublished = unpublishedReviewComments(comments, known);
2143
+ if (unpublished.length > 0)
2144
+ await remote.stage({ comments: unpublished, body: "" });
2145
+ const promotedReplies = await promoteLocalReplies(review, remote, publication, model, workingTree);
2146
+ return {
2147
+ publication,
2148
+ commentFingerprints,
2149
+ promotedComments: unpublished.length,
2150
+ promotedReplies
2151
+ };
2152
+ }
2153
+ async function promoteLocalReplies(review, remote, publication, model, workingTree) {
2154
+ const target = workingTree ? "local" : await reviewTargetId(review);
2155
+ const artifact = workingTree ? await latestThreadArtifact(review.cwd, review, target) : publication.state.overlayPath ?? await latestThreadArtifact(review.cwd, review, target);
2156
+ if (!artifact)
2157
+ return 0;
2158
+ if (!workingTree)
2159
+ publication.state.overlayPath = artifact;
2160
+ let count = 0;
2161
+ for (const thread of await readThreadArtifact(artifact)) {
2162
+ if (!thread.reply)
2163
+ continue;
2164
+ const body = withRemoteProvenance(thread.reply, model);
2165
+ const fingerprint = reviewReplyFingerprint(thread.id, body);
2166
+ if (publication.state.replies.includes(fingerprint))
2167
+ continue;
2168
+ await remote.reply({
2169
+ threadId: thread.id,
2170
+ body,
2171
+ resolve: !thread.question,
2172
+ question: thread.question
2173
+ });
2174
+ publication.state.replies.push(fingerprint);
2175
+ await saveReviewPublicationState(publication.path, publication.state);
2176
+ count += 1;
2177
+ }
2178
+ return count;
2179
+ }
2180
+ async function resolveReviewContext(cwd, target) {
2181
+ const vcs = await detectVcs(cwd);
2182
+ const forge = vcs.provider === "none" ? undefined : createForge(vcs);
2183
+ const requested = target ? normalizeTarget(target) : vcs.branch;
2184
+ const pr = forge ? await forge.viewPr(requested) : undefined;
2185
+ return { cwd, vcs, forge, pr };
2186
+ }
2187
+ function normalizeTarget(target) {
2188
+ return target.match(/\/(?:pull|merge_requests)\/(\d+)(?:\/|$)/)?.[1] ?? target;
2189
+ }
2190
+ function resolveTuicrSession(review, workingTree = false) {
2191
+ return resolveReviewSession(review.cwd, {
2192
+ branch: review.vcs.branch,
2193
+ workingTree,
2194
+ owner: review.vcs.provider === "none" ? undefined : review.vcs.owner,
2195
+ repo: review.vcs.provider === "none" ? undefined : review.vcs.repo,
2196
+ number: review.pr?.number
2197
+ });
2198
+ }
2199
+ async function launchLocalReview(review, workingTree) {
2200
+ const launched = await launch(review.cwd, workingTree ? undefined : review.pr?.number);
2201
+ const commandTarget = workingTree || !review.pr ? "working tree" : `PR/MR #${review.pr.number}`;
2202
+ return result(launched.launched ? `Opened ${commandTarget} in tuicr (${launched.via}).` : launched.instruction ?? `Run: ${launched.command}`, { launched, pr: review.pr, target: commandTarget });
2203
+ }
2204
+ async function switchBranch(cwd, branch) {
2205
+ const dirty = await runChecked("git", ["-C", cwd, "status", "--porcelain"]);
2206
+ if (dirty.stdout.trim())
2207
+ throw new Error(`Cannot switch to ${branch}: the current worktree has uncommitted changes.`);
2208
+ await runChecked("git", ["-C", cwd, "fetch", "origin", branch]);
2209
+ const local = await run("git", ["-C", cwd, "show-ref", "--verify", "--quiet", `refs/heads/${branch}`]);
2210
+ if (local.code === 0)
2211
+ await runChecked("git", ["-C", cwd, "switch", branch]);
2212
+ else
2213
+ await runChecked("git", ["-C", cwd, "switch", "--track", "-c", branch, `origin/${branch}`]);
2214
+ }
2215
+ async function reviewTargetId(review) {
2216
+ if (!review.pr)
2217
+ return "local";
2218
+ if (review.pr.headSha)
2219
+ return review.pr.headSha.slice(0, 12);
2220
+ if (review.pr.headRef === review.vcs.branch)
2221
+ return headSha(review.cwd);
2222
+ const remote = await run("git", ["-C", review.cwd, "ls-remote", "origin", `refs/heads/${review.pr.headRef}`]);
2223
+ return remote.stdout.trim().split(/\s+/)[0]?.slice(0, 12) || headSha(review.cwd);
2224
+ }
2225
+ async function headSha(cwd) {
2226
+ const result = await runChecked("git", ["-C", cwd, "rev-parse", "--short=12", "HEAD"]);
2227
+ return result.stdout.trim();
2228
+ }
2229
+ async function newReviewArtifactPath(cwd, target) {
2230
+ const dir = await reviewsDir(cwd);
2231
+ await mkdir3(dir, { recursive: true });
2232
+ return uniqueRecordPath(dir, reviewRecordName(target));
2233
+ }
2234
+ async function readThreadArtifact(path) {
2235
+ try {
2236
+ return parseThreadArtifact(await readFile6(path, "utf8"));
2237
+ } catch (error) {
2238
+ if (error.code === "ENOENT") {
2239
+ throw new Error(`Expected local reply overlay is missing: ${path}`);
2240
+ }
2241
+ throw error;
2242
+ }
2243
+ }
2244
+ async function latestThreadArtifact(cwd, review, target) {
2245
+ try {
2246
+ return await findLatestThreadArtifact(cwd, review, target);
2247
+ } catch (error) {
2248
+ throw new Error(`Cannot locate the latest review thread artifact for ${target}.`, { cause: error });
2249
+ }
2250
+ }
2251
+ async function findLatestThreadArtifact(cwd, review, target) {
2252
+ const dir = await reviewsDir(cwd);
2253
+ const suffix = reviewSlug(target) || "local";
2254
+ const names = await listReviewArtifactNames(dir);
2255
+ const candidates = await Promise.all(names.flatMap((name) => {
2256
+ if (!name.endsWith(".md"))
2257
+ return [];
2258
+ return [
2259
+ (async () => {
2260
+ const path = join7(dir, name);
2261
+ const info = await stat(path);
2262
+ return { path, name, content: await readFile6(path, "utf8"), modified: info.mtimeMs };
2263
+ })()
2264
+ ];
2265
+ }));
2266
+ return candidates.filter(({ name, content }) => {
2267
+ if (!content.startsWith("<!-- diffpi-threads:"))
2268
+ return false;
2269
+ if (review.pr && target !== "local")
2270
+ return content.includes(`- PR/MR: #${review.pr.number}`);
2271
+ return artifactNameMatches(name, suffix);
2272
+ }).sort((a, b) => b.modified - a.modified)[0]?.path;
2273
+ }
2274
+ async function listReviewArtifactNames(dir) {
2275
+ try {
2276
+ return await readdir(dir);
2277
+ } catch (error) {
2278
+ throw new Error(`Cannot read review artifacts in ${dir}.`, { cause: error });
2279
+ }
2280
+ }
2281
+ function deriveTitle(branch) {
2282
+ return branch.replace(/^(feature|feat|fix|bug|chore)\//, "").replace(/^eng-\d+-/i, "").replace(/[-_]+/g, " ").replace(/^\w/, (char) => char.toUpperCase());
2283
+ }
2284
+ function uniqueRecordPath(dir, base) {
2285
+ let path = join7(dir, `${base}.md`);
2286
+ let count = 2;
2287
+ while (existsSync2(path))
2288
+ path = join7(dir, `${base}-${count++}.md`);
2289
+ return path;
2290
+ }
2291
+ function artifactNameMatches(name, suffix) {
2292
+ if (!name.endsWith(".md"))
2293
+ return false;
2294
+ const stem = name.slice(0, -3);
2295
+ const marker = `-${suffix}`;
2296
+ const markerIndex = stem.lastIndexOf(marker);
2297
+ if (markerIndex < 0)
2298
+ return false;
2299
+ const tail = stem.slice(markerIndex + marker.length);
2300
+ return tail === "" || /^-\d+$/.test(tail);
2301
+ }
2302
+
28
2303
  // src/tools/setup.ts
29
- import { defineTool as defineTool2 } from "@earendil-works/pi-coding-agent";
30
- import { z as z2 } from "zod";
2304
+ import { defineTool as defineTool4 } from "@earendil-works/pi-coding-agent";
2305
+ import { z as z7 } from "zod";
31
2306
 
32
2307
  // src/setup.ts
2308
+ import { parseFrontmatter as parseFrontmatter2 } from "@earendil-works/pi-coding-agent";
2309
+ import { readdir as readdir3, readFile as readFile10 } from "node:fs/promises";
2310
+ import { homedir as homedir8 } from "node:os";
2311
+ import { basename as basename4, join as join12 } from "node:path";
2312
+
2313
+ // src/config.ts
2314
+ import { parseFrontmatter } from "@earendil-works/pi-coding-agent";
2315
+ import { z as z6 } from "zod";
33
2316
  import { homedir as homedir4 } from "node:os";
34
- import { join as join5 } from "node:path";
2317
+ import { join as join8 } from "node:path";
2318
+
2319
+ // src/fsx.ts
2320
+ import { readdir as readdir2, readFile as readFile7 } from "node:fs/promises";
2321
+ async function readTextIfExists(path) {
2322
+ try {
2323
+ return await readFile7(path, "utf8");
2324
+ } catch (error) {
2325
+ if (isMissingPath(error))
2326
+ return;
2327
+ throw error;
2328
+ }
2329
+ }
2330
+ function isMissingPath(error) {
2331
+ return error instanceof Error && "code" in error && error.code === "ENOENT";
2332
+ }
2333
+
2334
+ // src/config.ts
2335
+ var modelReferenceSchema = z6.string().trim().min(1);
2336
+ var agentConfigSchema = z6.object({
2337
+ models: z6.array(modelReferenceSchema).optional()
2338
+ }).strict();
2339
+ var diffpiConfigSchema = z6.object({
2340
+ agents: z6.record(z6.string(), agentConfigSchema).optional()
2341
+ }).strict();
2342
+ function diffpiConfigPaths(homeDir = homedir4()) {
2343
+ const directory = join8(homeDir, ".difflab", "diffpi");
2344
+ return {
2345
+ yaml: join8(directory, "config.yaml"),
2346
+ json: join8(directory, "config.json")
2347
+ };
2348
+ }
2349
+ async function loadDiffpiConfig(options = {}) {
2350
+ const paths = diffpiConfigPaths(options.homeDir);
2351
+ for (const [format, path] of [
2352
+ ["yaml", paths.yaml],
2353
+ ["json", paths.json]
2354
+ ]) {
2355
+ const content = await readTextIfExists(path);
2356
+ if (content === undefined)
2357
+ continue;
2358
+ try {
2359
+ const value = format === "yaml" ? parseYamlConfig(content) : JSON.parse(content);
2360
+ return { config: diffpiConfigSchema.parse(value ?? {}), path };
2361
+ } catch (error) {
2362
+ const reason = error instanceof Error ? error.message : String(error);
2363
+ throw new Error(`Invalid Diffpi config at ${path}: ${reason}`, { cause: error });
2364
+ }
2365
+ }
2366
+ return { config: {} };
2367
+ }
2368
+ function resolveAgentModelPreferences(agentId, profilePreferences, config) {
2369
+ const override = config.agents?.[agentId];
2370
+ if (override && Object.hasOwn(override, "models"))
2371
+ return [...override.models ?? []];
2372
+ return [...profilePreferences];
2373
+ }
2374
+ function findPreferredModel(models, preference) {
2375
+ const normalizedPreference = normalizeModelReference(preference);
2376
+ const exactReference = models.find((model) => normalizeModelReference(`${model.provider}/${model.id}`) === normalizedPreference);
2377
+ if (exactReference)
2378
+ return exactReference;
2379
+ const idPreference = preference.includes("/") ? preference.slice(preference.indexOf("/") + 1) : preference;
2380
+ const normalizedIdPreference = normalizeModelReference(idPreference);
2381
+ const exactId = models.find((model) => normalizeModelReference(model.id) === normalizedIdPreference);
2382
+ if (exactId)
2383
+ return exactId;
2384
+ const preferenceTokens = normalizedIdPreference.split("-").filter(Boolean);
2385
+ return models.find((model) => {
2386
+ const modelTokens = new Set(normalizeModelReference(model.id).split("-").filter(Boolean));
2387
+ return preferenceTokens.every((token) => modelTokens.has(token));
2388
+ });
2389
+ }
2390
+ function parseYamlConfig(content) {
2391
+ const document = content.replace(/^\uFEFF/, "").replace(/^---[^\S\r\n]*(?:#.*)?(?:\r?\n|$)/, "");
2392
+ return parseFrontmatter(`---
2393
+ ${document}
2394
+ ---
2395
+ `).frontmatter;
2396
+ }
2397
+ function normalizeModelReference(value) {
2398
+ return value.toLowerCase().replace(/^~/, "").replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
2399
+ }
35
2400
 
36
2401
  // src/mcp.ts
37
- import { mkdir, readFile, writeFile } from "node:fs/promises";
38
- import { homedir } from "node:os";
39
- import { dirname, join } from "node:path";
2402
+ import { mkdir as mkdir4, readFile as readFile8, writeFile as writeFile5 } from "node:fs/promises";
2403
+ import { homedir as homedir5 } from "node:os";
2404
+ import { dirname as dirname4, join as join9 } from "node:path";
40
2405
  var mcp = {
41
- globalConfigPath(homeDir = homedir()) {
42
- return join(homeDir, ".config", "mcp", "mcp.json");
2406
+ globalConfigPath(homeDir = homedir5()) {
2407
+ return join9(homeDir, ".config", "mcp", "mcp.json");
43
2408
  },
44
2409
  async serversEnsure(servers, options = {}) {
45
2410
  const path = options.path ?? mcp.globalConfigPath();
@@ -52,8 +2417,8 @@ var mcp = {
52
2417
  const next = { ...current, mcpServers: nextServers };
53
2418
  const changed = JSON.stringify(current) !== JSON.stringify(next);
54
2419
  if (changed && !options.dryRun) {
55
- await mkdir(dirname(path), { recursive: true });
56
- await writeFile(path, `${JSON.stringify(next, null, 2)}
2420
+ await mkdir4(dirname4(path), { recursive: true });
2421
+ await writeFile5(path, `${JSON.stringify(next, null, 2)}
57
2422
  `, "utf8");
58
2423
  }
59
2424
  return { path, changed, existed: currentText !== undefined, planned: changed && options.dryRun === true };
@@ -82,7 +2447,7 @@ function getParsedConfig(content, path) {
82
2447
  }
83
2448
  async function getOptionalFile(path) {
84
2449
  try {
85
- return await readFile(path, "utf8");
2450
+ return await readFile8(path, "utf8");
86
2451
  } catch (error) {
87
2452
  if (error instanceof Error && "code" in error && error.code === "ENOENT")
88
2453
  return;
@@ -94,68 +2459,9 @@ function isRecord(value) {
94
2459
  }
95
2460
 
96
2461
  // src/mise.ts
97
- import { mkdir as mkdir2, readFile as readFile2, writeFile as writeFile2 } from "node:fs/promises";
98
- import { homedir as homedir2 } from "node:os";
99
- import { basename, dirname as dirname2, join as join3 } from "node:path";
100
-
101
- // src/process.ts
102
- import { constants } from "node:fs";
103
- import { access } from "node:fs/promises";
104
- import { delimiter, join as join2 } from "node:path";
105
- import { spawn } from "node:child_process";
106
- var MAX_CAPTURED_OUTPUT_LENGTH = 65536;
107
- async function findExecutable(name) {
108
- if (name.includes("/")) {
109
- try {
110
- await access(name, constants.X_OK);
111
- return name;
112
- } catch {
113
- return;
114
- }
115
- }
116
- for (const directory of (process.env.PATH ?? "").split(delimiter)) {
117
- if (!directory)
118
- continue;
119
- const candidate = join2(directory, name);
120
- try {
121
- await access(candidate, constants.X_OK);
122
- return candidate;
123
- } catch {}
124
- }
125
- return;
126
- }
127
- function run(command, args, options = {}) {
128
- return new Promise((resolve, reject) => {
129
- const child = spawn(command, args, {
130
- cwd: options.cwd,
131
- env: options.env ?? process.env,
132
- stdio: ["ignore", "pipe", "pipe"]
133
- });
134
- let stdout = "";
135
- let stderr = "";
136
- child.stdout.on("data", (chunk) => {
137
- stdout = appendBounded(stdout, chunk.toString());
138
- });
139
- child.stderr.on("data", (chunk) => {
140
- stderr = appendBounded(stderr, chunk.toString());
141
- });
142
- child.on("error", reject);
143
- child.on("close", (code) => resolve({ code: code ?? 1, stdout, stderr }));
144
- });
145
- }
146
- async function runChecked(command, args, options = {}) {
147
- const result = await run(command, args, options);
148
- if (result.code === 0)
149
- return result;
150
- const detail = result.stderr.trim() || result.stdout.trim() || `exit code ${result.code}`;
151
- throw new Error(`${command} ${args.join(" ")} failed: ${detail}`);
152
- }
153
- function appendBounded(current, next) {
154
- const combined = current + next;
155
- return combined.length <= MAX_CAPTURED_OUTPUT_LENGTH ? combined : combined.slice(-MAX_CAPTURED_OUTPUT_LENGTH);
156
- }
157
-
158
- // src/mise.ts
2462
+ import { mkdir as mkdir5, readFile as readFile9, writeFile as writeFile6 } from "node:fs/promises";
2463
+ import { homedir as homedir6 } from "node:os";
2464
+ import { basename as basename3, dirname as dirname5, join as join10 } from "node:path";
159
2465
  var MISE_HOOK_START = "# >>> @difflab/pi mise >>>";
160
2466
  var MISE_HOOK_END = "# <<< @difflab/pi mise <<<";
161
2467
  var mise = {
@@ -163,11 +2469,11 @@ var mise = {
163
2469
  return findExecutable(name);
164
2470
  },
165
2471
  async install(options = {}) {
166
- const homeDir = options.homeDir ?? homedir2();
2472
+ const homeDir = options.homeDir ?? homedir6();
167
2473
  const platform = options.platform ?? process.platform;
168
2474
  if (platform === "win32")
169
2475
  throw new Error("Automatic mise installation supports macOS and Linux only.");
170
- const installedPath = join3(homeDir, ".local", "bin", "mise");
2476
+ const installedPath = join10(homeDir, ".local", "bin", "mise");
171
2477
  if (options.dryRun)
172
2478
  return installedPath;
173
2479
  await runChecked("sh", ["-c", "curl -fsSL https://mise.run | sh"]);
@@ -177,8 +2483,8 @@ var mise = {
177
2483
  return executable;
178
2484
  },
179
2485
  async hookEnsure(executable, options = {}) {
180
- const homeDir = options.homeDir ?? homedir2();
181
- const hook = getShellHook(basename(options.shell ?? process.env.SHELL ?? ""), executable, homeDir);
2486
+ const homeDir = options.homeDir ?? homedir6();
2487
+ const hook = getShellHook(basename3(options.shell ?? process.env.SHELL ?? ""), executable, homeDir);
182
2488
  const current = await getOptionalFile2(hook.path);
183
2489
  if (current.includes(MISE_HOOK_START))
184
2490
  return { path: hook.path, changed: false, planned: false };
@@ -187,8 +2493,8 @@ var mise = {
187
2493
  const separator = current.length === 0 || current.endsWith(`
188
2494
  `) ? "" : `
189
2495
  `;
190
- await mkdir2(dirname2(hook.path), { recursive: true });
191
- await writeFile2(hook.path, `${current}${separator}${hook.content}`, "utf8");
2496
+ await mkdir5(dirname5(hook.path), { recursive: true });
2497
+ await writeFile6(hook.path, `${current}${separator}${hook.content}`, "utf8");
192
2498
  return { path: hook.path, changed: true, planned: false };
193
2499
  },
194
2500
  async toolCheckGlobal(executable, tool, minimumVersion) {
@@ -205,7 +2511,7 @@ var mise = {
205
2511
  async toolInstallLocal(executable, specification, cwd = process.cwd()) {
206
2512
  await runChecked(executable, ["use", "--path", cwd, specification], { cwd });
207
2513
  },
208
- async toolUpdateAllGlobal(executable, homeDir = homedir2()) {
2514
+ async toolUpdateAllGlobal(executable, homeDir = homedir6()) {
209
2515
  await runChecked(executable, ["upgrade"], { cwd: homeDir });
210
2516
  }
211
2517
  };
@@ -214,7 +2520,7 @@ function getShellHook(shell, executable, homeDir) {
214
2520
  switch (shell.toLowerCase()) {
215
2521
  case "zsh":
216
2522
  return {
217
- path: join3(homeDir, ".zshrc"),
2523
+ path: join10(homeDir, ".zshrc"),
218
2524
  content: `${MISE_HOOK_START}
219
2525
  eval "$(${command} activate zsh)"
220
2526
  ${MISE_HOOK_END}
@@ -222,7 +2528,7 @@ ${MISE_HOOK_END}
222
2528
  };
223
2529
  case "fish":
224
2530
  return {
225
- path: join3(homeDir, ".config", "fish", "config.fish"),
2531
+ path: join10(homeDir, ".config", "fish", "config.fish"),
226
2532
  content: `${MISE_HOOK_START}
227
2533
  ${command} activate fish | source
228
2534
  ${MISE_HOOK_END}
@@ -231,7 +2537,7 @@ ${MISE_HOOK_END}
231
2537
  case "nu":
232
2538
  case "nushell":
233
2539
  return {
234
- path: join3(homeDir, ".config", "nushell", "config.nu"),
2540
+ path: join10(homeDir, ".config", "nushell", "config.nu"),
235
2541
  content: `${MISE_HOOK_START}
236
2542
  let mise_bin = ${command}
237
2543
  let mise_path = $nu.default-config-dir | path join mise.nu
@@ -242,7 +2548,7 @@ ${MISE_HOOK_END}
242
2548
  };
243
2549
  case "xonsh":
244
2550
  return {
245
- path: join3(homeDir, ".xonshrc"),
2551
+ path: join10(homeDir, ".xonshrc"),
246
2552
  content: `${MISE_HOOK_START}
247
2553
  execx($(${command} activate xonsh))
248
2554
  ${MISE_HOOK_END}
@@ -250,7 +2556,7 @@ ${MISE_HOOK_END}
250
2556
  };
251
2557
  case "elvish":
252
2558
  return {
253
- path: join3(homeDir, ".config", "elvish", "rc.elv"),
2559
+ path: join10(homeDir, ".config", "elvish", "rc.elv"),
254
2560
  content: `${MISE_HOOK_START}
255
2561
  var mise: = (ns [&])
256
2562
  eval (${command} activate elvish | slurp) &ns=$mise: &on-end={|ns| set mise: = $ns }
@@ -261,7 +2567,7 @@ ${MISE_HOOK_END}
261
2567
  case "pwsh":
262
2568
  case "powershell":
263
2569
  return {
264
- path: join3(homeDir, ".config", "powershell", "Microsoft.PowerShell_profile.ps1"),
2570
+ path: join10(homeDir, ".config", "powershell", "Microsoft.PowerShell_profile.ps1"),
265
2571
  content: `${MISE_HOOK_START}
266
2572
  (& ${command} activate pwsh) | Out-String | Invoke-Expression
267
2573
  ${MISE_HOOK_END}
@@ -270,7 +2576,7 @@ ${MISE_HOOK_END}
270
2576
  case "bash":
271
2577
  default:
272
2578
  return {
273
- path: join3(homeDir, ".bashrc"),
2579
+ path: join10(homeDir, ".bashrc"),
274
2580
  content: `${MISE_HOOK_START}
275
2581
  eval "$(${command} activate bash)"
276
2582
  ${MISE_HOOK_END}
@@ -309,7 +2615,7 @@ function isVersionAtLeast(version, minimumVersion) {
309
2615
  }
310
2616
  async function getOptionalFile2(path) {
311
2617
  try {
312
- return await readFile2(path, "utf8");
2618
+ return await readFile9(path, "utf8");
313
2619
  } catch (error) {
314
2620
  if (error instanceof Error && "code" in error && error.code === "ENOENT")
315
2621
  return "";
@@ -321,79 +2627,86 @@ function getShellQuoted(value) {
321
2627
  }
322
2628
 
323
2629
  // src/pi.ts
324
- import { mkdir as mkdir3, readFile as readFile3, writeFile as writeFile3 } from "node:fs/promises";
325
- import { homedir as homedir3 } from "node:os";
326
- import { dirname as dirname3, join as join4 } from "node:path";
2630
+ import { mkdir as mkdir6, writeFile as writeFile7 } from "node:fs/promises";
2631
+ import { homedir as homedir7 } from "node:os";
2632
+ import { dirname as dirname6, join as join11 } from "node:path";
327
2633
  var pi = {
328
- async executableCheck() {
329
- return findExecutable("pi");
330
- },
331
- async packageList(executable) {
332
- return (await runChecked(executable, ["list"])).stdout;
333
- },
334
- packageCheck(listOutput, source) {
335
- if (listOutput.includes(source))
2634
+ executableCheck: findPiExecutable,
2635
+ packageList: listPiPackages,
2636
+ packageCheck: hasPiPackage,
2637
+ packageInstall: installPiPackage,
2638
+ agentDir: resolvePiAgentDir,
2639
+ agentEnsure: ensurePiAgent,
2640
+ skillCheckGlobal: checkGlobalPiSkill,
2641
+ skillInstallGlobal: installGlobalPiSkills,
2642
+ configEnsure: ensurePiConfig
2643
+ };
2644
+ async function ensurePiAgent(filename, content, agentDir = resolvePiAgentDir(), dryRun = false) {
2645
+ const path = join11(agentDir, "agents", filename);
2646
+ const currentText = await readTextIfExists(path);
2647
+ const changed = currentText !== content;
2648
+ if (changed && !dryRun) {
2649
+ await mkdir6(dirname6(path), { recursive: true });
2650
+ await writeFile7(path, content, "utf8");
2651
+ }
2652
+ return { path, changed, existed: currentText !== undefined, planned: changed && dryRun };
2653
+ }
2654
+ async function checkGlobalPiSkill(name, agentDir = resolvePiAgentDir(), sharedSkillsDir = join11(homedir7(), ".agents", "skills")) {
2655
+ const roots = [join11(agentDir, "skills"), sharedSkillsDir];
2656
+ for (const root of roots) {
2657
+ if (await readTextIfExists(join11(root, name, "SKILL.md")) !== undefined)
336
2658
  return true;
337
- return source.startsWith("https://") && listOutput.includes(source.slice("https://".length));
338
- },
339
- async packageInstall(executable, source) {
340
- await runChecked(executable, ["install", source]);
341
- },
342
- agentDir(homeDir = homedir3()) {
343
- return getAgentDir(homeDir);
344
- },
345
- async skillCheckGlobal(name, agentDir = getAgentDir(), sharedSkillsDir = join4(homedir3(), ".agents", "skills")) {
346
- const roots = [join4(agentDir, "skills"), sharedSkillsDir];
347
- for (const root of roots) {
348
- if (await getOptionalFile3(join4(root, name, "SKILL.md")) !== undefined)
349
- return true;
350
- }
351
- return false;
352
- },
353
- async skillInstallGlobal(miseExecutable, source, names) {
354
- const selection = names.flatMap((name) => ["--skill", name]);
355
- await runChecked(miseExecutable, [
356
- "x",
357
- "node@22",
358
- "--",
359
- "npx",
360
- "-y",
361
- "skills",
362
- "add",
363
- source,
364
- ...selection,
365
- "--global",
366
- "--agent",
367
- "pi",
368
- "--yes"
369
- ]);
370
- },
371
- async configEnsure(path, update, dryRun = false) {
372
- const currentText = await getOptionalFile3(path);
373
- const current = getParsedObject(currentText, path);
374
- const next = update(current);
375
- const changed = JSON.stringify(current) !== JSON.stringify(next);
376
- if (changed && !dryRun) {
377
- await mkdir3(dirname3(path), { recursive: true });
378
- await writeFile3(path, `${JSON.stringify(next, null, 2)}
379
- `, "utf8");
380
- }
381
- return { path, changed, existed: currentText !== undefined, planned: changed && dryRun };
382
2659
  }
383
- };
384
- function getAgentDir(homeDir = homedir3()) {
385
- return process.env.PI_CODING_AGENT_DIR ?? (process.env.XDG_CONFIG_HOME ? join4(process.env.XDG_CONFIG_HOME, "pi") : join4(homeDir, ".pi", "agent"));
2660
+ return false;
386
2661
  }
387
- async function getOptionalFile3(path) {
388
- try {
389
- return await readFile3(path, "utf8");
390
- } catch (error) {
391
- if (error instanceof Error && "code" in error && error.code === "ENOENT")
392
- return;
393
- throw error;
2662
+ async function installGlobalPiSkills(miseExecutable, source, names) {
2663
+ const selection = names.flatMap((name) => ["--skill", name]);
2664
+ await runChecked(miseExecutable, [
2665
+ "x",
2666
+ "node@22",
2667
+ "--",
2668
+ "npx",
2669
+ "-y",
2670
+ "skills",
2671
+ "add",
2672
+ source,
2673
+ ...selection,
2674
+ "--global",
2675
+ "--agent",
2676
+ "pi",
2677
+ "--yes"
2678
+ ]);
2679
+ }
2680
+ async function ensurePiConfig(path, update, dryRun = false) {
2681
+ const currentText = await readTextIfExists(path);
2682
+ const current = parseJsonObject(currentText, path);
2683
+ const next = update(current);
2684
+ const changed = JSON.stringify(current) !== JSON.stringify(next);
2685
+ if (changed && !dryRun) {
2686
+ await mkdir6(dirname6(path), { recursive: true });
2687
+ await writeFile7(path, `${JSON.stringify(next, null, 2)}
2688
+ `, "utf8");
394
2689
  }
2690
+ return { path, changed, existed: currentText !== undefined, planned: changed && dryRun };
2691
+ }
2692
+ async function findPiExecutable() {
2693
+ return findExecutable("pi");
395
2694
  }
396
- function getParsedObject(content, path) {
2695
+ async function listPiPackages(executable) {
2696
+ return (await runChecked(executable, ["list"])).stdout;
2697
+ }
2698
+ function hasPiPackage(listOutput, source) {
2699
+ if (listOutput.includes(source))
2700
+ return true;
2701
+ return source.startsWith("https://") && listOutput.includes(source.slice("https://".length));
2702
+ }
2703
+ async function installPiPackage(executable, source) {
2704
+ await runChecked(executable, ["install", source]);
2705
+ }
2706
+ function resolvePiAgentDir(homeDir = homedir7()) {
2707
+ return process.env.PI_CODING_AGENT_DIR ?? (process.env.XDG_CONFIG_HOME ? join11(process.env.XDG_CONFIG_HOME, "pi") : join11(homeDir, ".pi", "agent"));
2708
+ }
2709
+ function parseJsonObject(content, path) {
397
2710
  if (!content?.trim())
398
2711
  return {};
399
2712
  try {
@@ -435,9 +2748,14 @@ var PI_SKILL_SOURCES = [
435
2748
  { repository: "AminBlg/SimpleEnglish", skills: ["simple-english"] }
436
2749
  ];
437
2750
  var MCP_ADAPTER_PACKAGE = "npm:pi-mcp-adapter";
2751
+ var BUNDLED_AGENTS_DIR = resolveBundledAgentsDir();
2752
+ var FORGE_DEPENDENCIES = {
2753
+ github: { name: "gh", tool: "gh", spec: "gh@latest", minimumVersion: undefined },
2754
+ gitlab: { name: "glab", tool: "glab", spec: "glab@latest", minimumVersion: undefined }
2755
+ };
438
2756
  async function ensureMise(options = {}) {
439
- const homeDir = options.homeDir ?? homedir4();
440
- const current = await mise.executableCheck() ?? await mise.executableCheck(join5(homeDir, ".local", "bin", "mise"));
2757
+ const homeDir = options.homeDir ?? homedir8();
2758
+ const current = await mise.executableCheck() ?? await mise.executableCheck(join12(homeDir, ".local", "bin", "mise"));
441
2759
  if (current)
442
2760
  return { executable: current, action: createSetupAction("mise", "ready", current) };
443
2761
  reportProgress(options, "Installing mise");
@@ -466,7 +2784,10 @@ async function ensureMiseHooks(miseExecutable, options = {}) {
466
2784
  async function ensureMiseDeps(miseExecutable, options = {}) {
467
2785
  const canRunMise = Boolean(await mise.executableCheck(miseExecutable));
468
2786
  const actions = [];
469
- for (const dependency of MISE_DEPENDENCIES) {
2787
+ const dependencies = [...MISE_DEPENDENCIES];
2788
+ if (options.forge && options.forge !== "none")
2789
+ dependencies.push(FORGE_DEPENDENCIES[options.forge]);
2790
+ for (const dependency of dependencies) {
470
2791
  const installed = canRunMise && await mise.toolCheckGlobal(miseExecutable, dependency.tool, dependency.minimumVersion);
471
2792
  if (installed) {
472
2793
  actions.push(createSetupAction(dependency.name, "ready", dependency.spec));
@@ -482,18 +2803,33 @@ async function ensureMiseDeps(miseExecutable, options = {}) {
482
2803
  async function ensurePiPlugins(options = {}) {
483
2804
  const actions = await ensurePiPackages(PI_PACKAGES, options);
484
2805
  const agentDir = options.agentDir ?? pi.agentDir(options.homeDir);
485
- const webSearch = await pi.configEnsure(join5(agentDir, "web-search.json"), (config) => ({ ...config, workflow: "auto-summary" }), options.dryRun);
2806
+ const webSearch = await pi.configEnsure(join12(agentDir, "web-search.json"), (config) => ({ ...config, workflow: "auto-summary" }), options.dryRun);
486
2807
  actions.push(getConfigSetupAction("web search settings", webSearch));
487
- const lsp = await pi.configEnsure(join5(agentDir, "pi-lsp.json"), (config) => ({
2808
+ const lsp = await pi.configEnsure(join12(agentDir, "pi-lsp.json"), (config) => ({
488
2809
  ...config,
489
2810
  progressive: { ...getRecord(config.progressive), enabled: true, inject: "none" }
490
2811
  }), options.dryRun);
491
2812
  actions.push(getConfigSetupAction("pi-lsp settings", lsp));
492
2813
  return actions;
493
2814
  }
2815
+ async function ensurePiAgents(options = {}) {
2816
+ const agentDir = options.agentDir ?? pi.agentDir(options.homeDir);
2817
+ const bundledAgentsDir = options.bundledAgentsDir ?? BUNDLED_AGENTS_DIR;
2818
+ const userConfig = await loadDiffpiConfig({ homeDir: options.homeDir });
2819
+ const entries = (await readdir3(bundledAgentsDir, { withFileTypes: true })).filter((entry) => entry.isFile() && entry.name.startsWith("diffpi-") && entry.name.endsWith(".md")).sort((left, right) => left.name.localeCompare(right.name));
2820
+ const actions = [];
2821
+ for (const entry of entries) {
2822
+ const id = basename4(entry.name, ".md").replace(/^diffpi-/, "");
2823
+ const source = await readFile10(join12(bundledAgentsDir, entry.name), "utf8");
2824
+ const content = materializeAgentModels(source, id, userConfig.config, options.availableModels);
2825
+ const result = await pi.agentEnsure(entry.name, content, agentDir, options.dryRun);
2826
+ actions.push(getConfigSetupAction(`pi agent ${id}`, result));
2827
+ }
2828
+ return actions;
2829
+ }
494
2830
  async function ensurePiSkills(miseExecutable, options = {}) {
495
2831
  const agentDir = options.agentDir ?? pi.agentDir(options.homeDir);
496
- const sharedSkillsDir = join5(options.homeDir ?? homedir4(), ".agents", "skills");
2832
+ const sharedSkillsDir = join12(options.homeDir ?? homedir8(), ".agents", "skills");
497
2833
  const actions = [];
498
2834
  for (const source of PI_SKILL_SOURCES) {
499
2835
  const missing = [];
@@ -537,6 +2873,12 @@ async function ensureMcpAdapters(miseExecutable, options = {}) {
537
2873
  } else if (options.issueTracker === "jira") {
538
2874
  servers.atlassian = { url: "https://mcp.atlassian.com/v1/mcp", auth: "oauth", protocolVersion: "auto" };
539
2875
  }
2876
+ if (options.forge === "github") {
2877
+ servers.github = { url: "https://api.githubcopilot.com/mcp/", auth: "oauth", protocolVersion: "auto" };
2878
+ } else if (options.forge === "gitlab") {
2879
+ const host = (await detectVcs(projectDir)).host || "gitlab.com";
2880
+ servers.gitlab = { url: `https://${host}/api/v4/mcp`, auth: "oauth", protocolVersion: "auto" };
2881
+ }
540
2882
  const result = await mcp.serversEnsure(servers, {
541
2883
  dryRun: options.dryRun,
542
2884
  path: mcp.globalConfigPath(options.homeDir)
@@ -550,13 +2892,81 @@ async function setupPi(options = {}) {
550
2892
  actions.push(await ensureMiseHooks(miseResult.executable, options));
551
2893
  actions.push(...await ensureMiseDeps(miseResult.executable, options));
552
2894
  actions.push(...await ensurePiPlugins(options));
2895
+ actions.push(...await ensurePiAgents(options));
553
2896
  actions.push(...await ensurePiSkills(miseResult.executable, options));
554
2897
  actions.push(...await ensureMcpAdapters(miseResult.executable, options));
2898
+ if (options.bindZedKey)
2899
+ actions.push(...await ensureZedIntegration(options));
555
2900
  return {
556
2901
  actions,
557
- restartPi: actions.some((item) => (item.status === "installed" || item.status === "updated") && (item.name.startsWith("pi package ") || item.name.startsWith("pi skill ") || item.name === "MCP configuration" || item.name === "web search settings" || item.name === "pi-lsp settings"))
2902
+ restartPi: setupRequiresRestart(actions)
558
2903
  };
559
2904
  }
2905
+ function setupRequiresRestart(actions) {
2906
+ return actions.some((item) => (item.status === "installed" || item.status === "updated") && (item.name.startsWith("pi package ") || item.name.startsWith("pi agent ") || item.name.startsWith("pi skill ") || item.name === "MCP configuration" || item.name === "web search settings" || item.name === "pi-lsp settings"));
2907
+ }
2908
+ function materializeAgentModels(content, agentId, config, availableModels) {
2909
+ const { frontmatter } = parseFrontmatter2(content.startsWith("\uFEFF") ? content.slice(1) : content);
2910
+ const profilePreferences = [...getTextList(frontmatter.model), ...getTextList(frontmatter.model_fallbacks)];
2911
+ const preferences = resolveAgentModelPreferences(agentId, profilePreferences, config);
2912
+ let selectedIndex = availableModels === undefined && preferences.length > 0 ? 0 : -1;
2913
+ let selectedModel = selectedIndex === 0 ? preferences[0] : undefined;
2914
+ if (availableModels) {
2915
+ for (const [index, preference] of preferences.entries()) {
2916
+ const match = findPreferredModel(availableModels, preference);
2917
+ if (!match)
2918
+ continue;
2919
+ selectedIndex = index;
2920
+ selectedModel = `${match.provider}/${match.id}`;
2921
+ break;
2922
+ }
2923
+ }
2924
+ const fallbacks = preferences.filter((_preference, index) => index !== selectedIndex);
2925
+ return replaceAgentModelFields(content, selectedModel, fallbacks);
2926
+ }
2927
+ function replaceAgentModelFields(content, model, fallbacks) {
2928
+ const newline = content.includes(`\r
2929
+ `) ? `\r
2930
+ ` : `
2931
+ `;
2932
+ const lines = content.replaceAll(`\r
2933
+ `, `
2934
+ `).split(`
2935
+ `);
2936
+ const closingDelimiter = lines.indexOf("---", 1);
2937
+ if (lines[0] !== "---" || closingDelimiter < 0)
2938
+ return content;
2939
+ const frontmatter = lines.slice(1, closingDelimiter).filter((line) => !/^model(?:_fallbacks)?:/.test(line));
2940
+ if (model)
2941
+ frontmatter.push(`model: ${model}`);
2942
+ if (fallbacks.length > 0)
2943
+ frontmatter.push(`model_fallbacks: ${fallbacks.join(", ")}`);
2944
+ return ["---", ...frontmatter, "---", ...lines.slice(closingDelimiter + 1)].join(newline);
2945
+ }
2946
+ async function ensureZedIntegration(options = {}) {
2947
+ if (options.dryRun) {
2948
+ const actions = [createSetupAction("Zed review task", "planned", "tasks.json")];
2949
+ if (options.bindZedKey)
2950
+ actions.push(createSetupAction("Zed review keybinding", "planned", "keymap.json"));
2951
+ return actions;
2952
+ }
2953
+ const actions = [];
2954
+ try {
2955
+ const task = await ensureZedReviewTask(options.homeDir);
2956
+ actions.push(createSetupAction("Zed review task", task.changed ? "installed" : "ready", task.path));
2957
+ } catch (error) {
2958
+ actions.push(createSetupAction("Zed review task", "skipped", error instanceof Error ? error.message : String(error)));
2959
+ }
2960
+ if (options.bindZedKey) {
2961
+ try {
2962
+ const key = await ensureZedReviewKeybinding(options.homeDir);
2963
+ actions.push(createSetupAction("Zed review keybinding", key.changed ? "installed" : "ready", key.path));
2964
+ } catch (error) {
2965
+ actions.push(createSetupAction("Zed review keybinding", "skipped", error instanceof Error ? error.message : String(error)));
2966
+ }
2967
+ }
2968
+ return actions;
2969
+ }
560
2970
  async function ensurePiPackages(packages, options) {
561
2971
  const executable = await pi.executableCheck();
562
2972
  if (!executable && !options.dryRun)
@@ -578,6 +2988,10 @@ ${source}`;
578
2988
  }
579
2989
  return actions;
580
2990
  }
2991
+ function getTextList(value) {
2992
+ const values = Array.isArray(value) ? value : typeof value === "string" ? value.split(",") : [];
2993
+ return values.filter((item) => typeof item === "string").map((item) => item.trim()).filter(Boolean);
2994
+ }
581
2995
  function getConfigSetupAction(name, result) {
582
2996
  if (!result.changed)
583
2997
  return createSetupAction(name, "ready", result.path);
@@ -596,11 +3010,13 @@ function getRecord(value) {
596
3010
  }
597
3011
 
598
3012
  // src/tools/setup.ts
599
- var setupParametersSchema = z2.object({
600
- issueTracker: z2.enum(["none", "linear", "jira"]).default("none").describe("Issue tracker MCP server to configure. Use none unless the user explicitly selects Linear or Jira.")
3013
+ var setupParametersSchema = z7.object({
3014
+ issueTracker: z7.enum(["none", "linear", "jira"]).default("none").describe("Issue tracker MCP server to configure. Use none unless the user explicitly selects Linear or Jira."),
3015
+ forge: z7.enum(["none", "github", "gitlab"]).default("none").describe("Forge to configure for /review. Installs gh or glab and registers its MCP server."),
3016
+ bindZedKey: z7.boolean().default(false).describe("Opt in to a Zed keybinding for the tuicr review task.")
601
3017
  });
602
- var setupParameters = z2.toJSONSchema(setupParametersSchema, { io: "input" });
603
- var diffpiSetupTool = defineTool2({
3018
+ var setupParameters = z7.toJSONSchema(setupParametersSchema, { io: "input" });
3019
+ var diffpiSetupTool = defineTool4({
604
3020
  name: "diffpi_setup",
605
3021
  label: "diffpi setup",
606
3022
  description: "Install or repair the @difflab/pi environment. This mutates user-level tool installations and configuration files.",
@@ -613,11 +3029,14 @@ var diffpiSetupTool = defineTool2({
613
3029
  ],
614
3030
  parameters: setupParameters,
615
3031
  executionMode: "sequential",
616
- async execute(_toolCallId, input, _signal, onUpdate) {
3032
+ async execute(_toolCallId, input, _signal, onUpdate, ctx) {
617
3033
  const params = setupParametersSchema.parse(input);
618
3034
  const result = await setupPi({
619
3035
  issueTracker: params.issueTracker,
3036
+ forge: params.forge,
3037
+ bindZedKey: params.bindZedKey,
620
3038
  installMiseHook: true,
3039
+ availableModels: ctx.modelRegistry.getAvailable(),
621
3040
  onProgress(message) {
622
3041
  onUpdate?.({ content: [{ type: "text", text: message }], details: {} });
623
3042
  }
@@ -625,7 +3044,7 @@ var diffpiSetupTool = defineTool2({
625
3044
  return formatResult(result, "Setup complete.");
626
3045
  }
627
3046
  });
628
- var diffpiValidateTool = defineTool2({
3047
+ var diffpiValidateTool = defineTool4({
629
3048
  name: "diffpi_validate",
630
3049
  label: "diffpi validate",
631
3050
  description: "Inspect the @difflab/pi environment without installing software or changing configuration files.",
@@ -637,12 +3056,15 @@ var diffpiValidateTool = defineTool2({
637
3056
  ],
638
3057
  parameters: setupParameters,
639
3058
  executionMode: "sequential",
640
- async execute(_toolCallId, input) {
3059
+ async execute(_toolCallId, input, _signal, _onUpdate, ctx) {
641
3060
  const params = setupParametersSchema.parse(input);
642
3061
  const result = await setupPi({
643
3062
  issueTracker: params.issueTracker,
3063
+ forge: params.forge,
3064
+ bindZedKey: params.bindZedKey,
644
3065
  installMiseHook: true,
645
- dryRun: true
3066
+ dryRun: true,
3067
+ availableModels: ctx.modelRegistry.getAvailable()
646
3068
  });
647
3069
  const incomplete = result.actions.some((item) => item.status === "planned");
648
3070
  return formatResult(result, incomplete ? "Setup is incomplete." : "Setup is ready.");
@@ -662,13 +3084,50 @@ ${lines.join(`
662
3084
  };
663
3085
  }
664
3086
 
3087
+ // src/tools/templates.ts
3088
+ import { defineTool as defineTool5 } from "@earendil-works/pi-coding-agent";
3089
+ import { z as z8 } from "zod";
3090
+ var templateSchema = z8.object({
3091
+ name: z8.string().min(1),
3092
+ variables: z8.record(z8.string(), z8.string()).optional(),
3093
+ homeDir: z8.string().optional()
3094
+ });
3095
+ var diffpiTemplateTool = defineTool5({
3096
+ name: "diffpi_template",
3097
+ label: "diffpi template",
3098
+ description: "Load a bundled Diffpi template or a user override and render named variables.",
3099
+ promptSnippet: "Use diffpi_template for package workflow templates",
3100
+ promptGuidelines: ["User overrides live under ~/.difflab/diffpi/templates."],
3101
+ parameters: z8.toJSONSchema(templateSchema, { io: "input" }),
3102
+ executionMode: "parallel",
3103
+ async execute(_id, input) {
3104
+ const params = templateSchema.parse(input);
3105
+ const template = await loadTemplate(params.name, { homeDir: params.homeDir });
3106
+ const content = renderTemplate(template.content, params.variables ?? {});
3107
+ return {
3108
+ content: [{ type: "text", text: content }],
3109
+ details: { name: params.name, path: template.path, source: template.source }
3110
+ };
3111
+ }
3112
+ });
3113
+
665
3114
  // src/tools/index.ts
666
- function createPiTools(pi) {
667
- return [diffpiSetupTool, diffpiValidateTool, createDiffpiReloadTool(pi)];
3115
+ function createPiTools(pi, modes) {
3116
+ return [
3117
+ diffpiSetupTool,
3118
+ diffpiValidateTool,
3119
+ createDiffpiReloadTool(pi),
3120
+ diffpiTemplateTool,
3121
+ ...createModeTools(modes),
3122
+ ...createReviewTools()
3123
+ ];
668
3124
  }
669
3125
  export {
670
3126
  createDiffpiReloadTool,
3127
+ createModeTools,
671
3128
  createPiTools,
3129
+ createReviewTools,
672
3130
  diffpiSetupTool,
3131
+ diffpiTemplateTool,
673
3132
  diffpiValidateTool
674
3133
  };