@difflab/pi 0.2.0 → 0.3.0

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 (94) hide show
  1. package/README.md +28 -4
  2. package/agents/diffpi-orchestrator.md +7 -5
  3. package/agents/diffpi-reviewer.md +36 -0
  4. package/dist/assets.d.ts +2 -0
  5. package/dist/assets.d.ts.map +1 -1
  6. package/dist/commands/index.d.ts +8 -0
  7. package/dist/commands/index.d.ts.map +1 -0
  8. package/dist/commands/mode.d.ts +5 -0
  9. package/dist/commands/mode.d.ts.map +1 -0
  10. package/dist/commands/reload.d.ts +4 -0
  11. package/dist/commands/reload.d.ts.map +1 -0
  12. package/dist/commands/review.d.ts +5 -0
  13. package/dist/commands/review.d.ts.map +1 -0
  14. package/dist/environment.d.ts +41 -0
  15. package/dist/environment.d.ts.map +1 -0
  16. package/dist/extensions/ghx.d.ts +5 -0
  17. package/dist/extensions/ghx.d.ts.map +1 -0
  18. package/dist/extensions/gitx.d.ts +10 -0
  19. package/dist/extensions/gitx.d.ts.map +1 -0
  20. package/dist/extensions/glabx.d.ts +5 -0
  21. package/dist/extensions/glabx.d.ts.map +1 -0
  22. package/dist/extensions/index.js +2732 -189
  23. package/dist/{mise.d.ts → extensions/misex.d.ts} +3 -1
  24. package/dist/extensions/misex.d.ts.map +1 -0
  25. package/dist/{process.d.ts → extensions/processx.d.ts} +3 -1
  26. package/dist/extensions/processx.d.ts.map +1 -0
  27. package/dist/extensions/tuicrx.d.ts +59 -0
  28. package/dist/extensions/tuicrx.d.ts.map +1 -0
  29. package/dist/extensions/zedx.d.ts +16 -0
  30. package/dist/extensions/zedx.d.ts.map +1 -0
  31. package/dist/gates.d.ts +12 -0
  32. package/dist/gates.d.ts.map +1 -0
  33. package/dist/index.d.ts +24 -2
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +1799 -137
  36. package/dist/modes.d.ts +1 -0
  37. package/dist/modes.d.ts.map +1 -1
  38. package/dist/review/github-review-backend.d.ts +5 -0
  39. package/dist/review/github-review-backend.d.ts.map +1 -0
  40. package/dist/review/gitlab-review-backend.d.ts +12 -0
  41. package/dist/review/gitlab-review-backend.d.ts.map +1 -0
  42. package/dist/review/index.d.ts +8 -0
  43. package/dist/review/index.d.ts.map +1 -0
  44. package/dist/review/local-review-backend.d.ts +3 -0
  45. package/dist/review/local-review-backend.d.ts.map +1 -0
  46. package/dist/review/review-backend.d.ts +8 -0
  47. package/dist/review/review-backend.d.ts.map +1 -0
  48. package/dist/review/review-markdown.d.ts +6 -0
  49. package/dist/review/review-markdown.d.ts.map +1 -0
  50. package/dist/review/review-state.d.ts +19 -0
  51. package/dist/review/review-state.d.ts.map +1 -0
  52. package/dist/review/types.d.ts +107 -0
  53. package/dist/review/types.d.ts.map +1 -0
  54. package/dist/setup.d.ts +7 -0
  55. package/dist/setup.d.ts.map +1 -1
  56. package/dist/store.d.ts +15 -0
  57. package/dist/store.d.ts.map +1 -0
  58. package/dist/templates.d.ts +14 -0
  59. package/dist/templates.d.ts.map +1 -0
  60. package/dist/tools/index.d.ts +2 -0
  61. package/dist/tools/index.d.ts.map +1 -1
  62. package/dist/tools/index.js +2722 -299
  63. package/dist/tools/review.d.ts +7 -0
  64. package/dist/tools/review.d.ts.map +1 -0
  65. package/dist/tools/setup.d.ts.map +1 -1
  66. package/dist/tools/templates.d.ts +3 -0
  67. package/dist/tools/templates.d.ts.map +1 -0
  68. package/dist/vcs/forge-backend.d.ts +7 -0
  69. package/dist/vcs/forge-backend.d.ts.map +1 -0
  70. package/dist/vcs/github.d.ts +5 -0
  71. package/dist/vcs/github.d.ts.map +1 -0
  72. package/dist/vcs/gitlab.d.ts +4 -0
  73. package/dist/vcs/gitlab.d.ts.map +1 -0
  74. package/dist/vcs/helpers.d.ts +7 -0
  75. package/dist/vcs/helpers.d.ts.map +1 -0
  76. package/dist/vcs/index.d.ts +6 -0
  77. package/dist/vcs/index.d.ts.map +1 -0
  78. package/dist/vcs/types.d.ts +32 -0
  79. package/dist/vcs/types.d.ts.map +1 -0
  80. package/package.json +3 -2
  81. package/skills/diffpi-setup/SKILL.md +3 -1
  82. package/skills/review/SKILL.md +17 -0
  83. package/skills/review/references/workflows/address.md +11 -0
  84. package/skills/review/references/workflows/auto.md +18 -0
  85. package/skills/review/references/workflows/complete.md +7 -0
  86. package/skills/review/references/workflows/edit.md +7 -0
  87. package/skills/review/references/workflows/help.md +30 -0
  88. package/skills/review/references/workflows/merge.md +6 -0
  89. package/skills/review/references/workflows/new.md +19 -0
  90. package/skills/review/references/workflows/open.md +3 -0
  91. package/skills/review/references/workflows/publish.md +8 -0
  92. package/templates/review/draft-pr.md +25 -0
  93. package/dist/mise.d.ts.map +0 -1
  94. package/dist/process.d.ts.map +0 -1
package/dist/index.js CHANGED
@@ -94,76 +94,14 @@ ${document}
94
94
  function normalizeModelReference(value) {
95
95
  return value.toLowerCase().replace(/^~/, "").replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
96
96
  }
97
- // src/mcp.ts
98
- import { mkdir, readFile as readFile2, writeFile } from "node:fs/promises";
99
- import { homedir as homedir2 } from "node:os";
100
- import { dirname, join as join2 } from "node:path";
101
- var mcp = {
102
- globalConfigPath(homeDir = homedir2()) {
103
- return join2(homeDir, ".config", "mcp", "mcp.json");
104
- },
105
- async serversEnsure(servers, options = {}) {
106
- const path = options.path ?? mcp.globalConfigPath();
107
- const currentText = await getOptionalFile(path);
108
- const current = getParsedConfig(currentText, path);
109
- const nextServers = { ...current.mcpServers };
110
- for (const [name, entry] of Object.entries(servers)) {
111
- nextServers[name] = mergeEntry(nextServers[name], entry);
112
- }
113
- const next = { ...current, mcpServers: nextServers };
114
- const changed = JSON.stringify(current) !== JSON.stringify(next);
115
- if (changed && !options.dryRun) {
116
- await mkdir(dirname(path), { recursive: true });
117
- await writeFile(path, `${JSON.stringify(next, null, 2)}
118
- `, "utf8");
119
- }
120
- return { path, changed, existed: currentText !== undefined, planned: changed && options.dryRun === true };
121
- }
122
- };
123
- function mergeEntry(current, required) {
124
- const merged = { ...current, ...required };
125
- if (current?.env || required.env)
126
- merged.env = { ...current?.env, ...required.env };
127
- return merged;
128
- }
129
- function getParsedConfig(content, path) {
130
- if (!content?.trim())
131
- return { mcpServers: {} };
132
- try {
133
- const value = JSON.parse(content);
134
- if (!isRecord(value))
135
- throw new Error("not an object");
136
- const servers = value.mcpServers;
137
- if (servers !== undefined && !isRecord(servers))
138
- throw new Error("mcpServers is not an object");
139
- return { ...value, mcpServers: servers ?? {} };
140
- } catch {
141
- throw new Error(`Expected a valid pi-mcp-adapter configuration in ${path}.`);
142
- }
143
- }
144
- async function getOptionalFile(path) {
145
- try {
146
- return await readFile2(path, "utf8");
147
- } catch (error) {
148
- if (error instanceof Error && "code" in error && error.code === "ENOENT")
149
- return;
150
- throw error;
151
- }
152
- }
153
- function isRecord(value) {
154
- return value !== null && typeof value === "object" && !Array.isArray(value);
155
- }
156
- // src/mise.ts
157
- import { mkdir as mkdir2, readFile as readFile3, writeFile as writeFile2 } from "node:fs/promises";
158
- import { homedir as homedir3 } from "node:os";
159
- import { basename, dirname as dirname2, join as join4 } from "node:path";
97
+ // src/environment.ts
98
+ import { basename } from "node:path";
160
99
 
161
- // src/process.ts
100
+ // src/extensions/processx.ts
101
+ import { spawn } from "node:child_process";
162
102
  import { constants } from "node:fs";
163
103
  import { access } from "node:fs/promises";
