@difflab/pi 0.1.0 → 0.2.0-rc.202609170717.9cb91d1

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 (59) hide show
  1. package/README.md +32 -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 +2 -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 +2054 -182
  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 +19 -1
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +1528 -149
  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.d.ts +57 -0
  32. package/dist/review.d.ts.map +1 -0
  33. package/dist/setup.d.ts +11 -0
  34. package/dist/setup.d.ts.map +1 -1
  35. package/dist/store.d.ts +15 -0
  36. package/dist/store.d.ts.map +1 -0
  37. package/dist/tools/index.d.ts +5 -2
  38. package/dist/tools/index.d.ts.map +1 -1
  39. package/dist/tools/index.js +1763 -171
  40. package/dist/tools/modes.d.ts +4 -0
  41. package/dist/tools/modes.d.ts.map +1 -0
  42. package/dist/tools/review.d.ts +7 -0
  43. package/dist/tools/review.d.ts.map +1 -0
  44. package/dist/tools/setup.d.ts.map +1 -1
  45. package/dist/tuicr.d.ts +43 -0
  46. package/dist/tuicr.d.ts.map +1 -0
  47. package/dist/zed.d.ts +11 -0
  48. package/dist/zed.d.ts.map +1 -0
  49. package/package.json +2 -1
  50. package/skills/diffpi-setup/SKILL.md +15 -1
  51. package/skills/mode/SKILL.md +38 -0
  52. package/skills/review/SKILL.md +13 -0
  53. package/skills/review/references/workflows/address.md +6 -0
  54. package/skills/review/references/workflows/complete.md +6 -0
  55. package/skills/review/references/workflows/help.md +12 -0
  56. package/skills/review/references/workflows/merge.md +6 -0
  57. package/skills/review/references/workflows/new.md +6 -0
  58. package/skills/review/references/workflows/open.md +6 -0
  59. package/skills/review/references/workflows/publish.md +5 -0
@@ -25,83 +25,119 @@ function createDiffpiReloadTool(pi) {
25
25
  });
26
26
  }
27
27
 
28
- // src/tools/setup.ts
28
+ // src/tools/modes.ts
29
29
  import { defineTool as defineTool2 } from "@earendil-works/pi-coding-agent";
30
30
  import { z as z2 } from "zod";
31
-
32
- // src/setup.ts
33
- import { homedir as homedir4 } from "node:os";
34
- import { join as join5 } from "node:path";
35
-
36
- // 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";
40
- var mcp = {
41
- globalConfigPath(homeDir = homedir()) {
42
- return join(homeDir, ".config", "mcp", "mcp.json");
43
- },
44
- async serversEnsure(servers, options = {}) {
45
- const path = options.path ?? mcp.globalConfigPath();
46
- const currentText = await getOptionalFile(path);
47
- const current = getParsedConfig(currentText, path);
48
- const nextServers = { ...current.mcpServers };
49
- for (const [name, entry] of Object.entries(servers)) {
50
- nextServers[name] = mergeEntry(nextServers[name], entry);
51
- }
52
- const next = { ...current, mcpServers: nextServers };
53
- const changed = JSON.stringify(current) !== JSON.stringify(next);
54
- if (changed && !options.dryRun) {
55
- await mkdir(dirname(path), { recursive: true });
56
- await writeFile(path, `${JSON.stringify(next, null, 2)}
57
- `, "utf8");
58
- }
59
- return { path, changed, existed: currentText !== undefined, planned: changed && options.dryRun === true };
60
- }
61
- };
62
- function mergeEntry(current, required) {
63
- const merged = { ...current, ...required };
64
- if (current?.env || required.env)
65
- merged.env = { ...current?.env, ...required.env };
66
- return merged;
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
+ ];
67
107
  }
