@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
package/dist/index.js CHANGED
@@ -1,66 +1,101 @@
1
- // src/mcp.ts
2
- import { mkdir, readFile, writeFile } from "node:fs/promises";
1
+ // src/config.ts
2
+ import { parseFrontmatter } from "@earendil-works/pi-coding-agent";
3
+ import { z } from "zod";
3
4
  import { homedir } from "node:os";
4
- import { dirname, join } from "node:path";
5
- var mcp = {
6
- globalConfigPath(homeDir = homedir()) {
7
- return join(homeDir, ".config", "mcp", "mcp.json");
8
- },
9
- async serversEnsure(servers, options = {}) {
10
- const path = options.path ?? mcp.globalConfigPath();
11
- const currentText = await getOptionalFile(path);
12
- const current = getParsedConfig(currentText, path);
13
- const nextServers = { ...current.mcpServers };
14
- for (const [name, entry] of Object.entries(servers)) {
15
- nextServers[name] = mergeEntry(nextServers[name], entry);
16
- }
17
- const next = { ...current, mcpServers: nextServers };
18
- const changed = JSON.stringify(current) !== JSON.stringify(next);
19
- if (changed && !options.dryRun) {
20
- await mkdir(dirname(path), { recursive: true });
21
- await writeFile(path, `${JSON.stringify(next, null, 2)}
22
- `, "utf8");
23
- }
24
- return { path, changed, existed: currentText !== undefined, planned: changed && options.dryRun === true };
25
- }
26
- };
27
- function mergeEntry(current, required) {
28
- const merged = { ...current, ...required };
29
- if (current?.env || required.env)
30
- merged.env = { ...current?.env, ...required.env };
31
- return merged;
32
- }
33
- function getParsedConfig(content, path) {
34
- if (!content?.trim())
35
- return { mcpServers: {} };
5
+ import { join } from "node:path";
6
+
7
+ // src/fsx.ts
8
+ import { readdir, readFile } from "node:fs/promises";
9
+ async function readDirectoryIfExists(path) {
36
10
  try {
37
- const value = JSON.parse(content);
38
- if (!isRecord(value))
39
- throw new Error("not an object");
40
- const servers = value.mcpServers;
41
- if (servers !== undefined && !isRecord(servers))
42
- throw new Error("mcpServers is not an object");
43
- return { ...value, mcpServers: servers ?? {} };
44
- } catch {
45
- throw new Error(`Expected a valid pi-mcp-adapter configuration in ${path}.`);
11
+ return await readdir(path, { withFileTypes: true });
12
+ } catch (error) {
13
+ if (isMissingPath(error))
14
+ return [];
15
+ throw error;
46
16
  }
47
17
  }
48
- async function getOptionalFile(path) {
18
+ async function readTextIfExists(path) {
49
19
  try {
50
20
  return await readFile(path, "utf8");
51
21
  } catch (error) {
52
- if (error instanceof Error && "code" in error && error.code === "ENOENT")
22
+ if (isMissingPath(error))
53
23
  return;
54
24
  throw error;
55
25
  }
56
26
  }
57
- function isRecord(value) {
58
- return value !== null && typeof value === "object" && !Array.isArray(value);
27
+ function isMissingPath(error) {
28
+ return error instanceof Error && "code" in error && error.code === "ENOENT";
59
29
  }
60
- // src/mise.ts
61
- import { mkdir as mkdir2, readFile as readFile2, writeFile as writeFile2 } from "node:fs/promises";
62
- import { homedir as homedir2 } from "node:os";
63
- import { basename, dirname as dirname2, join as join3 } from "node:path";
30
+
31
+ // src/config.ts
32
+ var modelReferenceSchema = z.string().trim().min(1);
33
+ var agentConfigSchema = z.object({
34
+ models: z.array(modelReferenceSchema).optional()
35
+ }).strict();
36
+ var diffpiConfigSchema = z.object({
37
+ agents: z.record(z.string(), agentConfigSchema).optional()
38
+ }).strict();
39
+ function diffpiConfigPaths(homeDir = homedir()) {
40
+ const directory = join(homeDir, ".difflab", "diffpi");
41
+ return {
42
+ yaml: join(directory, "config.yaml"),
43
+ json: join(directory, "config.json")
44
+ };
45
+ }
46
+ async function loadDiffpiConfig(options = {}) {
47
+ const paths = diffpiConfigPaths(options.homeDir);
48
+ for (const [format, path] of [
49
+ ["yaml", paths.yaml],
50
+ ["json", paths.json]
51
+ ]) {
52
+ const content = await readTextIfExists(path);
53
+ if (content === undefined)
54
+ continue;
55
+ try {
56
+ const value = format === "yaml" ? parseYamlConfig(content) : JSON.parse(content);
57
+ return { config: diffpiConfigSchema.parse(value ?? {}), path };
58
+ } catch (error) {
59
+ const reason = error instanceof Error ? error.message : String(error);
60
+ throw new Error(`Invalid Diffpi config at ${path}: ${reason}`, { cause: error });
61
+ }
62
+ }
63
+ return { config: {} };
64
+ }
65
+ function resolveAgentModelPreferences(agentId, profilePreferences, config) {
66
+ const override = config.agents?.[agentId];
67
+ if (override && Object.hasOwn(override, "models"))
68
+ return [...override.models ?? []];
69
+ return [...profilePreferences];
70
+ }
71
+ function findPreferredModel(models, preference) {
72
+ const normalizedPreference = normalizeModelReference(preference);
73
+ const exactReference = models.find((model) => normalizeModelReference(`${model.provider}/${model.id}`) === normalizedPreference);
74
+ if (exactReference)
75
+ return exactReference;
76
+ const idPreference = preference.includes("/") ? preference.slice(preference.indexOf("/") + 1) : preference;
77
+ const normalizedIdPreference = normalizeModelReference(idPreference);
78
+ const exactId = models.find((model) => normalizeModelReference(model.id) === normalizedIdPreference);
79
+ if (exactId)
80
+ return exactId;
81
+ const preferenceTokens = normalizedIdPreference.split("-").filter(Boolean);
82
+ return models.find((model) => {
83
+ const modelTokens = new Set(normalizeModelReference(model.id).split("-").filter(Boolean));
84
+ return preferenceTokens.every((token) => modelTokens.has(token));
85
+ });
86
+ }
87
+ function parseYamlConfig(content) {
88
+ const document = content.replace(/^\uFEFF/, "").replace(/^---[^\S\r\n]*(?:#.*)?(?:\r?\n|$)/, "");
89
+ return parseFrontmatter(`---
90
+ ${document}
91
+ ---
92
+ `).frontmatter;
93
+ }
94
+ function normalizeModelReference(value) {
95
+ return value.toLowerCase().replace(/^~/, "").replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
96
+ }
97
+ // src/environment.ts
98
+ import { basename } from "node:path";
64
99
 
65
100
  // src/process.ts
66
101
  import { constants } from "node:fs";
@@ -93,18 +128,35 @@ function run(command, args, options = {}) {
93
128
  const child = spawn(command, args, {
94
129
  cwd: options.cwd,
95
130
  env: options.env ?? process.env,
96
- stdio: ["ignore", "pipe", "pipe"]
131
+ stdio: [options.input === undefined ? "ignore" : "pipe", "pipe", "pipe"]
97
132
  });
98
133
  let stdout = "";
99
134
  let stderr = "";
100
- child.stdout.on("data", (chunk) => {
101
- stdout = appendBounded(stdout, chunk.toString());
135
+ const stdoutChunks = [];
136
+ const stderrChunks = [];
137
+ const unbounded = options.capture === "unbounded";
138
+ child.stdout?.on("data", (chunk) => {
139
+ const text = chunk.toString();
140
+ if (unbounded)
141
+ stdoutChunks.push(text);
142
+ else
143
+ stdout = appendBounded(stdout, text);
102
144
  });
103
- child.stderr.on("data", (chunk) => {
104
- stderr = appendBounded(stderr, chunk.toString());
145
+ child.stderr?.on("data", (chunk) => {
146
+ const text = chunk.toString();
147
+ if (unbounded)
148
+ stderrChunks.push(text);
149
+ else
150
+ stderr = appendBounded(stderr, text);
105
151
  });
106
152
  child.on("error", reject);
107
- child.on("close", (code) => resolve({ code: code ?? 1, stdout, stderr }));
153
+ child.on("close", (code) => resolve({
154
+ code: code ?? 1,
155
+ stdout: unbounded ? stdoutChunks.join("") : stdout,
156
+ stderr: unbounded ? stderrChunks.join("") : stderr
157
+ }));
158
+ if (options.input !== undefined && child.stdin)
159
+ child.stdin.end(options.input);
108
160
  });
109
161
  }
110
162
  async function runChecked(command, args, options = {}) {
@@ -119,7 +171,629 @@ function appendBounded(current, next) {
119
171
  return combined.length <= MAX_CAPTURED_OUTPUT_LENGTH ? combined : combined.slice(-MAX_CAPTURED_OUTPUT_LENGTH);
120
172
  }
121
173
 
174
+ // src/zed.ts
175
+ import { mkdir, readFile as readFile2, writeFile } from "node:fs/promises";
176
+ import { homedir as homedir2 } from "node:os";
177
+ import { dirname, join as join3 } from "node:path";
178
+ var ZED_REVIEW_TASK_NAME = "diffpi: tuicr review";
179
+ var REVIEW_KEYBINDING = "cmd-alt-r";
180
+ var REVIEW_TASK = {
181
+ label: ZED_REVIEW_TASK_NAME,
182
+ command: "tuicr",
183
+ args: ["-w"],
184
+ cwd: "$ZED_WORKTREE_ROOT",
185
+ use_new_terminal: true,
186
+ reveal: "always"
187
+ };
188
+ function zedTasksPath(homeDir = homedir2()) {
189
+ return join3(homeDir, ".config", "zed", "tasks.json");
190
+ }
191
+ function zedKeymapPath(homeDir = homedir2()) {
192
+ return join3(homeDir, ".config", "zed", "keymap.json");
193
+ }
194
+ async function ensureZedReviewTask(homeDir = homedir2()) {
195
+ const path = zedTasksPath(homeDir);
196
+ const currentText = await readOptional(path);
197
+ const tasks = parseJsonArray(currentText, path);
198
+ const index = tasks.findIndex((task) => task.label === ZED_REVIEW_TASK_NAME);
199
+ const next = [...tasks];
200
+ if (index >= 0)
201
+ next[index] = { ...tasks[index], ...REVIEW_TASK };
202
+ else
203
+ next.push(REVIEW_TASK);
204
+ const changed = JSON.stringify(tasks) !== JSON.stringify(next);
205
+ if (changed)
206
+ await writeJson(path, next);
207
+ return { path, changed, existed: currentText !== undefined };
208
+ }
209
+ async function ensureZedReviewKeybinding(homeDir = homedir2()) {
210
+ const path = zedKeymapPath(homeDir);
211
+ const currentText = await readOptional(path);
212
+ const entries = parseJsonArray(currentText, path);
213
+ const alreadyBound = entries.some((entry) => Object.values(entry.bindings ?? {}).some((action) => Array.isArray(action) && action[0] === "task::Spawn" && bindsReviewTask(action[1])));
214
+ if (alreadyBound)
215
+ return { path, changed: false, existed: currentText !== undefined };
216
+ const next = [
217
+ ...entries,
218
+ { context: "Workspace", bindings: { [REVIEW_KEYBINDING]: ["task::Spawn", { task_name: ZED_REVIEW_TASK_NAME }] } }
219
+ ];
220
+ await writeJson(path, next);
221
+ return { path, changed: true, existed: currentText !== undefined };
222
+ }
223
+ function bindsReviewTask(payload) {
224
+ return typeof payload === "object" && payload !== null && payload.task_name === ZED_REVIEW_TASK_NAME;
225
+ }
226
+ async function readOptional(path) {
227
+ try {
228
+ return await readFile2(path, "utf8");
229
+ } catch (error) {
230
+ if (error instanceof Error && "code" in error && error.code === "ENOENT")
231
+ return;
232
+ throw error;
233
+ }
234
+ }
235
+ function parseJsonArray(content, path) {
236
+ if (!content?.trim())
237
+ return [];
238
+ let value;
239
+ try {
240
+ value = JSON.parse(content);
241
+ } catch {
242
+ throw new Error(`Cannot safely edit ${path}: not strict JSON (it may contain JSONC comments).`);
243
+ }
244
+ if (!Array.isArray(value))
245
+ throw new Error(`Expected a JSON array in ${path}.`);
246
+ return value;
247
+ }
248
+ async function writeJson(path, value) {
249
+ await mkdir(dirname(path), { recursive: true });
250
+ await writeFile(path, `${JSON.stringify(value, null, 2)}
251
+ `, "utf8");
252
+ }
253
+
254
+ // src/environment.ts
255
+ function detectIde(env = process.env) {
256
+ const program = (env.TERM_PROGRAM ?? "").toLowerCase();
257
+ if (env.ZED_TERM === "true" || program === "zed")
258
+ return "zed";
259
+ if (env.CURSOR_TRACE_ID || program === "cursor")
260
+ return "cursor";
261
+ if (env.WINDSURF_ENV || program === "windsurf")
262
+ return "windsurf";
263
+ if (env.TERMINAL_EMULATOR?.toLowerCase().includes("jetbrains"))
264
+ return "jetbrains";
265
+ if (env.VSCODE_PID || env.VSCODE_GIT_IPC_HANDLE || program === "vscode")
266
+ return "vscode";
267
+ return "unknown";
268
+ }
269
+ function detectMux(env = process.env) {
270
+ if (env.ZELLIJ || env.ZELLIJ_SESSION_NAME)
271
+ return "zellij";
272
+ if (env.TMUX)
273
+ return "tmux";
274
+ if (env.STY)
275
+ return "screen";
276
+ return "none";
277
+ }
278
+ function detectShell(env = process.env) {
279
+ return env.SHELL ? basename(env.SHELL) : "unknown";
280
+ }
281
+ async function detectVcs(cwd) {
282
+ const root = (await run("git", ["-C", cwd, "rev-parse", "--show-toplevel"])).stdout.trim() || cwd;
283
+ const branch = (await run("git", ["-C", root, "rev-parse", "--abbrev-ref", "HEAD"])).stdout.trim();
284
+ const remote = (await run("git", ["-C", root, "remote", "get-url", "origin"])).stdout.trim();
285
+ return { ...parseRemote(remote), branch, root };
286
+ }
287
+ function parseRemote(remote) {
288
+ const empty = { provider: "none", host: "", owner: "", repo: "" };
289
+ if (!remote)
290
+ return empty;
291
+ const scp = remote.match(/^[^@]+@([^:]+):(.+?)(?:\.git)?$/);
292
+ const url = remote.match(/^[a-z]+:\/\/(?:[^@]+@)?([^/]+)\/(.+?)(?:\.git)?$/i);
293
+ const match = scp ?? url;
294
+ if (!match)
295
+ return empty;
296
+ const host = match[1];
297
+ const segments = match[2].split("/").filter(Boolean);
298
+ if (segments.length < 2)
299
+ return { ...empty, host };
300
+ const repo = segments.at(-1) ?? "";
301
+ const owner = segments.slice(0, -1).join("/");
302
+ const provider = /github/i.test(host) ? "github" : /gitlab/i.test(host) ? "gitlab" : "none";
303
+ return { provider, host, owner, repo };
304
+ }
305
+ async function openInNewTab(command, opts) {
306
+ const env = opts.env ?? process.env;
307
+ const name = opts.name ?? "review";
308
+ const printable = command.join(" ");
309
+ const mux = detectMux(env);
310
+ if (mux !== "none") {
311
+ const opened = await openMuxTab(mux, command, opts.cwd, name, printable);
312
+ if (opened)
313
+ return opened;
314
+ }
315
+ if (detectIde(env) === "zed") {
316
+ try {
317
+ await ensureZedReviewTask(opts.homeDir);
318
+ return {
319
+ launched: false,
320
+ configured: true,
321
+ via: "zed-task",
322
+ command: printable,
323
+ taskName: ZED_REVIEW_TASK_NAME,
324
+ instruction: `Run the Zed task "${ZED_REVIEW_TASK_NAME}".`
325
+ };
326
+ } catch {}
327
+ }
328
+ return { launched: false, via: "print", command: printable };
329
+ }
330
+ async function openMuxTab(mux, command, cwd, name, printable) {
331
+ if (mux === "zellij" && await findExecutable("zellij")) {
332
+ const result = await run("zellij", ["action", "new-tab", "--cwd", cwd, "--name", name, "--", ...command]);
333
+ if (result.code === 0)
334
+ return { launched: true, via: "zellij", command: printable };
335
+ const fallback = await run("zellij", ["run", "--cwd", cwd, "--name", name, "--", ...command]);
336
+ if (fallback.code === 0)
337
+ return { launched: true, via: "zellij-run", command: printable };
338
+ }
339
+ if (mux === "tmux" && await findExecutable("tmux")) {
340
+ const result = await run("tmux", ["new-window", "-c", cwd, "-n", name, printable]);
341
+ if (result.code === 0)
342
+ return { launched: true, via: "tmux", command: printable };
343
+ }
344
+ if (mux === "screen" && await findExecutable("screen")) {
345
+ const result = await run("screen", screenWindowArgs(command, cwd, name));
346
+ if (result.code === 0)
347
+ return { launched: true, via: "screen", command: printable };
348
+ }
349
+ return;
350
+ }
351
+ function screenWindowArgs(command, cwd, name) {
352
+ return ["-X", "screen", "-t", name, "sh", "-lc", 'cd -- "$1" && shift && exec "$@"', "sh", cwd, ...command];
353
+ }
354
+ // src/forge.ts
355
+ function createForge(vcs) {
356
+ if (vcs.provider === "github")
357
+ return new GithubForge(vcs);
358
+ if (vcs.provider === "gitlab")
359
+ return new GitlabForge(vcs);
360
+ throw new Error("No forge detected from the git remote. Use --local for an offline review.");
361
+ }
362
+
363
+ class GithubForge {
364
+ vcs;
365
+ provider = "github";
366
+ constructor(vcs) {
367
+ this.vcs = vcs;
368
+ }
369
+ repoFlag() {
370
+ return ["--repo", `${this.vcs.owner}/${this.vcs.repo}`];
371
+ }
372
+ async createDraftPr(options) {
373
+ const args = [
374
+ "pr",
375
+ "create",
376
+ ...this.repoFlag(),
377
+ "--title",
378
+ options.title,
379
+ "--body",
380
+ options.body,
381
+ "--base",
382
+ options.base,
383
+ "--head",
384
+ options.head
385
+ ];
386
+ if (options.draft !== false)
387
+ args.push("--draft");
388
+ await runChecked("gh", args);
389
+ const ref = await this.viewPr(options.head);
390
+ if (!ref)
391
+ throw new Error("Draft PR created but could not be resolved.");
392
+ return ref;
393
+ }
394
+ async viewPr(idOrBranch) {
395
+ const args = [
396
+ "pr",
397
+ "view",
398
+ idOrBranch,
399
+ ...this.repoFlag(),
400
+ "--json",
401
+ "number,title,url,isDraft,baseRefName,headRefName"
402
+ ];
403
+ const result = await run("gh", args);
404
+ if (result.code !== 0) {
405
+ if (isConfirmedMissingChange("github", result.stderr || result.stdout))
406
+ return;
407
+ throw commandFailure("gh", args, result);
408
+ }
409
+ if (!result.stdout.trim())
410
+ throw new Error("GitHub returned an empty pull request response.");
411
+ let data;
412
+ try {
413
+ data = JSON.parse(result.stdout);
414
+ } catch {
415
+ throw new Error("Cannot parse the GitHub pull request response as JSON.");
416
+ }
417
+ 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") {
418
+ throw new Error("GitHub returned an invalid pull request response.");
419
+ }
420
+ return {
421
+ number: data.number,
422
+ title: data.title,
423
+ url: data.url,
424
+ isDraft: data.isDraft,
425
+ baseRef: data.baseRefName,
426
+ headRef: data.headRefName
427
+ };
428
+ }
429
+ async defaultBranch() {
430
+ const result = await runChecked("gh", [
431
+ "repo",
432
+ "view",
433
+ `${this.vcs.owner}/${this.vcs.repo}`,
434
+ "--json",
435
+ "defaultBranchRef",
436
+ "--jq",
437
+ ".defaultBranchRef.name"
438
+ ]);
439
+ return requireBranchName(result.stdout, "GitHub");
440
+ }
441
+ async prDiff(id) {
442
+ return (await runChecked("gh", ["pr", "diff", String(id), ...this.repoFlag()], { capture: "unbounded" })).stdout;
443
+ }
444
+ async prChecks(id) {
445
+ return (await run("gh", ["pr", "checks", String(id), ...this.repoFlag()])).stdout;
446
+ }
447
+ async createPendingReview(id, comments, body) {
448
+ const payload = {
449
+ body,
450
+ comments: comments.map((comment) => ({
451
+ path: comment.file,
452
+ line: comment.line,
453
+ side: comment.side ?? "RIGHT",
454
+ body: comment.body
455
+ }))
456
+ };
457
+ await runChecked("gh", ["api", "--method", "POST", `/repos/${this.vcs.owner}/${this.vcs.repo}/pulls/${id}/reviews`, "--input", "-"], { input: JSON.stringify(payload) });
458
+ }
459
+ async submitReview(id, event, body) {
460
+ const pending = await runChecked("gh", [
461
+ "api",
462
+ `/repos/${this.vcs.owner}/${this.vcs.repo}/pulls/${id}/reviews`,
463
+ "--jq",
464
+ '[.[] | select(.state=="PENDING")] | last | .id'
465
+ ]);
466
+ const reviewId = pending.stdout.trim();
467
+ const endpoint = githubReviewSubmissionEndpoint(this.vcs.owner, this.vcs.repo, id, reviewId);
468
+ const args = ["api", "--method", "POST", endpoint, "-f", `event=${event}`];
469
+ if (body.trim())
470
+ args.push("-f", `body=${body}`);
471
+ await runChecked("gh", args);
472
+ }
473
+ async markReady(id) {
474
+ await runChecked("gh", ["pr", "ready", String(id), ...this.repoFlag()]);
475
+ }
476
+ async closePr(id, comment) {
477
+ const args = ["pr", "close", String(id), ...this.repoFlag()];
478
+ if (comment)
479
+ args.push("--comment", comment);
480
+ await runChecked("gh", args);
481
+ }
482
+ }
483
+
484
+ class GitlabForge {
485
+ vcs;
486
+ provider = "gitlab";
487
+ constructor(vcs) {
488
+ this.vcs = vcs;
489
+ }
490
+ project() {
491
+ return `${this.vcs.owner}/${this.vcs.repo}`;
492
+ }
493
+ async createDraftPr(options) {
494
+ await runChecked("glab", [
495
+ "mr",
496
+ "create",
497
+ "--repo",
498
+ this.project(),
499
+ "--title",
500
+ `Draft: ${options.title}`,
501
+ "--description",
502
+ options.body,
503
+ "--target-branch",
504
+ options.base,
505
+ "--source-branch",
506
+ options.head,
507
+ "--yes"
508
+ ]);
509
+ const ref = await this.viewPr(options.head);
510
+ if (!ref)
511
+ throw new Error("Draft MR created but could not be resolved.");
512
+ return ref;
513
+ }
514
+ async viewPr(idOrBranch) {
515
+ const args = ["mr", "view", idOrBranch, "--repo", this.project(), "--output", "json"];
516
+ const result = await run("glab", args);
517
+ if (result.code !== 0) {
518
+ if (isConfirmedMissingChange("gitlab", result.stderr || result.stdout))
519
+ return;
520
+ throw commandFailure("glab", args, result);
521
+ }
522
+ if (!result.stdout.trim())
523
+ throw new Error("GitLab returned an empty merge request response.");
524
+ let data;
525
+ try {
526
+ data = JSON.parse(result.stdout);
527
+ } catch {
528
+ throw new Error("Cannot parse the GitLab merge request response as JSON.");
529
+ }
530
+ if (typeof data.iid !== "number" || typeof data.title !== "string" || typeof data.web_url !== "string" || typeof data.target_branch !== "string" || typeof data.source_branch !== "string") {
531
+ throw new Error("GitLab returned an invalid merge request response.");
532
+ }
533
+ return {
534
+ number: data.iid,
535
+ title: data.title,
536
+ url: data.web_url,
537
+ isDraft: Boolean(data.draft ?? data.work_in_progress),
538
+ baseRef: data.target_branch,
539
+ headRef: data.source_branch
540
+ };
541
+ }
542
+ async defaultBranch() {
543
+ const result = await runChecked("glab", [
544
+ "api",
545
+ `projects/${encodeURIComponent(this.project())}`,
546
+ "--jq",
547
+ ".default_branch"
548
+ ]);
549
+ return requireBranchName(result.stdout, "GitLab");
550
+ }
551
+ async prDiff(id) {
552
+ return (await runChecked("glab", ["mr", "diff", String(id), "--repo", this.project()], { capture: "unbounded" })).stdout;
553
+ }
554
+ async prChecks() {
555
+ return (await run("glab", ["ci", "status", "--repo", this.project()])).stdout;
556
+ }
557
+ async createPendingReview(id, comments, body) {
558
+ const endpoint = `projects/${encodeURIComponent(this.project())}/merge_requests/${id}/draft_notes`;
559
+ if (body.trim()) {
560
+ await runChecked("glab", ["api", "--method", "POST", endpoint, "--input", "-"], {
561
+ input: JSON.stringify({ note: body })
562
+ });
563
+ }
564
+ if (comments.length === 0)
565
+ return;
566
+ const response = await runChecked("glab", [
567
+ "api",
568
+ `projects/${encodeURIComponent(this.project())}/merge_requests/${id}`
569
+ ]);
570
+ const diffRefs = parseGitlabDiffRefs(response.stdout);
571
+ for (const comment of comments) {
572
+ const payload = {
573
+ note: comment.body,
574
+ position: {
575
+ ...diffRefs,
576
+ position_type: "text",
577
+ new_path: comment.file,
578
+ old_path: comment.file,
579
+ new_line: comment.side === "LEFT" ? undefined : comment.line,
580
+ old_line: comment.side === "LEFT" ? comment.line : undefined
581
+ }
582
+ };
583
+ await runChecked("glab", ["api", "--method", "POST", endpoint, "--input", "-"], {
584
+ input: JSON.stringify(payload)
585
+ });
586
+ }
587
+ }
588
+ async submitReview(id, event, body) {
589
+ assertReviewEventSupported(this.provider, event);
590
+ const drafts = await runChecked("glab", [
591
+ "api",
592
+ `projects/${encodeURIComponent(this.project())}/merge_requests/${id}/draft_notes`
593
+ ]);
594
+ const hasDrafts = hasGitlabDraftNotes(drafts.stdout);
595
+ if (hasDrafts) {
596
+ await runChecked("glab", [
597
+ "api",
598
+ "--method",
599
+ "POST",
600
+ `projects/${encodeURIComponent(this.project())}/merge_requests/${id}/draft_notes/bulk_publish`
601
+ ]);
602
+ } else if (body.trim()) {
603
+ await runChecked("glab", ["mr", "note", String(id), "--repo", this.project(), "--message", body]);
604
+ }
605
+ if (event === "APPROVE")
606
+ await runChecked("glab", ["mr", "approve", String(id), "--repo", this.project()]);
607
+ }
608
+ async markReady(id) {
609
+ await runChecked("glab", ["mr", "update", String(id), "--repo", this.project(), "--ready"]);
610
+ }
611
+ async closePr(id, comment) {
612
+ if (comment)
613
+ await runChecked("glab", ["mr", "note", String(id), "--repo", this.project(), "--message", comment]);
614
+ await runChecked("glab", ["mr", "close", String(id), "--repo", this.project()]);
615
+ }
616
+ }
617
+ function assertReviewEventSupported(provider, event) {
618
+ if (provider === "gitlab" && event === "REQUEST_CHANGES") {
619
+ throw new Error("GitLab does not support REQUEST_CHANGES reviews; post a comment or reject the merge request manually.");
620
+ }
621
+ }
622
+ function isConfirmedMissingChange(provider, output) {
623
+ const message = output.toLowerCase();
624
+ if (provider === "github") {
625
+ return message.includes("no pull requests found for branch") || message.includes("could not find pull request") || message.includes("could not resolve to a pullrequest");
626
+ }
627
+ if (provider === "gitlab") {
628
+ return message.includes("no open merge request") || /failed to get open merge request/.test(message) && /404(?: not found)?/.test(message);
629
+ }
630
+ return false;
631
+ }
632
+ function commandFailure(command, args, result) {
633
+ const detail = result.stderr.trim() || result.stdout.trim() || `exit code ${result.code}`;
634
+ return new Error(`${command} ${args.join(" ")} failed: ${detail}`);
635
+ }
636
+ function requireBranchName(output, provider) {
637
+ const branch = output.trim();
638
+ if (!branch || branch === "null")
639
+ throw new Error(`${provider} did not return a default branch.`);
640
+ return branch;
641
+ }
642
+ function githubReviewSubmissionEndpoint(owner, repo, id, pendingReviewId) {
643
+ return pendingReviewId ? `/repos/${owner}/${repo}/pulls/${id}/reviews/${pendingReviewId}/events` : `/repos/${owner}/${repo}/pulls/${id}/reviews`;
644
+ }
645
+ function parseGitlabDiffRefs(input) {
646
+ let data;
647
+ try {
648
+ data = JSON.parse(input);
649
+ } catch {
650
+ throw new Error("Cannot create positioned GitLab draft notes: the merge request response was not valid JSON.");
651
+ }
652
+ const { base_sha, start_sha, head_sha } = data.diff_refs ?? {};
653
+ if (!base_sha || !start_sha || !head_sha) {
654
+ throw new Error("Cannot create positioned GitLab draft notes: merge request diff refs are unavailable.");
655
+ }
656
+ return { base_sha, start_sha, head_sha };
657
+ }
658
+ function hasGitlabDraftNotes(input) {
659
+ try {
660
+ const data = JSON.parse(input);
661
+ return Array.isArray(data) && data.length > 0;
662
+ } catch {
663
+ throw new Error("Cannot complete GitLab review: the draft notes response was not valid JSON.");
664
+ }
665
+ }
666
+ // src/gates.ts
667
+ var CONVENTIONAL_COMMIT = /^(feat|fix|perf|refactor|docs|chore|test|build|ci|style|revert)(\([^)]+\))?!?: .+/;
668
+ var MISE_GATES = ["format:check", "lint", "test"];
669
+ function checkConventionalSubject(subject) {
670
+ const trimmed = subject.trim();
671
+ const ok = CONVENTIONAL_COMMIT.test(trimmed);
672
+ return {
673
+ name: "conventional-subject",
674
+ status: ok ? "pass" : "warn",
675
+ detail: ok ? trimmed : `not a conventional-commit subject: "${trimmed}"`
676
+ };
677
+ }
678
+ async function runMiseGates(cwd) {
679
+ const tasks = await discoverMiseTasks(cwd);
680
+ const results = [];
681
+ for (const gate of MISE_GATES) {
682
+ const targets = tasks.get(gate) ?? [];
683
+ if (targets.length === 0) {
684
+ results.push({ name: gate, status: "skip", detail: "no mise recipe" });
685
+ continue;
686
+ }
687
+ const invocations = targets.flatMap((target, index) => index === 0 ? [target] : [":::", target]);
688
+ const result = await run("mise", ["run", ...invocations], { cwd });
689
+ results.push({
690
+ name: gate,
691
+ status: result.code === 0 ? "pass" : "fail",
692
+ detail: result.code === 0 ? "clean" : (result.stderr.trim() || result.stdout.trim()).slice(-400)
693
+ });
694
+ }
695
+ return results;
696
+ }
697
+ function ciGate(checksOutput) {
698
+ const text = checksOutput.toLowerCase();
699
+ if (!text.trim())
700
+ return { name: "ci", status: "skip", detail: "no CI output" };
701
+ if (/\bfail|error\b/.test(text))
702
+ return { name: "ci", status: "warn", detail: "CI failing" };
703
+ if (/\bpending|in progress|queued\b/.test(text))
704
+ return { name: "ci", status: "warn", detail: "CI pending" };
705
+ return { name: "ci", status: "pass", detail: "CI green" };
706
+ }
707
+ async function discoverMiseTasks(cwd) {
708
+ const result = await run("mise", ["tasks", "--json", "--all"], { cwd });
709
+ if (result.code !== 0)
710
+ return new Map;
711
+ return parseMiseTasks(result.stdout);
712
+ }
713
+ function parseMiseTasks(input) {
714
+ let tasks;
715
+ try {
716
+ tasks = JSON.parse(input);
717
+ } catch {
718
+ return new Map;
719
+ }
720
+ if (!Array.isArray(tasks))
721
+ return new Map;
722
+ const found = new Map;
723
+ for (const gate of MISE_GATES) {
724
+ const targets = tasks.flatMap((task) => {
725
+ if (typeof task.name !== "string")
726
+ return [];
727
+ return task.name === gate || task.name.endsWith(`:${gate}`) || task.aliases?.includes(gate) ? [task.name] : [];
728
+ });
729
+ if (targets.length > 0)
730
+ found.set(gate, [...new Set(targets)]);
731
+ }
732
+ return found;
733
+ }
734
+ // src/mcp.ts
735
+ import { mkdir as mkdir2, readFile as readFile3, writeFile as writeFile2 } from "node:fs/promises";
736
+ import { homedir as homedir3 } from "node:os";
737
+ import { dirname as dirname2, join as join4 } from "node:path";
738
+ var mcp = {
739
+ globalConfigPath(homeDir = homedir3()) {
740
+ return join4(homeDir, ".config", "mcp", "mcp.json");
741
+ },
742
+ async serversEnsure(servers, options = {}) {
743
+ const path = options.path ?? mcp.globalConfigPath();
744
+ const currentText = await getOptionalFile(path);
745
+ const current = getParsedConfig(currentText, path);
746
+ const nextServers = { ...current.mcpServers };
747
+ for (const [name, entry] of Object.entries(servers)) {
748
+ nextServers[name] = mergeEntry(nextServers[name], entry);
749
+ }
750
+ const next = { ...current, mcpServers: nextServers };
751
+ const changed = JSON.stringify(current) !== JSON.stringify(next);
752
+ if (changed && !options.dryRun) {
753
+ await mkdir2(dirname2(path), { recursive: true });
754
+ await writeFile2(path, `${JSON.stringify(next, null, 2)}
755
+ `, "utf8");
756
+ }
757
+ return { path, changed, existed: currentText !== undefined, planned: changed && options.dryRun === true };
758
+ }
759
+ };
760
+ function mergeEntry(current, required) {
761
+ const merged = { ...current, ...required };
762
+ if (current?.env || required.env)
763
+ merged.env = { ...current?.env, ...required.env };
764
+ return merged;
765
+ }
766
+ function getParsedConfig(content, path) {
767
+ if (!content?.trim())
768
+ return { mcpServers: {} };
769
+ try {
770
+ const value = JSON.parse(content);
771
+ if (!isRecord(value))
772
+ throw new Error("not an object");
773
+ const servers = value.mcpServers;
774
+ if (servers !== undefined && !isRecord(servers))
775
+ throw new Error("mcpServers is not an object");
776
+ return { ...value, mcpServers: servers ?? {} };
777
+ } catch {
778
+ throw new Error(`Expected a valid pi-mcp-adapter configuration in ${path}.`);
779
+ }
780
+ }
781
+ async function getOptionalFile(path) {
782
+ try {
783
+ return await readFile3(path, "utf8");
784
+ } catch (error) {
785
+ if (error instanceof Error && "code" in error && error.code === "ENOENT")
786
+ return;
787
+ throw error;
788
+ }
789
+ }
790
+ function isRecord(value) {
791
+ return value !== null && typeof value === "object" && !Array.isArray(value);
792
+ }
122
793
  // src/mise.ts
794
+ import { mkdir as mkdir3, readFile as readFile4, writeFile as writeFile3 } from "node:fs/promises";
795
+ import { homedir as homedir4 } from "node:os";
796
+ import { basename as basename2, dirname as dirname3, join as join5 } from "node:path";
123
797
  var MISE_HOOK_START = "# >>> @difflab/pi mise >>>";
124
798
  var MISE_HOOK_END = "# <<< @difflab/pi mise <<<";
125
799
  var mise = {
@@ -127,11 +801,11 @@ var mise = {
127
801
  return findExecutable(name);
128
802
  },
129
803
  async install(options = {}) {
130
- const homeDir = options.homeDir ?? homedir2();
804
+ const homeDir = options.homeDir ?? homedir4();
131
805
  const platform = options.platform ?? process.platform;
132
806
  if (platform === "win32")
133
807
  throw new Error("Automatic mise installation supports macOS and Linux only.");
134
- const installedPath = join3(homeDir, ".local", "bin", "mise");
808
+ const installedPath = join5(homeDir, ".local", "bin", "mise");
135
809
  if (options.dryRun)
136
810
  return installedPath;
137
811
  await runChecked("sh", ["-c", "curl -fsSL https://mise.run | sh"]);
@@ -141,8 +815,8 @@ var mise = {
141
815
  return executable;
142
816
  },
143
817
  async hookEnsure(executable, options = {}) {
144
- const homeDir = options.homeDir ?? homedir2();
145
- const hook = getShellHook(basename(options.shell ?? process.env.SHELL ?? ""), executable, homeDir);
818
+ const homeDir = options.homeDir ?? homedir4();
819
+ const hook = getShellHook(basename2(options.shell ?? process.env.SHELL ?? ""), executable, homeDir);
146
820
  const current = await getOptionalFile2(hook.path);
147
821
  if (current.includes(MISE_HOOK_START))
148
822
  return { path: hook.path, changed: false, planned: false };
@@ -151,8 +825,8 @@ var mise = {
151
825
  const separator = current.length === 0 || current.endsWith(`
152
826
  `) ? "" : `
153
827
  `;
154
- await mkdir2(dirname2(hook.path), { recursive: true });
155
- await writeFile2(hook.path, `${current}${separator}${hook.content}`, "utf8");
828
+ await mkdir3(dirname3(hook.path), { recursive: true });
829
+ await writeFile3(hook.path, `${current}${separator}${hook.content}`, "utf8");
156
830
  return { path: hook.path, changed: true, planned: false };
157
831
  },
158
832
  async toolCheckGlobal(executable, tool, minimumVersion) {
@@ -169,7 +843,7 @@ var mise = {
169
843
  async toolInstallLocal(executable, specification, cwd = process.cwd()) {
170
844
  await runChecked(executable, ["use", "--path", cwd, specification], { cwd });
171
845
  },
172
- async toolUpdateAllGlobal(executable, homeDir = homedir2()) {
846
+ async toolUpdateAllGlobal(executable, homeDir = homedir4()) {
173
847
  await runChecked(executable, ["upgrade"], { cwd: homeDir });
174
848
  }
175
849
  };
@@ -178,7 +852,7 @@ function getShellHook(shell, executable, homeDir) {
178
852
  switch (shell.toLowerCase()) {
179
853
  case "zsh":
180
854
  return {
181
- path: join3(homeDir, ".zshrc"),
855
+ path: join5(homeDir, ".zshrc"),
182
856
  content: `${MISE_HOOK_START}
183
857
  eval "$(${command} activate zsh)"
184
858
  ${MISE_HOOK_END}
@@ -186,7 +860,7 @@ ${MISE_HOOK_END}
186
860
  };
187
861
  case "fish":
188
862
  return {
189
- path: join3(homeDir, ".config", "fish", "config.fish"),
863
+ path: join5(homeDir, ".config", "fish", "config.fish"),
190
864
  content: `${MISE_HOOK_START}
191
865
  ${command} activate fish | source
192
866
  ${MISE_HOOK_END}
@@ -195,7 +869,7 @@ ${MISE_HOOK_END}
195
869
  case "nu":
196
870
  case "nushell":
197
871
  return {
198
- path: join3(homeDir, ".config", "nushell", "config.nu"),
872
+ path: join5(homeDir, ".config", "nushell", "config.nu"),
199
873
  content: `${MISE_HOOK_START}
200
874
  let mise_bin = ${command}
201
875
  let mise_path = $nu.default-config-dir | path join mise.nu
@@ -206,7 +880,7 @@ ${MISE_HOOK_END}
206
880
  };
207
881
  case "xonsh":
208
882
  return {
209
- path: join3(homeDir, ".xonshrc"),
883
+ path: join5(homeDir, ".xonshrc"),
210
884
  content: `${MISE_HOOK_START}
211
885
  execx($(${command} activate xonsh))
212
886
  ${MISE_HOOK_END}
@@ -214,7 +888,7 @@ ${MISE_HOOK_END}
214
888
  };
215
889
  case "elvish":
216
890
  return {
217
- path: join3(homeDir, ".config", "elvish", "rc.elv"),
891
+ path: join5(homeDir, ".config", "elvish", "rc.elv"),
218
892
  content: `${MISE_HOOK_START}
219
893
  var mise: = (ns [&])
220
894
  eval (${command} activate elvish | slurp) &ns=$mise: &on-end={|ns| set mise: = $ns }
@@ -225,7 +899,7 @@ ${MISE_HOOK_END}
225
899
  case "pwsh":
226
900
  case "powershell":
227
901
  return {
228
- path: join3(homeDir, ".config", "powershell", "Microsoft.PowerShell_profile.ps1"),
902
+ path: join5(homeDir, ".config", "powershell", "Microsoft.PowerShell_profile.ps1"),
229
903
  content: `${MISE_HOOK_START}
230
904
  (& ${command} activate pwsh) | Out-String | Invoke-Expression
231
905
  ${MISE_HOOK_END}
@@ -234,7 +908,7 @@ ${MISE_HOOK_END}
234
908
  case "bash":
235
909
  default:
236
910
  return {
237
- path: join3(homeDir, ".bashrc"),
911
+ path: join5(homeDir, ".bashrc"),
238
912
  content: `${MISE_HOOK_START}
239
913
  eval "$(${command} activate bash)"
240
914
  ${MISE_HOOK_END}
@@ -273,7 +947,7 @@ function isVersionAtLeast(version, minimumVersion) {
273
947
  }
274
948
  async function getOptionalFile2(path) {
275
949
  try {
276
- return await readFile2(path, "utf8");
950
+ return await readFile4(path, "utf8");
277
951
  } catch (error) {
278
952
  if (error instanceof Error && "code" in error && error.code === "ENOENT")
279
953
  return "";
@@ -283,80 +957,354 @@ async function getOptionalFile2(path) {
283
957
  function getShellQuoted(value) {
284
958
  return `'${value.replaceAll("'", "'\\''")}'`;
285
959
  }
286
- // src/pi.ts
287
- import { mkdir as mkdir3, readFile as readFile3, writeFile as writeFile3 } from "node:fs/promises";
288
- import { homedir as homedir3 } from "node:os";
289
- import { dirname as dirname3, join as join4 } from "node:path";
290
- var pi = {
291
- async executableCheck() {
292
- return findExecutable("pi");
293
- },
294
- async packageList(executable) {
295
- return (await runChecked(executable, ["list"])).stdout;
296
- },
297
- packageCheck(listOutput, source) {
298
- if (listOutput.includes(source))
299
- return true;
300
- return source.startsWith("https://") && listOutput.includes(source.slice("https://".length));
301
- },
302
- async packageInstall(executable, source) {
303
- await runChecked(executable, ["install", source]);
304
- },
305
- agentDir(homeDir = homedir3()) {
306
- return getAgentDir(homeDir);
307
- },
308
- async skillCheckGlobal(name, agentDir = getAgentDir(), sharedSkillsDir = join4(homedir3(), ".agents", "skills")) {
309
- const roots = [join4(agentDir, "skills"), sharedSkillsDir];
310
- for (const root of roots) {
311
- if (await getOptionalFile3(join4(root, name, "SKILL.md")) !== undefined)
312
- return true;
960
+ // src/modes.ts
961
+ import {
962
+ getAgentDir,
963
+ parseFrontmatter as parseFrontmatter2
964
+ } from "@earendil-works/pi-coding-agent";
965
+ import { readFile as readFile5 } from "node:fs/promises";
966
+ import { homedir as homedir5 } from "node:os";
967
+ import { basename as basename3, extname, join as join7 } from "node:path";
968
+
969
+ // src/assets.ts
970
+ import { existsSync } from "node:fs";
971
+ import { dirname as dirname4, join as join6 } from "node:path";
972
+ import { fileURLToPath } from "node:url";
973
+ function resolveBundledAgentsDir(moduleUrl = import.meta.url) {
974
+ const moduleDir = dirname4(fileURLToPath(moduleUrl));
975
+ const candidates = [
976
+ join6(moduleDir, "agents"),
977
+ join6(moduleDir, "..", "agents"),
978
+ join6(moduleDir, "..", "..", "agents")
979
+ ];
980
+ return candidates.find((path) => existsSync(path)) ?? candidates[1];
981
+ }
982
+
983
+ // src/modes.ts
984
+ async function discoverAgentModes(options) {
985
+ const agentDir = options.agentDir ?? getAgentDir();
986
+ const homeDir = options.homeDir ?? homedir5();
987
+ const modes = new Map;
988
+ const diagnostics = [];
989
+ await loadAgentModes(options.bundledAgentsDir ?? BUNDLED_AGENTS_DIR, "diffpi agent", modes, diagnostics);
990
+ await loadAgentModes(join7(agentDir, "agents"), "user agent", modes, diagnostics);
991
+ if (options.includeSkills) {
992
+ await loadSkillModes(join7(homeDir, ".agents", "skills"), "user skill", modes, diagnostics);
993
+ await loadSkillModes(join7(agentDir, "skills"), "pi user skill", modes, diagnostics);
994
+ }
995
+ if (options.projectTrusted === true) {
996
+ if (options.includeSkills) {
997
+ await loadSkillModes(join7(options.cwd, ".agents", "skills"), "project skill", modes, diagnostics);
998
+ await loadSkillModes(join7(options.cwd, ".pi", "skills"), "pi project skill", modes, diagnostics);
313
999
  }
314
- return false;
315
- },
316
- async skillInstallGlobal(miseExecutable, source, names) {
317
- const selection = names.flatMap((name) => ["--skill", name]);
318
- await runChecked(miseExecutable, [
319
- "x",
320
- "node@22",
321
- "--",
322
- "npx",
323
- "-y",
324
- "skills",
325
- "add",
326
- source,
327
- ...selection,
328
- "--global",
329
- "--agent",
330
- "pi",
331
- "--yes"
332
- ]);
333
- },
334
- async configEnsure(path, update, dryRun = false) {
335
- const currentText = await getOptionalFile3(path);
336
- const current = getParsedObject(currentText, path);
337
- const next = update(current);
338
- const changed = JSON.stringify(current) !== JSON.stringify(next);
339
- if (changed && !dryRun) {
340
- await mkdir3(dirname3(path), { recursive: true });
341
- await writeFile3(path, `${JSON.stringify(next, null, 2)}
342
- `, "utf8");
1000
+ await loadAgentModes(join7(options.cwd, ".agents", "agents"), "project agent", modes, diagnostics);
1001
+ await loadAgentModes(join7(options.cwd, ".pi", "agents"), "pi project agent", modes, diagnostics);
1002
+ }
1003
+ const userConfig = await loadDiffpiConfig({ homeDir });
1004
+ const configuredModes = [...modes.values()].map((mode) => ({
1005
+ ...mode,
1006
+ modelPreferences: resolveAgentModelPreferences(mode.id, mode.modelPreferences, userConfig.config)
1007
+ }));
1008
+ return {
1009
+ modes: configuredModes.sort((left, right) => left.id.localeCompare(right.id)),
1010
+ diagnostics
1011
+ };
1012
+ }
1013
+ function resolveAgentMode(modes, requested) {
1014
+ const name = requested.trim();
1015
+ if (!name)
1016
+ return { ok: false, message: "Agent name is required." };
1017
+ const exact = modes.find((mode) => mode.id === name);
1018
+ if (exact)
1019
+ return { ok: true, active: exact, message: `Active inline agent: ${exact.id}.` };
1020
+ const lowerName = name.toLowerCase();
1021
+ const matches = modes.filter((mode) => mode.id.toLowerCase() === lowerName);
1022
+ if (matches.length === 1) {
1023
+ const active = matches[0];
1024
+ return { ok: true, active, message: `Active inline agent: ${active.id}.` };
1025
+ }
1026
+ if (matches.length > 1) {
1027
+ return {
1028
+ ok: false,
1029
+ message: `Inline agent "${name}" is ambiguous. Use one of: ${matches.map((mode) => mode.id).join(", ")}.`
1030
+ };
1031
+ }
1032
+ return { ok: false, message: `Unknown inline agent "${name}". Run /skill:mode or diffpi_modes_list.` };
1033
+ }
1034
+ function createModeController(pi, options = {}) {
1035
+ let active;
1036
+ let baseline;
1037
+ const updateStatus = (ctx) => {
1038
+ ctx.ui.setStatus(MODE_STATUS_KEY, active ? `mode: ${active.id}` : undefined);
1039
+ };
1040
+ const list = (ctx, listOptions = {}) => discoverAgentModes({
1041
+ cwd: ctx.cwd,
1042
+ agentDir: options.agentDir,
1043
+ bundledAgentsDir: options.bundledAgentsDir,
1044
+ homeDir: options.homeDir,
1045
+ projectTrusted: ctx.isProjectTrusted(),
1046
+ includeSkills: listOptions.includeSkills
1047
+ });
1048
+ return {
1049
+ list,
1050
+ async set(agent, ctx) {
1051
+ const catalog = await list(ctx, { includeSkills: agent.includes(":") });
1052
+ const result = resolveAgentMode(catalog.modes, agent);
1053
+ if (!result.ok || !result.active)
1054
+ return result;
1055
+ baseline ??= captureRuntime(pi, ctx);
1056
+ if (active && baseline)
1057
+ await restoreRuntime(pi, baseline, ctx);
1058
+ active = result.active;
1059
+ const runtimeMessage = await applyModeRuntime(pi, active, ctx);
1060
+ pi.appendEntry(MODE_STATE_ENTRY, { active, baseline });
1061
+ updateStatus(ctx);
1062
+ return { ...result, message: `${result.message} ${runtimeMessage}` };
1063
+ },
1064
+ async unset(ctx) {
1065
+ if (!active)
1066
+ return { ok: true, message: "Inline agent is already clear." };
1067
+ if (baseline)
1068
+ await restoreRuntime(pi, baseline, ctx);
1069
+ active = undefined;
1070
+ pi.appendEntry(MODE_STATE_ENTRY, { active: null });
1071
+ baseline = undefined;
1072
+ updateStatus(ctx);
1073
+ return { ok: true, message: "Inline agent cleared. The previous model, thinking, tools, and prompt resume." };
1074
+ },
1075
+ async restore(ctx) {
1076
+ const previousActive = active;
1077
+ const previousBaseline = baseline;
1078
+ const entry = [...ctx.sessionManager.getBranch()].reverse().find((candidate) => candidate.type === "custom" && candidate.customType === MODE_STATE_ENTRY);
1079
+ const restored = entry?.data?.active;
1080
+ const restoredBaseline = entry?.data?.baseline;
1081
+ if (isAgentModeSnapshot(restored)) {
1082
+ active = restored;
1083
+ baseline = isModeBaseline(restoredBaseline) ? restoredBaseline : previousBaseline;
1084
+ await applyModeRuntime(pi, active, ctx);
1085
+ } else {
1086
+ if (previousActive && previousBaseline)
1087
+ pi.setActiveTools(previousBaseline.tools);
1088
+ active = undefined;
1089
+ baseline = undefined;
1090
+ }
1091
+ updateStatus(ctx);
1092
+ },
1093
+ apply(systemPrompt) {
1094
+ if (!active)
1095
+ return systemPrompt;
1096
+ if (active.promptStrategy === "replace")
1097
+ return active.systemPrompt;
1098
+ return `${systemPrompt}
1099
+
1100
+ ## Active inline agent: ${active.label}
1101
+
1102
+ ${active.systemPrompt}`;
1103
+ },
1104
+ getActive() {
1105
+ return active;
343
1106
  }
344
- return { path, changed, existed: currentText !== undefined, planned: changed && dryRun };
1107
+ };
1108
+ }
1109
+ var MODE_STATE_ENTRY = "diffpi-mode-state";
1110
+ var MODE_STATUS_KEY = "diffpi-mode";
1111
+ var MODE_CONTROL_TOOLS = ["ask_user_question", "diffpi_modes_list", "diffpi_modes_set", "diffpi_modes_unset"];
1112
+ var BUNDLED_AGENTS_DIR = resolveBundledAgentsDir();
1113
+ var THINKING_LEVELS = new Set(["off", "minimal", "low", "medium", "high", "xhigh", "max"]);
1114
+ async function applyModeRuntime(pi, mode, ctx) {
1115
+ let selectedModel;
1116
+ if (mode.modelPreferences.length > 0) {
1117
+ const scoped = ctx.scopedModels.length > 0 ? ctx.scopedModels.map((entry) => entry.model) : undefined;
1118
+ const availableModels = scoped ?? ctx.modelRegistry.getAvailable();
1119
+ for (const preference of mode.modelPreferences) {
1120
+ const model = findPreferredModel(availableModels, preference);
1121
+ if (model && await pi.setModel(model)) {
1122
+ selectedModel = `${model.provider}/${model.id}`;
1123
+ break;
1124
+ }
1125
+ }
1126
+ }
1127
+ if (mode.thinkingLevel)
1128
+ pi.setThinkingLevel(mode.thinkingLevel);
1129
+ if (mode.tools.length > 0) {
1130
+ const availableTools = new Set(pi.getAllTools().map((tool) => tool.name));
1131
+ const selectedTools = [...new Set([...mode.tools, ...MODE_CONTROL_TOOLS])].filter((tool) => availableTools.has(tool));
1132
+ if (selectedTools.length > 0)
1133
+ pi.setActiveTools(selectedTools);
345
1134
  }
1135
+ const parts = [];
1136
+ if (mode.modelPreferences.length > 0) {
1137
+ parts.push(selectedModel ? `Model: ${selectedModel}.` : "No preferred model was available; kept the current model.");
1138
+ }
1139
+ if (mode.thinkingLevel)
1140
+ parts.push(`Thinking: ${mode.thinkingLevel}.`);
1141
+ if (mode.tools.length > 0)
1142
+ parts.push("Applied the profile tool set.");
1143
+ return parts.join(" ") || "The profile changes the prompt only.";
1144
+ }
1145
+ async function restoreRuntime(pi, state, ctx) {
1146
+ if (state.model) {
1147
+ const model = ctx.modelRegistry.find(state.model.provider, state.model.id);
1148
+ if (model)
1149
+ await pi.setModel(model);
1150
+ }
1151
+ pi.setThinkingLevel(state.thinkingLevel);
1152
+ pi.setActiveTools(state.tools);
1153
+ }
1154
+ function captureRuntime(pi, ctx) {
1155
+ return {
1156
+ model: ctx.model ? { provider: ctx.model.provider, id: ctx.model.id } : undefined,
1157
+ thinkingLevel: pi.getThinkingLevel(),
1158
+ tools: pi.getActiveTools()
1159
+ };
1160
+ }
1161
+ async function loadSkillModes(skillsDir, source, modes, diagnostics) {
1162
+ const entries = await readDirectoryIfExists(skillsDir);
1163
+ for (const entry of entries.filter((item) => item.isDirectory()).sort((a, b) => a.name.localeCompare(b.name))) {
1164
+ await loadAgentModes(join7(skillsDir, entry.name, "agents"), `${source} ${entry.name}`, modes, diagnostics, entry.name);
1165
+ }
1166
+ }
1167
+ async function loadAgentModes(directory, source, modes, diagnostics, skillName) {
1168
+ const entries = await readDirectoryIfExists(directory);
1169
+ for (const entry of entries.filter((item) => item.isFile() && item.name.endsWith(".md")).sort((a, b) => a.name.localeCompare(b.name))) {
1170
+ const path = join7(directory, entry.name);
1171
+ try {
1172
+ const content = await readFile5(path, "utf8");
1173
+ const { frontmatter, body } = parseFrontmatter2(content.startsWith("\uFEFF") ? content.slice(1) : content);
1174
+ if (frontmatter.enabled === false || frontmatter.inline === false)
1175
+ continue;
1176
+ const name = getFrontmatterText(frontmatter.name) ?? basename3(path, extname(path));
1177
+ const systemPrompt = body.trim();
1178
+ if (!name || name.includes(":") || !systemPrompt) {
1179
+ diagnostics.push(`Skipped ${path}: agent name must not contain ":" and prompt body is required.`);
1180
+ continue;
1181
+ }
1182
+ const id = skillName ? `${skillName}:${name}` : name;
1183
+ modes.set(id, {
1184
+ id,
1185
+ label: getFrontmatterText(frontmatter.display_name) ?? name,
1186
+ description: getFrontmatterText(frontmatter.description) ?? `Inline agent from ${basename3(path)}`,
1187
+ systemPrompt,
1188
+ promptStrategy: frontmatter.prompt_mode === "append" ? "append" : "replace",
1189
+ modelPreferences: [
1190
+ ...getFrontmatterList(frontmatter.model),
1191
+ ...getFrontmatterList(frontmatter.model_fallbacks)
1192
+ ],
1193
+ thinkingLevel: getThinkingLevel(frontmatter.thinking),
1194
+ tools: getFrontmatterList(frontmatter.tools),
1195
+ source,
1196
+ sourcePath: path
1197
+ });
1198
+ } catch (error) {
1199
+ diagnostics.push(`Skipped ${path}: ${error instanceof Error ? error.message : String(error)}`);
1200
+ }
1201
+ }
1202
+ }
1203
+ function getFrontmatterText(value) {
1204
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
1205
+ }
1206
+ function getFrontmatterList(value) {
1207
+ const values = Array.isArray(value) ? value : typeof value === "string" ? value.split(",") : [];
1208
+ return values.filter((item) => typeof item === "string").map((item) => item.trim()).filter(Boolean);
1209
+ }
1210
+ function getThinkingLevel(value) {
1211
+ const level = getFrontmatterText(value);
1212
+ return level && THINKING_LEVELS.has(level) ? level : undefined;
1213
+ }
1214
+ function isAgentModeSnapshot(value) {
1215
+ if (!value || typeof value !== "object")
1216
+ return false;
1217
+ const candidate = value;
1218
+ return typeof candidate.id === "string" && typeof candidate.label === "string" && typeof candidate.description === "string" && typeof candidate.systemPrompt === "string" && (candidate.promptStrategy === "append" || candidate.promptStrategy === "replace") && Array.isArray(candidate.modelPreferences) && (candidate.thinkingLevel === undefined || THINKING_LEVELS.has(candidate.thinkingLevel)) && Array.isArray(candidate.tools) && typeof candidate.source === "string" && typeof candidate.sourcePath === "string";
1219
+ }
1220
+ function isModeBaseline(value) {
1221
+ if (!value || typeof value !== "object")
1222
+ return false;
1223
+ const candidate = value;
1224
+ const model = candidate.model;
1225
+ return (model === undefined || typeof model.provider === "string" && typeof model.id === "string") && candidate.thinkingLevel !== undefined && THINKING_LEVELS.has(candidate.thinkingLevel) && Array.isArray(candidate.tools) && candidate.tools.every((tool) => typeof tool === "string");
1226
+ }
1227
+ // src/pi.ts
1228
+ import { mkdir as mkdir4, writeFile as writeFile4 } from "node:fs/promises";
1229
+ import { homedir as homedir6 } from "node:os";
1230
+ import { dirname as dirname5, join as join8 } from "node:path";
1231
+ var pi = {
1232
+ executableCheck: findPiExecutable,
1233
+ packageList: listPiPackages,
1234
+ packageCheck: hasPiPackage,
1235
+ packageInstall: installPiPackage,
1236
+ agentDir: resolvePiAgentDir,
1237
+ agentEnsure: ensurePiAgent,
1238
+ skillCheckGlobal: checkGlobalPiSkill,
1239
+ skillInstallGlobal: installGlobalPiSkills,
1240
+ configEnsure: ensurePiConfig
346
1241
  };
347
- function getAgentDir(homeDir = homedir3()) {
348
- return process.env.PI_CODING_AGENT_DIR ?? (process.env.XDG_CONFIG_HOME ? join4(process.env.XDG_CONFIG_HOME, "pi") : join4(homeDir, ".pi", "agent"));
1242
+ async function ensurePiAgent(filename, content, agentDir = resolvePiAgentDir(), dryRun = false) {
1243
+ const path = join8(agentDir, "agents", filename);
1244
+ const currentText = await readTextIfExists(path);
1245
+ const changed = currentText !== content;
1246
+ if (changed && !dryRun) {
1247
+ await mkdir4(dirname5(path), { recursive: true });
1248
+ await writeFile4(path, content, "utf8");
1249
+ }
1250
+ return { path, changed, existed: currentText !== undefined, planned: changed && dryRun };
349
1251
  }
350
- async function getOptionalFile3(path) {
351
- try {
352
- return await readFile3(path, "utf8");
353
- } catch (error) {
354
- if (error instanceof Error && "code" in error && error.code === "ENOENT")
355
- return;
356
- throw error;
1252
+ async function checkGlobalPiSkill(name, agentDir = resolvePiAgentDir(), sharedSkillsDir = join8(homedir6(), ".agents", "skills")) {
1253
+ const roots = [join8(agentDir, "skills"), sharedSkillsDir];
1254
+ for (const root of roots) {
1255
+ if (await readTextIfExists(join8(root, name, "SKILL.md")) !== undefined)
1256
+ return true;
357
1257
  }
1258
+ return false;
1259
+ }
1260
+ async function installGlobalPiSkills(miseExecutable, source, names) {
1261
+ const selection = names.flatMap((name) => ["--skill", name]);
1262
+ await runChecked(miseExecutable, [
1263
+ "x",
1264
+ "node@22",
1265
+ "--",
1266
+ "npx",
1267
+ "-y",
1268
+ "skills",
1269
+ "add",
1270
+ source,
1271
+ ...selection,
1272
+ "--global",
1273
+ "--agent",
1274
+ "pi",
1275
+ "--yes"
1276
+ ]);
1277
+ }
1278
+ async function ensurePiConfig(path, update, dryRun = false) {
1279
+ const currentText = await readTextIfExists(path);
1280
+ const current = parseJsonObject(currentText, path);
1281
+ const next = update(current);
1282
+ const changed = JSON.stringify(current) !== JSON.stringify(next);
1283
+ if (changed && !dryRun) {
1284
+ await mkdir4(dirname5(path), { recursive: true });
1285
+ await writeFile4(path, `${JSON.stringify(next, null, 2)}
1286
+ `, "utf8");
1287
+ }
1288
+ return { path, changed, existed: currentText !== undefined, planned: changed && dryRun };
1289
+ }
1290
+ async function findPiExecutable() {
1291
+ return findExecutable("pi");
1292
+ }
1293
+ async function listPiPackages(executable) {
1294
+ return (await runChecked(executable, ["list"])).stdout;
358
1295
  }
359
- function getParsedObject(content, path) {
1296
+ function hasPiPackage(listOutput, source) {
1297
+ if (listOutput.includes(source))
1298
+ return true;
1299
+ return source.startsWith("https://") && listOutput.includes(source.slice("https://".length));
1300
+ }
1301
+ async function installPiPackage(executable, source) {
1302
+ await runChecked(executable, ["install", source]);
1303
+ }
1304
+ function resolvePiAgentDir(homeDir = homedir6()) {
1305
+ return process.env.PI_CODING_AGENT_DIR ?? (process.env.XDG_CONFIG_HOME ? join8(process.env.XDG_CONFIG_HOME, "pi") : join8(homeDir, ".pi", "agent"));
1306
+ }
1307
+ function parseJsonObject(content, path) {
360
1308
  if (!content?.trim())
361
1309
  return {};
362
1310
  try {
@@ -366,9 +1314,290 @@ function getParsedObject(content, path) {
366
1314
  } catch {}
367
1315
  throw new Error(`Expected valid JSON object in ${path}.`);
368
1316
  }
1317
+ // src/store.ts
1318
+ import { createHash } from "node:crypto";
1319
+ import { existsSync as existsSync2 } from "node:fs";
1320
+ import { mkdir as mkdir5, realpath, symlink } from "node:fs/promises";
1321
+ import { homedir as homedir7 } from "node:os";
1322
+ import { isAbsolute, join as join9, resolve } from "node:path";
1323
+ var STORE_LINK = join9(".pi", "diffpi");
1324
+ async function gitToplevel(cwd) {
1325
+ const result = await run("git", ["-C", cwd, "rev-parse", "--show-toplevel"]);
1326
+ const top = result.stdout.trim();
1327
+ return result.code === 0 && top ? top : resolve(cwd);
1328
+ }
1329
+ async function computeProjectSlug(cwd) {
1330
+ const root = await gitToplevel(cwd);
1331
+ const remoteResult = await run("git", ["-C", root, "remote", "get-url", "origin"]);
1332
+ const remote = remoteResult.code === 0 ? remoteResult.stdout.trim() : "";
1333
+ const commonResult = await run("git", ["-C", root, "rev-parse", "--git-common-dir"]);
1334
+ const common = commonResult.stdout.trim();
1335
+ let commonPath = root;
1336
+ if (commonResult.code === 0 && common) {
1337
+ const resolvedCommon = isAbsolute(common) ? common : join9(root, common);
1338
+ commonPath = resolve(resolvedCommon);
1339
+ }
1340
+ const canonicalCommon = await canonicalPath(commonPath);
1341
+ const identity = remote ? `remote:${normalizeRemote(remote)}` : `git-common-dir:${canonicalCommon}`;
1342
+ const name = remote ? repositoryName(remote) : basename4(resolve(canonicalCommon, "..")) || basename4(root);
1343
+ const readable = name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "unnamed";
1344
+ const digest = createHash("sha256").update(identity).digest("hex").slice(0, 12);
1345
+ return `${readable}-${digest}`;
1346
+ }
1347
+ function storeGlobalRoot(homeDir = homedir7()) {
1348
+ return join9(homeDir, ".difflab", "diffpi", "projects");
1349
+ }
1350
+ async function ensureStore(cwd, homeDir = homedir7()) {
1351
+ const root = await gitToplevel(cwd);
1352
+ const slug = await computeProjectSlug(root);
1353
+ const dest = join9(storeGlobalRoot(homeDir), slug);
1354
+ const link = join9(root, STORE_LINK);
1355
+ if (existsSync2(link))
1356
+ return { slug, root, dest, link, linked: true };
1357
+ await mkdir5(dest, { recursive: true });
1358
+ await mkdir5(join9(root, ".pi"), { recursive: true });
1359
+ await symlink(dest, link);
1360
+ return { slug, root, dest, link, linked: true };
1361
+ }
1362
+ async function storeDir(cwd, homeDir = homedir7()) {
1363
+ return (await ensureStore(cwd, homeDir)).dest;
1364
+ }
1365
+ async function reviewsDir(cwd, homeDir = homedir7()) {
1366
+ const dir = join9(await storeDir(cwd, homeDir), "reviews");
1367
+ await mkdir5(dir, { recursive: true });
1368
+ return dir;
1369
+ }
1370
+ async function sessionsDir(cwd, homeDir = homedir7()) {
1371
+ const dir = join9(await storeDir(cwd, homeDir), "sessions");
1372
+ await mkdir5(dir, { recursive: true });
1373
+ return dir;
1374
+ }
1375
+ async function canonicalPath(path) {
1376
+ try {
1377
+ return await realpath(path);
1378
+ } catch {
1379
+ return resolve(path);
1380
+ }
1381
+ }
1382
+ function normalizeRemote(remote) {
1383
+ return remote.trim().replace(/\.git\/?$/i, "").replace(/\/+$/, "").toLowerCase();
1384
+ }
1385
+ function repositoryName(remote) {
1386
+ const normalized = remote.trim().replace(/\.git\/?$/i, "").replace(/\/+$/, "");
1387
+ return normalized.split(/[/\\:]/).filter(Boolean).at(-1) ?? "";
1388
+ }
1389
+ function basename4(path) {
1390
+ const parts = resolve(path).split(/[/\\]/).filter(Boolean);
1391
+ return parts.at(-1) ?? "";
1392
+ }
1393
+ // src/tuicr.ts
1394
+ import { readFile as readFile6, realpath as realpath2 } from "node:fs/promises";
1395
+ import { resolve as resolve2 } from "node:path";
1396
+ async function tuicrAvailable() {
1397
+ return (await run("tuicr", ["--version"])).code === 0;
1398
+ }
1399
+ async function listSessions(repo = ".") {
1400
+ const result = await run("tuicr", ["review", "list", "--repo", repo]);
1401
+ if (result.code !== 0 || !result.stdout.trim())
1402
+ return [];
1403
+ let raw;
1404
+ try {
1405
+ raw = JSON.parse(result.stdout);
1406
+ } catch {
1407
+ return [];
1408
+ }
1409
+ return raw.map((entry) => ({
1410
+ slug: entry.slug,
1411
+ kind: entry.kind,
1412
+ path: entry.path,
1413
+ updatedAt: entry.updated_at,
1414
+ commentCount: entry.comment_count,
1415
+ anchor: entry.anchor,
1416
+ active: entry.active
1417
+ }));
1418
+ }
1419
+ async function resolveSession(cwd, branch) {
1420
+ return findMatchingSession(await listSessions(cwd), cwd, branch);
1421
+ }
1422
+ async function findMatchingSession(sessions, cwd, branch) {
1423
+ const repository = await canonicalPath2(await gitToplevel(cwd));
1424
+ for (const session of sessions) {
1425
+ if (session.kind !== "local")
1426
+ continue;
1427
+ try {
1428
+ const data = await readSession(session.path);
1429
+ if (data.branch_name !== branch || !data.repo_path)
1430
+ continue;
1431
+ if (await canonicalPath2(data.repo_path) === repository)
1432
+ return session;
1433
+ } catch {}
1434
+ }
1435
+ return;
1436
+ }
1437
+ async function readSession(path) {
1438
+ const content = await readFile6(path, "utf8");
1439
+ try {
1440
+ return JSON.parse(content);
1441
+ } catch {
1442
+ throw new Error(`Cannot parse tuicr session JSON: ${path}`);
1443
+ }
1444
+ }
1445
+ async function addComment(session, body, opts = {}) {
1446
+ const args = ["review", "add", "--session", session, body];
1447
+ if (opts.targetFile)
1448
+ args.push("--target-file", opts.targetFile);
1449
+ if (opts.line !== undefined)
1450
+ args.push("--line", String(opts.line));
1451
+ if (opts.username)
1452
+ args.push("--username", opts.username);
1453
+ await runChecked("tuicr", args);
1454
+ }
1455
+ async function launch(cwd) {
1456
+ return openInNewTab(["tuicr", "-w"], { cwd, name: "tuicr" });
1457
+ }
1458
+ function toFindings(session) {
1459
+ const comments = [];
1460
+ const bodyParts = (session.review_comments ?? []).map((comment) => comment.content);
1461
+ for (const [file, entry] of Object.entries(session.files ?? {})) {
1462
+ const fileComments = (entry.file_comments ?? []).map((comment) => comment.content);
1463
+ if (fileComments.length > 0)
1464
+ bodyParts.push(`File: ${file}
1465
+
1466
+ ${fileComments.join(`
1467
+
1468
+ `)}`);
1469
+ for (const [lineKey, lineComments] of Object.entries(entry.line_comments ?? {})) {
1470
+ const line = Number.parseInt(lineKey, 10);
1471
+ if (!Number.isFinite(line))
1472
+ continue;
1473
+ for (const lineComment of lineComments) {
1474
+ comments.push({ file, line, side: lineComment.side === "old" ? "LEFT" : "RIGHT", body: lineComment.content });
1475
+ }
1476
+ }
1477
+ }
1478
+ return {
1479
+ comments,
1480
+ body: bodyParts.join(`
1481
+
1482
+ `)
1483
+ };
1484
+ }
1485
+ async function canonicalPath2(path) {
1486
+ try {
1487
+ return await realpath2(path);
1488
+ } catch {
1489
+ return resolve2(path);
1490
+ }
1491
+ }
1492
+ // src/review.ts
1493
+ import { join as join10 } from "node:path";
1494
+ import { z as z2 } from "zod";
1495
+ var severitySchema = z2.enum(["BLOCKING", "CONSIDER", "NOTE"]);
1496
+ var findingSchema = z2.object({
1497
+ file: z2.string().min(1),
1498
+ line: z2.number().int().nonnegative(),
1499
+ severity: severitySchema,
1500
+ body: z2.string().min(1),
1501
+ reference: z2.string().optional().default("")
1502
+ });
1503
+ var findingsSchema = z2.array(findingSchema);
1504
+ function reviewSlug(input) {
1505
+ return input.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 50);
1506
+ }
1507
+ function mmddyy(date = new Date) {
1508
+ const mm = String(date.getMonth() + 1).padStart(2, "0");
1509
+ const dd = String(date.getDate()).padStart(2, "0");
1510
+ const yy = String(date.getFullYear() % 100).padStart(2, "0");
1511
+ return `${mm}${dd}${yy}`;
1512
+ }
1513
+ function reviewRecordName(branch, date = new Date) {
1514
+ return `${mmddyy(date)}-${reviewSlug(branch)}`;
1515
+ }
1516
+ function dedupeFindings(findings) {
1517
+ const rank = { BLOCKING: 3, CONSIDER: 2, NOTE: 1 };
1518
+ const byKey = new Map;
1519
+ for (const finding of findings) {
1520
+ const key = `${finding.file}:${finding.line}`;
1521
+ const existing = byKey.get(key);
1522
+ if (!existing || rank[finding.severity] > rank[existing.severity])
1523
+ byKey.set(key, finding);
1524
+ }
1525
+ return [...byKey.values()].sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line || rank[b.severity] - rank[a.severity]);
1526
+ }
1527
+ function toReviewComments(findings) {
1528
+ return findings.filter((finding) => finding.line > 0).map((finding) => ({
1529
+ file: finding.file,
1530
+ line: finding.line,
1531
+ side: "RIGHT",
1532
+ body: renderCommentBody(finding)
1533
+ }));
1534
+ }
1535
+ function renderReviewDoc(input) {
1536
+ const anchored = dedupeFindings(input.findings).filter((finding) => finding.line > 0);
1537
+ const lines = [`# Review: ${input.title}`, "", "## Metadata"];
1538
+ if (input.number !== undefined)
1539
+ lines.push(`- **PR/MR**: #${input.number}${input.url ? ` — ${input.url}` : ""}`);
1540
+ if (input.author)
1541
+ lines.push(`- **Author**: ${input.author}`);
1542
+ if (input.headRef && input.baseRef)
1543
+ lines.push(`- **Branch**: ${input.headRef} → ${input.baseRef}`);
1544
+ if (input.additions !== undefined)
1545
+ lines.push(`- **Stats**: +${input.additions} -${input.deletions ?? 0} across ${input.changedFiles ?? 0} files`);
1546
+ lines.push(`- **Reviewed**: ${input.timestamp ?? new Date().toISOString()}`, "");
1547
+ if (input.overallIssues.length > 0) {
1548
+ lines.push("## Overall issues", "");
1549
+ for (const issue of input.overallIssues)
1550
+ lines.push(`- ${issue}`);
1551
+ lines.push("");
1552
+ }
1553
+ lines.push("## Verification", "");
1554
+ for (const gate of input.gates)
1555
+ lines.push(`- ${gate.name}: ${gate.status} — ${gate.detail}`);
1556
+ lines.push("");
1557
+ if (input.notVerified.length > 0) {
1558
+ lines.push("## What was NOT verified", "");
1559
+ for (const item of input.notVerified)
1560
+ lines.push(`- ${item}`);
1561
+ lines.push("");
1562
+ }
1563
+ lines.push("## Inline Comments", "");
1564
+ for (const finding of anchored) {
1565
+ lines.push(`### ${finding.file}:${finding.line} — ${finding.severity}`, "", finding.body, "");
1566
+ if (finding.reference)
1567
+ lines.push(`> **Reference:** ${finding.reference}`, "");
1568
+ lines.push("---", "");
1569
+ }
1570
+ return `${lines.join(`
1571
+ `).trimEnd()}
1572
+ `;
1573
+ }
1574
+ function renderPrBody(intent, changes, validation) {
1575
+ const lines = ["## Intent", "", intent, "", "## Changes", ""];
1576
+ for (const change of changes)
1577
+ lines.push(`- ${change}`);
1578
+ lines.push("", "## Validation", "");
1579
+ for (const step of validation)
1580
+ lines.push(`- [ ] ${step}`);
1581
+ lines.push("- [ ] Existing tests pass", "");
1582
+ return `${lines.join(`
1583
+ `).trimEnd()}
1584
+ `;
1585
+ }
1586
+ function reviewWorkingDir(storeReviewsDir, slug) {
1587
+ return join10(storeReviewsDir, slug);
1588
+ }
1589
+ function renderCommentBody(finding) {
1590
+ const prefix = finding.severity === "BLOCKING" ? "**BLOCKING** " : "";
1591
+ const reference = finding.reference ? `
1592
+
1593
+ > **Reference:** ${finding.reference}` : "";
1594
+ return `${prefix}${finding.body}${reference}`;
1595
+ }
369
1596
  // src/setup.ts
370
- import { homedir as homedir4 } from "node:os";
371
- import { join as join5 } from "node:path";
1597
+ import { parseFrontmatter as parseFrontmatter3 } from "@earendil-works/pi-coding-agent";
1598
+ import { readdir as readdir2, readFile as readFile7 } from "node:fs/promises";
1599
+ import { homedir as homedir8 } from "node:os";
1600
+ import { basename as basename5, join as join11 } from "node:path";
372
1601
  var MISE_DEPENDENCIES = [
373
1602
  { name: "node", tool: "node", spec: "node@22", minimumVersion: "22.19.0" },
374
1603
  { name: "zellij", tool: "zellij", spec: "zellij@latest", minimumVersion: undefined },
@@ -399,9 +1628,14 @@ var PI_SKILL_SOURCES = [
399
1628
  { repository: "AminBlg/SimpleEnglish", skills: ["simple-english"] }
400
1629
  ];
401
1630
  var MCP_ADAPTER_PACKAGE = "npm:pi-mcp-adapter";
1631
+ var BUNDLED_AGENTS_DIR2 = resolveBundledAgentsDir();
1632
+ var FORGE_DEPENDENCIES = {
1633
+ github: { name: "gh", tool: "gh", spec: "gh@latest", minimumVersion: undefined },
1634
+ gitlab: { name: "glab", tool: "glab", spec: "glab@latest", minimumVersion: undefined }
1635
+ };
402
1636
  async function ensureMise(options = {}) {
403
- const homeDir = options.homeDir ?? homedir4();
404
- const current = await mise.executableCheck() ?? await mise.executableCheck(join5(homeDir, ".local", "bin", "mise"));
1637
+ const homeDir = options.homeDir ?? homedir8();
1638
+ const current = await mise.executableCheck() ?? await mise.executableCheck(join11(homeDir, ".local", "bin", "mise"));
405
1639
  if (current)
406
1640
  return { executable: current, action: createSetupAction("mise", "ready", current) };
407
1641
  reportProgress(options, "Installing mise");
@@ -430,7 +1664,10 @@ async function ensureMiseHooks(miseExecutable, options = {}) {
430
1664
  async function ensureMiseDeps(miseExecutable, options = {}) {
431
1665
  const canRunMise = Boolean(await mise.executableCheck(miseExecutable));
432
1666
  const actions = [];
433
- for (const dependency of MISE_DEPENDENCIES) {
1667
+ const dependencies = [...MISE_DEPENDENCIES];
1668
+ if (options.forge && options.forge !== "none")
1669
+ dependencies.push(FORGE_DEPENDENCIES[options.forge]);
1670
+ for (const dependency of dependencies) {
434
1671
  const installed = canRunMise && await mise.toolCheckGlobal(miseExecutable, dependency.tool, dependency.minimumVersion);
435
1672
  if (installed) {
436
1673
  actions.push(createSetupAction(dependency.name, "ready", dependency.spec));
@@ -446,18 +1683,33 @@ async function ensureMiseDeps(miseExecutable, options = {}) {
446
1683
  async function ensurePiPlugins(options = {}) {
447
1684
  const actions = await ensurePiPackages(PI_PACKAGES, options);
448
1685
  const agentDir = options.agentDir ?? pi.agentDir(options.homeDir);
449
- const webSearch = await pi.configEnsure(join5(agentDir, "web-search.json"), (config) => ({ ...config, workflow: "auto-summary" }), options.dryRun);
1686
+ const webSearch = await pi.configEnsure(join11(agentDir, "web-search.json"), (config) => ({ ...config, workflow: "auto-summary" }), options.dryRun);
450
1687
  actions.push(getConfigSetupAction("web search settings", webSearch));
451
- const lsp = await pi.configEnsure(join5(agentDir, "pi-lsp.json"), (config) => ({
1688
+ const lsp = await pi.configEnsure(join11(agentDir, "pi-lsp.json"), (config) => ({
452
1689
  ...config,
453
1690
  progressive: { ...getRecord(config.progressive), enabled: true, inject: "none" }
454
1691
  }), options.dryRun);
455
1692
  actions.push(getConfigSetupAction("pi-lsp settings", lsp));
456
1693
  return actions;
457
1694
  }
1695
+ async function ensurePiAgents(options = {}) {
1696
+ const agentDir = options.agentDir ?? pi.agentDir(options.homeDir);
1697
+ const bundledAgentsDir = options.bundledAgentsDir ?? BUNDLED_AGENTS_DIR2;
1698
+ const userConfig = await loadDiffpiConfig({ homeDir: options.homeDir });
1699
+ 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));
1700
+ const actions = [];
1701
+ for (const entry of entries) {
1702
+ const id = basename5(entry.name, ".md").replace(/^diffpi-/, "");
1703
+ const source = await readFile7(join11(bundledAgentsDir, entry.name), "utf8");
1704
+ const content = materializeAgentModels(source, id, userConfig.config, options.availableModels);
1705
+ const result = await pi.agentEnsure(entry.name, content, agentDir, options.dryRun);
1706
+ actions.push(getConfigSetupAction(`pi agent ${id}`, result));
1707
+ }
1708
+ return actions;
1709
+ }
458
1710
  async function ensurePiSkills(miseExecutable, options = {}) {
459
1711
  const agentDir = options.agentDir ?? pi.agentDir(options.homeDir);
460
- const sharedSkillsDir = join5(options.homeDir ?? homedir4(), ".agents", "skills");
1712
+ const sharedSkillsDir = join11(options.homeDir ?? homedir8(), ".agents", "skills");
461
1713
  const actions = [];
462
1714
  for (const source of PI_SKILL_SOURCES) {
463
1715
  const missing = [];
@@ -501,6 +1753,12 @@ async function ensureMcpAdapters(miseExecutable, options = {}) {
501
1753
  } else if (options.issueTracker === "jira") {
502
1754
  servers.atlassian = { url: "https://mcp.atlassian.com/v1/mcp", auth: "oauth", protocolVersion: "auto" };
503
1755
  }
1756
+ if (options.forge === "github") {
1757
+ servers.github = { url: "https://api.githubcopilot.com/mcp/", auth: "oauth", protocolVersion: "auto" };
1758
+ } else if (options.forge === "gitlab") {
1759
+ const host = (await detectVcs(projectDir)).host || "gitlab.com";
1760
+ servers.gitlab = { url: `https://${host}/api/v4/mcp`, auth: "oauth", protocolVersion: "auto" };
1761
+ }
504
1762
  const result = await mcp.serversEnsure(servers, {
505
1763
  dryRun: options.dryRun,
506
1764
  path: mcp.globalConfigPath(options.homeDir)
@@ -514,13 +1772,81 @@ async function setupPi(options = {}) {
514
1772
  actions.push(await ensureMiseHooks(miseResult.executable, options));
515
1773
  actions.push(...await ensureMiseDeps(miseResult.executable, options));
516
1774
  actions.push(...await ensurePiPlugins(options));
1775
+ actions.push(...await ensurePiAgents(options));
517
1776
  actions.push(...await ensurePiSkills(miseResult.executable, options));
518
1777
  actions.push(...await ensureMcpAdapters(miseResult.executable, options));
1778
+ if (options.bindZedKey)
1779
+ actions.push(...await ensureZedIntegration(options));
519
1780
  return {
520
1781
  actions,
521
- 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"))
1782
+ restartPi: setupRequiresRestart(actions)
522
1783
  };
523
1784
  }
1785
+ function setupRequiresRestart(actions) {
1786
+ 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"));
1787
+ }
1788
+ function materializeAgentModels(content, agentId, config, availableModels) {
1789
+ const { frontmatter } = parseFrontmatter3(content.startsWith("\uFEFF") ? content.slice(1) : content);
1790
+ const profilePreferences = [...getTextList(frontmatter.model), ...getTextList(frontmatter.model_fallbacks)];
1791
+ const preferences = resolveAgentModelPreferences(agentId, profilePreferences, config);
1792
+ let selectedIndex = availableModels === undefined && preferences.length > 0 ? 0 : -1;
1793
+ let selectedModel = selectedIndex === 0 ? preferences[0] : undefined;
1794
+ if (availableModels) {
1795
+ for (const [index, preference] of preferences.entries()) {
1796
+ const match = findPreferredModel(availableModels, preference);
1797
+ if (!match)
1798
+ continue;
1799
+ selectedIndex = index;
1800
+ selectedModel = `${match.provider}/${match.id}`;
1801
+ break;
1802
+ }
1803
+ }
1804
+ const fallbacks = preferences.filter((_preference, index) => index !== selectedIndex);
1805
+ return replaceAgentModelFields(content, selectedModel, fallbacks);
1806
+ }
1807
+ function replaceAgentModelFields(content, model, fallbacks) {
1808
+ const newline = content.includes(`\r
1809
+ `) ? `\r
1810
+ ` : `
1811
+ `;
1812
+ const lines = content.replaceAll(`\r
1813
+ `, `
1814
+ `).split(`
1815
+ `);
1816
+ const closingDelimiter = lines.indexOf("---", 1);
1817
+ if (lines[0] !== "---" || closingDelimiter < 0)
1818
+ return content;
1819
+ const frontmatter = lines.slice(1, closingDelimiter).filter((line) => !/^model(?:_fallbacks)?:/.test(line));
1820
+ if (model)
1821
+ frontmatter.push(`model: ${model}`);
1822
+ if (fallbacks.length > 0)
1823
+ frontmatter.push(`model_fallbacks: ${fallbacks.join(", ")}`);
1824
+ return ["---", ...frontmatter, "---", ...lines.slice(closingDelimiter + 1)].join(newline);
1825
+ }
1826
+ async function ensureZedIntegration(options = {}) {
1827
+ if (options.dryRun) {
1828
+ const actions = [createSetupAction("Zed review task", "planned", "tasks.json")];
1829
+ if (options.bindZedKey)
1830
+ actions.push(createSetupAction("Zed review keybinding", "planned", "keymap.json"));
1831
+ return actions;
1832
+ }
1833
+ const actions = [];
1834
+ try {
1835
+ const task = await ensureZedReviewTask(options.homeDir);
1836
+ actions.push(createSetupAction("Zed review task", task.changed ? "installed" : "ready", task.path));
1837
+ } catch (error) {
1838
+ actions.push(createSetupAction("Zed review task", "skipped", error instanceof Error ? error.message : String(error)));
1839
+ }
1840
+ if (options.bindZedKey) {
1841
+ try {
1842
+ const key = await ensureZedReviewKeybinding(options.homeDir);
1843
+ actions.push(createSetupAction("Zed review keybinding", key.changed ? "installed" : "ready", key.path));
1844
+ } catch (error) {
1845
+ actions.push(createSetupAction("Zed review keybinding", "skipped", error instanceof Error ? error.message : String(error)));
1846
+ }
1847
+ }
1848
+ return actions;
1849
+ }
524
1850
  async function ensurePiPackages(packages, options) {
525
1851
  const executable = await pi.executableCheck();
526
1852
  if (!executable && !options.dryRun)
@@ -542,6 +1868,10 @@ ${source}`;
542
1868
  }
543
1869
  return actions;
544
1870
  }
1871
+ function getTextList(value) {
1872
+ const values = Array.isArray(value) ? value : typeof value === "string" ? value.split(",") : [];
1873
+ return values.filter((item) => typeof item === "string").map((item) => item.trim()).filter(Boolean);
1874
+ }
545
1875
  function getConfigSetupAction(name, result) {
546
1876
  if (!result.changed)
547
1877
  return createSetupAction(name, "ready", result.path);
@@ -559,14 +1889,63 @@ function getRecord(value) {
559
1889
  return value !== null && typeof value === "object" && !Array.isArray(value) ? value : {};
560
1890
  }
561
1891
  export {
1892
+ CONVENTIONAL_COMMIT,
1893
+ ZED_REVIEW_TASK_NAME,
1894
+ addComment,
1895
+ checkConventionalSubject,
1896
+ ciGate,
1897
+ computeProjectSlug,
1898
+ createForge,
1899
+ createModeController,
1900
+ dedupeFindings,
1901
+ detectIde,
1902
+ detectMux,
1903
+ detectShell,
1904
+ detectVcs,
1905
+ diffpiConfigPaths,
1906
+ discoverAgentModes,
562
1907
  ensureMcpAdapters,
563
1908
  ensureMise,
564
1909
  ensureMiseDeps,
565
1910
  ensureMiseHooks,
1911
+ ensurePiAgents,
566
1912
  ensurePiPlugins,
567
1913
  ensurePiSkills,
1914
+ ensureStore,
1915
+ ensureZedReviewKeybinding,
1916
+ ensureZedReviewTask,
1917
+ findPreferredModel,
1918
+ findingSchema,
1919
+ findingsSchema,
1920
+ gitToplevel,
1921
+ launch,
1922
+ listSessions,
1923
+ loadDiffpiConfig,
568
1924
  mcp,
569
1925
  mise,
1926
+ mmddyy,
1927
+ openInNewTab,
1928
+ parseRemote,
570
1929
  pi,
571
- setupPi
1930
+ readSession,
1931
+ renderPrBody,
1932
+ renderReviewDoc,
1933
+ resolveAgentMode,
1934
+ resolveAgentModelPreferences,
1935
+ resolveSession,
1936
+ reviewRecordName,
1937
+ reviewSlug,
1938
+ reviewWorkingDir,
1939
+ reviewsDir,
1940
+ runMiseGates,
1941
+ sessionsDir,
1942
+ setupPi,
1943
+ severitySchema,
1944
+ storeDir,
1945
+ storeGlobalRoot,
1946
+ toFindings,
1947
+ toReviewComments,
1948
+ tuicrAvailable,
1949
+ zedKeymapPath,
1950
+ zedTasksPath
572
1951
  };