164
- import { delimiter, join as join3 } from "node:path";
165
- import { spawn } from "node:child_process";
166
- var MAX_CAPTURED_OUTPUT_LENGTH = 65536;
104
+ import { delimiter, join as join2 } from "node:path";
167
105
  async function findExecutable(name) {
168
106
  if (name.includes("/")) {
169
107
  try {
@@ -176,7 +114,7 @@ async function findExecutable(name) {
176
114
  for (const directory of (process.env.PATH ?? "").split(delimiter)) {
177
115
  if (!directory)
178
116
  continue;
179
- const candidate = join3(directory, name);
117
+ const candidate = join2(directory, name);
180
118
  try {
181
119
  await access(candidate, constants.X_OK);
182
120
  return candidate;
@@ -189,18 +127,35 @@ function run(command, args, options = {}) {
189
127
  const child = spawn(command, args, {
190
128
  cwd: options.cwd,
191
129
  env: options.env ?? process.env,
192
- stdio: ["ignore", "pipe", "pipe"]
130
+ stdio: [options.input === undefined ? "ignore" : "pipe", "pipe", "pipe"]
193
131
  });
194
132
  let stdout = "";
195
133
  let stderr = "";
196
- child.stdout.on("data", (chunk) => {
197
- stdout = appendBounded(stdout, chunk.toString());
134
+ const stdoutChunks = [];
135
+ const stderrChunks = [];
136
+ const unbounded = options.capture === "unbounded";
137
+ child.stdout?.on("data", (chunk) => {
138
+ const text = chunk.toString();
139
+ if (unbounded)
140
+ stdoutChunks.push(text);
141
+ else
142
+ stdout = appendBounded(stdout, text);
198
143
  });
199
- child.stderr.on("data", (chunk) => {
200
- stderr = appendBounded(stderr, chunk.toString());
144
+ child.stderr?.on("data", (chunk) => {
145
+ const text = chunk.toString();
146
+ if (unbounded)
147
+ stderrChunks.push(text);
148
+ else
149
+ stderr = appendBounded(stderr, text);
201
150
  });
202
151
  child.on("error", reject);
203
- child.on("close", (code) => resolve({ code: code ?? 1, stdout, stderr }));
152
+ child.on("close", (code) => resolve({
153
+ code: code ?? 1,
154
+ stdout: unbounded ? stdoutChunks.join("") : stdout,
155
+ stderr: unbounded ? stderrChunks.join("") : stderr
156
+ }));
157
+ if (options.input !== undefined && child.stdin)
158
+ child.stdin.end(options.input);
204
159
  });
205
160
  }
206
161
  async function runChecked(command, args, options = {}) {
@@ -210,24 +165,555 @@ async function runChecked(command, args, options = {}) {
210
165
  const detail = result.stderr.trim() || result.stdout.trim() || `exit code ${result.code}`;
211
166
  throw new Error(`${command} ${args.join(" ")} failed: ${detail}`);
212
167
  }
168
+ var MAX_CAPTURED_OUTPUT_LENGTH = 65536;
213
169
  function appendBounded(current, next) {
214
170
  const combined = current + next;
215
171
  return combined.length <= MAX_CAPTURED_OUTPUT_LENGTH ? combined : combined.slice(-MAX_CAPTURED_OUTPUT_LENGTH);
216
172
  }
217
173
 
218
- // src/mise.ts
174
+ // src/extensions/zedx.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_LOCAL_REVIEW_TASK_NAME = "diffpi: tuicr local review";
179
+ var ZED_REVIEW_TASK_NAME = ZED_LOCAL_REVIEW_TASK_NAME;
180
+ var ZED_PR_REVIEW_TASK_NAME = "diffpi: tuicr PR review";
181
+ var LEGACY_ZED_REVIEW_TASK_NAME = "diffpi: tuicr review";
182
+ var REVIEW_KEYBINDING = "cmd-alt-r";
183
+ function zedTasksPath(homeDir = homedir2()) {
184
+ return join3(homeDir, ".config", "zed", "tasks.json");
185
+ }
186
+ function zedKeymapPath(homeDir = homedir2()) {
187
+ return join3(homeDir, ".config", "zed", "keymap.json");
188
+ }
189
+ async function ensureZedReviewTask(homeDir = homedir2(), _command = ["tuicr", "-w", "-r", "main..HEAD"]) {
190
+ const path = zedTasksPath(homeDir);
191
+ const currentText = await readOptional(path);
192
+ const tasks = parseJsonArray(currentText, path);
193
+ const migrated = tasks.filter((task) => task.label !== LEGACY_ZED_REVIEW_TASK_NAME);
194
+ const next = [...migrated];
195
+ for (const task of [localReviewTask(), prReviewTask()]) {
196
+ const index = next.findIndex((existing) => existing.label === task.label);
197
+ if (index >= 0)
198
+ next[index] = { ...next[index], ...task };
199
+ else
200
+ next.push(task);
201
+ }
202
+ const changed = JSON.stringify(tasks) !== JSON.stringify(next);
203
+ if (changed)
204
+ await writeJson(path, next);
205
+ return { path, changed, existed: currentText !== undefined };
206
+ }
207
+ async function ensureZedReviewKeybinding(homeDir = homedir2()) {
208
+ const path = zedKeymapPath(homeDir);
209
+ const currentText = await readOptional(path);
210
+ const entries = parseJsonArray(currentText, path);
211
+ const migrated = entries.map(migrateReviewKeymapEntry);
212
+ const alreadyBound = migrated.some((entry) => Object.values(entry.bindings ?? {}).some((action) => Array.isArray(action) && action[0] === "task::Spawn" && bindsReviewTask(action[1])));
213
+ const next = alreadyBound ? migrated : [
214
+ ...migrated,
215
+ {
216
+ context: "Workspace",
217
+ bindings: { [REVIEW_KEYBINDING]: ["task::Spawn", { task_name: ZED_LOCAL_REVIEW_TASK_NAME }] }
218
+ }
219
+ ];
220
+ const changed = JSON.stringify(entries) !== JSON.stringify(next);
221
+ if (changed)
222
+ await writeJson(path, next);
223
+ return { path, changed, existed: currentText !== undefined };
224
+ }
225
+ function zedReviewTaskName(command) {
226
+ return command[0] === "tuicr" && command[1] === "pr" ? ZED_PR_REVIEW_TASK_NAME : ZED_LOCAL_REVIEW_TASK_NAME;
227
+ }
228
+ var LOCAL_REVIEW_SCRIPT = `set -eu
229
+ remote=$(git remote get-url origin 2>/dev/null || true)
230
+ branch=$(git branch --show-current)
231
+ base=
232
+ case "$remote" in
233
+ *github.com*)
234
+ base=$(gh pr list --head "$branch" --state open --limit 100 --json baseRefName,createdAt --jq 'sort_by(.createdAt) | reverse | .[0].baseRefName // empty' 2>/dev/null || true)
235
+ [ -n "$base" ] || base=$(gh repo view --json defaultBranchRef --jq .defaultBranchRef.name 2>/dev/null || true)
236
+ ;;
237
+ *gitlab*)
238
+ mr=$(glab mr list --source-branch "$branch" --order created_at --sort desc --per-page 100 --output json 2>/dev/null || true)
239
+ base=$(printf '%s' "$mr" | node -e 'let s=""; process.stdin.on("data",d=>s+=d).on("end",()=>{try{let a=JSON.parse(s);process.stdout.write(a[0]?.target_branch||"")}catch{}})')
240
+ [ -n "$base" ] || base=$(glab repo view --output json 2>/dev/null | node -e 'let s=""; process.stdin.on("data",d=>s+=d).on("end",()=>{try{process.stdout.write(JSON.parse(s).default_branch||"")}catch{}})' || true)
241
+ ;;
242
+ esac
243
+ [ -n "$base" ] || { echo 'diffpi: could not resolve a GitHub/GitLab review base' >&2; exit 1; }
244
+ exec tuicr -w -r "$base..HEAD"`;
245
+ var PR_REVIEW_SCRIPT = `set -eu
246
+ remote=$(git remote get-url origin 2>/dev/null || true)
247
+ branch=$(git branch --show-current)
248
+ case "$remote" in
249
+ *github.com*) number=$(gh pr list --head "$branch" --state open --limit 100 --json number,createdAt --jq 'sort_by(.createdAt) | reverse | .[0].number // empty' 2>/dev/null || true) ;;
250
+ *gitlab*) number=$(glab mr list --source-branch "$branch" --order created_at --sort desc --per-page 100 --output json 2>/dev/null | node -e 'let s=""; process.stdin.on("data",d=>s+=d).on("end",()=>{try{let a=JSON.parse(s);process.stdout.write(String(a[0]?.iid||a[0]?.number||""))}catch{}})' || true) ;;
251
+ *) number= ;;
252
+ esac
253
+ [ -n "$number" ] || { echo 'diffpi: could not resolve the current GitHub PR or GitLab MR' >&2; exit 1; }
254
+ exec tuicr pr "$number"`;
255
+ function staticReviewTask(label, script) {
256
+ return {
257
+ label,
258
+ command: "sh",
259
+ args: ["-lc", script],
260
+ cwd: "$ZED_WORKTREE_ROOT",
261
+ use_new_terminal: true,
262
+ reveal: "always",
263
+ reveal_target: "center"
264
+ };
265
+ }
266
+ function localReviewTask() {
267
+ return staticReviewTask(ZED_LOCAL_REVIEW_TASK_NAME, LOCAL_REVIEW_SCRIPT);
268
+ }
269
+ function prReviewTask() {
270
+ return staticReviewTask(ZED_PR_REVIEW_TASK_NAME, PR_REVIEW_SCRIPT);
271
+ }
272
+ function bindsReviewTask(payload) {
273
+ return typeof payload === "object" && payload !== null && payload.task_name === ZED_LOCAL_REVIEW_TASK_NAME;
274
+ }
275
+ function migrateReviewKeymapEntry(entry) {
276
+ const bindings = Object.fromEntries(Object.entries(entry.bindings ?? {}).map(([key, action]) => {
277
+ if (Array.isArray(action) && action[0] === "task::Spawn" && typeof action[1] === "object" && action[1] !== null && action[1].task_name === LEGACY_ZED_REVIEW_TASK_NAME) {
278
+ return [key, ["task::Spawn", { ...action[1], task_name: ZED_LOCAL_REVIEW_TASK_NAME }]];
279
+ }
280
+ return [key, action];
281
+ }));
282
+ return entry.bindings ? { ...entry, bindings } : entry;
283
+ }
284
+ async function readOptional(path) {
285
+ try {
286
+ return await readFile2(path, "utf8");
287
+ } catch (error) {
288
+ if (error instanceof Error && "code" in error && error.code === "ENOENT")
289
+ return;
290
+ throw error;
291
+ }
292
+ }
293
+ function parseJsonArray(content, path) {
294
+ if (!content?.trim())
295
+ return [];
296
+ let value;
297
+ try {
298
+ value = JSON.parse(content);
299
+ } catch {
300
+ throw new Error(`Cannot safely edit ${path}: not strict JSON (it may contain JSONC comments).`);
301
+ }
302
+ if (!Array.isArray(value))
303
+ throw new Error(`Expected a JSON array in ${path}.`);
304
+ return value;
305
+ }
306
+ async function writeJson(path, value) {
307
+ await mkdir(dirname(path), { recursive: true });
308
+ await writeFile(path, `${JSON.stringify(value, null, 2)}
309
+ `, "utf8");
310
+ }
311
+
312
+ // src/environment.ts
313
+ function detectIde(env = process.env) {
314
+ const program = (env.TERM_PROGRAM ?? "").toLowerCase();
315
+ if (env.ZED_TERM === "true" || program === "zed")
316
+ return "zed";
317
+ if (env.CURSOR_TRACE_ID || program === "cursor")
318
+ return "cursor";
319
+ if (env.WINDSURF_ENV || program === "windsurf")
320
+ return "windsurf";
321
+ if (env.TERMINAL_EMULATOR?.toLowerCase().includes("jetbrains"))
322
+ return "jetbrains";
323
+ if (env.VSCODE_PID || env.VSCODE_GIT_IPC_HANDLE || program === "vscode")
324
+ return "vscode";
325
+ return "unknown";
326
+ }
327
+ function detectMux(env = process.env) {
328
+ if (env.ZELLIJ || env.ZELLIJ_SESSION_NAME)
329
+ return "zellij";
330
+ if (env.TMUX)
331
+ return "tmux";
332
+ if (env.STY)
333
+ return "screen";
334
+ return "none";
335
+ }
336
+ function detectShell(env = process.env) {
337
+ return env.SHELL ? basename(env.SHELL) : "unknown";
338
+ }
339
+ async function detectVcs(cwd) {
340
+ const root = (await run("git", ["-C", cwd, "rev-parse", "--show-toplevel"])).stdout.trim() || cwd;
341
+ const branch = (await run("git", ["-C", root, "rev-parse", "--abbrev-ref", "HEAD"])).stdout.trim();
342
+ const remote = (await run("git", ["-C", root, "remote", "get-url", "origin"])).stdout.trim();
343
+ return { ...parseRemote(remote), branch, root };
344
+ }
345
+ function parseRemote(remote) {
346
+ const empty = { provider: "none", host: "", owner: "", repo: "" };
347
+ if (!remote)
348
+ return empty;
349
+ const scp = remote.match(/^[^@]+@([^:]+):(.+?)(?:\.git)?$/);
350
+ const url = remote.match(/^[a-z]+:\/\/(?:[^@]+@)?([^/]+)\/(.+?)(?:\.git)?$/i);
351
+ const match = scp ?? url;
352
+ if (!match)
353
+ return empty;
354
+ const host = match[1];
355
+ const segments = match[2].split("/").filter(Boolean);
356
+ if (segments.length < 2)
357
+ return { ...empty, host };
358
+ const repo = segments.at(-1) ?? "";
359
+ const owner = segments.slice(0, -1).join("/");
360
+ const provider = /github/i.test(host) ? "github" : /gitlab/i.test(host) ? "gitlab" : "none";
361
+ return { provider, host, owner, repo };
362
+ }
363
+ async function openInNewTab(command, opts) {
364
+ const env = opts.env ?? process.env;
365
+ const name = opts.name ?? "review";
366
+ const printable = command.join(" ");
367
+ const mux = detectMux(env);
368
+ if (mux !== "none") {
369
+ const opened = await openMuxTab(mux, command, opts.cwd, name, printable);
370
+ if (opened)
371
+ return opened;
372
+ }
373
+ if (detectIde(env) === "zed") {
374
+ try {
375
+ const taskName = zedReviewTaskName(command);
376
+ await ensureZedReviewTask(opts.homeDir, command);
377
+ return {
378
+ launched: false,
379
+ configured: true,
380
+ via: "zed-task",
381
+ command: printable,
382
+ taskName,
383
+ instruction: `Run the Zed task "${taskName}".`
384
+ };
385
+ } catch {}
386
+ }
387
+ return { launched: false, via: "print", command: printable };
388
+ }
389
+ function screenWindowArgs(command, cwd, name) {
390
+ return ["-X", "screen", "-t", name, "sh", "-lc", 'cd -- "$1" && shift && exec "$@"', "sh", cwd, ...command];
391
+ }
392
+ async function openMuxTab(mux, command, cwd, name, printable) {
393
+ if (mux === "zellij" && await findExecutable("zellij")) {
394
+ const result = await run("zellij", ["action", "new-tab", "--cwd", cwd, "--name", name, "--", ...command]);
395
+ if (result.code === 0)
396
+ return { launched: true, via: "zellij", command: printable };
397
+ const fallback = await run("zellij", ["run", "--cwd", cwd, "--name", name, "--", ...command]);
398
+ if (fallback.code === 0)
399
+ return { launched: true, via: "zellij-run", command: printable };
400
+ }
401
+ if (mux === "tmux" && await findExecutable("tmux")) {
402
+ const result = await run("tmux", ["new-window", "-c", cwd, "-n", name, printable]);
403
+ if (result.code === 0)
404
+ return { launched: true, via: "tmux", command: printable };
405
+ }
406
+ if (mux === "screen" && await findExecutable("screen")) {
407
+ const result = await run("screen", screenWindowArgs(command, cwd, name));
408
+ if (result.code === 0)
409
+ return { launched: true, via: "screen", command: printable };
410
+ }
411
+ return;
412
+ }
413
+ // src/extensions/gitx.ts
414
+ import { realpath } from "node:fs/promises";
415
+ import { basename as basename2, isAbsolute, join as join4, resolve } from "node:path";
416
+ async function gitToplevel(cwd) {
417
+ const result = await run("git", ["-C", cwd, "rev-parse", "--show-toplevel"]);
418
+ const top = result.stdout.trim();
419
+ return result.code === 0 && top ? top : resolve(cwd);
420
+ }
421
+ async function inspectGitRepository(cwd) {
422
+ const root = await gitToplevel(cwd);
423
+ const remoteResult = await run("git", ["-C", root, "remote", "get-url", "origin"]);
424
+ const remote = remoteResult.code === 0 ? remoteResult.stdout.trim() : "";
425
+ const commonResult = await run("git", ["-C", root, "rev-parse", "--git-common-dir"]);
426
+ const common = commonResult.stdout.trim();
427
+ const commonPath = commonResult.code === 0 && common ? resolve(isAbsolute(common) ? common : join4(root, common)) : root;
428
+ const commonDir = await canonicalPath(commonPath);
429
+ return {
430
+ root,
431
+ commonDir,
432
+ ...remote ? { remote } : {},
433
+ name: remote ? repositoryName(remote) : basename2(resolve(commonDir, "..")) || basename2(root),
434
+ identity: remote ? `remote:${normalizeRemote(remote)}` : `git-common-dir:${commonDir}`
435
+ };
436
+ }
437
+ function normalizeRemote(remote) {
438
+ return remote.trim().replace(/\.git\/?$/i, "").replace(/\/+$/, "").toLowerCase();
439
+ }
440
+ function repositoryName(remote) {
441
+ const normalized = remote.trim().replace(/\.git\/?$/i, "").replace(/\/+$/, "");
442
+ return normalized.split(/[/\\:]/).filter(Boolean).at(-1) ?? "";
443
+ }
444
+ async function canonicalPath(path) {
445
+ try {
446
+ return await realpath(path);
447
+ } catch {
448
+ return resolve(path);
449
+ }
450
+ }
451
+ // src/extensions/ghx.ts
452
+ function gh(args, options) {
453
+ return run("gh", args, options);
454
+ }
455
+ function ghChecked(args, options) {
456
+ return runChecked("gh", args, options);
457
+ }
458
+
459
+ // src/vcs/helpers.ts
460
+ function isConfirmedMissingChange(provider, output) {
461
+ const message = output.toLowerCase();
462
+ return provider === "github" ? message.includes("no pull requests found for branch") || message.includes("could not find pull request") || message.includes("could not resolve to a pullrequest") : provider === "gitlab" ? message.includes("no open merge request") || /failed to get open merge request/.test(message) && /404(?: not found)?/.test(message) : false;
463
+ }
464
+ function parseJson(value, name) {
465
+ try {
466
+ return JSON.parse(value);
467
+ } catch {
468
+ throw new Error(`Cannot parse the ${name} response as JSON.`);
469
+ }
470
+ }
471
+ function commandFailure(command, args, result) {
472
+ return new Error(`${command} ${args.join(" ")} failed: ${result.stderr.trim() || result.stdout.trim() || `exit code ${result.code}`}`);
473
+ }
474
+ function requireBranchName(output, provider) {
475
+ const branch = output.trim();
476
+ if (!branch || branch === "null")
477
+ throw new Error(`${provider} did not return a default branch.`);
478
+ return branch;
479
+ }
480
+
481
+ // src/vcs/github.ts
482
+ function GitHubVcsBackend(vcs) {
483
+ const repo = ["--repo", `${vcs.owner}/${vcs.repo}`];
484
+ const viewPr = async (idOrBranch) => {
485
+ const args = [
486
+ "pr",
487
+ "view",
488
+ idOrBranch,
489
+ ...repo,
490
+ "--json",
491
+ "number,title,url,isDraft,baseRefName,headRefName,headRefOid"
492
+ ];
493
+ const result = await gh(args);
494
+ if (result.code !== 0) {
495
+ if (isConfirmedMissingChange("github", result.stderr || result.stdout))
496
+ return;
497
+ throw commandFailure("gh", args, result);
498
+ }
499
+ if (!result.stdout.trim())
500
+ throw new Error("GitHub returned an empty pull request response.");
501
+ const data = parseJson(result.stdout, "GitHub pull request");
502
+ 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")
503
+ throw new Error("GitHub returned an invalid pull request response.");
504
+ return {
505
+ number: data.number,
506
+ title: data.title,
507
+ url: data.url,
508
+ isDraft: data.isDraft,
509
+ baseRef: data.baseRefName,
510
+ headRef: data.headRefName,
511
+ headSha: data.headRefOid
512
+ };
513
+ };
514
+ return {
515
+ provider: "github",
516
+ async createDraftPr(options) {
517
+ const args = [
518
+ "pr",
519
+ "create",
520
+ ...repo,
521
+ "--title",
522
+ options.title,
523
+ "--body",
524
+ options.body,
525
+ "--base",
526
+ options.base,
527
+ "--head",
528
+ options.head
529
+ ];
530
+ if (options.draft !== false)
531
+ args.push("--draft");
532
+ await ghChecked(args);
533
+ const ref = await viewPr(options.head);
534
+ if (!ref)
535
+ throw new Error("Draft PR created but could not be resolved.");
536
+ return ref;
537
+ },
538
+ viewPr,
539
+ async defaultBranch() {
540
+ return requireBranchName((await ghChecked([
541
+ "repo",
542
+ "view",
543
+ `${vcs.owner}/${vcs.repo}`,
544
+ "--json",
545
+ "defaultBranchRef",
546
+ "--jq",
547
+ ".defaultBranchRef.name"
548
+ ])).stdout, "GitHub");
549
+ },
550
+ async prDiff(id) {
551
+ return (await ghChecked(["pr", "diff", String(id), ...repo], { capture: "unbounded" })).stdout;
552
+ },
553
+ async prChecks(id) {
554
+ return (await gh(["pr", "checks", String(id), ...repo])).stdout;
555
+ },
556
+ async markReady(id) {
557
+ await ghChecked(["pr", "ready", String(id), ...repo]);
558
+ },
559
+ async closePr(id, comment) {
560
+ const args = ["pr", "close", String(id), ...repo];
561
+ if (comment)
562
+ args.push("--comment", comment);
563
+ await ghChecked(args);
564
+ },
565
+ async mergePr(id, subject) {
566
+ const readiness = await ghChecked([
567
+ "pr",
568
+ "view",
569
+ String(id),
570
+ ...repo,
571
+ "--json",
572
+ "isDraft,state,reviewDecision,mergeStateStatus,statusCheckRollup"
573
+ ]);
574
+ assertGitHubMergeReady(readiness.stdout);
575
+ await ghChecked(["pr", "merge", String(id), ...repo, "--squash", "--subject", subject]);
576
+ }
577
+ };
578
+ }
579
+ function assertGitHubMergeReady(input) {
580
+ const data = parseJson(input, "GitHub readiness");
581
+ const blockers = [];
582
+ if (data.state !== "OPEN")
583
+ blockers.push(`pull request state is ${data.state ?? "unknown"}`);
584
+ if (data.isDraft)
585
+ blockers.push("pull request is still a draft");
586
+ if (data.reviewDecision !== "APPROVED")
587
+ blockers.push(`review decision is ${data.reviewDecision || "not approved"}`);
588
+ if (data.mergeStateStatus !== "CLEAN")
589
+ blockers.push(`merge state is ${data.mergeStateStatus ?? "unknown"}`);
590
+ for (const check of data.statusCheckRollup ?? []) {
591
+ const name = check.name ?? check.context ?? "unnamed check";
592
+ if (check.__typename === "CheckRun") {
593
+ if (check.status !== "COMPLETED")
594
+ blockers.push(`${name} is ${check.status?.toLowerCase() ?? "pending"}`);
595
+ else if (!["SUCCESS", "SKIPPED", "NEUTRAL"].includes(check.conclusion ?? ""))
596
+ blockers.push(`${name} concluded ${(check.conclusion ?? "unknown").toLowerCase()}`);
597
+ } else if (check.state !== "SUCCESS")
598
+ blockers.push(`${name} is ${(check.state ?? "pending").toLowerCase()}`);
599
+ }
600
+ if (blockers.length > 0)
601
+ throw new Error(`Merge blocked: ${blockers.join("; ")}.`);
602
+ }
603
+
604
+ // src/extensions/glabx.ts
605
+ function glab(args, options) {
606
+ return run("glab", args, options);
607
+ }
608
+ function glabChecked(args, options) {
609
+ return runChecked("glab", args, options);
610
+ }
611
+
612
+ // src/vcs/gitlab.ts
613
+ function GitLabVcsBackend(vcs) {
614
+ const project = `${vcs.owner}/${vcs.repo}`;
615
+ const viewPr = async (idOrBranch) => {
616
+ const args = ["mr", "view", idOrBranch, "--repo", project, "--output", "json"];
617
+ const result = await glab(args);
618
+ if (result.code !== 0) {
619
+ if (isConfirmedMissingChange("gitlab", result.stderr || result.stdout))
620
+ return;
621
+ throw commandFailure("glab", args, result);
622
+ }
623
+ if (!result.stdout.trim())
624
+ throw new Error("GitLab returned an empty merge request response.");
625
+ const data = parseJson(result.stdout, "GitLab merge request");
626
+ if (typeof data.iid !== "number" || typeof data.title !== "string" || typeof data.web_url !== "string" || typeof data.target_branch !== "string" || typeof data.source_branch !== "string")
627
+ throw new Error("GitLab returned an invalid merge request response.");
628
+ return {
629
+ number: data.iid,
630
+ title: data.title,
631
+ url: data.web_url,
632
+ isDraft: Boolean(data.draft ?? data.work_in_progress),
633
+ baseRef: data.target_branch,
634
+ headRef: data.source_branch,
635
+ headSha: data.sha
636
+ };
637
+ };
638
+ return {
639
+ provider: "gitlab",
640
+ async createDraftPr(options) {
641
+ await glabChecked([
642
+ "mr",
643
+ "create",
644
+ "--repo",
645
+ project,
646
+ "--title",
647
+ `Draft: ${options.title}`,
648
+ "--description",
649
+ options.body,
650
+ "--target-branch",
651
+ options.base,
652
+ "--source-branch",
653
+ options.head,
654
+ "--yes"
655
+ ]);
656
+ const ref = await viewPr(options.head);
657
+ if (!ref)
658
+ throw new Error("Draft MR created but could not be resolved.");
659
+ return ref;
660
+ },
661
+ viewPr,
662
+ async defaultBranch() {
663
+ return requireBranchName((await glabChecked(["api", `projects/${encodeURIComponent(project)}`, "--jq", ".default_branch"])).stdout, "GitLab");
664
+ },
665
+ async prDiff(id) {
666
+ return (await glabChecked(["mr", "diff", String(id), "--repo", project], { capture: "unbounded" })).stdout;
667
+ },
668
+ async prChecks(id) {
669
+ return (await glabChecked([
670
+ "api",
671
+ `projects/${encodeURIComponent(project)}/merge_requests/${id}/pipelines?per_page=100`
672
+ ])).stdout;
673
+ },
674
+ async markReady(id) {
675
+ await glabChecked(["mr", "update", String(id), "--repo", project, "--ready"]);
676
+ },
677
+ async closePr(id, comment) {
678
+ if (comment)
679
+ await glabChecked(["mr", "note", String(id), "--repo", project, "--message", comment]);
680
+ await glabChecked(["mr", "close", String(id), "--repo", project]);
681
+ },
682
+ async mergePr() {
683
+ throw new Error("Merge is not supported by the GitLab forge adapter.");
684
+ }
685
+ };
686
+ }
687
+
688
+ // src/vcs/forge-backend.ts
689
+ function createForgeBackend(vcs) {
690
+ if (vcs.provider === "github")
691
+ return GitHubVcsBackend(vcs);
692
+ if (vcs.provider === "gitlab")
693
+ return GitLabVcsBackend(vcs);
694
+ throw new Error("No supported forge detected from the git remote. Use --local for an offline review.");
695
+ }
696
+ var createVcsBackend = createForgeBackend;
697
+ var createForge = createForgeBackend;
698
+ // src/extensions/misex.ts
699
+ import { mkdir as mkdir2, readFile as readFile3, writeFile as writeFile2 } from "node:fs/promises";
700
+ import { homedir as homedir3 } from "node:os";
701
+ import { basename as basename3, dirname as dirname2, join as join5 } from "node:path";
219
702
  var MISE_HOOK_START = "# >>> @difflab/pi mise >>>";
220
703
  var MISE_HOOK_END = "# <<< @difflab/pi mise <<<";
221
704
  var mise = {
222
705
  async executableCheck(name = "mise") {
223
706
  return findExecutable(name);
224
707
  },
708
+ async run(args, options = {}) {
709
+ return run("mise", args, options);
710
+ },
225
711
  async install(options = {}) {
226
712
  const homeDir = options.homeDir ?? homedir3();
227
713
  const platform = options.platform ?? process.platform;
228
714
  if (platform === "win32")
229
715
  throw new Error("Automatic mise installation supports macOS and Linux only.");
230
- const installedPath = join4(homeDir, ".local", "bin", "mise");
716
+ const installedPath = join5(homeDir, ".local", "bin", "mise");
231
717
  if (options.dryRun)
232
718
  return installedPath;
233
719
  await runChecked("sh", ["-c", "curl -fsSL https://mise.run | sh"]);
@@ -238,8 +724,8 @@ var mise = {
238
724
  },
239
725
  async hookEnsure(executable, options = {}) {
240
726
  const homeDir = options.homeDir ?? homedir3();
241
- const hook = getShellHook(basename(options.shell ?? process.env.SHELL ?? ""), executable, homeDir);
242
- const current = await getOptionalFile2(hook.path);
727
+ const hook = getShellHook(basename3(options.shell ?? process.env.SHELL ?? ""), executable, homeDir);
728
+ const current = await getOptionalFile(hook.path);
243
729
  if (current.includes(MISE_HOOK_START))
244
730
  return { path: hook.path, changed: false, planned: false };
245
731
  if (options.dryRun)
@@ -274,7 +760,7 @@ function getShellHook(shell, executable, homeDir) {
274
760
  switch (shell.toLowerCase()) {
275
761
  case "zsh":
276
762
  return {
277
- path: join4(homeDir, ".zshrc"),
763
+ path: join5(homeDir, ".zshrc"),
278
764
  content: `${MISE_HOOK_START}
279
765
  eval "$(${command} activate zsh)"
280
766
  ${MISE_HOOK_END}
@@ -282,7 +768,7 @@ ${MISE_HOOK_END}
282
768
  };
283
769
  case "fish":
284
770
  return {
285
- path: join4(homeDir, ".config", "fish", "config.fish"),
771
+ path: join5(homeDir, ".config", "fish", "config.fish"),
286
772
  content: `${MISE_HOOK_START}
287
773
  ${command} activate fish | source
288
774
  ${MISE_HOOK_END}
@@ -291,7 +777,7 @@ ${MISE_HOOK_END}
291
777
  case "nu":
292
778
  case "nushell":
293
779
  return {
294
- path: join4(homeDir, ".config", "nushell", "config.nu"),
780
+ path: join5(homeDir, ".config", "nushell", "config.nu"),
295
781
  content: `${MISE_HOOK_START}
296
782
  let mise_bin = ${command}
297
783
  let mise_path = $nu.default-config-dir | path join mise.nu
@@ -302,7 +788,7 @@ ${MISE_HOOK_END}
302
788
  };
303
789
  case "xonsh":
304
790
  return {
305
- path: join4(homeDir, ".xonshrc"),
791
+ path: join5(homeDir, ".xonshrc"),
306
792
  content: `${MISE_HOOK_START}
307
793
  execx($(${command} activate xonsh))
308
794
  ${MISE_HOOK_END}
@@ -310,7 +796,7 @@ ${MISE_HOOK_END}
310
796
  };
311
797
  case "elvish":
312
798
  return {
313
- path: join4(homeDir, ".config", "elvish", "rc.elv"),
799
+ path: join5(homeDir, ".config", "elvish", "rc.elv"),
314
800
  content: `${MISE_HOOK_START}
315
801
  var mise: = (ns [&])
316
802
  eval (${command} activate elvish | slurp) &ns=$mise: &on-end={|ns| set mise: = $ns }
@@ -321,7 +807,7 @@ ${MISE_HOOK_END}
321
807
  case "pwsh":
322
808
  case "powershell":
323
809
  return {
324
- path: join4(homeDir, ".config", "powershell", "Microsoft.PowerShell_profile.ps1"),
810
+ path: join5(homeDir, ".config", "powershell", "Microsoft.PowerShell_profile.ps1"),
325
811
  content: `${MISE_HOOK_START}
326
812
  (& ${command} activate pwsh) | Out-String | Invoke-Expression
327
813
  ${MISE_HOOK_END}
@@ -330,7 +816,7 @@ ${MISE_HOOK_END}
330
816
  case "bash":
331
817
  default:
332
818
  return {
333
- path: join4(homeDir, ".bashrc"),
819
+ path: join5(homeDir, ".bashrc"),
334
820
  content: `${MISE_HOOK_START}
335
821
  eval "$(${command} activate bash)"
336
822
  ${MISE_HOOK_END}
@@ -367,7 +853,7 @@ function isVersionAtLeast(version, minimumVersion) {
367
853
  }
368
854
  return true;
369
855
  }
370
- async function getOptionalFile2(path) {
856
+ async function getOptionalFile(path) {
371
857
  try {
372
858
  return await readFile3(path, "utf8");
373
859
  } catch (error) {
@@ -379,48 +865,1055 @@ async function getOptionalFile2(path) {
379
865
  function getShellQuoted(value) {
380
866
  return `'${value.replaceAll("'", "'\\''")}'`;
381
867
  }
868
+
869
+ // src/gates.ts
870
+ var CONVENTIONAL_COMMIT = /^(feat|fix|perf|refactor|docs|chore|test|build|ci|style|revert)(\([^)]+\))?!?: .+/;
871
+ var MISE_GATES = ["format:check", "lint", "test"];
872
+ function checkConventionalSubject(subject) {
873
+ const trimmed = subject.trim();
874
+ const ok = CONVENTIONAL_COMMIT.test(trimmed);
875
+ return {
876
+ name: "conventional-subject",
877
+ status: ok ? "pass" : "warn",
878
+ detail: ok ? trimmed : `not a conventional-commit subject: "${trimmed}"`
879
+ };
880
+ }
881
+ async function runMiseGates(cwd) {
882
+ const tasks = await discoverMiseTasks(cwd);
883
+ const results = [];
884
+ for (const gate of MISE_GATES) {
885
+ const targets = tasks.get(gate) ?? [];
886
+ if (targets.length === 0) {
887
+ results.push({ name: gate, status: "skip", detail: "no mise recipe" });
888
+ continue;
889
+ }
890
+ const invocations = targets.flatMap((target, index) => index === 0 ? [target] : [":::", target]);
891
+ const result = await mise.run(["run", ...invocations], { cwd });
892
+ results.push({
893
+ name: gate,
894
+ status: result.code === 0 ? "pass" : "fail",
895
+ detail: result.code === 0 ? "clean" : (result.stderr.trim() || result.stdout.trim()).slice(-400)
896
+ });
897
+ }
898
+ return results;
899
+ }
900
+ function ciGate(checksOutput) {
901
+ const text = checksOutput.toLowerCase();
902
+ if (!text.trim())
903
+ return { name: "ci", status: "skip", detail: "no CI output" };
904
+ if (/\bfail|error\b/.test(text))
905
+ return { name: "ci", status: "warn", detail: "CI failing" };
906
+ if (/\bpending|in progress|queued\b/.test(text))
907
+ return { name: "ci", status: "warn", detail: "CI pending" };
908
+ return { name: "ci", status: "pass", detail: "CI green" };
909
+ }
910
+ function parseMiseTasks(input) {
911
+ let tasks;
912
+ try {
913
+ tasks = JSON.parse(input);
914
+ } catch {
915
+ return new Map;
916
+ }
917
+ if (!Array.isArray(tasks))
918
+ return new Map;
919
+ const found = new Map;
920
+ for (const gate of MISE_GATES) {
921
+ const targets = tasks.flatMap((task) => {
922
+ if (typeof task.name !== "string")
923
+ return [];
924
+ return task.name === gate || task.name.endsWith(`:${gate}`) || task.aliases?.includes(gate) ? [task.name] : [];
925
+ });
926
+ if (targets.length > 0)
927
+ found.set(gate, [...new Set(targets)]);
928
+ }
929
+ return found;
930
+ }
931
+ async function discoverMiseTasks(cwd) {
932
+ const result = await mise.run(["tasks", "--json", "--all"], { cwd });
933
+ if (result.code !== 0)
934
+ return new Map;
935
+ return parseMiseTasks(result.stdout);
936
+ }
937
+ // src/review/types.ts
938
+ import { z as z2 } from "zod";
939
+ var severitySchema = z2.enum(["BLOCKING", "CONSIDER", "NOTE"]);
940
+ var findingSchema = z2.object({
941
+ file: z2.string().min(1),
942
+ line: z2.number().int().nonnegative(),
943
+ severity: severitySchema,
944
+ body: z2.string().min(1),
945
+ reference: z2.string().optional().default("")
946
+ });
947
+ var findingsSchema = z2.array(findingSchema);
948
+ function reviewSlug(input) {
949
+ return input.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 50);
950
+ }
951
+ function yymmdd(date = new Date) {
952
+ const yy = String(date.getFullYear() % 100).padStart(2, "0");
953
+ const mm = String(date.getMonth() + 1).padStart(2, "0");
954
+ const dd = String(date.getDate()).padStart(2, "0");
955
+ return `${yy}${mm}${dd}`;
956
+ }
957
+ function reviewRecordName(target, date = new Date) {
958
+ return `${yymmdd(date)}-${reviewSlug(target) || "uncommitted"}`;
959
+ }
960
+ function isLocalResponse(body) {
961
+ return /^<!-- diffpi(?:-local-response:[A-Za-z0-9_-]+)? -->\n/m.test(body);
962
+ }
963
+ function dedupeFindings(findings) {
964
+ const rank = { BLOCKING: 3, CONSIDER: 2, NOTE: 1 };
965
+ const byKey = new Map;
966
+ for (const finding of findings) {
967
+ const key = `${finding.file}:${finding.line}`;
968
+ const existing = byKey.get(key);
969
+ if (!existing || rank[finding.severity] > rank[existing.severity])
970
+ byKey.set(key, finding);
971
+ }
972
+ return [...byKey.values()].sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line || rank[b.severity] - rank[a.severity]);
973
+ }
974
+ function toReviewComments(findings, model) {
975
+ const comments = [];
976
+ for (const finding of findings) {
977
+ if (finding.line <= 0)
978
+ continue;
979
+ const body = renderCommentBody(finding);
980
+ comments.push({
981
+ file: finding.file,
982
+ line: finding.line,
983
+ side: "RIGHT",
984
+ body: model ? withRemoteProvenance(body, model) : body
985
+ });
986
+ }
987
+ return comments;
988
+ }
989
+ function withRemoteProvenance(body, model) {
990
+ const normalized = body.trimEnd();
991
+ if (/Generated review by Diffpi using `[^`]+`\.$/.test(normalized))
992
+ return body;
993
+ return `${normalized}
994
+
995
+ Generated review by Diffpi using \`${model}\`.`;
996
+ }
997
+ function localReviewAuthor(model) {
998
+ return `Agent: ${model}`;
999
+ }
1000
+ function renderCommentBody(finding) {
1001
+ const prefix = finding.severity === "BLOCKING" ? "**BLOCKING** " : "";
1002
+ const reference = finding.reference ? `
1003
+
1004
+ > **Reference:** ${finding.reference}` : "";
1005
+ return `${prefix}${finding.body}${reference}`;
1006
+ }
1007
+ // src/review/review-markdown.ts
1008
+ import { z as z3 } from "zod";
1009
+ var reviewThreadRecordSchema = z3.object({
1010
+ id: z3.string().min(1),
1011
+ file: z3.string().optional(),
1012
+ line: z3.number().int().positive().optional(),
1013
+ body: z3.string(),
1014
+ author: z3.string().optional(),
1015
+ resolved: z3.boolean(),
1016
+ addressed: z3.boolean().optional(),
1017
+ question: z3.boolean(),
1018
+ replies: z3.array(z3.string()).optional()
1019
+ });
1020
+ function renderReviewDoc(input) {
1021
+ const anchored = dedupeFindings(input.findings).filter((finding) => finding.line > 0);
1022
+ const lines = [`# Review: ${input.title}`, "", "## Metadata"];
1023
+ if (input.number !== undefined)
1024
+ lines.push(`- **PR/MR**: #${input.number}${input.url ? ` — ${input.url}` : ""}`);
1025
+ if (input.author)
1026
+ lines.push(`- **Author**: ${input.author}`);
1027
+ if (input.model)
1028
+ lines.push(`- **Review agent**: ${input.model}`);
1029
+ if (input.headRef && input.baseRef)
1030
+ lines.push(`- **Branch**: ${input.headRef} → ${input.baseRef}`);
1031
+ if (input.additions !== undefined)
1032
+ lines.push(`- **Stats**: +${input.additions} -${input.deletions ?? 0} across ${input.changedFiles ?? 0} files`);
1033
+ lines.push(`- **Reviewed**: ${input.timestamp ?? new Date().toISOString()}`, "");
1034
+ if (input.overallIssues.length > 0) {
1035
+ lines.push("## Overall issues", "");
1036
+ for (const issue of input.overallIssues)
1037
+ lines.push(`- ${issue}`);
1038
+ lines.push("");
1039
+ }
1040
+ lines.push("## Verification", "");
1041
+ for (const gate of input.gates)
1042
+ lines.push(`- ${gate.name}: ${gate.status} — ${gate.detail}`);
1043
+ lines.push("");
1044
+ if (input.notVerified.length > 0) {
1045
+ lines.push("## What was NOT verified", "");
1046
+ for (const item of input.notVerified)
1047
+ lines.push(`- ${item}`);
1048
+ lines.push("");
1049
+ }
1050
+ lines.push("## Inline Comments", "");
1051
+ for (const finding of anchored) {
1052
+ lines.push(`### ${finding.file}:${finding.line} — ${finding.severity}`, "", finding.body, "");
1053
+ if (finding.reference)
1054
+ lines.push(`> **Reference:** ${finding.reference}`, "");
1055
+ lines.push("---", "");
1056
+ }
1057
+ return `${lines.join(`
1058
+ `).trimEnd()}
1059
+ `;
1060
+ }
1061
+ function renderThreadArtifact(title, target, threads, options = {}) {
1062
+ const records = threads.map((thread) => ({
1063
+ id: thread.id,
1064
+ file: thread.file,
1065
+ line: thread.line,
1066
+ body: thread.body,
1067
+ author: thread.author,
1068
+ resolved: thread.resolved,
1069
+ addressed: thread.addressed,
1070
+ question: thread.question,
1071
+ replies: thread.replies
1072
+ }));
1073
+ const payload = Buffer.from(JSON.stringify(records), "utf8").toString("base64url");
1074
+ const lines = [
1075
+ `<!-- diffpi-threads:${payload} -->`,
1076
+ `# Review threads: ${title}`,
1077
+ "",
1078
+ "## Metadata",
1079
+ "",
1080
+ `- Target: ${target}`,
1081
+ `- Pulled: ${options.timestamp ?? new Date().toISOString()}`
1082
+ ];
1083
+ if (options.number !== undefined)
1084
+ lines.push(`- PR/MR: #${options.number}${options.url ? ` — ${options.url}` : ""}`);
1085
+ lines.push("", "## Replies", "");
1086
+ for (const thread of threads) {
1087
+ const id = Buffer.from(thread.id, "utf8").toString("base64url");
1088
+ lines.push(`### ${thread.file ?? "review"}:${thread.line ?? "n/a"} (${thread.id})`, "", `<!-- diffpi-reply-start:${id} -->`, thread.reply ?? "", `<!-- diffpi-reply-end:${id} -->`, "");
1089
+ }
1090
+ lines.push("## Source comments", "");
1091
+ for (const thread of threads) {
1092
+ lines.push(`### ${thread.file ?? "review"}:${thread.line ?? "n/a"} — ${thread.author ?? "unknown"}`, "", `Thread: ${thread.id}`, `Status: ${thread.resolved ? "resolved" : thread.addressed ? "addressed" : "open"}`, "", thread.body, "", "---", "");
1093
+ }
1094
+ return `${lines.join(`
1095
+ `).trimEnd()}
1096
+ `;
1097
+ }
1098
+ function parseThreadArtifact(content) {
1099
+ const payload = content.match(/^<!-- diffpi-threads:([A-Za-z0-9_-]+) -->$/m)?.[1];
1100
+ if (!payload)
1101
+ throw new Error("This file is not a Diffpi thread artifact.");
1102
+ let threads;
1103
+ try {
1104
+ const decoded = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
1105
+ threads = z3.array(reviewThreadRecordSchema).parse(decoded);
1106
+ } catch {
1107
+ throw new Error("Cannot parse the Diffpi thread artifact payload.");
1108
+ }
1109
+ const replies = content.split(/^## Source comments$/m, 1)[0] ?? "";
1110
+ return threads.map((thread) => {
1111
+ const id = Buffer.from(thread.id, "utf8").toString("base64url");
1112
+ const startMarker = `<!-- diffpi-reply-start:${id} -->
1113
+ `;
1114
+ const endMarker = `
1115
+ <!-- diffpi-reply-end:${id} -->`;
1116
+ const start = replies.indexOf(startMarker);
1117
+ const end = start < 0 ? -1 : replies.indexOf(endMarker, start + startMarker.length);
1118
+ const reply = start >= 0 && end >= 0 ? replies.slice(start + startMarker.length, end).trim() : "";
1119
+ return reply ? { ...thread, reply } : thread;
1120
+ });
1121
+ }
1122
+ function upsertThreadReply(content, threadId, body, question, resolved) {
1123
+ const threads = parseThreadArtifact(content);
1124
+ const thread = threads.find((candidate) => candidate.id === threadId);
1125
+ if (!thread)
1126
+ throw new Error(`Review thread ${threadId} was not found in the local artifact.`);
1127
+ thread.reply = body;
1128
+ thread.addressed = true;
1129
+ if (question !== undefined)
1130
+ thread.question = question;
1131
+ if (resolved !== undefined)
1132
+ thread.resolved = resolved;
1133
+ const title = content.match(/^# Review threads: (.+)$/m)?.[1] ?? "review";
1134
+ const target = content.match(/^- Target: (.+)$/m)?.[1] ?? "local";
1135
+ const timestamp = content.match(/^- Pulled: (.+)$/m)?.[1];
1136
+ const pr = content.match(/^- PR\/MR: #(\d+)(?: — (.+))?$/m);
1137
+ return renderThreadArtifact(title, target, threads, {
1138
+ timestamp,
1139
+ number: pr ? Number.parseInt(pr[1], 10) : undefined,
1140
+ url: pr?.[2]
1141
+ });
1142
+ }
1143
+ // src/review/github-review-backend.ts
1144
+ function GitHubReviewBackend(vcs, number) {
1145
+ const pendingReview = async () => {
1146
+ const result = await ghChecked([
1147
+ "api",
1148
+ `/repos/${vcs.owner}/${vcs.repo}/pulls/${number}/reviews`,
1149
+ "--jq",
1150
+ '[.[] | select(.state=="PENDING")] | last | {id: (.id | tostring), nodeId: .node_id}'
1151
+ ]);
1152
+ if (!result.stdout.trim())
1153
+ return;
1154
+ let pending;
1155
+ try {
1156
+ pending = JSON.parse(result.stdout);
1157
+ } catch {
1158
+ throw new Error("Cannot parse the pending GitHub review identifier as JSON.");
1159
+ }
1160
+ if (!pending.id || !pending.nodeId)
1161
+ return;
1162
+ return { id: pending.id, nodeId: pending.nodeId };
1163
+ };
1164
+ const listThreads = async () => {
1165
+ const threads = [];
1166
+ let cursor;
1167
+ do {
1168
+ const args = [
1169
+ "api",
1170
+ "graphql",
1171
+ "-f",
1172
+ `query=${GITHUB_THREADS_QUERY}`,
1173
+ "-f",
1174
+ `owner=${vcs.owner}`,
1175
+ "-f",
1176
+ `repo=${vcs.repo}`,
1177
+ "-F",
1178
+ `number=${number}`
1179
+ ];
1180
+ if (cursor)
1181
+ args.push("-f", `after=${cursor}`);
1182
+ const result = await ghChecked(args);
1183
+ let data;
1184
+ try {
1185
+ data = JSON.parse(result.stdout);
1186
+ } catch {
1187
+ throw new Error("Cannot parse GitHub review threads as JSON.");
1188
+ }
1189
+ const connection = data.data?.repository?.pullRequest?.reviewThreads;
1190
+ threads.push(...connection?.nodes ?? []);
1191
+ cursor = connection?.pageInfo?.hasNextPage ? connection.pageInfo.endCursor : undefined;
1192
+ if (connection?.pageInfo?.hasNextPage && !cursor) {
1193
+ throw new Error("GitHub review thread pagination did not return an end cursor.");
1194
+ }
1195
+ } while (cursor);
1196
+ return threads.map((thread) => {
1197
+ const nodes = thread.comments?.nodes ?? [];
1198
+ const comment = nodes[0];
1199
+ const body = comment?.body ?? "";
1200
+ return {
1201
+ id: thread.id,
1202
+ file: thread.path,
1203
+ line: thread.line,
1204
+ body,
1205
+ author: comment?.author?.login,
1206
+ resolved: thread.isResolved,
1207
+ question: /\?\s*$/.test(body.trim()),
1208
+ replies: nodes.slice(1).map((node) => node.body)
1209
+ };
1210
+ });
1211
+ };
1212
+ return {
1213
+ kind: "remote",
1214
+ async stage(draft) {
1215
+ const pending = await pendingReview();
1216
+ if (!pending) {
1217
+ const payload = {
1218
+ body: draft.body,
1219
+ comments: draft.comments.map((comment) => ({
1220
+ path: comment.file,
1221
+ line: comment.line,
1222
+ side: comment.side ?? "RIGHT",
1223
+ body: comment.body
1224
+ }))
1225
+ };
1226
+ await ghChecked(["api", "--method", "POST", `/repos/${vcs.owner}/${vcs.repo}/pulls/${number}/reviews`, "--input", "-"], { input: JSON.stringify(payload) });
1227
+ return;
1228
+ }
1229
+ if (draft.body.trim()) {
1230
+ await ghChecked([
1231
+ "api",
1232
+ "--method",
1233
+ "PUT",
1234
+ `/repos/${vcs.owner}/${vcs.repo}/pulls/${number}/reviews/${pending.id}`,
1235
+ "-f",
1236
+ `body=${draft.body}`
1237
+ ]);
1238
+ }
1239
+ for (const comment of draft.comments) {
1240
+ await ghChecked([
1241
+ "api",
1242
+ "graphql",
1243
+ "-f",
1244
+ `query=${GITHUB_ADD_THREAD_MUTATION}`,
1245
+ "-f",
1246
+ `reviewId=${pending.nodeId}`,
1247
+ "-f",
1248
+ `body=${comment.body}`,
1249
+ "-f",
1250
+ `path=${comment.file}`,
1251
+ "-F",
1252
+ `line=${comment.line}`,
1253
+ "-f",
1254
+ `side=${comment.side ?? "RIGHT"}`
1255
+ ]);
1256
+ }
1257
+ },
1258
+ async readDraft() {
1259
+ const pending = await pendingReview();
1260
+ if (!pending)
1261
+ return { comments: [], body: "" };
1262
+ const review = await ghChecked(["api", `/repos/${vcs.owner}/${vcs.repo}/pulls/${number}/reviews/${pending.id}`]);
1263
+ const comments = await ghChecked([
1264
+ "api",
1265
+ `/repos/${vcs.owner}/${vcs.repo}/pulls/${number}/reviews/${pending.id}/comments`
1266
+ ]);
1267
+ let reviewData;
1268
+ let commentData;
1269
+ try {
1270
+ reviewData = JSON.parse(review.stdout);
1271
+ commentData = JSON.parse(comments.stdout);
1272
+ } catch {
1273
+ throw new Error("Cannot parse the pending GitHub review as JSON.");
1274
+ }
1275
+ return {
1276
+ body: reviewData.body ?? "",
1277
+ comments: commentData.map((comment) => ({
1278
+ file: comment.path,
1279
+ line: comment.line ?? comment.original_line ?? 1,
1280
+ side: comment.side,
1281
+ body: comment.body
1282
+ }))
1283
+ };
1284
+ },
1285
+ listThreads,
1286
+ async reply(input) {
1287
+ const threads = await listThreads();
1288
+ const existing = threads.find((thread) => thread.id === input.threadId);
1289
+ if (!existing?.replies?.includes(input.body)) {
1290
+ await ghChecked([
1291
+ "api",
1292
+ "graphql",
1293
+ "-f",
1294
+ `query=${GITHUB_REPLY_MUTATION}`,
1295
+ "-f",
1296
+ `threadId=${input.threadId}`,
1297
+ "-f",
1298
+ `body=${input.body}`
1299
+ ]);
1300
+ }
1301
+ if (input.resolve) {
1302
+ await ghChecked([
1303
+ "api",
1304
+ "graphql",
1305
+ "-f",
1306
+ `query=${GITHUB_RESOLVE_MUTATION}`,
1307
+ "-f",
1308
+ `threadId=${input.threadId}`
1309
+ ]);
1310
+ }
1311
+ },
1312
+ async publish(event) {
1313
+ const pending = await pendingReview();
1314
+ if (!pending && event === "COMMENT")
1315
+ return;
1316
+ if (!pending && event === "REQUEST_CHANGES") {
1317
+ throw new Error("GitHub requires pending comments before publishing a request-changes review without a body.");
1318
+ }
1319
+ const endpoint = githubReviewSubmissionEndpoint(vcs.owner, vcs.repo, number, pending?.id ?? "");
1320
+ await ghChecked(["api", "--method", "POST", endpoint, "-f", `event=${event}`]);
1321
+ }
1322
+ };
1323
+ }
1324
+ function githubReviewSubmissionEndpoint(owner, repo, id, pendingReviewId) {
1325
+ return pendingReviewId ? `/repos/${owner}/${repo}/pulls/${id}/reviews/${pendingReviewId}/events` : `/repos/${owner}/${repo}/pulls/${id}/reviews`;
1326
+ }
1327
+ var GITHUB_THREADS_QUERY = `query($owner:String!,$repo:String!,$number:Int!,$after:String){repository(owner:$owner,name:$repo){pullRequest(number:$number){reviewThreads(first:100,after:$after){nodes{id,isResolved,path,line,comments(first:100){nodes{body,author{login}}}}pageInfo{hasNextPage,endCursor}}}}}`;
1328
+ var GITHUB_ADD_THREAD_MUTATION = `mutation($reviewId:ID!,$body:String!,$path:String!,$line:Int!,$side:DiffSide!){addPullRequestReviewThread(input:{pullRequestReviewId:$reviewId,body:$body,path:$path,line:$line,side:$side}){thread{id}}}`;
1329
+ var GITHUB_REPLY_MUTATION = `mutation($threadId:ID!,$body:String!){addPullRequestReviewThreadReply(input:{pullRequestReviewThreadId:$threadId,body:$body}){comment{id}}}`;
1330
+ var GITHUB_RESOLVE_MUTATION = `mutation($threadId:ID!){resolveReviewThread(input:{threadId:$threadId}){thread{isResolved}}}`;
1331
+
1332
+ // src/review/gitlab-review-backend.ts
1333
+ function GitLabReviewBackend(vcs, number) {
1334
+ const project = () => `${vcs.owner}/${vcs.repo}`;
1335
+ const mergeRequestEndpoint = () => `projects/${encodeURIComponent(project())}/merge_requests/${number}`;
1336
+ const listThreads = async () => {
1337
+ const discussions = [];
1338
+ for (let page = 1;; page += 1) {
1339
+ const result = await glabChecked(["api", `${mergeRequestEndpoint()}/discussions?per_page=100&page=${page}`]);
1340
+ let batch;
1341
+ try {
1342
+ batch = JSON.parse(result.stdout);
1343
+ } catch {
1344
+ throw new Error("Cannot parse GitLab review discussions as JSON.");
1345
+ }
1346
+ discussions.push(...batch);
1347
+ if (batch.length < 100)
1348
+ break;
1349
+ }
1350
+ return discussions.map((discussion) => {
1351
+ const notes = discussion.notes ?? [];
1352
+ const note = notes[0];
1353
+ const body = note?.body ?? "";
1354
+ return {
1355
+ id: discussion.id,
1356
+ file: note?.position?.new_path ?? note?.position?.old_path,
1357
+ line: note?.position?.new_line ?? note?.position?.old_line,
1358
+ body,
1359
+ author: note?.author?.username,
1360
+ resolved: Boolean(discussion.resolved),
1361
+ question: /\?\s*$/.test(body.trim()),
1362
+ replies: notes.slice(1).map((reply) => reply.body)
1363
+ };
1364
+ });
1365
+ };
1366
+ return {
1367
+ kind: "remote",
1368
+ async stage(draft) {
1369
+ const endpoint = `${mergeRequestEndpoint()}/draft_notes`;
1370
+ if (draft.body.trim()) {
1371
+ await glabChecked(["api", "--method", "POST", endpoint, "--input", "-"], {
1372
+ input: JSON.stringify({ note: draft.body })
1373
+ });
1374
+ }
1375
+ if (draft.comments.length === 0)
1376
+ return;
1377
+ const response = await glabChecked(["api", mergeRequestEndpoint()]);
1378
+ const diffRefs = parseGitlabDiffRefs(response.stdout);
1379
+ for (const comment of draft.comments) {
1380
+ const payload = {
1381
+ note: comment.body,
1382
+ position: {
1383
+ ...diffRefs,
1384
+ position_type: "text",
1385
+ new_path: comment.file,
1386
+ old_path: comment.file,
1387
+ new_line: comment.side === "LEFT" ? undefined : comment.line,
1388
+ old_line: comment.side === "LEFT" ? comment.line : undefined
1389
+ }
1390
+ };
1391
+ await glabChecked(["api", "--method", "POST", endpoint, "--input", "-"], {
1392
+ input: JSON.stringify(payload)
1393
+ });
1394
+ }
1395
+ },
1396
+ async readDraft() {
1397
+ const result = await glabChecked(["api", `${mergeRequestEndpoint()}/draft_notes`]);
1398
+ let notes;
1399
+ try {
1400
+ notes = JSON.parse(result.stdout);
1401
+ } catch {
1402
+ throw new Error("Cannot parse GitLab draft notes as JSON.");
1403
+ }
1404
+ const comments = [];
1405
+ const body = [];
1406
+ for (const note of notes) {
1407
+ if (!note.note)
1408
+ continue;
1409
+ const file = note.position?.new_path ?? note.position?.old_path;
1410
+ const line = note.position?.new_line ?? note.position?.old_line;
1411
+ if (file && line) {
1412
+ comments.push({
1413
+ file,
1414
+ line,
1415
+ side: note.position?.new_line ? "RIGHT" : "LEFT",
1416
+ body: note.note
1417
+ });
1418
+ } else {
1419
+ body.push(note.note);
1420
+ }
1421
+ }
1422
+ return { comments, body: body.join(`
1423
+
1424
+ `) };
1425
+ },
1426
+ listThreads,
1427
+ async reply(input) {
1428
+ const endpoint = `${mergeRequestEndpoint()}/discussions/${encodeURIComponent(input.threadId)}`;
1429
+ const threads = await listThreads();
1430
+ const existing = threads.find((thread) => thread.id === input.threadId);
1431
+ if (!existing?.replies?.includes(input.body)) {
1432
+ await glabChecked(["api", "--method", "POST", `${endpoint}/notes`, "--input", "-"], {
1433
+ input: JSON.stringify({ body: input.body })
1434
+ });
1435
+ }
1436
+ if (input.resolve)
1437
+ await glabChecked(["api", "--method", "PUT", `${endpoint}?resolved=true`]);
1438
+ },
1439
+ async publish(event) {
1440
+ assertReviewEventSupported(vcs.provider, event);
1441
+ const drafts = await glabChecked(["api", `${mergeRequestEndpoint()}/draft_notes`]);
1442
+ if (hasGitlabDraftNotes(drafts.stdout)) {
1443
+ await glabChecked(["api", "--method", "POST", `${mergeRequestEndpoint()}/draft_notes/bulk_publish`]);
1444
+ }
1445
+ if (event === "APPROVE")
1446
+ await glabChecked(["mr", "approve", String(number), "--repo", project()]);
1447
+ }
1448
+ };
1449
+ }
1450
+ function assertReviewEventSupported(provider, event) {
1451
+ if (provider === "gitlab" && event === "REQUEST_CHANGES") {
1452
+ throw new Error("GitLab does not support REQUEST_CHANGES reviews; post a comment or reject the merge request manually.");
1453
+ }
1454
+ }
1455
+ function parseGitlabDiffRefs(input) {
1456
+ let data;
1457
+ try {
1458
+ data = JSON.parse(input);
1459
+ } catch {
1460
+ throw new Error("Cannot create positioned GitLab draft notes: the merge request response was not valid JSON.");
1461
+ }
1462
+ const { base_sha, start_sha, head_sha } = data.diff_refs ?? {};
1463
+ if (!base_sha || !start_sha || !head_sha) {
1464
+ throw new Error("Cannot create positioned GitLab draft notes: merge request diff refs are unavailable.");
1465
+ }
1466
+ return { base_sha, start_sha, head_sha };
1467
+ }
1468
+ function hasGitlabDraftNotes(input) {
1469
+ try {
1470
+ const data = JSON.parse(input);
1471
+ return Array.isArray(data) && data.length > 0;
1472
+ } catch {
1473
+ throw new Error("Cannot publish the GitLab review: the draft notes response was not valid JSON.");
1474
+ }
1475
+ }
1476
+
1477
+ // src/review/local-review-backend.ts
1478
+ import { readFile as readFile5, writeFile as writeFile3 } from "node:fs/promises";
1479
+
1480
+ // src/extensions/tuicrx.ts
1481
+ import { readFile as readFile4, realpath as realpath2 } from "node:fs/promises";
1482
+ import { resolve as resolve2 } from "node:path";
1483
+ async function tuicrAvailable() {
1484
+ return Boolean(await findExecutable("tuicr"));
1485
+ }
1486
+ async function listSessions(repo = ".") {
1487
+ await requireTuicr();
1488
+ const result = await run("tuicr", ["review", "list", "--repo", repo]);
1489
+ if (result.code !== 0 || !result.stdout.trim())
1490
+ return [];
1491
+ try {
1492
+ const raw = JSON.parse(result.stdout);
1493
+ return raw.map((entry) => ({
1494
+ slug: entry.slug,
1495
+ kind: entry.kind,
1496
+ path: entry.path,
1497
+ updatedAt: entry.updated_at,
1498
+ commentCount: entry.comment_count,
1499
+ anchor: entry.anchor,
1500
+ active: entry.active
1501
+ }));
1502
+ } catch {
1503
+ throw new Error("Cannot parse tuicr review list output.");
1504
+ }
1505
+ }
1506
+ async function addComment(session, body, opts = {}) {
1507
+ await requireTuicr();
1508
+ const args = ["review", "add", "--session", session, body];
1509
+ if (opts.targetFile)
1510
+ args.push("--target-file", opts.targetFile);
1511
+ if (opts.line !== undefined)
1512
+ args.push("--line", String(opts.line));
1513
+ if (opts.side)
1514
+ args.push("--side", opts.side);
1515
+ if (opts.username)
1516
+ args.push("--username", opts.username);
1517
+ await runChecked("tuicr", args);
1518
+ }
1519
+ async function launch(cwd, pr, localBase) {
1520
+ let command;
1521
+ if (pr !== undefined)
1522
+ command = ["tuicr", "pr", String(pr)];
1523
+ else if (localBase)
1524
+ command = ["tuicr", "-w", "-r", `${localBase}..HEAD`];
1525
+ else
1526
+ throw new Error("Cannot launch a local tuicr review: no base branch could be determined.");
1527
+ if (!await tuicrAvailable()) {
1528
+ return {
1529
+ launched: false,
1530
+ via: "print",
1531
+ command: command.join(" "),
1532
+ reason: "tuicr is not installed or is not available on PATH.",
1533
+ instruction: `Install tuicr, then run: ${command.join(" ")}`
1534
+ };
1535
+ }
1536
+ return openInNewTab(command, { cwd, name: "tuicr" });
1537
+ }
1538
+ async function resolveSession(cwd, branch) {
1539
+ const sessions = await listSessions(cwd);
1540
+ return findMatchingSession(sessions, cwd, branch);
1541
+ }
1542
+ async function resolveReviewSession(cwd, target) {
1543
+ if (!target.workingTree && target.owner && target.repo && target.number !== undefined) {
1544
+ return resolvePrSession(cwd, target.owner, target.repo, target.number);
1545
+ }
1546
+ return resolveSession(cwd, target.branch);
1547
+ }
1548
+ async function resolvePrSession(cwd, owner, repo, number) {
1549
+ const coordinate = `${owner}/${repo}`.toLowerCase();
1550
+ const sessions = await listSessions(cwd);
1551
+ return sessions.find((session) => {
1552
+ const slug = session.slug.toLowerCase();
1553
+ return session.kind === "pr" && slug.includes(coordinate) && (slug.endsWith(`/pr/${number}`) || slug.endsWith(`/mr/${number}`));
1554
+ });
1555
+ }
1556
+ async function findMatchingSession(sessions, cwd, branch) {
1557
+ const repository = await canonicalPath2(await gitToplevel(cwd));
1558
+ for (const session of sessions) {
1559
+ if (session.kind !== "local")
1560
+ continue;
1561
+ try {
1562
+ const data = await readSession(session.path);
1563
+ if (data.branch_name !== branch || !data.repo_path)
1564
+ continue;
1565
+ if (await canonicalPath2(data.repo_path) === repository)
1566
+ return session;
1567
+ } catch {}
1568
+ }
1569
+ return;
1570
+ }
1571
+ async function readSession(path) {
1572
+ const content = await readFile4(path, "utf8");
1573
+ try {
1574
+ return JSON.parse(content);
1575
+ } catch {
1576
+ throw new Error(`Cannot parse tuicr session JSON: ${path}`);
1577
+ }
1578
+ }
1579
+ function toFindings(session, options = {}) {
1580
+ const comments = [];
1581
+ const include = (comment) => (!options.agentOnly || commentAuthor(comment)?.startsWith("Agent: ")) && (!options.excludeLocalResponses || !isLocalResponse(comment.content));
1582
+ const bodyParts = (session.review_comments ?? []).flatMap((comment) => include(comment) ? [comment.content] : []);
1583
+ for (const [file, entry] of Object.entries(session.files ?? {})) {
1584
+ const fileComments = (entry.file_comments ?? []).flatMap((comment) => include(comment) ? [comment.content] : []);
1585
+ if (fileComments.length > 0)
1586
+ bodyParts.push(`File: ${file}
1587
+
1588
+ ${fileComments.join(`
1589
+
1590
+ `)}`);
1591
+ for (const [lineKey, lineComments] of Object.entries(entry.line_comments ?? {})) {
1592
+ const line = Number.parseInt(lineKey, 10);
1593
+ if (!Number.isFinite(line))
1594
+ continue;
1595
+ for (const lineComment of lineComments) {
1596
+ if (!include(lineComment))
1597
+ continue;
1598
+ const comment = {
1599
+ file,
1600
+ line,
1601
+ side: lineComment.side === "old" ? "LEFT" : "RIGHT",
1602
+ body: lineComment.content
1603
+ };
1604
+ const author = commentAuthor(lineComment);
1605
+ if (author)
1606
+ comment.author = author;
1607
+ comments.push(comment);
1608
+ }
1609
+ }
1610
+ }
1611
+ return { comments, body: bodyParts.join(`
1612
+
1613
+ `) };
1614
+ }
1615
+ async function requireTuicr() {
1616
+ if (!await tuicrAvailable())
1617
+ throw new Error("tuicr is not installed or is not available on PATH.");
1618
+ }
1619
+ function commentAuthor(comment) {
1620
+ return comment.username ?? comment.author;
1621
+ }
1622
+ async function canonicalPath2(path) {
1623
+ try {
1624
+ return await realpath2(path);
1625
+ } catch {
1626
+ return resolve2(path);
1627
+ }
1628
+ }
1629
+
1630
+ // src/review/local-review-backend.ts
1631
+ function LocalReviewBackend(options) {
1632
+ return {
1633
+ kind: "local",
1634
+ async stage(draft) {
1635
+ for (const comment of draft.comments) {
1636
+ await addComment(options.session, comment.body, {
1637
+ targetFile: comment.file,
1638
+ line: comment.line,
1639
+ side: comment.side === "LEFT" ? "old" : "new",
1640
+ username: options.author
1641
+ });
1642
+ }
1643
+ if (draft.body.trim())
1644
+ await addComment(options.session, draft.body, { username: options.author });
1645
+ },
1646
+ async readDraft() {
1647
+ return toFindings(await readSession(options.session), { agentOnly: true, excludeLocalResponses: true });
1648
+ },
1649
+ async listThreads() {
1650
+ try {
1651
+ return parseThreadArtifact(await readFile5(options.artifactPath, "utf8"));
1652
+ } catch (error) {
1653
+ if (error.code === "ENOENT")
1654
+ return [];
1655
+ throw error;
1656
+ }
1657
+ },
1658
+ async reply(input) {
1659
+ const content = await readFile5(options.artifactPath, "utf8");
1660
+ await writeFile3(options.artifactPath, upsertThreadReply(content, input.threadId, input.body, input.question, input.resolve), "utf8");
1661
+ },
1662
+ async publish() {
1663
+ throw new Error("Promote a local draft through a remote review backend before publishing it.");
1664
+ }
1665
+ };
1666
+ }
1667
+
1668
+ // src/review/review-backend.ts
1669
+ function createRemoteReviewBackend(vcs, number) {
1670
+ if (vcs.provider === "github")
1671
+ return GitHubReviewBackend(vcs, number);
1672
+ if (vcs.provider === "gitlab")
1673
+ return GitLabReviewBackend(vcs, number);
1674
+ throw new Error("A remote review backend requires a GitHub or GitLab repository.");
1675
+ }
1676
+ function createLocalReviewBackend(options) {
1677
+ return LocalReviewBackend(options);
1678
+ }
1679
+ // src/review/review-state.ts
1680
+ import { createHash as createHash2 } from "node:crypto";
1681
+ import { readFile as readFile6, rename, writeFile as writeFile4 } from "node:fs/promises";
1682
+ import { join as join7 } from "node:path";
1683
+ import { z as z4 } from "zod";
1684
+
1685
+ // src/store.ts
1686
+ import { createHash } from "node:crypto";
1687
+ import { lstat, mkdir as mkdir3, readlink, realpath as realpath3, symlink, unlink } from "node:fs/promises";
1688
+ import { homedir as homedir4 } from "node:os";
1689
+ import { dirname as dirname3, isAbsolute as isAbsolute2, join as join6, resolve as resolve3 } from "node:path";
1690
+ var STORE_LINK = ".diffpi";
1691
+ var LEGACY_STORE_LINK = join6(".pi", "diffpi");
1692
+ async function computeProjectSlug(cwd) {
1693
+ return projectSlug(await inspectGitRepository(cwd));
1694
+ }
1695
+ function storeGlobalRoot(homeDir = homedir4()) {
1696
+ return join6(homeDir, ".difflab", "diffpi", "projects");
1697
+ }
1698
+ async function ensureStore(cwd, homeDir = homedir4()) {
1699
+ const repository = await inspectGitRepository(cwd);
1700
+ const root = repository.root;
1701
+ const slug = projectSlug(repository);
1702
+ const dest = join6(storeGlobalRoot(homeDir), slug);
1703
+ const link = join6(root, STORE_LINK);
1704
+ await mkdir3(dest, { recursive: true });
1705
+ try {
1706
+ await assertStoreLink(link, dest);
1707
+ } catch (error) {
1708
+ if (error.code !== "ENOENT")
1709
+ throw error;
1710
+ await symlink(dest, link);
1711
+ }
1712
+ await removeLegacyStoreLink(join6(root, LEGACY_STORE_LINK), dest);
1713
+ return { slug, root, dest, link, linked: true };
1714
+ }
1715
+ async function storeDir(cwd, homeDir = homedir4()) {
1716
+ const store = await ensureStore(cwd, homeDir);
1717
+ return store.dest;
1718
+ }
1719
+ async function reviewsDir(cwd, homeDir = homedir4()) {
1720
+ const store = await ensureStore(cwd, homeDir);
1721
+ const dir = join6(store.link, "review");
1722
+ await mkdir3(dir, { recursive: true });
1723
+ return dir;
1724
+ }
1725
+ async function sessionsDir(cwd, homeDir = homedir4()) {
1726
+ const store = await ensureStore(cwd, homeDir);
1727
+ const dir = join6(store.link, "sessions");
1728
+ await mkdir3(dir, { recursive: true });
1729
+ return dir;
1730
+ }
1731
+ async function assertStoreLink(path, dest) {
1732
+ const entry = await lstat(path);
1733
+ if (!entry.isSymbolicLink())
1734
+ throw new Error(`${path} exists and is not a symlink.`);
1735
+ const target = await symlinkTarget(path);
1736
+ if (target !== await canonicalPath3(dest))
1737
+ throw new Error(`${path} points to ${target}, not ${dest}.`);
1738
+ }
1739
+ async function removeLegacyStoreLink(path, dest) {
1740
+ try {
1741
+ const entry = await lstat(path);
1742
+ if (!entry.isSymbolicLink())
1743
+ return;
1744
+ const target = await symlinkTarget(path);
1745
+ if (target === await canonicalPath3(dest))
1746
+ await unlink(path);
1747
+ } catch (error) {
1748
+ if (error.code !== "ENOENT")
1749
+ throw error;
1750
+ }
1751
+ }
1752
+ async function symlinkTarget(path) {
1753
+ const target = await readlink(path);
1754
+ return canonicalPath3(isAbsolute2(target) ? target : resolve3(dirname3(path), target));
1755
+ }
1756
+ async function canonicalPath3(path) {
1757
+ try {
1758
+ return await realpath3(path);
1759
+ } catch {
1760
+ return resolve3(path);
1761
+ }
1762
+ }
1763
+ function projectSlug(repository) {
1764
+ const readable = repository.name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "unnamed";
1765
+ const digest = createHash("sha256").update(repository.identity).digest("hex").slice(0, 12);
1766
+ return `${readable}-${digest}`;
1767
+ }
1768
+
1769
+ // src/review/review-state.ts
1770
+ var reviewPublicationStateSchema = z4.object({
1771
+ target: z4.string().optional(),
1772
+ bodies: z4.array(z4.string()).default([]),
1773
+ comments: z4.array(z4.string()).default([]),
1774
+ replies: z4.array(z4.string()).default([]),
1775
+ overlayPath: z4.string().optional()
1776
+ });
1777
+ function reviewBodyFingerprint(body) {
1778
+ return digest(body);
1779
+ }
1780
+ function reviewCommentFingerprint(comment) {
1781
+ return digest([comment.file, String(comment.line), comment.side ?? "RIGHT", comment.body].join("\x00"));
1782
+ }
1783
+ function reviewReplyFingerprint(threadId, body) {
1784
+ return digest(`${threadId}\x00${body}`);
1785
+ }
1786
+ function unpublishedReviewComments(comments, knownFingerprints) {
1787
+ return comments.filter((comment) => !knownFingerprints.has(reviewCommentFingerprint(comment)));
1788
+ }
1789
+ async function loadReviewPublicationState(cwd, vcs, number, homeDir) {
1790
+ const target = `${vcs.provider}:${vcs.owner}/${vcs.repo}#${number}`;
1791
+ const path = join7(await sessionsDir(cwd, homeDir), `review-publish-${digest(target).slice(0, 16)}.json`);
1792
+ try {
1793
+ const parsed = reviewPublicationStateSchema.parse(JSON.parse(await readFile6(path, "utf8")));
1794
+ return { path, state: { ...parsed, target } };
1795
+ } catch (error) {
1796
+ if (error.code === "ENOENT") {
1797
+ return { path, state: { target, bodies: [], comments: [], replies: [] } };
1798
+ }
1799
+ if (error instanceof SyntaxError || error instanceof z4.ZodError) {
1800
+ throw new Error(`Cannot parse review publication state: ${path}`);
1801
+ }
1802
+ throw error;
1803
+ }
1804
+ }
1805
+ async function saveReviewPublicationState(path, state) {
1806
+ const temp = `${path}.${process.pid}.tmp`;
1807
+ await writeFile4(temp, `${JSON.stringify(state, null, 2)}
1808
+ `, "utf8");
1809
+ await rename(temp, path);
1810
+ }
1811
+ function digest(value) {
1812
+ return createHash2("sha256").update(value).digest("hex");
1813
+ }
1814
+ // src/mcp.ts
1815
+ import { mkdir as mkdir4, readFile as readFile7, writeFile as writeFile5 } from "node:fs/promises";
1816
+ import { homedir as homedir5 } from "node:os";
1817
+ import { dirname as dirname4, join as join8 } from "node:path";
1818
+ var mcp = {
1819
+ globalConfigPath(homeDir = homedir5()) {
1820
+ return join8(homeDir, ".config", "mcp", "mcp.json");
1821
+ },
1822
+ async serversEnsure(servers, options = {}) {
1823
+ const path = options.path ?? mcp.globalConfigPath();
1824
+ const currentText = await getOptionalFile2(path);
1825
+ const current = getParsedConfig(currentText, path);
1826
+ const nextServers = { ...current.mcpServers };
1827
+ for (const [name, entry] of Object.entries(servers)) {
1828
+ nextServers[name] = mergeEntry(nextServers[name], entry);
1829
+ }
1830
+ const next = { ...current, mcpServers: nextServers };
1831
+ const changed = JSON.stringify(current) !== JSON.stringify(next);
1832
+ if (changed && !options.dryRun) {
1833
+ await mkdir4(dirname4(path), { recursive: true });
1834
+ await writeFile5(path, `${JSON.stringify(next, null, 2)}
1835
+ `, "utf8");
1836
+ }
1837
+ return { path, changed, existed: currentText !== undefined, planned: changed && options.dryRun === true };
1838
+ }
1839
+ };
1840
+ function mergeEntry(current, required) {
1841
+ const merged = { ...current, ...required };
1842
+ if (current?.env || required.env)
1843
+ merged.env = { ...current?.env, ...required.env };
1844
+ return merged;
1845
+ }
1846
+ function getParsedConfig(content, path) {
1847
+ if (!content?.trim())
1848
+ return { mcpServers: {} };
1849
+ try {
1850
+ const value = JSON.parse(content);
1851
+ if (!isRecord(value))
1852
+ throw new Error("not an object");
1853
+ const servers = value.mcpServers;
1854
+ if (servers !== undefined && !isRecord(servers))
1855
+ throw new Error("mcpServers is not an object");
1856
+ return { ...value, mcpServers: servers ?? {} };
1857
+ } catch {
1858
+ throw new Error(`Expected a valid pi-mcp-adapter configuration in ${path}.`);
1859
+ }
1860
+ }
1861
+ async function getOptionalFile2(path) {
1862
+ try {
1863
+ return await readFile7(path, "utf8");
1864
+ } catch (error) {
1865
+ if (error instanceof Error && "code" in error && error.code === "ENOENT")
1866
+ return;
1867
+ throw error;
1868
+ }
1869
+ }
1870
+ function isRecord(value) {
1871
+ return value !== null && typeof value === "object" && !Array.isArray(value);
1872
+ }
382
1873
  // src/modes.ts
383
1874
  import {
384
1875
  getAgentDir,
385
1876
  parseFrontmatter as parseFrontmatter2
386
1877
  } from "@earendil-works/pi-coding-agent";
387
- import { readFile as readFile4 } from "node:fs/promises";
388
- import { homedir as homedir4 } from "node:os";
389
- import { basename as basename2, extname, join as join6 } from "node:path";
1878
+ import { readFile as readFile8 } from "node:fs/promises";
1879
+ import { homedir as homedir6 } from "node:os";
1880
+ import { basename as basename4, extname, join as join10 } from "node:path";
390
1881
 
391
1882
  // src/assets.ts
392
1883
  import { existsSync } from "node:fs";
393
- import { dirname as dirname3, join as join5 } from "node:path";
1884
+ import { dirname as dirname5, join as join9 } from "node:path";
394
1885
  import { fileURLToPath } from "node:url";
395
- function resolveBundledAgentsDir(moduleUrl = import.meta.url) {
396
- const moduleDir = dirname3(fileURLToPath(moduleUrl));
397
- const candidates = [
398
- join5(moduleDir, "agents"),
399
- join5(moduleDir, "..", "agents"),
400
- join5(moduleDir, "..", "..", "agents")
401
- ];
1886
+ function resolveBundledAssetDir(name, moduleUrl = import.meta.url) {
1887
+ const moduleDir = dirname5(fileURLToPath(moduleUrl));
1888
+ const candidates = [join9(moduleDir, name), join9(moduleDir, "..", name), join9(moduleDir, "..", "..", name)];
402
1889
  return candidates.find((path) => existsSync(path)) ?? candidates[1];
403
1890
  }
1891
+ function resolveBundledAgentsDir(moduleUrl = import.meta.url) {
1892
+ return resolveBundledAssetDir("agents", moduleUrl);
1893
+ }
1894
+ function resolveBundledTemplatesDir(moduleUrl = import.meta.url) {
1895
+ return resolveBundledAssetDir("templates", moduleUrl);
1896
+ }
404
1897
 
405
1898
  // src/modes.ts
406
1899
  async function discoverAgentModes(options) {
407
1900
  const agentDir = options.agentDir ?? getAgentDir();
408
- const homeDir = options.homeDir ?? homedir4();
1901
+ const homeDir = options.homeDir ?? homedir6();
409
1902
  const modes = new Map;
410
1903
  const diagnostics = [];
411
1904
  await loadAgentModes(options.bundledAgentsDir ?? BUNDLED_AGENTS_DIR, "diffpi agent", modes, diagnostics);
412
- await loadAgentModes(join6(agentDir, "agents"), "user agent", modes, diagnostics);
1905
+ await loadAgentModes(join10(agentDir, "agents"), "user agent", modes, diagnostics);
413
1906
  if (options.includeSkills) {
414
- await loadSkillModes(join6(homeDir, ".agents", "skills"), "user skill", modes, diagnostics);
415
- await loadSkillModes(join6(agentDir, "skills"), "pi user skill", modes, diagnostics);
1907
+ await loadSkillModes(join10(homeDir, ".agents", "skills"), "user skill", modes, diagnostics);
1908
+ await loadSkillModes(join10(agentDir, "skills"), "pi user skill", modes, diagnostics);
416
1909
  }
417
1910
  if (options.projectTrusted === true) {
418
1911
  if (options.includeSkills) {
419
- await loadSkillModes(join6(options.cwd, ".agents", "skills"), "project skill", modes, diagnostics);
420
- await loadSkillModes(join6(options.cwd, ".pi", "skills"), "pi project skill", modes, diagnostics);
1912
+ await loadSkillModes(join10(options.cwd, ".agents", "skills"), "project skill", modes, diagnostics);
1913
+ await loadSkillModes(join10(options.cwd, ".pi", "skills"), "pi project skill", modes, diagnostics);
421
1914
  }
422
- await loadAgentModes(join6(options.cwd, ".agents", "agents"), "project agent", modes, diagnostics);
423
- await loadAgentModes(join6(options.cwd, ".pi", "agents"), "pi project agent", modes, diagnostics);
1915
+ await loadAgentModes(join10(options.cwd, ".agents", "agents"), "project agent", modes, diagnostics);
1916
+ await loadAgentModes(join10(options.cwd, ".pi", "agents"), "pi project agent", modes, diagnostics);
424
1917
  }
425
1918
  const userConfig = await loadDiffpiConfig({ homeDir });
426
1919
  const configuredModes = [...modes.values()].map((mode) => ({
@@ -457,7 +1950,11 @@ function createModeController(pi, options = {}) {
457
1950
  let active;
458
1951
  let baseline;
459
1952
  const updateStatus = (ctx) => {
460
- ctx.ui.setStatus(MODE_STATUS_KEY, active ? `mode: ${active.id}` : undefined);
1953
+ const model = ctx.model ? ` · ${ctx.model.provider}/${ctx.model.id}` : "";
1954
+ const label = active?.label.toUpperCase() ?? "DEFAULT";
1955
+ const badge = `AGENT ${label}${model}`;
1956
+ ctx.ui.setStatus(MODE_STATUS_KEY, undefined);
1957
+ ctx.ui.setWidget?.(MODE_WIDGET_KEY, [badge], { placement: "belowEditor" });
461
1958
  };
462
1959
  const list = (ctx, listOptions = {}) => discoverAgentModes({
463
1960
  cwd: ctx.cwd,
@@ -512,6 +2009,9 @@ function createModeController(pi, options = {}) {
512
2009
  }
513
2010
  updateStatus(ctx);
514
2011
  },
2012
+ refresh(ctx) {
2013
+ updateStatus(ctx);
2014
+ },
515
2015
  apply(systemPrompt) {
516
2016
  if (!active)
517
2017
  return systemPrompt;
@@ -530,6 +2030,7 @@ ${active.systemPrompt}`;
530
2030
  }
531
2031
  var MODE_STATE_ENTRY = "diffpi-mode-state";
532
2032
  var MODE_STATUS_KEY = "diffpi-mode";
2033
+ var MODE_WIDGET_KEY = "diffpi-mode-badge";
533
2034
  var MODE_CONTROL_TOOLS = ["ask_user_question", "diffpi_modes_list", "diffpi_modes_set", "diffpi_modes_unset"];
534
2035
  var BUNDLED_AGENTS_DIR = resolveBundledAgentsDir();
535
2036
  var THINKING_LEVELS = new Set(["off", "minimal", "low", "medium", "high", "xhigh", "max"]);
@@ -583,19 +2084,19 @@ function captureRuntime(pi, ctx) {
583
2084
  async function loadSkillModes(skillsDir, source, modes, diagnostics) {
584
2085
  const entries = await readDirectoryIfExists(skillsDir);
585
2086
  for (const entry of entries.filter((item) => item.isDirectory()).sort((a, b) => a.name.localeCompare(b.name))) {
586
- await loadAgentModes(join6(skillsDir, entry.name, "agents"), `${source} ${entry.name}`, modes, diagnostics, entry.name);
2087
+ await loadAgentModes(join10(skillsDir, entry.name, "agents"), `${source} ${entry.name}`, modes, diagnostics, entry.name);
587
2088
  }
588
2089
  }
589
2090
  async function loadAgentModes(directory, source, modes, diagnostics, skillName) {
590
2091
  const entries = await readDirectoryIfExists(directory);
591
2092
  for (const entry of entries.filter((item) => item.isFile() && item.name.endsWith(".md")).sort((a, b) => a.name.localeCompare(b.name))) {
592
- const path = join6(directory, entry.name);
2093
+ const path = join10(directory, entry.name);
593
2094
  try {
594
- const content = await readFile4(path, "utf8");
2095
+ const content = await readFile8(path, "utf8");
595
2096
  const { frontmatter, body } = parseFrontmatter2(content.startsWith("\uFEFF") ? content.slice(1) : content);
596
2097
  if (frontmatter.enabled === false || frontmatter.inline === false)
597
2098
  continue;
598
- const name = getFrontmatterText(frontmatter.name) ?? basename2(path, extname(path));
2099
+ const name = getFrontmatterText(frontmatter.name) ?? basename4(path, extname(path));
599
2100
  const systemPrompt = body.trim();
600
2101
  if (!name || name.includes(":") || !systemPrompt) {
601
2102
  diagnostics.push(`Skipped ${path}: agent name must not contain ":" and prompt body is required.`);
@@ -605,7 +2106,7 @@ async function loadAgentModes(directory, source, modes, diagnostics, skillName)
605
2106
  modes.set(id, {
606
2107
  id,
607
2108
  label: getFrontmatterText(frontmatter.display_name) ?? name,
608
- description: getFrontmatterText(frontmatter.description) ?? `Inline agent from ${basename2(path)}`,
2109
+ description: getFrontmatterText(frontmatter.description) ?? `Inline agent from ${basename4(path)}`,
609
2110
  systemPrompt,
610
2111
  promptStrategy: frontmatter.prompt_mode === "append" ? "append" : "replace",
611
2112
  modelPreferences: [
@@ -647,9 +2148,9 @@ function isModeBaseline(value) {
647
2148
  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");
648
2149
  }
649
2150
  // src/pi.ts
650
- import { mkdir as mkdir3, writeFile as writeFile3 } from "node:fs/promises";
651
- import { homedir as homedir5 } from "node:os";
652
- import { dirname as dirname4, join as join7 } from "node:path";
2151
+ import { mkdir as mkdir5, writeFile as writeFile6 } from "node:fs/promises";
2152
+ import { homedir as homedir7 } from "node:os";
2153
+ import { dirname as dirname6, join as join11 } from "node:path";
653
2154
  var pi = {
654
2155
  executableCheck: findPiExecutable,
655
2156
  packageList: listPiPackages,
@@ -662,19 +2163,19 @@ var pi = {
662
2163
  configEnsure: ensurePiConfig
663
2164
  };
664
2165
  async function ensurePiAgent(filename, content, agentDir = resolvePiAgentDir(), dryRun = false) {
665
- const path = join7(agentDir, "agents", filename);
2166
+ const path = join11(agentDir, "agents", filename);
666
2167
  const currentText = await readTextIfExists(path);
667
2168
  const changed = currentText !== content;
668
2169
  if (changed && !dryRun) {
669
- await mkdir3(dirname4(path), { recursive: true });
670
- await writeFile3(path, content, "utf8");
2170
+ await mkdir5(dirname6(path), { recursive: true });
2171
+ await writeFile6(path, content, "utf8");
671
2172
  }
672
2173
  return { path, changed, existed: currentText !== undefined, planned: changed && dryRun };
673
2174
  }
674
- async function checkGlobalPiSkill(name, agentDir = resolvePiAgentDir(), sharedSkillsDir = join7(homedir5(), ".agents", "skills")) {
675
- const roots = [join7(agentDir, "skills"), sharedSkillsDir];
2175
+ async function checkGlobalPiSkill(name, agentDir = resolvePiAgentDir(), sharedSkillsDir = join11(homedir7(), ".agents", "skills")) {
2176
+ const roots = [join11(agentDir, "skills"), sharedSkillsDir];
676
2177
  for (const root of roots) {
677
- if (await readTextIfExists(join7(root, name, "SKILL.md")) !== undefined)
2178
+ if (await readTextIfExists(join11(root, name, "SKILL.md")) !== undefined)
678
2179
  return true;
679
2180
  }
680
2181
  return false;
@@ -703,8 +2204,8 @@ async function ensurePiConfig(path, update, dryRun = false) {
703
2204
  const next = update(current);
704
2205
  const changed = JSON.stringify(current) !== JSON.stringify(next);
705
2206
  if (changed && !dryRun) {
706
- await mkdir3(dirname4(path), { recursive: true });
707
- await writeFile3(path, `${JSON.stringify(next, null, 2)}
2207
+ await mkdir5(dirname6(path), { recursive: true });
2208
+ await writeFile6(path, `${JSON.stringify(next, null, 2)}
708
2209
  `, "utf8");
709
2210
  }
710
2211
  return { path, changed, existed: currentText !== undefined, planned: changed && dryRun };
@@ -723,8 +2224,8 @@ function hasPiPackage(listOutput, source) {
723
2224
  async function installPiPackage(executable, source) {
724
2225
  await runChecked(executable, ["install", source]);
725
2226
  }
726
- function resolvePiAgentDir(homeDir = homedir5()) {
727
- return process.env.PI_CODING_AGENT_DIR ?? (process.env.XDG_CONFIG_HOME ? join7(process.env.XDG_CONFIG_HOME, "pi") : join7(homeDir, ".pi", "agent"));
2227
+ function resolvePiAgentDir(homeDir = homedir7()) {
2228
+ return process.env.PI_CODING_AGENT_DIR ?? (process.env.XDG_CONFIG_HOME ? join11(process.env.XDG_CONFIG_HOME, "pi") : join11(homeDir, ".pi", "agent"));
728
2229
  }
729
2230
  function parseJsonObject(content, path) {
730
2231
  if (!content?.trim())
@@ -738,9 +2239,9 @@ function parseJsonObject(content, path) {
738
2239
  }
739
2240
  // src/setup.ts
740
2241
  import { parseFrontmatter as parseFrontmatter3 } from "@earendil-works/pi-coding-agent";
741
- import { readdir as readdir2, readFile as readFile5 } from "node:fs/promises";
742
- import { homedir as homedir6 } from "node:os";
743
- import { basename as basename3, join as join8 } from "node:path";
2242
+ import { readdir as readdir2, readFile as readFile9 } from "node:fs/promises";
2243
+ import { homedir as homedir8 } from "node:os";
2244
+ import { basename as basename5, join as join12 } from "node:path";
744
2245
  var MISE_DEPENDENCIES = [
745
2246
  { name: "node", tool: "node", spec: "node@22", minimumVersion: "22.19.0" },
746
2247
  { name: "zellij", tool: "zellij", spec: "zellij@latest", minimumVersion: undefined },
@@ -768,13 +2269,18 @@ var PI_PACKAGES = [
768
2269
  ];
769
2270
  var PI_SKILL_SOURCES = [
770
2271
  { repository: "arabold/docs-mcp-server", skills: ["docs-manage", "docs-search", "fetch-url"] },
771
- { repository: "AminBlg/SimpleEnglish", skills: ["simple-english"] }
2272
+ { repository: "AminBlg/SimpleEnglish", skills: ["simple-english"] },
2273
+ { repository: "cloudvoyant/codevoyant", skills: ["git"] }
772
2274
  ];
773
2275
  var MCP_ADAPTER_PACKAGE = "npm:pi-mcp-adapter";
774
2276
  var BUNDLED_AGENTS_DIR2 = resolveBundledAgentsDir();
2277
+ var FORGE_DEPENDENCIES = {
2278
+ github: { name: "gh", tool: "gh", spec: "gh@latest", minimumVersion: undefined },
2279
+ gitlab: { name: "glab", tool: "glab", spec: "glab@latest", minimumVersion: undefined }
2280
+ };
775
2281
  async function ensureMise(options = {}) {
776
- const homeDir = options.homeDir ?? homedir6();
777
- const current = await mise.executableCheck() ?? await mise.executableCheck(join8(homeDir, ".local", "bin", "mise"));
2282
+ const homeDir = options.homeDir ?? homedir8();
2283
+ const current = await mise.executableCheck() ?? await mise.executableCheck(join12(homeDir, ".local", "bin", "mise"));
778
2284
  if (current)
779
2285
  return { executable: current, action: createSetupAction("mise", "ready", current) };
780
2286
  reportProgress(options, "Installing mise");
@@ -803,7 +2309,11 @@ async function ensureMiseHooks(miseExecutable, options = {}) {
803
2309
  async function ensureMiseDeps(miseExecutable, options = {}) {
804
2310
  const canRunMise = Boolean(await mise.executableCheck(miseExecutable));
805
2311
  const actions = [];
806
- for (const dependency of MISE_DEPENDENCIES) {
2312
+ const dependencies = [
2313
+ ...MISE_DEPENDENCIES,
2314
+ ...configuredForges(options).map((forge) => FORGE_DEPENDENCIES[forge])
2315
+ ];
2316
+ for (const dependency of dependencies) {
807
2317
  const installed = canRunMise && await mise.toolCheckGlobal(miseExecutable, dependency.tool, dependency.minimumVersion);
808
2318
  if (installed) {
809
2319
  actions.push(createSetupAction(dependency.name, "ready", dependency.spec));
@@ -819,9 +2329,9 @@ async function ensureMiseDeps(miseExecutable, options = {}) {
819
2329
  async function ensurePiPlugins(options = {}) {
820
2330
  const actions = await ensurePiPackages(PI_PACKAGES, options);
821
2331
  const agentDir = options.agentDir ?? pi.agentDir(options.homeDir);
822
- const webSearch = await pi.configEnsure(join8(agentDir, "web-search.json"), (config) => ({ ...config, workflow: "auto-summary" }), options.dryRun);
2332
+ const webSearch = await pi.configEnsure(join12(agentDir, "web-search.json"), (config) => ({ ...config, workflow: "auto-summary" }), options.dryRun);
823
2333
  actions.push(getConfigSetupAction("web search settings", webSearch));
824
- const lsp = await pi.configEnsure(join8(agentDir, "pi-lsp.json"), (config) => ({
2334
+ const lsp = await pi.configEnsure(join12(agentDir, "pi-lsp.json"), (config) => ({
825
2335
  ...config,
826
2336
  progressive: { ...getRecord(config.progressive), enabled: true, inject: "none" }
827
2337
  }), options.dryRun);
@@ -835,8 +2345,8 @@ async function ensurePiAgents(options = {}) {
835
2345
  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));
836
2346
  const actions = [];
837
2347
  for (const entry of entries) {
838
- const id = basename3(entry.name, ".md").replace(/^diffpi-/, "");
839
- const source = await readFile5(join8(bundledAgentsDir, entry.name), "utf8");
2348
+ const id = basename5(entry.name, ".md").replace(/^diffpi-/, "");
2349
+ const source = await readFile9(join12(bundledAgentsDir, entry.name), "utf8");
840
2350
  const content = materializeAgentModels(source, id, userConfig.config, options.availableModels);
841
2351
  const result = await pi.agentEnsure(entry.name, content, agentDir, options.dryRun);
842
2352
  actions.push(getConfigSetupAction(`pi agent ${id}`, result));
@@ -845,7 +2355,7 @@ async function ensurePiAgents(options = {}) {
845
2355
  }
846
2356
  async function ensurePiSkills(miseExecutable, options = {}) {
847
2357
  const agentDir = options.agentDir ?? pi.agentDir(options.homeDir);
848
- const sharedSkillsDir = join8(options.homeDir ?? homedir6(), ".agents", "skills");
2358
+ const sharedSkillsDir = join12(options.homeDir ?? homedir8(), ".agents", "skills");
849
2359
  const actions = [];
850
2360
  for (const source of PI_SKILL_SOURCES) {
851
2361
  const missing = [];
@@ -889,6 +2399,14 @@ async function ensureMcpAdapters(miseExecutable, options = {}) {
889
2399
  } else if (options.issueTracker === "jira") {
890
2400
  servers.atlassian = { url: "https://mcp.atlassian.com/v1/mcp", auth: "oauth", protocolVersion: "auto" };
891
2401
  }
2402
+ for (const forge of configuredForges(options)) {
2403
+ if (forge === "github") {
2404
+ servers.github = { url: "https://api.githubcopilot.com/mcp/", auth: "oauth", protocolVersion: "auto" };
2405
+ continue;
2406
+ }
2407
+ const host = await gitlabMcpHost(projectDir);
2408
+ servers.gitlab = { url: `https://${host}/api/v4/mcp`, auth: "oauth", protocolVersion: "auto" };
2409
+ }
892
2410
  const result = await mcp.serversEnsure(servers, {
893
2411
  dryRun: options.dryRun,
894
2412
  path: mcp.globalConfigPath(options.homeDir)
@@ -905,6 +2423,8 @@ async function setupPi(options = {}) {
905
2423
  actions.push(...await ensurePiAgents(options));
906
2424
  actions.push(...await ensurePiSkills(miseResult.executable, options));
907
2425
  actions.push(...await ensureMcpAdapters(miseResult.executable, options));
2426
+ if (options.bindZedKey)
2427
+ actions.push(...await ensureZedIntegration(options));
908
2428
  return {
909
2429
  actions,
910
2430
  restartPi: setupRequiresRestart(actions)
@@ -913,6 +2433,40 @@ async function setupPi(options = {}) {
913
2433
  function setupRequiresRestart(actions) {
914
2434
  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"));
915
2435
  }
2436
+ async function ensureZedIntegration(options = {}) {
2437
+ if (options.dryRun) {
2438
+ const actions = [
2439
+ createSetupAction("Zed review tasks", "planned", "global static runtime-resolver tasks in tasks.json")
2440
+ ];
2441
+ if (options.bindZedKey)
2442
+ actions.push(createSetupAction("Zed review keybinding", "planned", "keymap.json"));
2443
+ return actions;
2444
+ }
2445
+ const actions = [];
2446
+ try {
2447
+ const tasks = await ensureZedReviewTask(options.homeDir);
2448
+ actions.push(createSetupAction("Zed review tasks", tasks.changed ? "installed" : "ready", tasks.path));
2449
+ } catch (error) {
2450
+ actions.push(createSetupAction("Zed review tasks", "skipped", error instanceof Error ? error.message : String(error)));
2451
+ }
2452
+ if (options.bindZedKey) {
2453
+ try {
2454
+ const key = await ensureZedReviewKeybinding(options.homeDir);
2455
+ actions.push(createSetupAction("Zed review keybinding", key.changed ? "installed" : "ready", key.path));
2456
+ } catch (error) {
2457
+ actions.push(createSetupAction("Zed review keybinding", "skipped", error instanceof Error ? error.message : String(error)));
2458
+ }
2459
+ }
2460
+ return actions;
2461
+ }
2462
+ async function gitlabMcpHost(projectDir) {
2463
+ try {
2464
+ const vcs = await detectVcs(projectDir);
2465
+ return vcs.provider === "gitlab" && vcs.host ? vcs.host : "gitlab.com";
2466
+ } catch {
2467
+ return "gitlab.com";
2468
+ }
2469
+ }
916
2470
  function materializeAgentModels(content, agentId, config, availableModels) {
917
2471
  const { frontmatter } = parseFrontmatter3(content.startsWith("\uFEFF") ? content.slice(1) : content);
918
2472
  const profilePreferences = [...getTextList(frontmatter.model), ...getTextList(frontmatter.model_fallbacks)];
@@ -972,6 +2526,10 @@ ${source}`;
972
2526
  }
973
2527
  return actions;
974
2528
  }
2529
+ function configuredForges(options) {
2530
+ const selected = options.forges ?? (options.forge && options.forge !== "none" ? [options.forge] : []);
2531
+ return [...new Set(selected)];
2532
+ }
975
2533
  function getTextList(value) {
976
2534
  const values = Array.isArray(value) ? value : typeof value === "string" ? value.split(",") : [];
977
2535
  return values.filter((item) => typeof item === "string").map((item) => item.trim()).filter(Boolean);
@@ -992,8 +2550,65 @@ function reportProgress(options, message) {
992
2550
  function getRecord(value) {
993
2551
  return value !== null && typeof value === "object" && !Array.isArray(value) ? value : {};
994
2552
  }
2553
+ // src/templates.ts
2554
+ import { readFile as readFile10 } from "node:fs/promises";
2555
+ import { homedir as homedir9 } from "node:os";
2556
+ import { join as join13, normalize } from "node:path";
2557
+ async function loadTemplate(name, options = {}) {
2558
+ const relative = templateRelativePath(name);
2559
+ const userPath = join13(options.homeDir ?? homedir9(), ".difflab", "diffpi", "templates", relative);
2560
+ const bundledPath = join13(options.bundledDir ?? resolveBundledTemplatesDir(), relative);
2561
+ const user = await readOptionalFile(userPath);
2562
+ if (user !== undefined)
2563
+ return { name, path: userPath, source: "user", content: user };
2564
+ const bundled = await readOptionalFile(bundledPath);
2565
+ if (bundled !== undefined)
2566
+ return { name, path: bundledPath, source: "bundled", content: bundled };
2567
+ throw new Error(`Template "${name}" was not found at ${userPath} or ${bundledPath}.`);
2568
+ }
2569
+ function renderTemplate(content, variables) {
2570
+ return content.replace(/\{\{\s*([a-zA-Z0-9_.-]+)\s*\}\}/g, (match, key) => variables[key] ?? match);
2571
+ }
2572
+ function templateRelativePath(name) {
2573
+ const normalized = normalize(name.replaceAll("\\", "/")).replace(/^\.\//, "");
2574
+ if (!normalized || normalized.startsWith("../") || normalized.includes("/../") || normalized.startsWith("/")) {
2575
+ throw new Error(`Invalid template name: ${name}`);
2576
+ }
2577
+ return normalized.endsWith(".md") ? normalized : `${normalized}.md`;
2578
+ }
2579
+ async function readOptionalFile(path) {
2580
+ try {
2581
+ return await readFile10(path, "utf8");
2582
+ } catch (error) {
2583
+ if (error.code === "ENOENT")
2584
+ return;
2585
+ throw error;
2586
+ }
2587
+ }
995
2588
  export {
2589
+ CONVENTIONAL_COMMIT,
2590
+ GitHubVcsBackend,
2591
+ GitLabVcsBackend,
2592
+ ZED_LOCAL_REVIEW_TASK_NAME,
2593
+ ZED_PR_REVIEW_TASK_NAME,
2594
+ ZED_REVIEW_TASK_NAME,
2595
+ addComment,
2596
+ assertGitHubMergeReady,
2597
+ assertReviewEventSupported,
2598
+ checkConventionalSubject,
2599
+ ciGate,
2600
+ computeProjectSlug,
2601
+ createForge,
2602
+ createForgeBackend,
2603
+ createLocalReviewBackend,
996
2604
  createModeController,
2605
+ createRemoteReviewBackend,
2606
+ createVcsBackend,
2607
+ dedupeFindings,
2608
+ detectIde,
2609
+ detectMux,
2610
+ detectShell,
2611
+ detectVcs,
997
2612
  diffpiConfigPaths,
998
2613
  discoverAgentModes,
999
2614
  ensureMcpAdapters,
@@ -1003,12 +2618,59 @@ export {
1003
2618
  ensurePiAgents,
1004
2619
  ensurePiPlugins,
1005
2620
  ensurePiSkills,
2621
+ ensureStore,
2622
+ ensureZedReviewKeybinding,
2623
+ ensureZedReviewTask,
1006
2624
  findPreferredModel,
2625
+ findingSchema,
2626
+ findingsSchema,
2627
+ gitToplevel,
2628
+ githubReviewSubmissionEndpoint,
2629
+ hasGitlabDraftNotes,
2630
+ launch,
2631
+ listSessions,
1007
2632
  loadDiffpiConfig,
2633
+ loadReviewPublicationState,
2634
+ loadTemplate,
2635
+ localReviewAuthor,
1008
2636
  mcp,
1009
2637
  mise,
2638
+ openInNewTab,
2639
+ parseGitlabDiffRefs,
2640
+ parseRemote,
2641
+ parseThreadArtifact,
1010
2642
  pi,
2643
+ readSession,
2644
+ renderReviewDoc,
2645
+ renderTemplate,
2646
+ renderThreadArtifact,
1011
2647
  resolveAgentMode,
1012
2648
  resolveAgentModelPreferences,
1013
- setupPi
2649
+ resolvePrSession,
2650
+ resolveReviewSession,
2651
+ resolveSession,
2652
+ reviewBodyFingerprint,
2653
+ reviewCommentFingerprint,
2654
+ reviewRecordName,
2655
+ reviewReplyFingerprint,
2656
+ reviewSlug,
2657
+ reviewsDir,
2658
+ runMiseGates,
2659
+ saveReviewPublicationState,
2660
+ sessionsDir,
2661
+ setupPi,
2662
+ severitySchema,
2663
+ storeDir,
2664
+ storeGlobalRoot,
2665
+ templateRelativePath,
2666
+ toFindings,
2667
+ toReviewComments,
2668
+ tuicrAvailable,
2669
+ unpublishedReviewComments,
2670
+ upsertThreadReply,
2671
+ withRemoteProvenance,
2672
+ yymmdd,
2673
+ zedKeymapPath,
2674
+ zedReviewTaskName,
2675
+ zedTasksPath
1014
2676
  };