68
- function getParsedConfig(content, path) {
69
- if (!content?.trim())
70
- return { mcpServers: {} };
71
- try {
72
- const value = JSON.parse(content);
73
- if (!isRecord(value))
74
- throw new Error("not an object");
75
- const servers = value.mcpServers;
76
- if (servers !== undefined && !isRecord(servers))
77
- throw new Error("mcpServers is not an object");
78
- return { ...value, mcpServers: servers ?? {} };
79
- } catch {
80
- throw new Error(`Expected a valid pi-mcp-adapter configuration in ${path}.`);
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})`);
81
113
  }
82
- }
83
- async function getOptionalFile(path) {
84
- try {
85
- return await readFile(path, "utf8");
86
- } catch (error) {
87
- if (error instanceof Error && "code" in error && error.code === "ENOENT")
88
- return;
89
- throw error;
114
+ if (catalog.diagnostics.length > 0) {
115
+ lines.push("", "Skipped agent files:");
116
+ for (const diagnostic of catalog.diagnostics)
117
+ lines.push(`- ${sanitize(diagnostic)}`);
90
118
  }
119
+ lines.push("", "Inline mode applies the profile prompt, preferred available model, thinking level, and tool set.");
120
+ return lines.join(`
121
+ `);
91
122
  }
92
- function isRecord(value) {
93
- return value !== null && typeof value === "object" && !Array.isArray(value);
123
+ function sanitize(value) {
124
+ return value.replace(/[\r\n\t]+/g, " ").replace(/\s+/g, " ").trim();
94
125
  }
95
126
 
96
- // 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";
127
+ // src/tools/review.ts
128
+ import { existsSync as existsSync2 } from "node:fs";
129
+ import { mkdir as mkdir3, writeFile as writeFile2 } from "node:fs/promises";
130
+ import { join as join5 } from "node:path";
131
+ import { defineTool as defineTool3 } from "@earendil-works/pi-coding-agent";
132
+ import { z as z4 } from "zod";
133
+
134
+ // src/environment.ts
135
+ import { basename } from "node:path";
100
136
 
101
137
  // src/process.ts
102
138
  import { constants } from "node:fs";
103
139
  import { access } from "node:fs/promises";
104
- import { delimiter, join as join2 } from "node:path";
140
+ import { delimiter, join } from "node:path";
105
141
  import { spawn } from "node:child_process";
106
142
  var MAX_CAPTURED_OUTPUT_LENGTH = 65536;
107
143
  async function findExecutable(name) {
@@ -116,7 +152,7 @@ async function findExecutable(name) {
116
152
  for (const directory of (process.env.PATH ?? "").split(delimiter)) {
117
153
  if (!directory)
118
154
  continue;
119
- const candidate = join2(directory, name);
155
+ const candidate = join(directory, name);
120
156
  try {
121
157
  await access(candidate, constants.X_OK);
122
158
  return candidate;
@@ -129,18 +165,35 @@ function run(command, args, options = {}) {
129
165
  const child = spawn(command, args, {
130
166
  cwd: options.cwd,
131
167
  env: options.env ?? process.env,
132
- stdio: ["ignore", "pipe", "pipe"]
168
+ stdio: [options.input === undefined ? "ignore" : "pipe", "pipe", "pipe"]
133
169
  });
134
170
  let stdout = "";
135
171
  let stderr = "";
136
- child.stdout.on("data", (chunk) => {
137
- stdout = appendBounded(stdout, chunk.toString());
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);
138
181
  });
139
- child.stderr.on("data", (chunk) => {
140
- stderr = appendBounded(stderr, chunk.toString());
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);
141
188
  });
142
189
  child.on("error", reject);
143
- child.on("close", (code) => resolve({ code: code ?? 1, stdout, stderr }));
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);
144
197
  });
145
198
  }
146
199
  async function runChecked(command, args, options = {}) {
@@ -155,7 +208,1422 @@ function appendBounded(current, next) {
155
208
  return combined.length <= MAX_CAPTURED_OUTPUT_LENGTH ? combined : combined.slice(-MAX_CAPTURED_OUTPUT_LENGTH);
156
209
  }
157
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"
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
+ };
466
+ }
467
+ async defaultBranch() {
468
+ const result = await runChecked("gh", [
469
+ "repo",
470
+ "view",
471
+ `${this.vcs.owner}/${this.vcs.repo}`,
472
+ "--json",
473
+ "defaultBranchRef",
474
+ "--jq",
475
+ ".defaultBranchRef.name"
476
+ ]);
477
+ return requireBranchName(result.stdout, "GitHub");
478
+ }
479
+ async prDiff(id) {
480
+ return (await runChecked("gh", ["pr", "diff", String(id), ...this.repoFlag()], { capture: "unbounded" })).stdout;
481
+ }
482
+ async prChecks(id) {
483
+ return (await run("gh", ["pr", "checks", String(id), ...this.repoFlag()])).stdout;
484
+ }
485
+ async createPendingReview(id, comments, body) {
486
+ const payload = {
487
+ body,
488
+ comments: comments.map((comment) => ({
489
+ path: comment.file,
490
+ line: comment.line,
491
+ side: comment.side ?? "RIGHT",
492
+ body: comment.body
493
+ }))
494
+ };
495
+ await runChecked("gh", ["api", "--method", "POST", `/repos/${this.vcs.owner}/${this.vcs.repo}/pulls/${id}/reviews`, "--input", "-"], { input: JSON.stringify(payload) });
496
+ }
497
+ async submitReview(id, event, body) {
498
+ const pending = await runChecked("gh", [
499
+ "api",
500
+ `/repos/${this.vcs.owner}/${this.vcs.repo}/pulls/${id}/reviews`,
501
+ "--jq",
502
+ '[.[] | select(.state=="PENDING")] | last | .id'
503
+ ]);
504
+ const reviewId = pending.stdout.trim();
505
+ const endpoint = githubReviewSubmissionEndpoint(this.vcs.owner, this.vcs.repo, id, reviewId);
506
+ const args = ["api", "--method", "POST", endpoint, "-f", `event=${event}`];
507
+ if (body.trim())
508
+ args.push("-f", `body=${body}`);
509
+ await runChecked("gh", args);
510
+ }
511
+ async markReady(id) {
512
+ await runChecked("gh", ["pr", "ready", String(id), ...this.repoFlag()]);
513
+ }
514
+ async closePr(id, comment) {
515
+ const args = ["pr", "close", String(id), ...this.repoFlag()];
516
+ if (comment)
517
+ args.push("--comment", comment);
518
+ await runChecked("gh", args);
519
+ }
520
+ }
521
+
522
+ class GitlabForge {
523
+ vcs;
524
+ provider = "gitlab";
525
+ constructor(vcs) {
526
+ this.vcs = vcs;
527
+ }
528
+ project() {
529
+ return `${this.vcs.owner}/${this.vcs.repo}`;
530
+ }
531
+ async createDraftPr(options) {
532
+ await runChecked("glab", [
533
+ "mr",
534
+ "create",
535
+ "--repo",
536
+ this.project(),
537
+ "--title",
538
+ `Draft: ${options.title}`,
539
+ "--description",
540
+ options.body,
541
+ "--target-branch",
542
+ options.base,
543
+ "--source-branch",
544
+ options.head,
545
+ "--yes"
546
+ ]);
547
+ const ref = await this.viewPr(options.head);
548
+ if (!ref)
549
+ throw new Error("Draft MR created but could not be resolved.");
550
+ return ref;
551
+ }
552
+ async viewPr(idOrBranch) {
553
+ const args = ["mr", "view", idOrBranch, "--repo", this.project(), "--output", "json"];
554
+ const result = await run("glab", args);
555
+ if (result.code !== 0) {
556
+ if (isConfirmedMissingChange("gitlab", result.stderr || result.stdout))
557
+ return;
558
+ throw commandFailure("glab", args, result);
559
+ }
560
+ if (!result.stdout.trim())
561
+ throw new Error("GitLab returned an empty merge request response.");
562
+ let data;
563
+ try {
564
+ data = JSON.parse(result.stdout);
565
+ } catch {
566
+ throw new Error("Cannot parse the GitLab merge request response as JSON.");
567
+ }
568
+ if (typeof data.iid !== "number" || typeof data.title !== "string" || typeof data.web_url !== "string" || typeof data.target_branch !== "string" || typeof data.source_branch !== "string") {
569
+ throw new Error("GitLab returned an invalid merge request response.");
570
+ }
571
+ return {
572
+ number: data.iid,
573
+ title: data.title,
574
+ url: data.web_url,
575
+ isDraft: Boolean(data.draft ?? data.work_in_progress),
576
+ baseRef: data.target_branch,
577
+ headRef: data.source_branch
578
+ };
579
+ }
580
+ async defaultBranch() {
581
+ const result = await runChecked("glab", [
582
+ "api",
583
+ `projects/${encodeURIComponent(this.project())}`,
584
+ "--jq",
585
+ ".default_branch"
586
+ ]);
587
+ return requireBranchName(result.stdout, "GitLab");
588
+ }
589
+ async prDiff(id) {
590
+ return (await runChecked("glab", ["mr", "diff", String(id), "--repo", this.project()], { capture: "unbounded" })).stdout;
591
+ }
592
+ async prChecks() {
593
+ return (await run("glab", ["ci", "status", "--repo", this.project()])).stdout;
594
+ }
595
+ async createPendingReview(id, comments, body) {
596
+ const endpoint = `projects/${encodeURIComponent(this.project())}/merge_requests/${id}/draft_notes`;
597
+ if (body.trim()) {
598
+ await runChecked("glab", ["api", "--method", "POST", endpoint, "--input", "-"], {
599
+ input: JSON.stringify({ note: body })
600
+ });
601
+ }
602
+ if (comments.length === 0)
603
+ return;
604
+ const response = await runChecked("glab", [
605
+ "api",
606
+ `projects/${encodeURIComponent(this.project())}/merge_requests/${id}`
607
+ ]);
608
+ const diffRefs = parseGitlabDiffRefs(response.stdout);
609
+ for (const comment of comments) {
610
+ const payload = {
611
+ note: comment.body,
612
+ position: {
613
+ ...diffRefs,
614
+ position_type: "text",
615
+ new_path: comment.file,
616
+ old_path: comment.file,
617
+ new_line: comment.side === "LEFT" ? undefined : comment.line,
618
+ old_line: comment.side === "LEFT" ? comment.line : undefined
619
+ }
620
+ };
621
+ await runChecked("glab", ["api", "--method", "POST", endpoint, "--input", "-"], {
622
+ input: JSON.stringify(payload)
623
+ });
624
+ }
625
+ }
626
+ async submitReview(id, event, body) {
627
+ assertReviewEventSupported(this.provider, event);
628
+ const drafts = await runChecked("glab", [
629
+ "api",
630
+ `projects/${encodeURIComponent(this.project())}/merge_requests/${id}/draft_notes`
631
+ ]);
632
+ const hasDrafts = hasGitlabDraftNotes(drafts.stdout);
633
+ if (hasDrafts) {
634
+ await runChecked("glab", [
635
+ "api",
636
+ "--method",
637
+ "POST",
638
+ `projects/${encodeURIComponent(this.project())}/merge_requests/${id}/draft_notes/bulk_publish`
639
+ ]);
640
+ } else if (body.trim()) {
641
+ await runChecked("glab", ["mr", "note", String(id), "--repo", this.project(), "--message", body]);
642
+ }
643
+ if (event === "APPROVE")
644
+ await runChecked("glab", ["mr", "approve", String(id), "--repo", this.project()]);
645
+ }
646
+ async markReady(id) {
647
+ await runChecked("glab", ["mr", "update", String(id), "--repo", this.project(), "--ready"]);
648
+ }
649
+ async closePr(id, comment) {
650
+ if (comment)
651
+ await runChecked("glab", ["mr", "note", String(id), "--repo", this.project(), "--message", comment]);
652
+ await runChecked("glab", ["mr", "close", String(id), "--repo", this.project()]);
653
+ }
654
+ }
655
+ function assertReviewEventSupported(provider, event) {
656
+ if (provider === "gitlab" && event === "REQUEST_CHANGES") {
657
+ throw new Error("GitLab does not support REQUEST_CHANGES reviews; post a comment or reject the merge request manually.");
658
+ }
659
+ }
660
+ function isConfirmedMissingChange(provider, output) {
661
+ const message = output.toLowerCase();
662
+ if (provider === "github") {
663
+ return message.includes("no pull requests found for branch") || message.includes("could not find pull request") || message.includes("could not resolve to a pullrequest");
664
+ }
665
+ if (provider === "gitlab") {
666
+ return message.includes("no open merge request") || /failed to get open merge request/.test(message) && /404(?: not found)?/.test(message);
667
+ }
668
+ return false;
669
+ }
670
+ function commandFailure(command, args, result) {
671
+ const detail = result.stderr.trim() || result.stdout.trim() || `exit code ${result.code}`;
672
+ return new Error(`${command} ${args.join(" ")} failed: ${detail}`);
673
+ }
674
+ function requireBranchName(output, provider) {
675
+ const branch = output.trim();
676
+ if (!branch || branch === "null")
677
+ throw new Error(`${provider} did not return a default branch.`);
678
+ return branch;
679
+ }
680
+ function githubReviewSubmissionEndpoint(owner, repo, id, pendingReviewId) {
681
+ return pendingReviewId ? `/repos/${owner}/${repo}/pulls/${id}/reviews/${pendingReviewId}/events` : `/repos/${owner}/${repo}/pulls/${id}/reviews`;
682
+ }
683
+ function parseGitlabDiffRefs(input) {
684
+ let data;
685
+ try {
686
+ data = JSON.parse(input);
687
+ } catch {
688
+ throw new Error("Cannot create positioned GitLab draft notes: the merge request response was not valid JSON.");
689
+ }
690
+ const { base_sha, start_sha, head_sha } = data.diff_refs ?? {};
691
+ if (!base_sha || !start_sha || !head_sha) {
692
+ throw new Error("Cannot create positioned GitLab draft notes: merge request diff refs are unavailable.");
693
+ }
694
+ return { base_sha, start_sha, head_sha };
695
+ }
696
+ function hasGitlabDraftNotes(input) {
697
+ try {
698
+ const data = JSON.parse(input);
699
+ return Array.isArray(data) && data.length > 0;
700
+ } catch {
701
+ throw new Error("Cannot complete GitLab review: the draft notes response was not valid JSON.");
702
+ }
703
+ }
704
+
705
+ // src/gates.ts
706
+ var CONVENTIONAL_COMMIT = /^(feat|fix|perf|refactor|docs|chore|test|build|ci|style|revert)(\([^)]+\))?!?: .+/;
707
+ var MISE_GATES = ["format:check", "lint", "test"];
708
+ function checkConventionalSubject(subject) {
709
+ const trimmed = subject.trim();
710
+ const ok = CONVENTIONAL_COMMIT.test(trimmed);
711
+ return {
712
+ name: "conventional-subject",
713
+ status: ok ? "pass" : "warn",
714
+ detail: ok ? trimmed : `not a conventional-commit subject: "${trimmed}"`
715
+ };
716
+ }
717
+ async function runMiseGates(cwd) {
718
+ const tasks = await discoverMiseTasks(cwd);
719
+ const results = [];
720
+ for (const gate of MISE_GATES) {
721
+ const targets = tasks.get(gate) ?? [];
722
+ if (targets.length === 0) {
723
+ results.push({ name: gate, status: "skip", detail: "no mise recipe" });
724
+ continue;
725
+ }
726
+ const invocations = targets.flatMap((target, index) => index === 0 ? [target] : [":::", target]);
727
+ const result = await run("mise", ["run", ...invocations], { cwd });
728
+ results.push({
729
+ name: gate,
730
+ status: result.code === 0 ? "pass" : "fail",
731
+ detail: result.code === 0 ? "clean" : (result.stderr.trim() || result.stdout.trim()).slice(-400)
732
+ });
733
+ }
734
+ return results;
735
+ }
736
+ function ciGate(checksOutput) {
737
+ const text = checksOutput.toLowerCase();
738
+ if (!text.trim())
739
+ return { name: "ci", status: "skip", detail: "no CI output" };
740
+ if (/\bfail|error\b/.test(text))
741
+ return { name: "ci", status: "warn", detail: "CI failing" };
742
+ if (/\bpending|in progress|queued\b/.test(text))
743
+ return { name: "ci", status: "warn", detail: "CI pending" };
744
+ return { name: "ci", status: "pass", detail: "CI green" };
745
+ }
746
+ async function discoverMiseTasks(cwd) {
747
+ const result = await run("mise", ["tasks", "--json", "--all"], { cwd });
748
+ if (result.code !== 0)
749
+ return new Map;
750
+ return parseMiseTasks(result.stdout);
751
+ }
752
+ function parseMiseTasks(input) {
753
+ let tasks;
754
+ try {
755
+ tasks = JSON.parse(input);
756
+ } catch {
757
+ return new Map;
758
+ }
759
+ if (!Array.isArray(tasks))
760
+ return new Map;
761
+ const found = new Map;
762
+ for (const gate of MISE_GATES) {
763
+ const targets = tasks.flatMap((task) => {
764
+ if (typeof task.name !== "string")
765
+ return [];
766
+ return task.name === gate || task.name.endsWith(`:${gate}`) || task.aliases?.includes(gate) ? [task.name] : [];
767
+ });
768
+ if (targets.length > 0)
769
+ found.set(gate, [...new Set(targets)]);
770
+ }
771
+ return found;
772
+ }
773
+
774
+ // src/review.ts
775
+ import { join as join3 } from "node:path";
776
+ import { z as z3 } from "zod";
777
+ var severitySchema = z3.enum(["BLOCKING", "CONSIDER", "NOTE"]);
778
+ var findingSchema = z3.object({
779
+ file: z3.string().min(1),
780
+ line: z3.number().int().nonnegative(),
781
+ severity: severitySchema,
782
+ body: z3.string().min(1),
783
+ reference: z3.string().optional().default("")
784
+ });
785
+ var findingsSchema = z3.array(findingSchema);
786
+ function reviewSlug(input) {
787
+ return input.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 50);
788
+ }
789
+ function mmddyy(date = new Date) {
790
+ const mm = String(date.getMonth() + 1).padStart(2, "0");
791
+ const dd = String(date.getDate()).padStart(2, "0");
792
+ const yy = String(date.getFullYear() % 100).padStart(2, "0");
793
+ return `${mm}${dd}${yy}`;
794
+ }
795
+ function reviewRecordName(branch, date = new Date) {
796
+ return `${mmddyy(date)}-${reviewSlug(branch)}`;
797
+ }
798
+ function dedupeFindings(findings) {
799
+ const rank = { BLOCKING: 3, CONSIDER: 2, NOTE: 1 };
800
+ const byKey = new Map;
801
+ for (const finding of findings) {
802
+ const key = `${finding.file}:${finding.line}`;
803
+ const existing = byKey.get(key);
804
+ if (!existing || rank[finding.severity] > rank[existing.severity])
805
+ byKey.set(key, finding);
806
+ }
807
+ return [...byKey.values()].sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line || rank[b.severity] - rank[a.severity]);
808
+ }
809
+ function toReviewComments(findings) {
810
+ return findings.filter((finding) => finding.line > 0).map((finding) => ({
811
+ file: finding.file,
812
+ line: finding.line,
813
+ side: "RIGHT",
814
+ body: renderCommentBody(finding)
815
+ }));
816
+ }
817
+ function renderReviewDoc(input) {
818
+ const anchored = dedupeFindings(input.findings).filter((finding) => finding.line > 0);
819
+ const lines = [`# Review: ${input.title}`, "", "## Metadata"];
820
+ if (input.number !== undefined)
821
+ lines.push(`- **PR/MR**: #${input.number}${input.url ? ` — ${input.url}` : ""}`);
822
+ if (input.author)
823
+ lines.push(`- **Author**: ${input.author}`);
824
+ if (input.headRef && input.baseRef)
825
+ lines.push(`- **Branch**: ${input.headRef} → ${input.baseRef}`);
826
+ if (input.additions !== undefined)
827
+ lines.push(`- **Stats**: +${input.additions} -${input.deletions ?? 0} across ${input.changedFiles ?? 0} files`);
828
+ lines.push(`- **Reviewed**: ${input.timestamp ?? new Date().toISOString()}`, "");
829
+ if (input.overallIssues.length > 0) {
830
+ lines.push("## Overall issues", "");
831
+ for (const issue of input.overallIssues)
832
+ lines.push(`- ${issue}`);
833
+ lines.push("");
834
+ }
835
+ lines.push("## Verification", "");
836
+ for (const gate of input.gates)
837
+ lines.push(`- ${gate.name}: ${gate.status} — ${gate.detail}`);
838
+ lines.push("");
839
+ if (input.notVerified.length > 0) {
840
+ lines.push("## What was NOT verified", "");
841
+ for (const item of input.notVerified)
842
+ lines.push(`- ${item}`);
843
+ lines.push("");
844
+ }
845
+ lines.push("## Inline Comments", "");
846
+ for (const finding of anchored) {
847
+ lines.push(`### ${finding.file}:${finding.line} — ${finding.severity}`, "", finding.body, "");
848
+ if (finding.reference)
849
+ lines.push(`> **Reference:** ${finding.reference}`, "");
850
+ lines.push("---", "");
851
+ }
852
+ return `${lines.join(`
853
+ `).trimEnd()}
854
+ `;
855
+ }
856
+ function reviewWorkingDir(storeReviewsDir, slug) {
857
+ return join3(storeReviewsDir, slug);
858
+ }
859
+ function renderCommentBody(finding) {
860
+ const prefix = finding.severity === "BLOCKING" ? "**BLOCKING** " : "";
861
+ const reference = finding.reference ? `
862
+
863
+ > **Reference:** ${finding.reference}` : "";
864
+ return `${prefix}${finding.body}${reference}`;
865
+ }
866
+
867
+ // src/store.ts
868
+ import { createHash } from "node:crypto";
869
+ import { existsSync } from "node:fs";
870
+ import { mkdir as mkdir2, realpath, symlink } from "node:fs/promises";
871
+ import { homedir as homedir2 } from "node:os";
872
+ import { isAbsolute, join as join4, resolve } from "node:path";
873
+ var STORE_LINK = join4(".pi", "diffpi");
874
+ async function gitToplevel(cwd) {
875
+ const result = await run("git", ["-C", cwd, "rev-parse", "--show-toplevel"]);
876
+ const top = result.stdout.trim();
877
+ return result.code === 0 && top ? top : resolve(cwd);
878
+ }
879
+ async function computeProjectSlug(cwd) {
880
+ const root = await gitToplevel(cwd);
881
+ const remoteResult = await run("git", ["-C", root, "remote", "get-url", "origin"]);
882
+ const remote = remoteResult.code === 0 ? remoteResult.stdout.trim() : "";
883
+ const commonResult = await run("git", ["-C", root, "rev-parse", "--git-common-dir"]);
884
+ const common = commonResult.stdout.trim();
885
+ let commonPath = root;
886
+ if (commonResult.code === 0 && common) {
887
+ const resolvedCommon = isAbsolute(common) ? common : join4(root, common);
888
+ commonPath = resolve(resolvedCommon);
889
+ }
890
+ const canonicalCommon = await canonicalPath(commonPath);
891
+ const identity = remote ? `remote:${normalizeRemote(remote)}` : `git-common-dir:${canonicalCommon}`;
892
+ const name = remote ? repositoryName(remote) : basename2(resolve(canonicalCommon, "..")) || basename2(root);
893
+ const readable = name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "unnamed";
894
+ const digest = createHash("sha256").update(identity).digest("hex").slice(0, 12);
895
+ return `${readable}-${digest}`;
896
+ }
897
+ function storeGlobalRoot(homeDir = homedir2()) {
898
+ return join4(homeDir, ".difflab", "diffpi", "projects");
899
+ }
900
+ async function ensureStore(cwd, homeDir = homedir2()) {
901
+ const root = await gitToplevel(cwd);
902
+ const slug = await computeProjectSlug(root);
903
+ const dest = join4(storeGlobalRoot(homeDir), slug);
904
+ const link = join4(root, STORE_LINK);
905
+ if (existsSync(link))
906
+ return { slug, root, dest, link, linked: true };
907
+ await mkdir2(dest, { recursive: true });
908
+ await mkdir2(join4(root, ".pi"), { recursive: true });
909
+ await symlink(dest, link);
910
+ return { slug, root, dest, link, linked: true };
911
+ }
912
+ async function storeDir(cwd, homeDir = homedir2()) {
913
+ return (await ensureStore(cwd, homeDir)).dest;
914
+ }
915
+ async function reviewsDir(cwd, homeDir = homedir2()) {
916
+ const dir = join4(await storeDir(cwd, homeDir), "reviews");
917
+ await mkdir2(dir, { recursive: true });
918
+ return dir;
919
+ }
920
+ async function canonicalPath(path) {
921
+ try {
922
+ return await realpath(path);
923
+ } catch {
924
+ return resolve(path);
925
+ }
926
+ }
927
+ function normalizeRemote(remote) {
928
+ return remote.trim().replace(/\.git\/?$/i, "").replace(/\/+$/, "").toLowerCase();
929
+ }
930
+ function repositoryName(remote) {
931
+ const normalized = remote.trim().replace(/\.git\/?$/i, "").replace(/\/+$/, "");
932
+ return normalized.split(/[/\\:]/).filter(Boolean).at(-1) ?? "";
933
+ }
934
+ function basename2(path) {
935
+ const parts = resolve(path).split(/[/\\]/).filter(Boolean);
936
+ return parts.at(-1) ?? "";
937
+ }
938
+
939
+ // src/tuicr.ts
940
+ import { readFile as readFile2, realpath as realpath2 } from "node:fs/promises";
941
+ import { resolve as resolve2 } from "node:path";
942
+ async function listSessions(repo = ".") {
943
+ const result = await run("tuicr", ["review", "list", "--repo", repo]);
944
+ if (result.code !== 0 || !result.stdout.trim())
945
+ return [];
946
+ let raw;
947
+ try {
948
+ raw = JSON.parse(result.stdout);
949
+ } catch {
950
+ return [];
951
+ }
952
+ return raw.map((entry) => ({
953
+ slug: entry.slug,
954
+ kind: entry.kind,
955
+ path: entry.path,
956
+ updatedAt: entry.updated_at,
957
+ commentCount: entry.comment_count,
958
+ anchor: entry.anchor,
959
+ active: entry.active
960
+ }));
961
+ }
962
+ async function resolveSession(cwd, branch) {
963
+ return findMatchingSession(await listSessions(cwd), cwd, branch);
964
+ }
965
+ async function findMatchingSession(sessions, cwd, branch) {
966
+ const repository = await canonicalPath2(await gitToplevel(cwd));
967
+ for (const session of sessions) {
968
+ if (session.kind !== "local")
969
+ continue;
970
+ try {
971
+ const data = await readSession(session.path);
972
+ if (data.branch_name !== branch || !data.repo_path)
973
+ continue;
974
+ if (await canonicalPath2(data.repo_path) === repository)
975
+ return session;
976
+ } catch {}
977
+ }
978
+ return;
979
+ }
980
+ async function readSession(path) {
981
+ const content = await readFile2(path, "utf8");
982
+ try {
983
+ return JSON.parse(content);
984
+ } catch {
985
+ throw new Error(`Cannot parse tuicr session JSON: ${path}`);
986
+ }
987
+ }
988
+ async function launch(cwd) {
989
+ return openInNewTab(["tuicr", "-w"], { cwd, name: "tuicr" });
990
+ }
991
+ function toFindings(session) {
992
+ const comments = [];
993
+ const bodyParts = (session.review_comments ?? []).map((comment) => comment.content);
994
+ for (const [file, entry] of Object.entries(session.files ?? {})) {
995
+ const fileComments = (entry.file_comments ?? []).map((comment) => comment.content);
996
+ if (fileComments.length > 0)
997
+ bodyParts.push(`File: ${file}
998
+
999
+ ${fileComments.join(`
1000
+
1001
+ `)}`);
1002
+ for (const [lineKey, lineComments] of Object.entries(entry.line_comments ?? {})) {
1003
+ const line = Number.parseInt(lineKey, 10);
1004
+ if (!Number.isFinite(line))
1005
+ continue;
1006
+ for (const lineComment of lineComments) {
1007
+ comments.push({ file, line, side: lineComment.side === "old" ? "LEFT" : "RIGHT", body: lineComment.content });
1008
+ }
1009
+ }
1010
+ }
1011
+ return {
1012
+ comments,
1013
+ body: bodyParts.join(`
1014
+
1015
+ `)
1016
+ };
1017
+ }
1018
+ async function canonicalPath2(path) {
1019
+ try {
1020
+ return await realpath2(path);
1021
+ } catch {
1022
+ return resolve2(path);
1023
+ }
1024
+ }
1025
+
1026
+ // src/tools/review.ts
1027
+ var contextSchema = z4.object({ cwd: z4.string().optional() });
1028
+ var localSchema = contextSchema.extend({ local: z4.boolean().optional() });
1029
+ var openSchema = localSchema.extend({ title: z4.string().optional(), base: z4.string().optional() });
1030
+ var submitSchema = localSchema.extend({
1031
+ findings: findingsSchema,
1032
+ overallIssues: z4.array(z4.string()).optional(),
1033
+ notVerified: z4.array(z4.string()).optional(),
1034
+ title: z4.string().optional()
1035
+ });
1036
+ var completeSchema = contextSchema.extend({
1037
+ action: z4.enum(["accept", "reject", "close", "local"]),
1038
+ comment: z4.string().optional()
1039
+ });
1040
+ var eventSchema = localSchema.extend({ event: z4.enum(["APPROVE", "REQUEST_CHANGES", "COMMENT"]).optional() });
1041
+ var respondSchema = contextSchema.extend({
1042
+ body: z4.string().min(1),
1043
+ file: z4.string().optional(),
1044
+ line: z4.number().int().positive().optional()
1045
+ });
1046
+ function parameters(schema) {
1047
+ return z4.toJSONSchema(schema, { io: "input" });
1048
+ }
1049
+ function cwdOf(params) {
1050
+ return params.cwd ?? process.cwd();
1051
+ }
1052
+ function result(text, details = {}) {
1053
+ return { content: [{ type: "text", text }], details };
1054
+ }
1055
+ function conventionalMergeGuard(subject) {
1056
+ return checkConventionalSubject(subject);
1057
+ }
1058
+ function assertGitHubMergeReady(input) {
1059
+ let data;
1060
+ try {
1061
+ data = JSON.parse(input);
1062
+ } catch {
1063
+ throw new Error("Merge blocked: GitHub readiness response was not valid JSON.");
1064
+ }
1065
+ const blockers = [];
1066
+ if (data.state !== "OPEN")
1067
+ blockers.push(`pull request state is ${data.state ?? "unknown"}`);
1068
+ if (data.isDraft)
1069
+ blockers.push("pull request is still a draft");
1070
+ if (data.reviewDecision !== "APPROVED") {
1071
+ blockers.push(`review decision is ${data.reviewDecision || "not approved"}`);
1072
+ }
1073
+ if (data.mergeStateStatus !== "CLEAN") {
1074
+ blockers.push(`merge state is ${data.mergeStateStatus ?? "unknown"}`);
1075
+ }
1076
+ for (const check of data.statusCheckRollup ?? []) {
1077
+ const name = check.name ?? check.context ?? "unnamed check";
1078
+ if (check.__typename === "CheckRun") {
1079
+ if (check.status !== "COMPLETED")
1080
+ blockers.push(`${name} is ${check.status?.toLowerCase() ?? "pending"}`);
1081
+ else if (!["SUCCESS", "SKIPPED", "NEUTRAL"].includes(check.conclusion ?? "")) {
1082
+ blockers.push(`${name} concluded ${(check.conclusion ?? "unknown").toLowerCase()}`);
1083
+ }
1084
+ } else if (check.state !== "SUCCESS") {
1085
+ blockers.push(`${name} is ${(check.state ?? "pending").toLowerCase()}`);
1086
+ }
1087
+ }
1088
+ if (blockers.length > 0)
1089
+ throw new Error(`Merge blocked: ${blockers.join("; ")}.`);
1090
+ }
1091
+ function reviewSubmissionBody(body) {
1092
+ return body.trim() || "Inline comments only.";
1093
+ }
1094
+ function createReviewTools() {
1095
+ return [
1096
+ defineTool3({
1097
+ name: "review_context",
1098
+ label: "review context",
1099
+ description: "Read-only orientation for the current forge, environment, store, branch, PR, and tuicr session.",
1100
+ promptSnippet: "Call review_context first",
1101
+ promptGuidelines: ["Call this before every review workflow."],
1102
+ parameters: parameters(contextSchema),
1103
+ executionMode: "parallel",
1104
+ async execute(_id, input) {
1105
+ const params = contextSchema.parse(input);
1106
+ const cwd = cwdOf(params);
1107
+ const vcs = await detectVcs(cwd);
1108
+ const store = await ensureStore(cwd);
1109
+ const env = { ide: detectIde(), mux: detectMux(), shell: detectShell() };
1110
+ const forge = vcs.provider === "none" ? undefined : createForge(vcs);
1111
+ const pr = forge ? await forge.viewPr(vcs.branch) : undefined;
1112
+ const baseRef = pr?.baseRef ?? (forge ? await forge.defaultBranch() : "local");
1113
+ const session = await resolveSession(cwd, vcs.branch);
1114
+ return result([
1115
+ `Forge: ${vcs.provider}${vcs.provider === "none" ? "" : ` (${vcs.owner}/${vcs.repo})`}`,
1116
+ `Branch: ${vcs.branch} → ${baseRef}`,
1117
+ `Env: ide=${env.ide} mux=${env.mux} shell=${env.shell}`,
1118
+ `Store: ${store.link} → ${store.dest}`,
1119
+ pr ? `PR/MR: #${pr.number} ${pr.url}` : "PR/MR: none",
1120
+ session ? `tuicr: ${session.slug} (${session.commentCount} comments)` : "tuicr: none"
1121
+ ].join(`
1122
+ `), { vcs, env, store, pr, session, baseRef });
1123
+ }
1124
+ }),
1125
+ defineTool3({
1126
+ name: "review_open",
1127
+ label: "review open",
1128
+ description: "Create a draft PR/MR, or launch a local tuicr review.",
1129
+ promptSnippet: "Call review_open to start",
1130
+ promptGuidelines: ["Use local for the offline tuicr flow."],
1131
+ parameters: parameters(openSchema),
1132
+ executionMode: "sequential",
1133
+ async execute(_id, input) {
1134
+ const params = openSchema.parse(input);
1135
+ const cwd = cwdOf(params);
1136
+ await ensureStore(cwd);
1137
+ const vcs = await detectVcs(cwd);
1138
+ if (params.local || vcs.provider === "none") {
1139
+ const launched = await launch(cwd);
1140
+ const record = join5(await reviewsDir(cwd), `${reviewRecordName(vcs.branch)}.md`);
1141
+ if (!existsSync2(record))
1142
+ await writeFile2(record, `# Local review: ${vcs.branch}
1143
+ `, "utf8");
1144
+ return result(launched.launched ? `Opened tuicr (${launched.via}). Record: ${record}` : `${launched.instruction ?? `Run: ${launched.command}`}
1145
+ Record: ${record}`, { launched, record });
1146
+ }
1147
+ const forge = createForge(vcs);
1148
+ const pr = await forge.createDraftPr({
1149
+ title: params.title ?? deriveTitle(vcs.branch),
1150
+ body: "<!-- fill in intent, changes, validation -->",
1151
+ base: params.base ?? await forge.defaultBranch(),
1152
+ head: vcs.branch
1153
+ });
1154
+ return result(`Draft PR/MR created: ${pr.url}`, { pr });
1155
+ }
1156
+ }),
1157
+ defineTool3({
1158
+ name: "review_diff",
1159
+ label: "review diff",
1160
+ description: "Fetch the forge diff or the local working-tree diff.",
1161
+ promptSnippet: "Call review_diff for the code under review",
1162
+ promptGuidelines: ["Ground findings in this diff."],
1163
+ parameters: parameters(localSchema),
1164
+ executionMode: "parallel",
1165
+ async execute(_id, input) {
1166
+ const params = localSchema.parse(input);
1167
+ const cwd = cwdOf(params);
1168
+ const vcs = await detectVcs(cwd);
1169
+ if (params.local || vcs.provider === "none") {
1170
+ const diff = await run("git", ["-C", cwd, "diff", "HEAD"]);
1171
+ return result(diff.stdout || "No working-tree changes.", { diff: diff.stdout });
1172
+ }
1173
+ const pr = await createForge(vcs).viewPr(vcs.branch);
1174
+ if (!pr)
1175
+ return result("No open PR/MR. Run review_open first.");
1176
+ const diff = await createForge(vcs).prDiff(pr.number);
1177
+ return result(diff || "Empty diff.", { diff, pr });
1178
+ }
1179
+ }),
1180
+ defineTool3({
1181
+ name: "review_gates",
1182
+ label: "review gates",
1183
+ description: "Run format, lint, test, conventional-subject, and available CI checks.",
1184
+ promptSnippet: "Call review_gates before submitting findings",
1185
+ promptGuidelines: ["Report skipped gates as skipped."],
1186
+ parameters: parameters(localSchema),
1187
+ executionMode: "parallel",
1188
+ async execute(_id, input) {
1189
+ const params = localSchema.parse(input);
1190
+ const cwd = cwdOf(params);
1191
+ const vcs = await detectVcs(cwd);
1192
+ const gates = await runMiseGates(cwd);
1193
+ const subject = (await run("git", ["-C", cwd, "log", "-1", "--format=%s"])).stdout.trim();
1194
+ if (subject)
1195
+ gates.push(checkConventionalSubject(subject));
1196
+ if (!params.local && vcs.provider !== "none") {
1197
+ const forge = createForge(vcs);
1198
+ const pr = await forge.viewPr(vcs.branch);
1199
+ if (pr)
1200
+ gates.push(ciGate(await forge.prChecks(pr.number)));
1201
+ }
1202
+ return result(gates.map((gate) => `- ${gate.name}: ${gate.status} — ${gate.detail}`).join(`
1203
+ `), {
1204
+ results: gates
1205
+ });
1206
+ }
1207
+ }),
1208
+ defineTool3({
1209
+ name: "review_submit",
1210
+ label: "review submit",
1211
+ description: "Render findings to the shared review store and create a pending forge review unless local.",
1212
+ promptSnippet: "Call review_submit with the findings JSON",
1213
+ promptGuidelines: ["Use concrete file and line values."],
1214
+ parameters: parameters(submitSchema),
1215
+ executionMode: "sequential",
1216
+ async execute(_id, input) {
1217
+ const params = submitSchema.parse(input);
1218
+ const cwd = cwdOf(params);
1219
+ await ensureStore(cwd);
1220
+ const vcs = await detectVcs(cwd);
1221
+ const findings = dedupeFindings(params.findings);
1222
+ const slug = reviewSlug(params.title ?? vcs.branch) || "review";
1223
+ const dir = reviewWorkingDir(await reviewsDir(cwd), slug);
1224
+ await mkdir3(dir, { recursive: true });
1225
+ const gates = await runMiseGates(cwd);
1226
+ const forge = !params.local && vcs.provider !== "none" ? createForge(vcs) : undefined;
1227
+ const pr = forge ? await forge.viewPr(vcs.branch) : undefined;
1228
+ const baseRef = pr?.baseRef ?? (forge ? await forge.defaultBranch() : "local");
1229
+ const docPath = join5(dir, "new-review.md");
1230
+ await writeFile2(docPath, renderReviewDoc({
1231
+ title: params.title ?? vcs.branch,
1232
+ headRef: vcs.branch,
1233
+ baseRef,
1234
+ findings,
1235
+ overallIssues: params.overallIssues ?? [],
1236
+ gates,
1237
+ notVerified: params.notVerified ?? []
1238
+ }), "utf8");
1239
+ if (!forge)
1240
+ return result(`Local review written: ${docPath}`, { docPath, count: findings.length });
1241
+ if (!pr)
1242
+ return result(`No open PR/MR. Review written: ${docPath}`, { docPath });
1243
+ await forge.createPendingReview(pr.number, toReviewComments(findings), reviewSubmissionBody((params.overallIssues ?? []).join(`
1244
+ `)));
1245
+ return result(`Pending review posted to #${pr.number}. Doc: ${docPath}`, {
1246
+ docPath,
1247
+ pr,
1248
+ count: findings.length
1249
+ });
1250
+ }
1251
+ }),
1252
+ defineTool3({
1253
+ name: "review_comments",
1254
+ label: "review comments",
1255
+ description: "Read unresolved local tuicr comments. Forge thread retrieval is delegated to the forge MCP when available.",
1256
+ promptSnippet: "Call review_comments before addressing findings",
1257
+ promptGuidelines: ["Use forge MCP thread tools when the review is remote."],
1258
+ parameters: parameters(localSchema),
1259
+ executionMode: "parallel",
1260
+ async execute(_id, input) {
1261
+ const params = localSchema.parse(input);
1262
+ const cwd = cwdOf(params);
1263
+ const vcs = await detectVcs(cwd);
1264
+ const session = await resolveSession(cwd, vcs.branch);
1265
+ if (!session)
1266
+ return result("No tuicr session found.");
1267
+ const normalized = toFindings(await readSession(session.path));
1268
+ return result(normalized.comments.map((comment) => `${comment.file}:${comment.line} — ${comment.body}`).join(`
1269
+ `) || "No comments.", { session, comments: normalized.comments });
1270
+ }
1271
+ }),
1272
+ defineTool3({
1273
+ name: "review_respond",
1274
+ label: "review respond",
1275
+ description: "Append a response to the local review record; remote responses should use the forge MCP thread tool.",
1276
+ promptSnippet: "Call review_respond after addressing a local comment",
1277
+ promptGuidelines: ["For remote reviews, prefer the forge MCP response and resolve tools."],
1278
+ parameters: parameters(respondSchema),
1279
+ executionMode: "sequential",
1280
+ async execute(_id, input) {
1281
+ const params = respondSchema.parse(input);
1282
+ const cwd = cwdOf(params);
1283
+ const path = join5(await reviewsDir(cwd), `${reviewRecordName((await detectVcs(cwd)).branch)}.md`);
1284
+ await mkdir3(join5(path, ".."), { recursive: true });
1285
+ await writeFile2(path, `
1286
+ ## Response${params.file ? ` — ${params.file}:${params.line ?? 1}` : ""}
1287
+
1288
+ ${params.body}
1289
+ `, { encoding: "utf8", flag: "a" });
1290
+ return result(`Response recorded: ${path}`, { path });
1291
+ }
1292
+ }),
1293
+ defineTool3({
1294
+ name: "review_publish",
1295
+ label: "review publish",
1296
+ description: "Mark a draft ready and submit its pending forge review, optionally publishing a local tuicr session.",
1297
+ promptSnippet: "Call review_publish to publish",
1298
+ promptGuidelines: ["Pass local to publish a tuicr session first."],
1299
+ parameters: parameters(eventSchema),
1300
+ executionMode: "sequential",
1301
+ async execute(_id, input) {
1302
+ const params = eventSchema.parse(input);
1303
+ const cwd = cwdOf(params);
1304
+ const vcs = await detectVcs(cwd);
1305
+ if (vcs.provider === "none")
1306
+ return result("No forge detected; local review remains in the shared store.");
1307
+ const forge = createForge(vcs);
1308
+ const pr = await forge.viewPr(vcs.branch);
1309
+ if (!pr)
1310
+ return result("No open PR/MR for this branch.");
1311
+ const event = params.event ?? "COMMENT";
1312
+ assertReviewEventSupported(forge.provider, event);
1313
+ let body = "";
1314
+ if (params.local) {
1315
+ const session = await resolveSession(cwd, vcs.branch);
1316
+ if (!session)
1317
+ return result("No tuicr session to publish.");
1318
+ const normalized = toFindings(await readSession(session.path));
1319
+ body = reviewSubmissionBody(normalized.body);
1320
+ await forge.createPendingReview(pr.number, normalized.comments, body);
1321
+ }
1322
+ if (pr.isDraft)
1323
+ await forge.markReady(pr.number);
1324
+ await forge.submitReview(pr.number, event, body);
1325
+ return result(`Published #${pr.number} (${event}).`, { pr, event });
1326
+ }
1327
+ }),
1328
+ defineTool3({
1329
+ name: "review_complete",
1330
+ label: "review complete",
1331
+ description: "Approve, request changes, close, or archive a review. This never merges.",
1332
+ promptSnippet: "Call review_complete to finish without merging",
1333
+ promptGuidelines: ["Use review_merge separately for merging."],
1334
+ parameters: parameters(completeSchema),
1335
+ executionMode: "sequential",
1336
+ async execute(_id, input) {
1337
+ const params = completeSchema.parse(input);
1338
+ const cwd = cwdOf(params);
1339
+ const vcs = await detectVcs(cwd);
1340
+ if (params.action === "local") {
1341
+ const source = await resolveSession(cwd, vcs.branch);
1342
+ const dest = uniqueRecordPath(await reviewsDir(cwd), reviewRecordName(vcs.branch));
1343
+ if (source) {
1344
+ const normalized = toFindings(await readSession(source.path));
1345
+ await writeFile2(dest, `# Completed review: ${vcs.branch}
1346
+
1347
+ ${normalized.body}
1348
+
1349
+ ${normalized.comments.map((comment) => `- ${comment.file}:${comment.line} — ${comment.body}`).join(`
1350
+ `)}
1351
+ `, "utf8");
1352
+ } else
1353
+ await writeFile2(dest, `# Completed review: ${vcs.branch}
1354
+ `, "utf8");
1355
+ return result(`Local review archived: ${dest}`, { dest });
1356
+ }
1357
+ if (vcs.provider === "none")
1358
+ return result("No forge detected. Use action=local for a local review.");
1359
+ const forge = createForge(vcs);
1360
+ const pr = await forge.viewPr(vcs.branch);
1361
+ if (!pr)
1362
+ return result("No open PR/MR for this branch.");
1363
+ if (params.action === "accept") {
1364
+ if (pr.isDraft)
1365
+ await forge.markReady(pr.number);
1366
+ await forge.submitReview(pr.number, "APPROVE", params.comment ?? "Approved.");
1367
+ return result(`Approved #${pr.number}. Merge separately with review_merge.`, { pr });
1368
+ }
1369
+ if (params.action === "reject") {
1370
+ await forge.submitReview(pr.number, "REQUEST_CHANGES", params.comment ?? "Requesting changes.");
1371
+ return result(`Requested changes on #${pr.number}.`, { pr });
1372
+ }
1373
+ await forge.closePr(pr.number, params.comment);
1374
+ return result(`Closed #${pr.number}.`, { pr });
1375
+ }
1376
+ }),
1377
+ defineTool3({
1378
+ name: "review_merge",
1379
+ label: "review merge",
1380
+ description: "Squash-merge an approved GitHub PR after checking its conventional subject.",
1381
+ promptSnippet: "Call review_merge only after review_complete accept",
1382
+ promptGuidelines: ["This is intentionally GitHub-only until GitLab merge support is added."],
1383
+ parameters: parameters(contextSchema.extend({ subject: z4.string().optional() })),
1384
+ executionMode: "sequential",
1385
+ async execute(_id, input) {
1386
+ const params = contextSchema.extend({ subject: z4.string().optional() }).parse(input);
1387
+ const cwd = cwdOf(params);
1388
+ const vcs = await detectVcs(cwd);
1389
+ if (vcs.provider !== "github")
1390
+ return result("review_merge currently supports GitHub only.");
1391
+ const pr = await createForge(vcs).viewPr(vcs.branch);
1392
+ if (!pr)
1393
+ return result("No open PR/MR for this branch.");
1394
+ const subject = params.subject ?? pr.title;
1395
+ const guard = conventionalMergeGuard(subject);
1396
+ if (guard.status !== "pass") {
1397
+ return result(`Merge blocked: ${guard.detail}`, { pr, guard });
1398
+ }
1399
+ const readiness = await runChecked("gh", [
1400
+ "pr",
1401
+ "view",
1402
+ String(pr.number),
1403
+ "--repo",
1404
+ `${vcs.owner}/${vcs.repo}`,
1405
+ "--json",
1406
+ "isDraft,state,reviewDecision,mergeStateStatus,statusCheckRollup"
1407
+ ], { capture: "unbounded" });
1408
+ assertGitHubMergeReady(readiness.stdout);
1409
+ await runChecked("gh", [
1410
+ "pr",
1411
+ "merge",
1412
+ String(pr.number),
1413
+ "--repo",
1414
+ `${vcs.owner}/${vcs.repo}`,
1415
+ "--squash",
1416
+ "--subject",
1417
+ subject
1418
+ ]);
1419
+ return result(`Merged #${pr.number} with subject: ${subject}.`, { pr, guard });
1420
+ }
1421
+ }),
1422
+ defineTool3({
1423
+ name: "review_launch",
1424
+ label: "review launch",
1425
+ description: "Open tuicr in a mux tab, configure its Zed task, or print the command.",
1426
+ promptSnippet: "Call review_launch for the interactive tuicr TUI",
1427
+ promptGuidelines: ["Show the returned command when launch cannot open a tab."],
1428
+ parameters: parameters(contextSchema),
1429
+ executionMode: "sequential",
1430
+ async execute(_id, input) {
1431
+ const params = contextSchema.parse(input);
1432
+ const launchResult = await launch(cwdOf(params));
1433
+ return result(launchResult.launched ? `Opened tuicr (${launchResult.via}).` : launchResult.instruction ?? `Run: ${launchResult.command}`, {
1434
+ launch: launchResult
1435
+ });
1436
+ }
1437
+ })
1438
+ ];
1439
+ }
1440
+ function deriveTitle(branch) {
1441
+ return branch.replace(/^(feature|feat|fix|bug|chore)\//, "").replace(/^eng-\d+-/i, "").replace(/[-_]+/g, " ").replace(/^\w/, (char) => char.toUpperCase());
1442
+ }
1443
+ function uniqueRecordPath(dir, base) {
1444
+ let path = join5(dir, `${base}.md`);
1445
+ let count = 2;
1446
+ while (existsSync2(path))
1447
+ path = join5(dir, `${base}-${count++}.md`);
1448
+ return path;
1449
+ }
1450
+
1451
+ // src/tools/setup.ts
1452
+ import { defineTool as defineTool4 } from "@earendil-works/pi-coding-agent";
1453
+ import { z as z6 } from "zod";
1454
+
1455
+ // src/setup.ts
1456
+ import { parseFrontmatter as parseFrontmatter2 } from "@earendil-works/pi-coding-agent";
1457
+ import { readdir as readdir2, readFile as readFile6 } from "node:fs/promises";
1458
+ import { homedir as homedir7 } from "node:os";
1459
+ import { basename as basename4, join as join11 } from "node:path";
1460
+
1461
+ // src/assets.ts
1462
+ import { existsSync as existsSync3 } from "node:fs";
1463
+ import { dirname as dirname2, join as join6 } from "node:path";
1464
+ import { fileURLToPath } from "node:url";
1465
+ function resolveBundledAgentsDir(moduleUrl = import.meta.url) {
1466
+ const moduleDir = dirname2(fileURLToPath(moduleUrl));
1467
+ const candidates = [
1468
+ join6(moduleDir, "agents"),
1469
+ join6(moduleDir, "..", "agents"),
1470
+ join6(moduleDir, "..", "..", "agents")
1471
+ ];
1472
+ return candidates.find((path) => existsSync3(path)) ?? candidates[1];
1473
+ }
1474
+
1475
+ // src/config.ts
1476
+ import { parseFrontmatter } from "@earendil-works/pi-coding-agent";
1477
+ import { z as z5 } from "zod";
1478
+ import { homedir as homedir3 } from "node:os";
1479
+ import { join as join7 } from "node:path";
1480
+
1481
+ // src/fsx.ts
1482
+ import { readdir, readFile as readFile3 } from "node:fs/promises";
1483
+ async function readTextIfExists(path) {
1484
+ try {
1485
+ return await readFile3(path, "utf8");
1486
+ } catch (error) {
1487
+ if (isMissingPath(error))
1488
+ return;
1489
+ throw error;
1490
+ }
1491
+ }
1492
+ function isMissingPath(error) {
1493
+ return error instanceof Error && "code" in error && error.code === "ENOENT";
1494
+ }
1495
+
1496
+ // src/config.ts
1497
+ var modelReferenceSchema = z5.string().trim().min(1);
1498
+ var agentConfigSchema = z5.object({
1499
+ models: z5.array(modelReferenceSchema).optional()
1500
+ }).strict();
1501
+ var diffpiConfigSchema = z5.object({
1502
+ agents: z5.record(z5.string(), agentConfigSchema).optional()
1503
+ }).strict();
1504
+ function diffpiConfigPaths(homeDir = homedir3()) {
1505
+ const directory = join7(homeDir, ".difflab", "diffpi");
1506
+ return {
1507
+ yaml: join7(directory, "config.yaml"),
1508
+ json: join7(directory, "config.json")
1509
+ };
1510
+ }
1511
+ async function loadDiffpiConfig(options = {}) {
1512
+ const paths = diffpiConfigPaths(options.homeDir);
1513
+ for (const [format, path] of [
1514
+ ["yaml", paths.yaml],
1515
+ ["json", paths.json]
1516
+ ]) {
1517
+ const content = await readTextIfExists(path);
1518
+ if (content === undefined)
1519
+ continue;
1520
+ try {
1521
+ const value = format === "yaml" ? parseYamlConfig(content) : JSON.parse(content);
1522
+ return { config: diffpiConfigSchema.parse(value ?? {}), path };
1523
+ } catch (error) {
1524
+ const reason = error instanceof Error ? error.message : String(error);
1525
+ throw new Error(`Invalid Diffpi config at ${path}: ${reason}`, { cause: error });
1526
+ }
1527
+ }
1528
+ return { config: {} };
1529
+ }
1530
+ function resolveAgentModelPreferences(agentId, profilePreferences, config) {
1531
+ const override = config.agents?.[agentId];
1532
+ if (override && Object.hasOwn(override, "models"))
1533
+ return [...override.models ?? []];
1534
+ return [...profilePreferences];
1535
+ }
1536
+ function findPreferredModel(models, preference) {
1537
+ const normalizedPreference = normalizeModelReference(preference);
1538
+ const exactReference = models.find((model) => normalizeModelReference(`${model.provider}/${model.id}`) === normalizedPreference);
1539
+ if (exactReference)
1540
+ return exactReference;
1541
+ const idPreference = preference.includes("/") ? preference.slice(preference.indexOf("/") + 1) : preference;
1542
+ const normalizedIdPreference = normalizeModelReference(idPreference);
1543
+ const exactId = models.find((model) => normalizeModelReference(model.id) === normalizedIdPreference);
1544
+ if (exactId)
1545
+ return exactId;
1546
+ const preferenceTokens = normalizedIdPreference.split("-").filter(Boolean);
1547
+ return models.find((model) => {
1548
+ const modelTokens = new Set(normalizeModelReference(model.id).split("-").filter(Boolean));
1549
+ return preferenceTokens.every((token) => modelTokens.has(token));
1550
+ });
1551
+ }
1552
+ function parseYamlConfig(content) {
1553
+ const document = content.replace(/^\uFEFF/, "").replace(/^---[^\S\r\n]*(?:#.*)?(?:\r?\n|$)/, "");
1554
+ return parseFrontmatter(`---
1555
+ ${document}
1556
+ ---
1557
+ `).frontmatter;
1558
+ }
1559
+ function normalizeModelReference(value) {
1560
+ return value.toLowerCase().replace(/^~/, "").replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
1561
+ }
1562
+
1563
+ // src/mcp.ts
1564
+ import { mkdir as mkdir4, readFile as readFile4, writeFile as writeFile3 } from "node:fs/promises";
1565
+ import { homedir as homedir4 } from "node:os";
1566
+ import { dirname as dirname3, join as join8 } from "node:path";
1567
+ var mcp = {
1568
+ globalConfigPath(homeDir = homedir4()) {
1569
+ return join8(homeDir, ".config", "mcp", "mcp.json");
1570
+ },
1571
+ async serversEnsure(servers, options = {}) {
1572
+ const path = options.path ?? mcp.globalConfigPath();
1573
+ const currentText = await getOptionalFile(path);
1574
+ const current = getParsedConfig(currentText, path);
1575
+ const nextServers = { ...current.mcpServers };
1576
+ for (const [name, entry] of Object.entries(servers)) {
1577
+ nextServers[name] = mergeEntry(nextServers[name], entry);
1578
+ }
1579
+ const next = { ...current, mcpServers: nextServers };
1580
+ const changed = JSON.stringify(current) !== JSON.stringify(next);
1581
+ if (changed && !options.dryRun) {
1582
+ await mkdir4(dirname3(path), { recursive: true });
1583
+ await writeFile3(path, `${JSON.stringify(next, null, 2)}
1584
+ `, "utf8");
1585
+ }
1586
+ return { path, changed, existed: currentText !== undefined, planned: changed && options.dryRun === true };
1587
+ }
1588
+ };
1589
+ function mergeEntry(current, required) {
1590
+ const merged = { ...current, ...required };
1591
+ if (current?.env || required.env)
1592
+ merged.env = { ...current?.env, ...required.env };
1593
+ return merged;
1594
+ }
1595
+ function getParsedConfig(content, path) {
1596
+ if (!content?.trim())
1597
+ return { mcpServers: {} };
1598
+ try {
1599
+ const value = JSON.parse(content);
1600
+ if (!isRecord(value))
1601
+ throw new Error("not an object");
1602
+ const servers = value.mcpServers;
1603
+ if (servers !== undefined && !isRecord(servers))
1604
+ throw new Error("mcpServers is not an object");
1605
+ return { ...value, mcpServers: servers ?? {} };
1606
+ } catch {
1607
+ throw new Error(`Expected a valid pi-mcp-adapter configuration in ${path}.`);
1608
+ }
1609
+ }
1610
+ async function getOptionalFile(path) {
1611
+ try {
1612
+ return await readFile4(path, "utf8");
1613
+ } catch (error) {
1614
+ if (error instanceof Error && "code" in error && error.code === "ENOENT")
1615
+ return;
1616
+ throw error;
1617
+ }
1618
+ }
1619
+ function isRecord(value) {
1620
+ return value !== null && typeof value === "object" && !Array.isArray(value);
1621
+ }
1622
+
158
1623
  // src/mise.ts
1624
+ import { mkdir as mkdir5, readFile as readFile5, writeFile as writeFile4 } from "node:fs/promises";
1625
+ import { homedir as homedir5 } from "node:os";
1626
+ import { basename as basename3, dirname as dirname4, join as join9 } from "node:path";
159
1627
  var MISE_HOOK_START = "# >>> @difflab/pi mise >>>";
160
1628
  var MISE_HOOK_END = "# <<< @difflab/pi mise <<<";
161
1629
  var mise = {
@@ -163,11 +1631,11 @@ var mise = {
163
1631
  return findExecutable(name);
164
1632
  },
165
1633
  async install(options = {}) {
166
- const homeDir = options.homeDir ?? homedir2();
1634
+ const homeDir = options.homeDir ?? homedir5();
167
1635
  const platform = options.platform ?? process.platform;
168
1636
  if (platform === "win32")
169
1637
  throw new Error("Automatic mise installation supports macOS and Linux only.");
170
- const installedPath = join3(homeDir, ".local", "bin", "mise");
1638
+ const installedPath = join9(homeDir, ".local", "bin", "mise");
171
1639
  if (options.dryRun)
172
1640
  return installedPath;
173
1641
  await runChecked("sh", ["-c", "curl -fsSL https://mise.run | sh"]);
@@ -177,8 +1645,8 @@ var mise = {
177
1645
  return executable;
178
1646
  },
179
1647
  async hookEnsure(executable, options = {}) {
180
- const homeDir = options.homeDir ?? homedir2();
181
- const hook = getShellHook(basename(options.shell ?? process.env.SHELL ?? ""), executable, homeDir);
1648
+ const homeDir = options.homeDir ?? homedir5();
1649
+ const hook = getShellHook(basename3(options.shell ?? process.env.SHELL ?? ""), executable, homeDir);
182
1650
  const current = await getOptionalFile2(hook.path);
183
1651
  if (current.includes(MISE_HOOK_START))
184
1652
  return { path: hook.path, changed: false, planned: false };
@@ -187,8 +1655,8 @@ var mise = {
187
1655
  const separator = current.length === 0 || current.endsWith(`
188
1656
  `) ? "" : `
189
1657
  `;
190
- await mkdir2(dirname2(hook.path), { recursive: true });
191
- await writeFile2(hook.path, `${current}${separator}${hook.content}`, "utf8");
1658
+ await mkdir5(dirname4(hook.path), { recursive: true });
1659
+ await writeFile4(hook.path, `${current}${separator}${hook.content}`, "utf8");
192
1660
  return { path: hook.path, changed: true, planned: false };
193
1661
  },
194
1662
  async toolCheckGlobal(executable, tool, minimumVersion) {
@@ -205,7 +1673,7 @@ var mise = {
205
1673
  async toolInstallLocal(executable, specification, cwd = process.cwd()) {
206
1674
  await runChecked(executable, ["use", "--path", cwd, specification], { cwd });
207
1675
  },
208
- async toolUpdateAllGlobal(executable, homeDir = homedir2()) {
1676
+ async toolUpdateAllGlobal(executable, homeDir = homedir5()) {
209
1677
  await runChecked(executable, ["upgrade"], { cwd: homeDir });
210
1678
  }
211
1679
  };
@@ -214,7 +1682,7 @@ function getShellHook(shell, executable, homeDir) {
214
1682
  switch (shell.toLowerCase()) {
215
1683
  case "zsh":
216
1684
  return {
217
- path: join3(homeDir, ".zshrc"),
1685
+ path: join9(homeDir, ".zshrc"),
218
1686
  content: `${MISE_HOOK_START}
219
1687
  eval "$(${command} activate zsh)"
220
1688
  ${MISE_HOOK_END}
@@ -222,7 +1690,7 @@ ${MISE_HOOK_END}
222
1690
  };
223
1691
  case "fish":
224
1692
  return {
225
- path: join3(homeDir, ".config", "fish", "config.fish"),
1693
+ path: join9(homeDir, ".config", "fish", "config.fish"),
226
1694
  content: `${MISE_HOOK_START}
227
1695
  ${command} activate fish | source
228
1696
  ${MISE_HOOK_END}
@@ -231,7 +1699,7 @@ ${MISE_HOOK_END}
231
1699
  case "nu":
232
1700
  case "nushell":
233
1701
  return {
234
- path: join3(homeDir, ".config", "nushell", "config.nu"),
1702
+ path: join9(homeDir, ".config", "nushell", "config.nu"),
235
1703
  content: `${MISE_HOOK_START}
236
1704
  let mise_bin = ${command}
237
1705
  let mise_path = $nu.default-config-dir | path join mise.nu
@@ -242,7 +1710,7 @@ ${MISE_HOOK_END}
242
1710
  };
243
1711
  case "xonsh":
244
1712
  return {
245
- path: join3(homeDir, ".xonshrc"),
1713
+ path: join9(homeDir, ".xonshrc"),
246
1714
  content: `${MISE_HOOK_START}
247
1715
  execx($(${command} activate xonsh))
248
1716
  ${MISE_HOOK_END}
@@ -250,7 +1718,7 @@ ${MISE_HOOK_END}
250
1718
  };
251
1719
  case "elvish":
252
1720
  return {
253
- path: join3(homeDir, ".config", "elvish", "rc.elv"),
1721
+ path: join9(homeDir, ".config", "elvish", "rc.elv"),
254
1722
  content: `${MISE_HOOK_START}
255
1723
  var mise: = (ns [&])
256
1724
  eval (${command} activate elvish | slurp) &ns=$mise: &on-end={|ns| set mise: = $ns }
@@ -261,7 +1729,7 @@ ${MISE_HOOK_END}
261
1729
  case "pwsh":
262
1730
  case "powershell":
263
1731
  return {
264
- path: join3(homeDir, ".config", "powershell", "Microsoft.PowerShell_profile.ps1"),
1732
+ path: join9(homeDir, ".config", "powershell", "Microsoft.PowerShell_profile.ps1"),
265
1733
  content: `${MISE_HOOK_START}
266
1734
  (& ${command} activate pwsh) | Out-String | Invoke-Expression
267
1735
  ${MISE_HOOK_END}
@@ -270,7 +1738,7 @@ ${MISE_HOOK_END}
270
1738
  case "bash":
271
1739
  default:
272
1740
  return {
273
- path: join3(homeDir, ".bashrc"),
1741
+ path: join9(homeDir, ".bashrc"),
274
1742
  content: `${MISE_HOOK_START}
275
1743
  eval "$(${command} activate bash)"
276
1744
  ${MISE_HOOK_END}
@@ -309,7 +1777,7 @@ function isVersionAtLeast(version, minimumVersion) {
309
1777
  }
310
1778
  async function getOptionalFile2(path) {
311
1779
  try {
312
- return await readFile2(path, "utf8");
1780
+ return await readFile5(path, "utf8");
313
1781
  } catch (error) {
314
1782
  if (error instanceof Error && "code" in error && error.code === "ENOENT")
315
1783
  return "";
@@ -321,79 +1789,86 @@ function getShellQuoted(value) {
321
1789
  }
322
1790
 
323
1791
  // 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";
1792
+ import { mkdir as mkdir6, writeFile as writeFile5 } from "node:fs/promises";
1793
+ import { homedir as homedir6 } from "node:os";
1794
+ import { dirname as dirname5, join as join10 } from "node:path";
327
1795
  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))
1796
+ executableCheck: findPiExecutable,
1797
+ packageList: listPiPackages,
1798
+ packageCheck: hasPiPackage,
1799
+ packageInstall: installPiPackage,
1800
+ agentDir: resolvePiAgentDir,
1801
+ agentEnsure: ensurePiAgent,
1802
+ skillCheckGlobal: checkGlobalPiSkill,
1803
+ skillInstallGlobal: installGlobalPiSkills,
1804
+ configEnsure: ensurePiConfig
1805
+ };
1806
+ async function ensurePiAgent(filename, content, agentDir = resolvePiAgentDir(), dryRun = false) {
1807
+ const path = join10(agentDir, "agents", filename);
1808
+ const currentText = await readTextIfExists(path);
1809
+ const changed = currentText !== content;
1810
+ if (changed && !dryRun) {
1811
+ await mkdir6(dirname5(path), { recursive: true });
1812
+ await writeFile5(path, content, "utf8");
1813
+ }
1814
+ return { path, changed, existed: currentText !== undefined, planned: changed && dryRun };
1815
+ }
1816
+ async function checkGlobalPiSkill(name, agentDir = resolvePiAgentDir(), sharedSkillsDir = join10(homedir6(), ".agents", "skills")) {
1817
+ const roots = [join10(agentDir, "skills"), sharedSkillsDir];
1818
+ for (const root of roots) {
1819
+ if (await readTextIfExists(join10(root, name, "SKILL.md")) !== undefined)
336
1820
  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
1821
  }
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"));
1822
+ return false;
386
1823
  }
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;
1824
+ async function installGlobalPiSkills(miseExecutable, source, names) {
1825
+ const selection = names.flatMap((name) => ["--skill", name]);
1826
+ await runChecked(miseExecutable, [
1827
+ "x",
1828
+ "node@22",
1829
+ "--",
1830
+ "npx",
1831
+ "-y",
1832
+ "skills",
1833
+ "add",
1834
+ source,
1835
+ ...selection,
1836
+ "--global",
1837
+ "--agent",
1838
+ "pi",
1839
+ "--yes"
1840
+ ]);
1841
+ }
1842
+ async function ensurePiConfig(path, update, dryRun = false) {
1843
+ const currentText = await readTextIfExists(path);
1844
+ const current = parseJsonObject(currentText, path);
1845
+ const next = update(current);
1846
+ const changed = JSON.stringify(current) !== JSON.stringify(next);
1847
+ if (changed && !dryRun) {
1848
+ await mkdir6(dirname5(path), { recursive: true });
1849
+ await writeFile5(path, `${JSON.stringify(next, null, 2)}
1850
+ `, "utf8");
394
1851
  }
1852
+ return { path, changed, existed: currentText !== undefined, planned: changed && dryRun };
1853
+ }
1854
+ async function findPiExecutable() {
1855
+ return findExecutable("pi");
1856
+ }
1857
+ async function listPiPackages(executable) {
1858
+ return (await runChecked(executable, ["list"])).stdout;
395
1859
  }
396
- function getParsedObject(content, path) {
1860
+ function hasPiPackage(listOutput, source) {
1861
+ if (listOutput.includes(source))
1862
+ return true;
1863
+ return source.startsWith("https://") && listOutput.includes(source.slice("https://".length));
1864
+ }
1865
+ async function installPiPackage(executable, source) {
1866
+ await runChecked(executable, ["install", source]);
1867
+ }
1868
+ function resolvePiAgentDir(homeDir = homedir6()) {
1869
+ return process.env.PI_CODING_AGENT_DIR ?? (process.env.XDG_CONFIG_HOME ? join10(process.env.XDG_CONFIG_HOME, "pi") : join10(homeDir, ".pi", "agent"));
1870
+ }
1871
+ function parseJsonObject(content, path) {
397
1872
  if (!content?.trim())
398
1873
  return {};
399
1874
  try {
@@ -435,9 +1910,14 @@ var PI_SKILL_SOURCES = [
435
1910
  { repository: "AminBlg/SimpleEnglish", skills: ["simple-english"] }
436
1911
  ];
437
1912
  var MCP_ADAPTER_PACKAGE = "npm:pi-mcp-adapter";
1913
+ var BUNDLED_AGENTS_DIR = resolveBundledAgentsDir();
1914
+ var FORGE_DEPENDENCIES = {
1915
+ github: { name: "gh", tool: "gh", spec: "gh@latest", minimumVersion: undefined },
1916
+ gitlab: { name: "glab", tool: "glab", spec: "glab@latest", minimumVersion: undefined }
1917
+ };
438
1918
  async function ensureMise(options = {}) {
439
- const homeDir = options.homeDir ?? homedir4();
440
- const current = await mise.executableCheck() ?? await mise.executableCheck(join5(homeDir, ".local", "bin", "mise"));
1919
+ const homeDir = options.homeDir ?? homedir7();
1920
+ const current = await mise.executableCheck() ?? await mise.executableCheck(join11(homeDir, ".local", "bin", "mise"));
441
1921
  if (current)
442
1922
  return { executable: current, action: createSetupAction("mise", "ready", current) };
443
1923
  reportProgress(options, "Installing mise");
@@ -466,7 +1946,10 @@ async function ensureMiseHooks(miseExecutable, options = {}) {
466
1946
  async function ensureMiseDeps(miseExecutable, options = {}) {
467
1947
  const canRunMise = Boolean(await mise.executableCheck(miseExecutable));
468
1948
  const actions = [];
469
- for (const dependency of MISE_DEPENDENCIES) {
1949
+ const dependencies = [...MISE_DEPENDENCIES];
1950
+ if (options.forge && options.forge !== "none")
1951
+ dependencies.push(FORGE_DEPENDENCIES[options.forge]);
1952
+ for (const dependency of dependencies) {
470
1953
  const installed = canRunMise && await mise.toolCheckGlobal(miseExecutable, dependency.tool, dependency.minimumVersion);
471
1954
  if (installed) {
472
1955
  actions.push(createSetupAction(dependency.name, "ready", dependency.spec));
@@ -482,18 +1965,33 @@ async function ensureMiseDeps(miseExecutable, options = {}) {
482
1965
  async function ensurePiPlugins(options = {}) {
483
1966
  const actions = await ensurePiPackages(PI_PACKAGES, options);
484
1967
  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);
1968
+ const webSearch = await pi.configEnsure(join11(agentDir, "web-search.json"), (config) => ({ ...config, workflow: "auto-summary" }), options.dryRun);
486
1969
  actions.push(getConfigSetupAction("web search settings", webSearch));
487
- const lsp = await pi.configEnsure(join5(agentDir, "pi-lsp.json"), (config) => ({
1970
+ const lsp = await pi.configEnsure(join11(agentDir, "pi-lsp.json"), (config) => ({
488
1971
  ...config,
489
1972
  progressive: { ...getRecord(config.progressive), enabled: true, inject: "none" }
490
1973
  }), options.dryRun);
491
1974
  actions.push(getConfigSetupAction("pi-lsp settings", lsp));
492
1975
  return actions;
493
1976
  }
1977
+ async function ensurePiAgents(options = {}) {
1978
+ const agentDir = options.agentDir ?? pi.agentDir(options.homeDir);
1979
+ const bundledAgentsDir = options.bundledAgentsDir ?? BUNDLED_AGENTS_DIR;
1980
+ const userConfig = await loadDiffpiConfig({ homeDir: options.homeDir });
1981
+ const entries = (await readdir2(bundledAgentsDir, { withFileTypes: true })).filter((entry) => entry.isFile() && entry.name.startsWith("diffpi-") && entry.name.endsWith(".md")).sort((left, right) => left.name.localeCompare(right.name));
1982
+ const actions = [];
1983
+ for (const entry of entries) {
1984
+ const id = basename4(entry.name, ".md").replace(/^diffpi-/, "");
1985
+ const source = await readFile6(join11(bundledAgentsDir, entry.name), "utf8");
1986
+ const content = materializeAgentModels(source, id, userConfig.config, options.availableModels);
1987
+ const result = await pi.agentEnsure(entry.name, content, agentDir, options.dryRun);
1988
+ actions.push(getConfigSetupAction(`pi agent ${id}`, result));
1989
+ }
1990
+ return actions;
1991
+ }
494
1992
  async function ensurePiSkills(miseExecutable, options = {}) {
495
1993
  const agentDir = options.agentDir ?? pi.agentDir(options.homeDir);
496
- const sharedSkillsDir = join5(options.homeDir ?? homedir4(), ".agents", "skills");
1994
+ const sharedSkillsDir = join11(options.homeDir ?? homedir7(), ".agents", "skills");
497
1995
  const actions = [];
498
1996
  for (const source of PI_SKILL_SOURCES) {
499
1997
  const missing = [];
@@ -537,6 +2035,12 @@ async function ensureMcpAdapters(miseExecutable, options = {}) {
537
2035
  } else if (options.issueTracker === "jira") {
538
2036
  servers.atlassian = { url: "https://mcp.atlassian.com/v1/mcp", auth: "oauth", protocolVersion: "auto" };
539
2037
  }
2038
+ if (options.forge === "github") {
2039
+ servers.github = { url: "https://api.githubcopilot.com/mcp/", auth: "oauth", protocolVersion: "auto" };
2040
+ } else if (options.forge === "gitlab") {
2041
+ const host = (await detectVcs(projectDir)).host || "gitlab.com";
2042
+ servers.gitlab = { url: `https://${host}/api/v4/mcp`, auth: "oauth", protocolVersion: "auto" };
2043
+ }
540
2044
  const result = await mcp.serversEnsure(servers, {
541
2045
  dryRun: options.dryRun,
542
2046
  path: mcp.globalConfigPath(options.homeDir)
@@ -550,13 +2054,81 @@ async function setupPi(options = {}) {
550
2054
  actions.push(await ensureMiseHooks(miseResult.executable, options));
551
2055
  actions.push(...await ensureMiseDeps(miseResult.executable, options));
552
2056
  actions.push(...await ensurePiPlugins(options));
2057
+ actions.push(...await ensurePiAgents(options));
553
2058
  actions.push(...await ensurePiSkills(miseResult.executable, options));
554
2059
  actions.push(...await ensureMcpAdapters(miseResult.executable, options));
2060
+ if (options.bindZedKey)
2061
+ actions.push(...await ensureZedIntegration(options));
555
2062
  return {
556
2063
  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"))
2064
+ restartPi: setupRequiresRestart(actions)
558
2065
  };
559
2066
  }
2067
+ function setupRequiresRestart(actions) {
2068
+ 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"));
2069
+ }
2070
+ function materializeAgentModels(content, agentId, config, availableModels) {
2071
+ const { frontmatter } = parseFrontmatter2(content.startsWith("\uFEFF") ? content.slice(1) : content);
2072
+ const profilePreferences = [...getTextList(frontmatter.model), ...getTextList(frontmatter.model_fallbacks)];
2073
+ const preferences = resolveAgentModelPreferences(agentId, profilePreferences, config);
2074
+ let selectedIndex = availableModels === undefined && preferences.length > 0 ? 0 : -1;
2075
+ let selectedModel = selectedIndex === 0 ? preferences[0] : undefined;
2076
+ if (availableModels) {
2077
+ for (const [index, preference] of preferences.entries()) {
2078
+ const match = findPreferredModel(availableModels, preference);
2079
+ if (!match)
2080
+ continue;
2081
+ selectedIndex = index;
2082
+ selectedModel = `${match.provider}/${match.id}`;
2083
+ break;
2084
+ }
2085
+ }
2086
+ const fallbacks = preferences.filter((_preference, index) => index !== selectedIndex);
2087
+ return replaceAgentModelFields(content, selectedModel, fallbacks);
2088
+ }
2089
+ function replaceAgentModelFields(content, model, fallbacks) {
2090
+ const newline = content.includes(`\r
2091
+ `) ? `\r
2092
+ ` : `
2093
+ `;
2094
+ const lines = content.replaceAll(`\r
2095
+ `, `
2096
+ `).split(`
2097
+ `);
2098
+ const closingDelimiter = lines.indexOf("---", 1);
2099
+ if (lines[0] !== "---" || closingDelimiter < 0)
2100
+ return content;
2101
+ const frontmatter = lines.slice(1, closingDelimiter).filter((line) => !/^model(?:_fallbacks)?:/.test(line));
2102
+ if (model)
2103
+ frontmatter.push(`model: ${model}`);
2104
+ if (fallbacks.length > 0)
2105
+ frontmatter.push(`model_fallbacks: ${fallbacks.join(", ")}`);
2106
+ return ["---", ...frontmatter, "---", ...lines.slice(closingDelimiter + 1)].join(newline);
2107
+ }
2108
+ async function ensureZedIntegration(options = {}) {
2109
+ if (options.dryRun) {
2110
+ const actions = [createSetupAction("Zed review task", "planned", "tasks.json")];
2111
+ if (options.bindZedKey)
2112
+ actions.push(createSetupAction("Zed review keybinding", "planned", "keymap.json"));
2113
+ return actions;
2114
+ }
2115
+ const actions = [];
2116
+ try {
2117
+ const task = await ensureZedReviewTask(options.homeDir);
2118
+ actions.push(createSetupAction("Zed review task", task.changed ? "installed" : "ready", task.path));
2119
+ } catch (error) {
2120
+ actions.push(createSetupAction("Zed review task", "skipped", error instanceof Error ? error.message : String(error)));
2121
+ }
2122
+ if (options.bindZedKey) {
2123
+ try {
2124
+ const key = await ensureZedReviewKeybinding(options.homeDir);
2125
+ actions.push(createSetupAction("Zed review keybinding", key.changed ? "installed" : "ready", key.path));
2126
+ } catch (error) {
2127
+ actions.push(createSetupAction("Zed review keybinding", "skipped", error instanceof Error ? error.message : String(error)));
2128
+ }
2129
+ }
2130
+ return actions;
2131
+ }
560
2132
  async function ensurePiPackages(packages, options) {
561
2133
  const executable = await pi.executableCheck();
562
2134
  if (!executable && !options.dryRun)
@@ -578,6 +2150,10 @@ ${source}`;
578
2150
  }
579
2151
  return actions;
580
2152
  }
2153
+ function getTextList(value) {
2154
+ const values = Array.isArray(value) ? value : typeof value === "string" ? value.split(",") : [];
2155
+ return values.filter((item) => typeof item === "string").map((item) => item.trim()).filter(Boolean);
2156
+ }
581
2157
  function getConfigSetupAction(name, result) {
582
2158
  if (!result.changed)
583
2159
  return createSetupAction(name, "ready", result.path);
@@ -596,11 +2172,13 @@ function getRecord(value) {
596
2172
  }
597
2173
 
598
2174
  // 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.")
2175
+ var setupParametersSchema = z6.object({
2176
+ issueTracker: z6.enum(["none", "linear", "jira"]).default("none").describe("Issue tracker MCP server to configure. Use none unless the user explicitly selects Linear or Jira."),
2177
+ forge: z6.enum(["none", "github", "gitlab"]).default("none").describe("Forge to configure for /review. Installs gh or glab and registers its MCP server."),
2178
+ bindZedKey: z6.boolean().default(false).describe("Opt in to a Zed keybinding for the tuicr review task.")
601
2179
  });
602
- var setupParameters = z2.toJSONSchema(setupParametersSchema, { io: "input" });
603
- var diffpiSetupTool = defineTool2({
2180
+ var setupParameters = z6.toJSONSchema(setupParametersSchema, { io: "input" });
2181
+ var diffpiSetupTool = defineTool4({
604
2182
  name: "diffpi_setup",
605
2183
  label: "diffpi setup",
606
2184
  description: "Install or repair the @difflab/pi environment. This mutates user-level tool installations and configuration files.",
@@ -613,11 +2191,14 @@ var diffpiSetupTool = defineTool2({
613
2191
  ],
614
2192
  parameters: setupParameters,
615
2193
  executionMode: "sequential",
616
- async execute(_toolCallId, input, _signal, onUpdate) {
2194
+ async execute(_toolCallId, input, _signal, onUpdate, ctx) {
617
2195
  const params = setupParametersSchema.parse(input);
618
2196
  const result = await setupPi({
619
2197
  issueTracker: params.issueTracker,
2198
+ forge: params.forge,
2199
+ bindZedKey: params.bindZedKey,
620
2200
  installMiseHook: true,
2201
+ availableModels: ctx.modelRegistry.getAvailable(),
621
2202
  onProgress(message) {
622
2203
  onUpdate?.({ content: [{ type: "text", text: message }], details: {} });
623
2204
  }
@@ -625,7 +2206,7 @@ var diffpiSetupTool = defineTool2({
625
2206
  return formatResult(result, "Setup complete.");
626
2207
  }
627
2208
  });
628
- var diffpiValidateTool = defineTool2({
2209
+ var diffpiValidateTool = defineTool4({
629
2210
  name: "diffpi_validate",
630
2211
  label: "diffpi validate",
631
2212
  description: "Inspect the @difflab/pi environment without installing software or changing configuration files.",
@@ -637,12 +2218,15 @@ var diffpiValidateTool = defineTool2({
637
2218
  ],
638
2219
  parameters: setupParameters,
639
2220
  executionMode: "sequential",
640
- async execute(_toolCallId, input) {
2221
+ async execute(_toolCallId, input, _signal, _onUpdate, ctx) {
641
2222
  const params = setupParametersSchema.parse(input);
642
2223
  const result = await setupPi({
643
2224
  issueTracker: params.issueTracker,
2225
+ forge: params.forge,
2226
+ bindZedKey: params.bindZedKey,
644
2227
  installMiseHook: true,
645
- dryRun: true
2228
+ dryRun: true,
2229
+ availableModels: ctx.modelRegistry.getAvailable()
646
2230
  });
647
2231
  const incomplete = result.actions.some((item) => item.status === "planned");
648
2232
  return formatResult(result, incomplete ? "Setup is incomplete." : "Setup is ready.");
@@ -663,12 +2247,20 @@ ${lines.join(`
663
2247
  }
664
2248
 
665
2249
  // src/tools/index.ts
666
- function createPiTools(pi) {
667
- return [diffpiSetupTool, diffpiValidateTool, createDiffpiReloadTool(pi)];
2250
+ function createPiTools(pi, modes) {
2251
+ return [
2252
+ diffpiSetupTool,
2253
+ diffpiValidateTool,
2254
+ createDiffpiReloadTool(pi),
2255
+ ...createModeTools(modes),
2256
+ ...createReviewTools()
2257
+ ];
668
2258
  }
669
2259
  export {
670
2260
  createDiffpiReloadTool,
2261
+ createModeTools,
671
2262
  createPiTools,
2263
+ createReviewTools,
672
2264
  diffpiSetupTool,
673
2265
  diffpiValidateTool
674
2266
  };