@difflab/pi 0.2.0 → 0.3.0-rc.202609201609.21ba1d2

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 (138) hide show
  1. package/README.md +48 -4
  2. package/agents/diffpi-orchestrator.md +17 -5
  3. package/agents/diffpi-planner.md +30 -0
  4. package/agents/diffpi-reviewer.md +36 -0
  5. package/agents/diffpi-worker.md +9 -1
  6. package/dist/assets.d.ts +2 -0
  7. package/dist/assets.d.ts.map +1 -1
  8. package/dist/cli/plan.d.ts +7 -0
  9. package/dist/cli/plan.d.ts.map +1 -0
  10. package/dist/cli.d.ts +3 -0
  11. package/dist/cli.d.ts.map +1 -0
  12. package/dist/cli.js +2855 -0
  13. package/dist/commands/background.d.ts +39 -0
  14. package/dist/commands/background.d.ts.map +1 -0
  15. package/dist/commands/index.d.ts +9 -0
  16. package/dist/commands/index.d.ts.map +1 -0
  17. package/dist/commands/mode.d.ts +5 -0
  18. package/dist/commands/mode.d.ts.map +1 -0
  19. package/dist/commands/plan-parser.d.ts +18 -0
  20. package/dist/commands/plan-parser.d.ts.map +1 -0
  21. package/dist/commands/plan.d.ts +21 -0
  22. package/dist/commands/plan.d.ts.map +1 -0
  23. package/dist/commands/reload.d.ts +4 -0
  24. package/dist/commands/reload.d.ts.map +1 -0
  25. package/dist/commands/review.d.ts +5 -0
  26. package/dist/commands/review.d.ts.map +1 -0
  27. package/dist/environment.d.ts +41 -0
  28. package/dist/environment.d.ts.map +1 -0
  29. package/dist/extensions/ghx.d.ts +5 -0
  30. package/dist/extensions/ghx.d.ts.map +1 -0
  31. package/dist/extensions/gitx.d.ts +10 -0
  32. package/dist/extensions/gitx.d.ts.map +1 -0
  33. package/dist/extensions/glabx.d.ts +5 -0
  34. package/dist/extensions/glabx.d.ts.map +1 -0
  35. package/dist/extensions/index.js +4788 -301
  36. package/dist/{mise.d.ts → extensions/misex.d.ts} +3 -1
  37. package/dist/extensions/misex.d.ts.map +1 -0
  38. package/dist/{process.d.ts → extensions/processx.d.ts} +3 -1
  39. package/dist/extensions/processx.d.ts.map +1 -0
  40. package/dist/extensions/tuicrx.d.ts +66 -0
  41. package/dist/extensions/tuicrx.d.ts.map +1 -0
  42. package/dist/extensions/zedx.d.ts +18 -0
  43. package/dist/extensions/zedx.d.ts.map +1 -0
  44. package/dist/gates.d.ts +12 -0
  45. package/dist/gates.d.ts.map +1 -0
  46. package/dist/index.d.ts +27 -2
  47. package/dist/index.d.ts.map +1 -1
  48. package/dist/index.js +2817 -142
  49. package/dist/modes.d.ts +1 -0
  50. package/dist/modes.d.ts.map +1 -1
  51. package/dist/plan/annotations.d.ts +25 -0
  52. package/dist/plan/annotations.d.ts.map +1 -0
  53. package/dist/plan/escalation.d.ts +4 -0
  54. package/dist/plan/escalation.d.ts.map +1 -0
  55. package/dist/plan/execution.d.ts +19 -0
  56. package/dist/plan/execution.d.ts.map +1 -0
  57. package/dist/plan/index.d.ts +15 -0
  58. package/dist/plan/index.d.ts.map +1 -0
  59. package/dist/plan/lock.d.ts +7 -0
  60. package/dist/plan/lock.d.ts.map +1 -0
  61. package/dist/plan/log.d.ts +5 -0
  62. package/dist/plan/log.d.ts.map +1 -0
  63. package/dist/plan/markdown.d.ts +6 -0
  64. package/dist/plan/markdown.d.ts.map +1 -0
  65. package/dist/plan/store.d.ts +34 -0
  66. package/dist/plan/store.d.ts.map +1 -0
  67. package/dist/plan/transitions.d.ts +9 -0
  68. package/dist/plan/transitions.d.ts.map +1 -0
  69. package/dist/plan/types.d.ts +146 -0
  70. package/dist/plan/types.d.ts.map +1 -0
  71. package/dist/review/github-review-backend.d.ts +5 -0
  72. package/dist/review/github-review-backend.d.ts.map +1 -0
  73. package/dist/review/gitlab-review-backend.d.ts +12 -0
  74. package/dist/review/gitlab-review-backend.d.ts.map +1 -0
  75. package/dist/review/index.d.ts +8 -0
  76. package/dist/review/index.d.ts.map +1 -0
  77. package/dist/review/local-review-backend.d.ts +3 -0
  78. package/dist/review/local-review-backend.d.ts.map +1 -0
  79. package/dist/review/review-backend.d.ts +8 -0
  80. package/dist/review/review-backend.d.ts.map +1 -0
  81. package/dist/review/review-markdown.d.ts +6 -0
  82. package/dist/review/review-markdown.d.ts.map +1 -0
  83. package/dist/review/review-state.d.ts +19 -0
  84. package/dist/review/review-state.d.ts.map +1 -0
  85. package/dist/review/types.d.ts +107 -0
  86. package/dist/review/types.d.ts.map +1 -0
  87. package/dist/setup.d.ts +7 -0
  88. package/dist/setup.d.ts.map +1 -1
  89. package/dist/store.d.ts +16 -0
  90. package/dist/store.d.ts.map +1 -0
  91. package/dist/templates.d.ts +14 -0
  92. package/dist/templates.d.ts.map +1 -0
  93. package/dist/tools/index.d.ts +3 -0
  94. package/dist/tools/index.d.ts.map +1 -1
  95. package/dist/tools/index.js +4459 -299
  96. package/dist/tools/plan.d.ts +6 -0
  97. package/dist/tools/plan.d.ts.map +1 -0
  98. package/dist/tools/review.d.ts +7 -0
  99. package/dist/tools/review.d.ts.map +1 -0
  100. package/dist/tools/setup.d.ts.map +1 -1
  101. package/dist/tools/templates.d.ts +3 -0
  102. package/dist/tools/templates.d.ts.map +1 -0
  103. package/dist/vcs/forge-backend.d.ts +7 -0
  104. package/dist/vcs/forge-backend.d.ts.map +1 -0
  105. package/dist/vcs/github.d.ts +5 -0
  106. package/dist/vcs/github.d.ts.map +1 -0
  107. package/dist/vcs/gitlab.d.ts +4 -0
  108. package/dist/vcs/gitlab.d.ts.map +1 -0
  109. package/dist/vcs/helpers.d.ts +7 -0
  110. package/dist/vcs/helpers.d.ts.map +1 -0
  111. package/dist/vcs/index.d.ts +6 -0
  112. package/dist/vcs/index.d.ts.map +1 -0
  113. package/dist/vcs/types.d.ts +32 -0
  114. package/dist/vcs/types.d.ts.map +1 -0
  115. package/package.json +7 -2
  116. package/skills/diffpi-setup/SKILL.md +3 -1
  117. package/skills/plan/SKILL.md +13 -0
  118. package/skills/plan/references/workflows/annotate.md +6 -0
  119. package/skills/plan/references/workflows/finalize.md +7 -0
  120. package/skills/plan/references/workflows/go.md +7 -0
  121. package/skills/plan/references/workflows/help.md +13 -0
  122. package/skills/plan/references/workflows/init.md +6 -0
  123. package/skills/plan/references/workflows/new.md +7 -0
  124. package/skills/plan/references/workflows/update.md +7 -0
  125. package/skills/review/SKILL.md +17 -0
  126. package/skills/review/references/workflows/address.md +11 -0
  127. package/skills/review/references/workflows/auto.md +18 -0
  128. package/skills/review/references/workflows/complete.md +7 -0
  129. package/skills/review/references/workflows/edit.md +7 -0
  130. package/skills/review/references/workflows/help.md +30 -0
  131. package/skills/review/references/workflows/merge.md +6 -0
  132. package/skills/review/references/workflows/new.md +19 -0
  133. package/skills/review/references/workflows/open.md +3 -0
  134. package/skills/review/references/workflows/publish.md +8 -0
  135. package/templates/plan/PLAN.md +38 -0
  136. package/templates/review/draft-pr.md +25 -0
  137. package/dist/mise.d.ts.map +0 -1
  138. package/dist/process.d.ts.map +0 -1
package/dist/index.js CHANGED
@@ -94,76 +94,31 @@ ${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";
97
+ // src/environment.ts
98
+ import { readFile as readFile3 } from "node:fs/promises";
99
+ import { basename, join as join5 } from "node:path";
100
+
101
+ // src/assets.ts
102
+ import { existsSync } from "node:fs";
100
103
  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
- }
104
+ import { fileURLToPath } from "node:url";
105
+ function resolveBundledAssetDir(name, moduleUrl = import.meta.url) {
106
+ const moduleDir = dirname(fileURLToPath(moduleUrl));
107
+ const candidates = [join2(moduleDir, name), join2(moduleDir, "..", name), join2(moduleDir, "..", "..", name)];
108
+ return candidates.find((path) => existsSync(path)) ?? candidates[1];
143
109
  }
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
- }
110
+ function resolveBundledAgentsDir(moduleUrl = import.meta.url) {
111
+ return resolveBundledAssetDir("agents", moduleUrl);
152
112
  }
153
- function isRecord(value) {
154
- return value !== null && typeof value === "object" && !Array.isArray(value);
113
+ function resolveBundledTemplatesDir(moduleUrl = import.meta.url) {
114
+ return resolveBundledAssetDir("templates", moduleUrl);
155
115
  }
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";
160
116
 
161
- // src/process.ts
117
+ // src/extensions/processx.ts
118
+ import { spawn } from "node:child_process";
162
119
  import { constants } from "node:fs";
163
120
  import { access } from "node:fs/promises";
164
121
  import { delimiter, join as join3 } from "node:path";
165
- import { spawn } from "node:child_process";
166
- var MAX_CAPTURED_OUTPUT_LENGTH = 65536;
167
122
  async function findExecutable(name) {
168
123
  if (name.includes("/")) {
169
124
  try {
@@ -189,18 +144,35 @@ function run(command, args, options = {}) {
189
144
  const child = spawn(command, args, {
190
145
  cwd: options.cwd,
191
146
  env: options.env ?? process.env,
192
- stdio: ["ignore", "pipe", "pipe"]
147
+ stdio: [options.input === undefined ? "ignore" : "pipe", "pipe", "pipe"]
193
148
  });
194
149
  let stdout = "";
195
150
  let stderr = "";
196
- child.stdout.on("data", (chunk) => {
197
- stdout = appendBounded(stdout, chunk.toString());
151
+ const stdoutChunks = [];
152
+ const stderrChunks = [];
153
+ const unbounded = options.capture === "unbounded";
154
+ child.stdout?.on("data", (chunk) => {
155
+ const text = chunk.toString();
156
+ if (unbounded)
157
+ stdoutChunks.push(text);
158
+ else
159
+ stdout = appendBounded(stdout, text);
198
160
  });
199
- child.stderr.on("data", (chunk) => {
200
- stderr = appendBounded(stderr, chunk.toString());
161
+ child.stderr?.on("data", (chunk) => {
162
+ const text = chunk.toString();
163
+ if (unbounded)
164
+ stderrChunks.push(text);
165
+ else
166
+ stderr = appendBounded(stderr, text);
201
167
  });
202
168
  child.on("error", reject);
203
- child.on("close", (code) => resolve({ code: code ?? 1, stdout, stderr }));
169
+ child.on("close", (code) => resolve({
170
+ code: code ?? 1,
171
+ stdout: unbounded ? stdoutChunks.join("") : stdout,
172
+ stderr: unbounded ? stderrChunks.join("") : stderr
173
+ }));
174
+ if (options.input !== undefined && child.stdin)
175
+ child.stdin.end(options.input);
204
176
  });
205
177
  }
206
178
  async function runChecked(command, args, options = {}) {
@@ -210,24 +182,595 @@ async function runChecked(command, args, options = {}) {
210
182
  const detail = result.stderr.trim() || result.stdout.trim() || `exit code ${result.code}`;
211
183
  throw new Error(`${command} ${args.join(" ")} failed: ${detail}`);
212
184
  }
185
+ var MAX_CAPTURED_OUTPUT_LENGTH = 65536;
213
186
  function appendBounded(current, next) {
214
187
  const combined = current + next;
215
188
  return combined.length <= MAX_CAPTURED_OUTPUT_LENGTH ? combined : combined.slice(-MAX_CAPTURED_OUTPUT_LENGTH);
216
189
  }
217
190
 
218
- // src/mise.ts
191
+ // src/extensions/zedx.ts
192
+ import { mkdir, readFile as readFile2, writeFile } from "node:fs/promises";
193
+ import { homedir as homedir2 } from "node:os";
194
+ import { dirname as dirname2, join as join4 } from "node:path";
195
+ var ZED_LOCAL_REVIEW_TASK_NAME = "diffpi: tuicr local review";
196
+ var ZED_REVIEW_TASK_NAME = ZED_LOCAL_REVIEW_TASK_NAME;
197
+ var ZED_PR_REVIEW_TASK_NAME = "diffpi: tuicr PR review";
198
+ var ZED_PLAN_ANNOTATE_TASK_NAME = "diffpi: annotate plan";
199
+ var LEGACY_ZED_REVIEW_TASK_NAME = "diffpi: tuicr review";
200
+ var REVIEW_KEYBINDING = "cmd-alt-r";
201
+ function zedTasksPath(homeDir = homedir2()) {
202
+ return join4(homeDir, ".config", "zed", "tasks.json");
203
+ }
204
+ function zedKeymapPath(homeDir = homedir2()) {
205
+ return join4(homeDir, ".config", "zed", "keymap.json");
206
+ }
207
+ async function ensureZedReviewTask(homeDir = homedir2(), _command = ["tuicr", "-w", "-r", "main..HEAD"]) {
208
+ const path = zedTasksPath(homeDir);
209
+ const currentText = await readOptional(path);
210
+ const tasks = parseJsonArray(currentText, path);
211
+ const migrated = tasks.filter((task) => task.label !== LEGACY_ZED_REVIEW_TASK_NAME);
212
+ const next = [...migrated];
213
+ for (const task of [localReviewTask(), prReviewTask()]) {
214
+ const index = next.findIndex((existing) => existing.label === task.label);
215
+ if (index >= 0)
216
+ next[index] = { ...next[index], ...task };
217
+ else
218
+ next.push(task);
219
+ }
220
+ const changed = JSON.stringify(tasks) !== JSON.stringify(next);
221
+ if (changed)
222
+ await writeJson(path, next);
223
+ return { path, changed, existed: currentText !== undefined };
224
+ }
225
+ async function ensureZedPlanTask(packageVersion, homeDir = homedir2()) {
226
+ if (!/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(packageVersion)) {
227
+ throw new Error(`Invalid @difflab/pi package version: ${packageVersion}.`);
228
+ }
229
+ const path = zedTasksPath(homeDir);
230
+ const currentText = await readOptional(path);
231
+ const tasks = parseJsonArray(currentText, path);
232
+ const task = {
233
+ label: ZED_PLAN_ANNOTATE_TASK_NAME,
234
+ command: "npx",
235
+ args: ["--yes", `@difflab/pi@${packageVersion}`, "plan", "annotate", "--cwd", "$ZED_WORKTREE_ROOT"],
236
+ cwd: "$ZED_WORKTREE_ROOT",
237
+ use_new_terminal: true,
238
+ reveal: "always",
239
+ reveal_target: "center"
240
+ };
241
+ const next = [...tasks];
242
+ const index = next.findIndex((candidate) => candidate.label === task.label);
243
+ if (index >= 0)
244
+ next[index] = { ...next[index], ...task };
245
+ else
246
+ next.push(task);
247
+ const changed = JSON.stringify(tasks) !== JSON.stringify(next);
248
+ if (changed)
249
+ await writeJson(path, next);
250
+ return { path, changed, existed: currentText !== undefined };
251
+ }
252
+ async function ensureZedReviewKeybinding(homeDir = homedir2()) {
253
+ const path = zedKeymapPath(homeDir);
254
+ const currentText = await readOptional(path);
255
+ const entries = parseJsonArray(currentText, path);
256
+ const migrated = entries.map(migrateReviewKeymapEntry);
257
+ const alreadyBound = migrated.some((entry) => Object.values(entry.bindings ?? {}).some((action) => Array.isArray(action) && action[0] === "task::Spawn" && bindsReviewTask(action[1])));
258
+ const next = alreadyBound ? migrated : [
259
+ ...migrated,
260
+ {
261
+ context: "Workspace",
262
+ bindings: { [REVIEW_KEYBINDING]: ["task::Spawn", { task_name: ZED_LOCAL_REVIEW_TASK_NAME }] }
263
+ }
264
+ ];
265
+ const changed = JSON.stringify(entries) !== JSON.stringify(next);
266
+ if (changed)
267
+ await writeJson(path, next);
268
+ return { path, changed, existed: currentText !== undefined };
269
+ }
270
+ function zedReviewTaskName(command) {
271
+ if (command.some((value, index) => value === "plan" && command[index + 1] === "annotate")) {
272
+ return ZED_PLAN_ANNOTATE_TASK_NAME;
273
+ }
274
+ return command[0] === "tuicr" && command[1] === "pr" ? ZED_PR_REVIEW_TASK_NAME : ZED_LOCAL_REVIEW_TASK_NAME;
275
+ }
276
+ var LOCAL_REVIEW_SCRIPT = `set -eu
277
+ remote=$(git remote get-url origin 2>/dev/null || true)
278
+ branch=$(git branch --show-current)
279
+ base=
280
+ case "$remote" in
281
+ *github.com*)
282
+ 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)
283
+ [ -n "$base" ] || base=$(gh repo view --json defaultBranchRef --jq .defaultBranchRef.name 2>/dev/null || true)
284
+ ;;
285
+ *gitlab*)
286
+ mr=$(glab mr list --source-branch "$branch" --order created_at --sort desc --per-page 100 --output json 2>/dev/null || true)
287
+ 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{}})')
288
+ [ -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)
289
+ ;;
290
+ esac
291
+ [ -n "$base" ] || { echo 'diffpi: could not resolve a GitHub/GitLab review base' >&2; exit 1; }
292
+ exec tuicr -w -r "$base..HEAD"`;
293
+ var PR_REVIEW_SCRIPT = `set -eu
294
+ remote=$(git remote get-url origin 2>/dev/null || true)
295
+ branch=$(git branch --show-current)
296
+ case "$remote" in
297
+ *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) ;;
298
+ *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) ;;
299
+ *) number= ;;
300
+ esac
301
+ [ -n "$number" ] || { echo 'diffpi: could not resolve the current GitHub PR or GitLab MR' >&2; exit 1; }
302
+ exec tuicr pr "$number"`;
303
+ function staticReviewTask(label, script) {
304
+ return {
305
+ label,
306
+ command: "sh",
307
+ args: ["-lc", script],
308
+ cwd: "$ZED_WORKTREE_ROOT",
309
+ use_new_terminal: true,
310
+ reveal: "always",
311
+ reveal_target: "center"
312
+ };
313
+ }
314
+ function localReviewTask() {
315
+ return staticReviewTask(ZED_LOCAL_REVIEW_TASK_NAME, LOCAL_REVIEW_SCRIPT);
316
+ }
317
+ function prReviewTask() {
318
+ return staticReviewTask(ZED_PR_REVIEW_TASK_NAME, PR_REVIEW_SCRIPT);
319
+ }
320
+ function bindsReviewTask(payload) {
321
+ return typeof payload === "object" && payload !== null && payload.task_name === ZED_LOCAL_REVIEW_TASK_NAME;
322
+ }
323
+ function migrateReviewKeymapEntry(entry) {
324
+ const bindings = Object.fromEntries(Object.entries(entry.bindings ?? {}).map(([key, action]) => {
325
+ if (Array.isArray(action) && action[0] === "task::Spawn" && typeof action[1] === "object" && action[1] !== null && action[1].task_name === LEGACY_ZED_REVIEW_TASK_NAME) {
326
+ return [key, ["task::Spawn", { ...action[1], task_name: ZED_LOCAL_REVIEW_TASK_NAME }]];
327
+ }
328
+ return [key, action];
329
+ }));
330
+ return entry.bindings ? { ...entry, bindings } : entry;
331
+ }
332
+ async function readOptional(path) {
333
+ try {
334
+ return await readFile2(path, "utf8");
335
+ } catch (error) {
336
+ if (error instanceof Error && "code" in error && error.code === "ENOENT")
337
+ return;
338
+ throw error;
339
+ }
340
+ }
341
+ function parseJsonArray(content, path) {
342
+ if (!content?.trim())
343
+ return [];
344
+ let value;
345
+ try {
346
+ value = JSON.parse(content);
347
+ } catch {
348
+ throw new Error(`Cannot safely edit ${path}: not strict JSON (it may contain JSONC comments).`);
349
+ }
350
+ if (!Array.isArray(value))
351
+ throw new Error(`Expected a JSON array in ${path}.`);
352
+ return value;
353
+ }
354
+ async function writeJson(path, value) {
355
+ await mkdir(dirname2(path), { recursive: true });
356
+ await writeFile(path, `${JSON.stringify(value, null, 2)}
357
+ `, "utf8");
358
+ }
359
+
360
+ // src/environment.ts
361
+ function detectIde(env = process.env) {
362
+ const program = (env.TERM_PROGRAM ?? "").toLowerCase();
363
+ if (env.ZED_TERM === "true" || program === "zed")
364
+ return "zed";
365
+ if (env.CURSOR_TRACE_ID || program === "cursor")
366
+ return "cursor";
367
+ if (env.WINDSURF_ENV || program === "windsurf")
368
+ return "windsurf";
369
+ if (env.TERMINAL_EMULATOR?.toLowerCase().includes("jetbrains"))
370
+ return "jetbrains";
371
+ if (env.VSCODE_PID || env.VSCODE_GIT_IPC_HANDLE || program === "vscode")
372
+ return "vscode";
373
+ return "unknown";
374
+ }
375
+ function detectMux(env = process.env) {
376
+ if (env.ZELLIJ || env.ZELLIJ_SESSION_NAME)
377
+ return "zellij";
378
+ if (env.TMUX)
379
+ return "tmux";
380
+ if (env.STY)
381
+ return "screen";
382
+ return "none";
383
+ }
384
+ function detectShell(env = process.env) {
385
+ return env.SHELL ? basename(env.SHELL) : "unknown";
386
+ }
387
+ async function detectVcs(cwd) {
388
+ const root = (await run("git", ["-C", cwd, "rev-parse", "--show-toplevel"])).stdout.trim() || cwd;
389
+ const branch = (await run("git", ["-C", root, "rev-parse", "--abbrev-ref", "HEAD"])).stdout.trim();
390
+ const remote = (await run("git", ["-C", root, "remote", "get-url", "origin"])).stdout.trim();
391
+ return { ...parseRemote(remote), branch, root };
392
+ }
393
+ function parseRemote(remote) {
394
+ const empty = { provider: "none", host: "", owner: "", repo: "" };
395
+ if (!remote)
396
+ return empty;
397
+ const scp = remote.match(/^[^@]+@([^:]+):(.+?)(?:\.git)?$/);
398
+ const url = remote.match(/^[a-z]+:\/\/(?:[^@]+@)?([^/]+)\/(.+?)(?:\.git)?$/i);
399
+ const match = scp ?? url;
400
+ if (!match)
401
+ return empty;
402
+ const host = match[1];
403
+ const segments = match[2].split("/").filter(Boolean);
404
+ if (segments.length < 2)
405
+ return { ...empty, host };
406
+ const repo = segments.at(-1) ?? "";
407
+ const owner = segments.slice(0, -1).join("/");
408
+ const provider = /github/i.test(host) ? "github" : /gitlab/i.test(host) ? "gitlab" : "none";
409
+ return { provider, host, owner, repo };
410
+ }
411
+ async function openInNewTab(command, opts) {
412
+ const env = opts.env ?? process.env;
413
+ const name = opts.name ?? "review";
414
+ const printable = command.join(" ");
415
+ const mux = detectMux(env);
416
+ if (mux !== "none") {
417
+ const opened = await openMuxTab(mux, command, opts.cwd, name, printable);
418
+ if (opened)
419
+ return opened;
420
+ }
421
+ if (detectIde(env) === "zed") {
422
+ try {
423
+ const taskName = zedReviewTaskName(command);
424
+ if (taskName === ZED_PLAN_ANNOTATE_TASK_NAME)
425
+ await ensureZedPlanTask(await packageVersion(), opts.homeDir);
426
+ else
427
+ await ensureZedReviewTask(opts.homeDir, command);
428
+ return {
429
+ launched: false,
430
+ configured: true,
431
+ via: "zed-task",
432
+ command: printable,
433
+ taskName,
434
+ instruction: `Run the Zed task "${taskName}".`
435
+ };
436
+ } catch {}
437
+ }
438
+ return { launched: false, via: "print", command: printable };
439
+ }
440
+ function screenWindowArgs(command, cwd, name) {
441
+ return ["-X", "screen", "-t", name, "sh", "-lc", 'cd -- "$1" && shift && exec "$@"', "sh", cwd, ...command];
442
+ }
443
+ async function packageVersion() {
444
+ const value = JSON.parse(await readFile3(join5(resolveBundledAgentsDir(), "..", "package.json"), "utf8"));
445
+ if (typeof value.version !== "string")
446
+ throw new Error("Cannot resolve the installed @difflab/pi version.");
447
+ return value.version;
448
+ }
449
+ async function openMuxTab(mux, command, cwd, name, printable) {
450
+ if (mux === "zellij" && await findExecutable("zellij")) {
451
+ const result = await run("zellij", ["action", "new-tab", "--cwd", cwd, "--name", name, "--", ...command]);
452
+ if (result.code === 0)
453
+ return { launched: true, via: "zellij", command: printable };
454
+ const fallback = await run("zellij", ["run", "--cwd", cwd, "--name", name, "--", ...command]);
455
+ if (fallback.code === 0)
456
+ return { launched: true, via: "zellij-run", command: printable };
457
+ }
458
+ if (mux === "tmux" && await findExecutable("tmux")) {
459
+ const result = await run("tmux", ["new-window", "-c", cwd, "-n", name, printable]);
460
+ if (result.code === 0)
461
+ return { launched: true, via: "tmux", command: printable };
462
+ }
463
+ if (mux === "screen" && await findExecutable("screen")) {
464
+ const result = await run("screen", screenWindowArgs(command, cwd, name));
465
+ if (result.code === 0)
466
+ return { launched: true, via: "screen", command: printable };
467
+ }
468
+ return;
469
+ }
470
+ // src/extensions/gitx.ts
471
+ import { realpath } from "node:fs/promises";
472
+ import { basename as basename2, isAbsolute, join as join6, resolve } from "node:path";
473
+ async function gitToplevel(cwd) {
474
+ const result = await run("git", ["-C", cwd, "rev-parse", "--show-toplevel"]);
475
+ const top = result.stdout.trim();
476
+ return result.code === 0 && top ? top : resolve(cwd);
477
+ }
478
+ async function inspectGitRepository(cwd) {
479
+ const root = await gitToplevel(cwd);
480
+ const remoteResult = await run("git", ["-C", root, "remote", "get-url", "origin"]);
481
+ const remote = remoteResult.code === 0 ? remoteResult.stdout.trim() : "";
482
+ const commonResult = await run("git", ["-C", root, "rev-parse", "--git-common-dir"]);
483
+ const common = commonResult.stdout.trim();
484
+ const commonPath = commonResult.code === 0 && common ? resolve(isAbsolute(common) ? common : join6(root, common)) : root;
485
+ const commonDir = await canonicalPath(commonPath);
486
+ return {
487
+ root,
488
+ commonDir,
489
+ ...remote ? { remote } : {},
490
+ name: remote ? repositoryName(remote) : basename2(resolve(commonDir, "..")) || basename2(root),
491
+ identity: remote ? `remote:${normalizeRemote(remote)}` : `git-common-dir:${commonDir}`
492
+ };
493
+ }
494
+ function normalizeRemote(remote) {
495
+ return remote.trim().replace(/\.git\/?$/i, "").replace(/\/+$/, "").toLowerCase();
496
+ }
497
+ function repositoryName(remote) {
498
+ const normalized = remote.trim().replace(/\.git\/?$/i, "").replace(/\/+$/, "");
499
+ return normalized.split(/[/\\:]/).filter(Boolean).at(-1) ?? "";
500
+ }
501
+ async function canonicalPath(path) {
502
+ try {
503
+ return await realpath(path);
504
+ } catch {
505
+ return resolve(path);
506
+ }
507
+ }
508
+ // src/extensions/ghx.ts
509
+ function gh(args, options) {
510
+ return run("gh", args, options);
511
+ }
512
+ function ghChecked(args, options) {
513
+ return runChecked("gh", args, options);
514
+ }
515
+
516
+ // src/vcs/helpers.ts
517
+ function isConfirmedMissingChange(provider, output) {
518
+ const message = output.toLowerCase();
519
+ 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;
520
+ }
521
+ function parseJson(value, name) {
522
+ try {
523
+ return JSON.parse(value);
524
+ } catch {
525
+ throw new Error(`Cannot parse the ${name} response as JSON.`);
526
+ }
527
+ }
528
+ function commandFailure(command, args, result) {
529
+ return new Error(`${command} ${args.join(" ")} failed: ${result.stderr.trim() || result.stdout.trim() || `exit code ${result.code}`}`);
530
+ }
531
+ function requireBranchName(output, provider) {
532
+ const branch = output.trim();
533
+ if (!branch || branch === "null")
534
+ throw new Error(`${provider} did not return a default branch.`);
535
+ return branch;
536
+ }
537
+
538
+ // src/vcs/github.ts
539
+ function GitHubVcsBackend(vcs) {
540
+ const repo = ["--repo", `${vcs.owner}/${vcs.repo}`];
541
+ const viewPr = async (idOrBranch) => {
542
+ const args = [
543
+ "pr",
544
+ "view",
545
+ idOrBranch,
546
+ ...repo,
547
+ "--json",
548
+ "number,title,url,isDraft,baseRefName,headRefName,headRefOid"
549
+ ];
550
+ const result = await gh(args);
551
+ if (result.code !== 0) {
552
+ if (isConfirmedMissingChange("github", result.stderr || result.stdout))
553
+ return;
554
+ throw commandFailure("gh", args, result);
555
+ }
556
+ if (!result.stdout.trim())
557
+ throw new Error("GitHub returned an empty pull request response.");
558
+ const data = parseJson(result.stdout, "GitHub pull request");
559
+ 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")
560
+ throw new Error("GitHub returned an invalid pull request response.");
561
+ return {
562
+ number: data.number,
563
+ title: data.title,
564
+ url: data.url,
565
+ isDraft: data.isDraft,
566
+ baseRef: data.baseRefName,
567
+ headRef: data.headRefName,
568
+ headSha: data.headRefOid
569
+ };
570
+ };
571
+ return {
572
+ provider: "github",
573
+ async createDraftPr(options) {
574
+ const args = [
575
+ "pr",
576
+ "create",
577
+ ...repo,
578
+ "--title",
579
+ options.title,
580
+ "--body",
581
+ options.body,
582
+ "--base",
583
+ options.base,
584
+ "--head",
585
+ options.head
586
+ ];
587
+ if (options.draft !== false)
588
+ args.push("--draft");
589
+ await ghChecked(args);
590
+ const ref = await viewPr(options.head);
591
+ if (!ref)
592
+ throw new Error("Draft PR created but could not be resolved.");
593
+ return ref;
594
+ },
595
+ viewPr,
596
+ async defaultBranch() {
597
+ return requireBranchName((await ghChecked([
598
+ "repo",
599
+ "view",
600
+ `${vcs.owner}/${vcs.repo}`,
601
+ "--json",
602
+ "defaultBranchRef",
603
+ "--jq",
604
+ ".defaultBranchRef.name"
605
+ ])).stdout, "GitHub");
606
+ },
607
+ async prDiff(id) {
608
+ return (await ghChecked(["pr", "diff", String(id), ...repo], { capture: "unbounded" })).stdout;
609
+ },
610
+ async prChecks(id) {
611
+ return (await gh(["pr", "checks", String(id), ...repo])).stdout;
612
+ },
613
+ async markReady(id) {
614
+ await ghChecked(["pr", "ready", String(id), ...repo]);
615
+ },
616
+ async closePr(id, comment) {
617
+ const args = ["pr", "close", String(id), ...repo];
618
+ if (comment)
619
+ args.push("--comment", comment);
620
+ await ghChecked(args);
621
+ },
622
+ async mergePr(id, subject) {
623
+ const readiness = await ghChecked([
624
+ "pr",
625
+ "view",
626
+ String(id),
627
+ ...repo,
628
+ "--json",
629
+ "isDraft,state,reviewDecision,mergeStateStatus,statusCheckRollup"
630
+ ]);
631
+ assertGitHubMergeReady(readiness.stdout);
632
+ await ghChecked(["pr", "merge", String(id), ...repo, "--squash", "--subject", subject]);
633
+ }
634
+ };
635
+ }
636
+ function assertGitHubMergeReady(input) {
637
+ const data = parseJson(input, "GitHub readiness");
638
+ const blockers = [];
639
+ if (data.state !== "OPEN")
640
+ blockers.push(`pull request state is ${data.state ?? "unknown"}`);
641
+ if (data.isDraft)
642
+ blockers.push("pull request is still a draft");
643
+ if (data.reviewDecision !== "APPROVED")
644
+ blockers.push(`review decision is ${data.reviewDecision || "not approved"}`);
645
+ if (data.mergeStateStatus !== "CLEAN")
646
+ blockers.push(`merge state is ${data.mergeStateStatus ?? "unknown"}`);
647
+ for (const check of data.statusCheckRollup ?? []) {
648
+ const name = check.name ?? check.context ?? "unnamed check";
649
+ if (check.__typename === "CheckRun") {
650
+ if (check.status !== "COMPLETED")
651
+ blockers.push(`${name} is ${check.status?.toLowerCase() ?? "pending"}`);
652
+ else if (!["SUCCESS", "SKIPPED", "NEUTRAL"].includes(check.conclusion ?? ""))
653
+ blockers.push(`${name} concluded ${(check.conclusion ?? "unknown").toLowerCase()}`);
654
+ } else if (check.state !== "SUCCESS")
655
+ blockers.push(`${name} is ${(check.state ?? "pending").toLowerCase()}`);
656
+ }
657
+ if (blockers.length > 0)
658
+ throw new Error(`Merge blocked: ${blockers.join("; ")}.`);
659
+ }
660
+
661
+ // src/extensions/glabx.ts
662
+ function glab(args, options) {
663
+ return run("glab", args, options);
664
+ }
665
+ function glabChecked(args, options) {
666
+ return runChecked("glab", args, options);
667
+ }
668
+
669
+ // src/vcs/gitlab.ts
670
+ function GitLabVcsBackend(vcs) {
671
+ const project = `${vcs.owner}/${vcs.repo}`;
672
+ const viewPr = async (idOrBranch) => {
673
+ const args = ["mr", "view", idOrBranch, "--repo", project, "--output", "json"];
674
+ const result = await glab(args);
675
+ if (result.code !== 0) {
676
+ if (isConfirmedMissingChange("gitlab", result.stderr || result.stdout))
677
+ return;
678
+ throw commandFailure("glab", args, result);
679
+ }
680
+ if (!result.stdout.trim())
681
+ throw new Error("GitLab returned an empty merge request response.");
682
+ const data = parseJson(result.stdout, "GitLab merge request");
683
+ if (typeof data.iid !== "number" || typeof data.title !== "string" || typeof data.web_url !== "string" || typeof data.target_branch !== "string" || typeof data.source_branch !== "string")
684
+ throw new Error("GitLab returned an invalid merge request response.");
685
+ return {
686
+ number: data.iid,
687
+ title: data.title,
688
+ url: data.web_url,
689
+ isDraft: Boolean(data.draft ?? data.work_in_progress),
690
+ baseRef: data.target_branch,
691
+ headRef: data.source_branch,
692
+ headSha: data.sha
693
+ };
694
+ };
695
+ return {
696
+ provider: "gitlab",
697
+ async createDraftPr(options) {
698
+ await glabChecked([
699
+ "mr",
700
+ "create",
701
+ "--repo",
702
+ project,
703
+ "--title",
704
+ `Draft: ${options.title}`,
705
+ "--description",
706
+ options.body,
707
+ "--target-branch",
708
+ options.base,
709
+ "--source-branch",
710
+ options.head,
711
+ "--yes"
712
+ ]);
713
+ const ref = await viewPr(options.head);
714
+ if (!ref)
715
+ throw new Error("Draft MR created but could not be resolved.");
716
+ return ref;
717
+ },
718
+ viewPr,
719
+ async defaultBranch() {
720
+ return requireBranchName((await glabChecked(["api", `projects/${encodeURIComponent(project)}`, "--jq", ".default_branch"])).stdout, "GitLab");
721
+ },
722
+ async prDiff(id) {
723
+ return (await glabChecked(["mr", "diff", String(id), "--repo", project], { capture: "unbounded" })).stdout;
724
+ },
725
+ async prChecks(id) {
726
+ return (await glabChecked([
727
+ "api",
728
+ `projects/${encodeURIComponent(project)}/merge_requests/${id}/pipelines?per_page=100`
729
+ ])).stdout;
730
+ },
731
+ async markReady(id) {
732
+ await glabChecked(["mr", "update", String(id), "--repo", project, "--ready"]);
733
+ },
734
+ async closePr(id, comment) {
735
+ if (comment)
736
+ await glabChecked(["mr", "note", String(id), "--repo", project, "--message", comment]);
737
+ await glabChecked(["mr", "close", String(id), "--repo", project]);
738
+ },
739
+ async mergePr() {
740
+ throw new Error("Merge is not supported by the GitLab forge adapter.");
741
+ }
742
+ };
743
+ }
744
+
745
+ // src/vcs/forge-backend.ts
746
+ function createForgeBackend(vcs) {
747
+ if (vcs.provider === "github")
748
+ return GitHubVcsBackend(vcs);
749
+ if (vcs.provider === "gitlab")
750
+ return GitLabVcsBackend(vcs);
751
+ throw new Error("No supported forge detected from the git remote. Use --local for an offline review.");
752
+ }
753
+ var createVcsBackend = createForgeBackend;
754
+ var createForge = createForgeBackend;
755
+ // src/extensions/misex.ts
756
+ import { mkdir as mkdir2, readFile as readFile4, writeFile as writeFile2 } from "node:fs/promises";
757
+ import { homedir as homedir3 } from "node:os";
758
+ import { basename as basename3, dirname as dirname3, join as join7 } from "node:path";
219
759
  var MISE_HOOK_START = "# >>> @difflab/pi mise >>>";
220
760
  var MISE_HOOK_END = "# <<< @difflab/pi mise <<<";
221
761
  var mise = {
222
762
  async executableCheck(name = "mise") {
223
763
  return findExecutable(name);
224
764
  },
765
+ async run(args, options = {}) {
766
+ return run("mise", args, options);
767
+ },
225
768
  async install(options = {}) {
226
769
  const homeDir = options.homeDir ?? homedir3();
227
770
  const platform = options.platform ?? process.platform;
228
771
  if (platform === "win32")
229
772
  throw new Error("Automatic mise installation supports macOS and Linux only.");
230
- const installedPath = join4(homeDir, ".local", "bin", "mise");
773
+ const installedPath = join7(homeDir, ".local", "bin", "mise");
231
774
  if (options.dryRun)
232
775
  return installedPath;
233
776
  await runChecked("sh", ["-c", "curl -fsSL https://mise.run | sh"]);
@@ -238,8 +781,8 @@ var mise = {
238
781
  },
239
782
  async hookEnsure(executable, options = {}) {
240
783
  const homeDir = options.homeDir ?? homedir3();
241
- const hook = getShellHook(basename(options.shell ?? process.env.SHELL ?? ""), executable, homeDir);
242
- const current = await getOptionalFile2(hook.path);
784
+ const hook = getShellHook(basename3(options.shell ?? process.env.SHELL ?? ""), executable, homeDir);
785
+ const current = await getOptionalFile(hook.path);
243
786
  if (current.includes(MISE_HOOK_START))
244
787
  return { path: hook.path, changed: false, planned: false };
245
788
  if (options.dryRun)
@@ -247,7 +790,7 @@ var mise = {
247
790
  const separator = current.length === 0 || current.endsWith(`
248
791
  `) ? "" : `
249
792
  `;
250
- await mkdir2(dirname2(hook.path), { recursive: true });
793
+ await mkdir2(dirname3(hook.path), { recursive: true });
251
794
  await writeFile2(hook.path, `${current}${separator}${hook.content}`, "utf8");
252
795
  return { path: hook.path, changed: true, planned: false };
253
796
  },
@@ -274,7 +817,7 @@ function getShellHook(shell, executable, homeDir) {
274
817
  switch (shell.toLowerCase()) {
275
818
  case "zsh":
276
819
  return {
277
- path: join4(homeDir, ".zshrc"),
820
+ path: join7(homeDir, ".zshrc"),
278
821
  content: `${MISE_HOOK_START}
279
822
  eval "$(${command} activate zsh)"
280
823
  ${MISE_HOOK_END}
@@ -282,7 +825,7 @@ ${MISE_HOOK_END}
282
825
  };
283
826
  case "fish":
284
827
  return {
285
- path: join4(homeDir, ".config", "fish", "config.fish"),
828
+ path: join7(homeDir, ".config", "fish", "config.fish"),
286
829
  content: `${MISE_HOOK_START}
287
830
  ${command} activate fish | source
288
831
  ${MISE_HOOK_END}
@@ -291,7 +834,7 @@ ${MISE_HOOK_END}
291
834
  case "nu":
292
835
  case "nushell":
293
836
  return {
294
- path: join4(homeDir, ".config", "nushell", "config.nu"),
837
+ path: join7(homeDir, ".config", "nushell", "config.nu"),
295
838
  content: `${MISE_HOOK_START}
296
839
  let mise_bin = ${command}
297
840
  let mise_path = $nu.default-config-dir | path join mise.nu
@@ -302,7 +845,7 @@ ${MISE_HOOK_END}
302
845
  };
303
846
  case "xonsh":
304
847
  return {
305
- path: join4(homeDir, ".xonshrc"),
848
+ path: join7(homeDir, ".xonshrc"),
306
849
  content: `${MISE_HOOK_START}
307
850
  execx($(${command} activate xonsh))
308
851
  ${MISE_HOOK_END}
@@ -310,7 +853,7 @@ ${MISE_HOOK_END}
310
853
  };
311
854
  case "elvish":
312
855
  return {
313
- path: join4(homeDir, ".config", "elvish", "rc.elv"),
856
+ path: join7(homeDir, ".config", "elvish", "rc.elv"),
314
857
  content: `${MISE_HOOK_START}
315
858
  var mise: = (ns [&])
316
859
  eval (${command} activate elvish | slurp) &ns=$mise: &on-end={|ns| set mise: = $ns }
@@ -321,7 +864,7 @@ ${MISE_HOOK_END}
321
864
  case "pwsh":
322
865
  case "powershell":
323
866
  return {
324
- path: join4(homeDir, ".config", "powershell", "Microsoft.PowerShell_profile.ps1"),
867
+ path: join7(homeDir, ".config", "powershell", "Microsoft.PowerShell_profile.ps1"),
325
868
  content: `${MISE_HOOK_START}
326
869
  (& ${command} activate pwsh) | Out-String | Invoke-Expression
327
870
  ${MISE_HOOK_END}
@@ -330,7 +873,7 @@ ${MISE_HOOK_END}
330
873
  case "bash":
331
874
  default:
332
875
  return {
333
- path: join4(homeDir, ".bashrc"),
876
+ path: join7(homeDir, ".bashrc"),
334
877
  content: `${MISE_HOOK_START}
335
878
  eval "$(${command} activate bash)"
336
879
  ${MISE_HOOK_END}
@@ -367,9 +910,9 @@ function isVersionAtLeast(version, minimumVersion) {
367
910
  }
368
911
  return true;
369
912
  }
370
- async function getOptionalFile2(path) {
913
+ async function getOptionalFile(path) {
371
914
  try {
372
- return await readFile3(path, "utf8");
915
+ return await readFile4(path, "utf8");
373
916
  } catch (error) {
374
917
  if (error instanceof Error && "code" in error && error.code === "ENOENT")
375
918
  return "";
@@ -379,48 +922,1044 @@ async function getOptionalFile2(path) {
379
922
  function getShellQuoted(value) {
380
923
  return `'${value.replaceAll("'", "'\\''")}'`;
381
924
  }
382
- // src/modes.ts
383
- import {
384
- getAgentDir,
385
- parseFrontmatter as parseFrontmatter2
386
- } 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";
390
925
 
391
- // src/assets.ts
392
- import { existsSync } from "node:fs";
393
- import { dirname as dirname3, join as join5 } from "node:path";
394
- 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")
926
+ // src/gates.ts
927
+ var CONVENTIONAL_COMMIT = /^(feat|fix|perf|refactor|docs|chore|test|build|ci|style|revert)(\([^)]+\))?!?: .+/;
928
+ var MISE_GATES = ["format:check", "lint", "test"];
929
+ function checkConventionalSubject(subject) {
930
+ const trimmed = subject.trim();
931
+ const ok = CONVENTIONAL_COMMIT.test(trimmed);
932
+ return {
933
+ name: "conventional-subject",
934
+ status: ok ? "pass" : "warn",
935
+ detail: ok ? trimmed : `not a conventional-commit subject: "${trimmed}"`
936
+ };
937
+ }
938
+ async function runMiseGates(cwd) {
939
+ const tasks = await discoverMiseTasks(cwd);
940
+ const results = [];
941
+ for (const gate of MISE_GATES) {
942
+ const targets = tasks.get(gate) ?? [];
943
+ if (targets.length === 0) {
944
+ results.push({ name: gate, status: "skip", detail: "no mise recipe" });
945
+ continue;
946
+ }
947
+ const invocations = targets.flatMap((target, index) => index === 0 ? [target] : [":::", target]);
948
+ const result = await mise.run(["run", ...invocations], { cwd });
949
+ results.push({
950
+ name: gate,
951
+ status: result.code === 0 ? "pass" : "fail",
952
+ detail: result.code === 0 ? "clean" : (result.stderr.trim() || result.stdout.trim()).slice(-400)
953
+ });
954
+ }
955
+ return results;
956
+ }
957
+ function ciGate(checksOutput) {
958
+ const text = checksOutput.toLowerCase();
959
+ if (!text.trim())
960
+ return { name: "ci", status: "skip", detail: "no CI output" };
961
+ if (/\bfail|error\b/.test(text))
962
+ return { name: "ci", status: "warn", detail: "CI failing" };
963
+ if (/\bpending|in progress|queued\b/.test(text))
964
+ return { name: "ci", status: "warn", detail: "CI pending" };
965
+ return { name: "ci", status: "pass", detail: "CI green" };
966
+ }
967
+ function parseMiseTasks(input) {
968
+ let tasks;
969
+ try {
970
+ tasks = JSON.parse(input);
971
+ } catch {
972
+ return new Map;
973
+ }
974
+ if (!Array.isArray(tasks))
975
+ return new Map;
976
+ const found = new Map;
977
+ for (const gate of MISE_GATES) {
978
+ const targets = tasks.flatMap((task) => {
979
+ if (typeof task.name !== "string")
980
+ return [];
981
+ return task.name === gate || task.name.endsWith(`:${gate}`) || task.aliases?.includes(gate) ? [task.name] : [];
982
+ });
983
+ if (targets.length > 0)
984
+ found.set(gate, [...new Set(targets)]);
985
+ }
986
+ return found;
987
+ }
988
+ async function discoverMiseTasks(cwd) {
989
+ const result = await mise.run(["tasks", "--json", "--all"], { cwd });
990
+ if (result.code !== 0)
991
+ return new Map;
992
+ return parseMiseTasks(result.stdout);
993
+ }
994
+ // src/review/types.ts
995
+ import { z as z2 } from "zod";
996
+ var severitySchema = z2.enum(["BLOCKING", "CONSIDER", "NOTE"]);
997
+ var findingSchema = z2.object({
998
+ file: z2.string().min(1),
999
+ line: z2.number().int().nonnegative(),
1000
+ severity: severitySchema,
1001
+ body: z2.string().min(1),
1002
+ reference: z2.string().optional().default("")
1003
+ });
1004
+ var findingsSchema = z2.array(findingSchema);
1005
+ function reviewSlug(input) {
1006
+ return input.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 50);
1007
+ }
1008
+ function yymmdd(date = new Date) {
1009
+ const yy = String(date.getFullYear() % 100).padStart(2, "0");
1010
+ const mm = String(date.getMonth() + 1).padStart(2, "0");
1011
+ const dd = String(date.getDate()).padStart(2, "0");
1012
+ return `${yy}${mm}${dd}`;
1013
+ }
1014
+ function reviewRecordName(target, date = new Date) {
1015
+ return `${yymmdd(date)}-${reviewSlug(target) || "uncommitted"}`;
1016
+ }
1017
+ function isLocalResponse(body) {
1018
+ return /^<!-- diffpi(?:-local-response:[A-Za-z0-9_-]+)? -->\n/m.test(body);
1019
+ }
1020
+ function dedupeFindings(findings) {
1021
+ const rank = { BLOCKING: 3, CONSIDER: 2, NOTE: 1 };
1022
+ const byKey = new Map;
1023
+ for (const finding of findings) {
1024
+ const key = `${finding.file}:${finding.line}`;
1025
+ const existing = byKey.get(key);
1026
+ if (!existing || rank[finding.severity] > rank[existing.severity])
1027
+ byKey.set(key, finding);
1028
+ }
1029
+ return [...byKey.values()].sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line || rank[b.severity] - rank[a.severity]);
1030
+ }
1031
+ function toReviewComments(findings, model) {
1032
+ const comments = [];
1033
+ for (const finding of findings) {
1034
+ if (finding.line <= 0)
1035
+ continue;
1036
+ const body = renderCommentBody(finding);
1037
+ comments.push({
1038
+ file: finding.file,
1039
+ line: finding.line,
1040
+ side: "RIGHT",
1041
+ body: model ? withRemoteProvenance(body, model) : body
1042
+ });
1043
+ }
1044
+ return comments;
1045
+ }
1046
+ function withRemoteProvenance(body, model) {
1047
+ const normalized = body.trimEnd();
1048
+ if (/Generated review by Diffpi using `[^`]+`\.$/.test(normalized))
1049
+ return body;
1050
+ return `${normalized}
1051
+
1052
+ Generated review by Diffpi using \`${model}\`.`;
1053
+ }
1054
+ function localReviewAuthor(model) {
1055
+ return `Agent: ${model}`;
1056
+ }
1057
+ function renderCommentBody(finding) {
1058
+ const prefix = finding.severity === "BLOCKING" ? "**BLOCKING** " : "";
1059
+ const reference = finding.reference ? `
1060
+
1061
+ > **Reference:** ${finding.reference}` : "";
1062
+ return `${prefix}${finding.body}${reference}`;
1063
+ }
1064
+ // src/review/review-markdown.ts
1065
+ import { z as z3 } from "zod";
1066
+ var reviewThreadRecordSchema = z3.object({
1067
+ id: z3.string().min(1),
1068
+ file: z3.string().optional(),
1069
+ line: z3.number().int().positive().optional(),
1070
+ body: z3.string(),
1071
+ author: z3.string().optional(),
1072
+ resolved: z3.boolean(),
1073
+ addressed: z3.boolean().optional(),
1074
+ question: z3.boolean(),
1075
+ replies: z3.array(z3.string()).optional()
1076
+ });
1077
+ function renderReviewDoc(input) {
1078
+ const anchored = dedupeFindings(input.findings).filter((finding) => finding.line > 0);
1079
+ const lines = [`# Review: ${input.title}`, "", "## Metadata"];
1080
+ if (input.number !== undefined)
1081
+ lines.push(`- **PR/MR**: #${input.number}${input.url ? ` — ${input.url}` : ""}`);
1082
+ if (input.author)
1083
+ lines.push(`- **Author**: ${input.author}`);
1084
+ if (input.model)
1085
+ lines.push(`- **Review agent**: ${input.model}`);
1086
+ if (input.headRef && input.baseRef)
1087
+ lines.push(`- **Branch**: ${input.headRef} → ${input.baseRef}`);
1088
+ if (input.additions !== undefined)
1089
+ lines.push(`- **Stats**: +${input.additions} -${input.deletions ?? 0} across ${input.changedFiles ?? 0} files`);
1090
+ lines.push(`- **Reviewed**: ${input.timestamp ?? new Date().toISOString()}`, "");
1091
+ if (input.overallIssues.length > 0) {
1092
+ lines.push("## Overall issues", "");
1093
+ for (const issue of input.overallIssues)
1094
+ lines.push(`- ${issue}`);
1095
+ lines.push("");
1096
+ }
1097
+ lines.push("## Verification", "");
1098
+ for (const gate of input.gates)
1099
+ lines.push(`- ${gate.name}: ${gate.status} — ${gate.detail}`);
1100
+ lines.push("");
1101
+ if (input.notVerified.length > 0) {
1102
+ lines.push("## What was NOT verified", "");
1103
+ for (const item of input.notVerified)
1104
+ lines.push(`- ${item}`);
1105
+ lines.push("");
1106
+ }
1107
+ lines.push("## Inline Comments", "");
1108
+ for (const finding of anchored) {
1109
+ lines.push(`### ${finding.file}:${finding.line} — ${finding.severity}`, "", finding.body, "");
1110
+ if (finding.reference)
1111
+ lines.push(`> **Reference:** ${finding.reference}`, "");
1112
+ lines.push("---", "");
1113
+ }
1114
+ return `${lines.join(`
1115
+ `).trimEnd()}
1116
+ `;
1117
+ }
1118
+ function renderThreadArtifact(title, target, threads, options = {}) {
1119
+ const records = threads.map((thread) => ({
1120
+ id: thread.id,
1121
+ file: thread.file,
1122
+ line: thread.line,
1123
+ body: thread.body,
1124
+ author: thread.author,
1125
+ resolved: thread.resolved,
1126
+ addressed: thread.addressed,
1127
+ question: thread.question,
1128
+ replies: thread.replies
1129
+ }));
1130
+ const payload = Buffer.from(JSON.stringify(records), "utf8").toString("base64url");
1131
+ const lines = [
1132
+ `<!-- diffpi-threads:${payload} -->`,
1133
+ `# Review threads: ${title}`,
1134
+ "",
1135
+ "## Metadata",
1136
+ "",
1137
+ `- Target: ${target}`,
1138
+ `- Pulled: ${options.timestamp ?? new Date().toISOString()}`
401
1139
  ];
402
- return candidates.find((path) => existsSync(path)) ?? candidates[1];
1140
+ if (options.number !== undefined)
1141
+ lines.push(`- PR/MR: #${options.number}${options.url ? ` — ${options.url}` : ""}`);
1142
+ lines.push("", "## Replies", "");
1143
+ for (const thread of threads) {
1144
+ const id = Buffer.from(thread.id, "utf8").toString("base64url");
1145
+ lines.push(`### ${thread.file ?? "review"}:${thread.line ?? "n/a"} (${thread.id})`, "", `<!-- diffpi-reply-start:${id} -->`, thread.reply ?? "", `<!-- diffpi-reply-end:${id} -->`, "");
1146
+ }
1147
+ lines.push("## Source comments", "");
1148
+ for (const thread of threads) {
1149
+ 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, "", "---", "");
1150
+ }
1151
+ return `${lines.join(`
1152
+ `).trimEnd()}
1153
+ `;
1154
+ }
1155
+ function parseThreadArtifact(content) {
1156
+ const payload = content.match(/^<!-- diffpi-threads:([A-Za-z0-9_-]+) -->$/m)?.[1];
1157
+ if (!payload)
1158
+ throw new Error("This file is not a Diffpi thread artifact.");
1159
+ let threads;
1160
+ try {
1161
+ const decoded = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
1162
+ threads = z3.array(reviewThreadRecordSchema).parse(decoded);
1163
+ } catch {
1164
+ throw new Error("Cannot parse the Diffpi thread artifact payload.");
1165
+ }
1166
+ const replies = content.split(/^## Source comments$/m, 1)[0] ?? "";
1167
+ return threads.map((thread) => {
1168
+ const id = Buffer.from(thread.id, "utf8").toString("base64url");
1169
+ const startMarker = `<!-- diffpi-reply-start:${id} -->
1170
+ `;
1171
+ const endMarker = `
1172
+ <!-- diffpi-reply-end:${id} -->`;
1173
+ const start = replies.indexOf(startMarker);
1174
+ const end = start < 0 ? -1 : replies.indexOf(endMarker, start + startMarker.length);
1175
+ const reply = start >= 0 && end >= 0 ? replies.slice(start + startMarker.length, end).trim() : "";
1176
+ return reply ? { ...thread, reply } : thread;
1177
+ });
1178
+ }
1179
+ function upsertThreadReply(content, threadId, body, question, resolved) {
1180
+ const threads = parseThreadArtifact(content);
1181
+ const thread = threads.find((candidate) => candidate.id === threadId);
1182
+ if (!thread)
1183
+ throw new Error(`Review thread ${threadId} was not found in the local artifact.`);
1184
+ thread.reply = body;
1185
+ thread.addressed = true;
1186
+ if (question !== undefined)
1187
+ thread.question = question;
1188
+ if (resolved !== undefined)
1189
+ thread.resolved = resolved;
1190
+ const title = content.match(/^# Review threads: (.+)$/m)?.[1] ?? "review";
1191
+ const target = content.match(/^- Target: (.+)$/m)?.[1] ?? "local";
1192
+ const timestamp = content.match(/^- Pulled: (.+)$/m)?.[1];
1193
+ const pr = content.match(/^- PR\/MR: #(\d+)(?: — (.+))?$/m);
1194
+ return renderThreadArtifact(title, target, threads, {
1195
+ timestamp,
1196
+ number: pr ? Number.parseInt(pr[1], 10) : undefined,
1197
+ url: pr?.[2]
1198
+ });
1199
+ }
1200
+ // src/review/github-review-backend.ts
1201
+ function GitHubReviewBackend(vcs, number) {
1202
+ const pendingReview = async () => {
1203
+ const result = await ghChecked([
1204
+ "api",
1205
+ `/repos/${vcs.owner}/${vcs.repo}/pulls/${number}/reviews`,
1206
+ "--jq",
1207
+ '[.[] | select(.state=="PENDING")] | last | {id: (.id | tostring), nodeId: .node_id}'
1208
+ ]);
1209
+ if (!result.stdout.trim())
1210
+ return;
1211
+ let pending;
1212
+ try {
1213
+ pending = JSON.parse(result.stdout);
1214
+ } catch {
1215
+ throw new Error("Cannot parse the pending GitHub review identifier as JSON.");
1216
+ }
1217
+ if (!pending.id || !pending.nodeId)
1218
+ return;
1219
+ return { id: pending.id, nodeId: pending.nodeId };
1220
+ };
1221
+ const listThreads = async () => {
1222
+ const threads = [];
1223
+ let cursor;
1224
+ do {
1225
+ const args = [
1226
+ "api",
1227
+ "graphql",
1228
+ "-f",
1229
+ `query=${GITHUB_THREADS_QUERY}`,
1230
+ "-f",
1231
+ `owner=${vcs.owner}`,
1232
+ "-f",
1233
+ `repo=${vcs.repo}`,
1234
+ "-F",
1235
+ `number=${number}`
1236
+ ];
1237
+ if (cursor)
1238
+ args.push("-f", `after=${cursor}`);
1239
+ const result = await ghChecked(args);
1240
+ let data;
1241
+ try {
1242
+ data = JSON.parse(result.stdout);
1243
+ } catch {
1244
+ throw new Error("Cannot parse GitHub review threads as JSON.");
1245
+ }
1246
+ const connection = data.data?.repository?.pullRequest?.reviewThreads;
1247
+ threads.push(...connection?.nodes ?? []);
1248
+ cursor = connection?.pageInfo?.hasNextPage ? connection.pageInfo.endCursor : undefined;
1249
+ if (connection?.pageInfo?.hasNextPage && !cursor) {
1250
+ throw new Error("GitHub review thread pagination did not return an end cursor.");
1251
+ }
1252
+ } while (cursor);
1253
+ return threads.map((thread) => {
1254
+ const nodes = thread.comments?.nodes ?? [];
1255
+ const comment = nodes[0];
1256
+ const body = comment?.body ?? "";
1257
+ return {
1258
+ id: thread.id,
1259
+ file: thread.path,
1260
+ line: thread.line,
1261
+ body,
1262
+ author: comment?.author?.login,
1263
+ resolved: thread.isResolved,
1264
+ question: /\?\s*$/.test(body.trim()),
1265
+ replies: nodes.slice(1).map((node) => node.body)
1266
+ };
1267
+ });
1268
+ };
1269
+ return {
1270
+ kind: "remote",
1271
+ async stage(draft) {
1272
+ const pending = await pendingReview();
1273
+ if (!pending) {
1274
+ const payload = {
1275
+ body: draft.body,
1276
+ comments: draft.comments.map((comment) => ({
1277
+ path: comment.file,
1278
+ line: comment.line,
1279
+ side: comment.side ?? "RIGHT",
1280
+ body: comment.body
1281
+ }))
1282
+ };
1283
+ await ghChecked(["api", "--method", "POST", `/repos/${vcs.owner}/${vcs.repo}/pulls/${number}/reviews`, "--input", "-"], { input: JSON.stringify(payload) });
1284
+ return;
1285
+ }
1286
+ if (draft.body.trim()) {
1287
+ await ghChecked([
1288
+ "api",
1289
+ "--method",
1290
+ "PUT",
1291
+ `/repos/${vcs.owner}/${vcs.repo}/pulls/${number}/reviews/${pending.id}`,
1292
+ "-f",
1293
+ `body=${draft.body}`
1294
+ ]);
1295
+ }
1296
+ for (const comment of draft.comments) {
1297
+ await ghChecked([
1298
+ "api",
1299
+ "graphql",
1300
+ "-f",
1301
+ `query=${GITHUB_ADD_THREAD_MUTATION}`,
1302
+ "-f",
1303
+ `reviewId=${pending.nodeId}`,
1304
+ "-f",
1305
+ `body=${comment.body}`,
1306
+ "-f",
1307
+ `path=${comment.file}`,
1308
+ "-F",
1309
+ `line=${comment.line}`,
1310
+ "-f",
1311
+ `side=${comment.side ?? "RIGHT"}`
1312
+ ]);
1313
+ }
1314
+ },
1315
+ async readDraft() {
1316
+ const pending = await pendingReview();
1317
+ if (!pending)
1318
+ return { comments: [], body: "" };
1319
+ const review = await ghChecked(["api", `/repos/${vcs.owner}/${vcs.repo}/pulls/${number}/reviews/${pending.id}`]);
1320
+ const comments = await ghChecked([
1321
+ "api",
1322
+ `/repos/${vcs.owner}/${vcs.repo}/pulls/${number}/reviews/${pending.id}/comments`
1323
+ ]);
1324
+ let reviewData;
1325
+ let commentData;
1326
+ try {
1327
+ reviewData = JSON.parse(review.stdout);
1328
+ commentData = JSON.parse(comments.stdout);
1329
+ } catch {
1330
+ throw new Error("Cannot parse the pending GitHub review as JSON.");
1331
+ }
1332
+ return {
1333
+ body: reviewData.body ?? "",
1334
+ comments: commentData.map((comment) => ({
1335
+ file: comment.path,
1336
+ line: comment.line ?? comment.original_line ?? 1,
1337
+ side: comment.side,
1338
+ body: comment.body
1339
+ }))
1340
+ };
1341
+ },
1342
+ listThreads,
1343
+ async reply(input) {
1344
+ const threads = await listThreads();
1345
+ const existing = threads.find((thread) => thread.id === input.threadId);
1346
+ if (!existing?.replies?.includes(input.body)) {
1347
+ await ghChecked([
1348
+ "api",
1349
+ "graphql",
1350
+ "-f",
1351
+ `query=${GITHUB_REPLY_MUTATION}`,
1352
+ "-f",
1353
+ `threadId=${input.threadId}`,
1354
+ "-f",
1355
+ `body=${input.body}`
1356
+ ]);
1357
+ }
1358
+ if (input.resolve) {
1359
+ await ghChecked([
1360
+ "api",
1361
+ "graphql",
1362
+ "-f",
1363
+ `query=${GITHUB_RESOLVE_MUTATION}`,
1364
+ "-f",
1365
+ `threadId=${input.threadId}`
1366
+ ]);
1367
+ }
1368
+ },
1369
+ async publish(event) {
1370
+ const pending = await pendingReview();
1371
+ if (!pending && event === "COMMENT")
1372
+ return;
1373
+ if (!pending && event === "REQUEST_CHANGES") {
1374
+ throw new Error("GitHub requires pending comments before publishing a request-changes review without a body.");
1375
+ }
1376
+ const endpoint = githubReviewSubmissionEndpoint(vcs.owner, vcs.repo, number, pending?.id ?? "");
1377
+ await ghChecked(["api", "--method", "POST", endpoint, "-f", `event=${event}`]);
1378
+ }
1379
+ };
403
1380
  }
1381
+ function githubReviewSubmissionEndpoint(owner, repo, id, pendingReviewId) {
1382
+ return pendingReviewId ? `/repos/${owner}/${repo}/pulls/${id}/reviews/${pendingReviewId}/events` : `/repos/${owner}/${repo}/pulls/${id}/reviews`;
1383
+ }
1384
+ 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}}}}}`;
1385
+ 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}}}`;
1386
+ var GITHUB_REPLY_MUTATION = `mutation($threadId:ID!,$body:String!){addPullRequestReviewThreadReply(input:{pullRequestReviewThreadId:$threadId,body:$body}){comment{id}}}`;
1387
+ var GITHUB_RESOLVE_MUTATION = `mutation($threadId:ID!){resolveReviewThread(input:{threadId:$threadId}){thread{isResolved}}}`;
404
1388
 
1389
+ // src/review/gitlab-review-backend.ts
1390
+ function GitLabReviewBackend(vcs, number) {
1391
+ const project = () => `${vcs.owner}/${vcs.repo}`;
1392
+ const mergeRequestEndpoint = () => `projects/${encodeURIComponent(project())}/merge_requests/${number}`;
1393
+ const listThreads = async () => {
1394
+ const discussions = [];
1395
+ for (let page = 1;; page += 1) {
1396
+ const result = await glabChecked(["api", `${mergeRequestEndpoint()}/discussions?per_page=100&page=${page}`]);
1397
+ let batch;
1398
+ try {
1399
+ batch = JSON.parse(result.stdout);
1400
+ } catch {
1401
+ throw new Error("Cannot parse GitLab review discussions as JSON.");
1402
+ }
1403
+ discussions.push(...batch);
1404
+ if (batch.length < 100)
1405
+ break;
1406
+ }
1407
+ return discussions.map((discussion) => {
1408
+ const notes = discussion.notes ?? [];
1409
+ const note = notes[0];
1410
+ const body = note?.body ?? "";
1411
+ return {
1412
+ id: discussion.id,
1413
+ file: note?.position?.new_path ?? note?.position?.old_path,
1414
+ line: note?.position?.new_line ?? note?.position?.old_line,
1415
+ body,
1416
+ author: note?.author?.username,
1417
+ resolved: Boolean(discussion.resolved),
1418
+ question: /\?\s*$/.test(body.trim()),
1419
+ replies: notes.slice(1).map((reply) => reply.body)
1420
+ };
1421
+ });
1422
+ };
1423
+ return {
1424
+ kind: "remote",
1425
+ async stage(draft) {
1426
+ const endpoint = `${mergeRequestEndpoint()}/draft_notes`;
1427
+ if (draft.body.trim()) {
1428
+ await glabChecked(["api", "--method", "POST", endpoint, "--input", "-"], {
1429
+ input: JSON.stringify({ note: draft.body })
1430
+ });
1431
+ }
1432
+ if (draft.comments.length === 0)
1433
+ return;
1434
+ const response = await glabChecked(["api", mergeRequestEndpoint()]);
1435
+ const diffRefs = parseGitlabDiffRefs(response.stdout);
1436
+ for (const comment of draft.comments) {
1437
+ const payload = {
1438
+ note: comment.body,
1439
+ position: {
1440
+ ...diffRefs,
1441
+ position_type: "text",
1442
+ new_path: comment.file,
1443
+ old_path: comment.file,
1444
+ new_line: comment.side === "LEFT" ? undefined : comment.line,
1445
+ old_line: comment.side === "LEFT" ? comment.line : undefined
1446
+ }
1447
+ };
1448
+ await glabChecked(["api", "--method", "POST", endpoint, "--input", "-"], {
1449
+ input: JSON.stringify(payload)
1450
+ });
1451
+ }
1452
+ },
1453
+ async readDraft() {
1454
+ const result = await glabChecked(["api", `${mergeRequestEndpoint()}/draft_notes`]);
1455
+ let notes;
1456
+ try {
1457
+ notes = JSON.parse(result.stdout);
1458
+ } catch {
1459
+ throw new Error("Cannot parse GitLab draft notes as JSON.");
1460
+ }
1461
+ const comments = [];
1462
+ const body = [];
1463
+ for (const note of notes) {
1464
+ if (!note.note)
1465
+ continue;
1466
+ const file = note.position?.new_path ?? note.position?.old_path;
1467
+ const line = note.position?.new_line ?? note.position?.old_line;
1468
+ if (file && line) {
1469
+ comments.push({
1470
+ file,
1471
+ line,
1472
+ side: note.position?.new_line ? "RIGHT" : "LEFT",
1473
+ body: note.note
1474
+ });
1475
+ } else {
1476
+ body.push(note.note);
1477
+ }
1478
+ }
1479
+ return { comments, body: body.join(`
1480
+
1481
+ `) };
1482
+ },
1483
+ listThreads,
1484
+ async reply(input) {
1485
+ const endpoint = `${mergeRequestEndpoint()}/discussions/${encodeURIComponent(input.threadId)}`;
1486
+ const threads = await listThreads();
1487
+ const existing = threads.find((thread) => thread.id === input.threadId);
1488
+ if (!existing?.replies?.includes(input.body)) {
1489
+ await glabChecked(["api", "--method", "POST", `${endpoint}/notes`, "--input", "-"], {
1490
+ input: JSON.stringify({ body: input.body })
1491
+ });
1492
+ }
1493
+ if (input.resolve)
1494
+ await glabChecked(["api", "--method", "PUT", `${endpoint}?resolved=true`]);
1495
+ },
1496
+ async publish(event) {
1497
+ assertReviewEventSupported(vcs.provider, event);
1498
+ const drafts = await glabChecked(["api", `${mergeRequestEndpoint()}/draft_notes`]);
1499
+ if (hasGitlabDraftNotes(drafts.stdout)) {
1500
+ await glabChecked(["api", "--method", "POST", `${mergeRequestEndpoint()}/draft_notes/bulk_publish`]);
1501
+ }
1502
+ if (event === "APPROVE")
1503
+ await glabChecked(["mr", "approve", String(number), "--repo", project()]);
1504
+ }
1505
+ };
1506
+ }
1507
+ function assertReviewEventSupported(provider, event) {
1508
+ if (provider === "gitlab" && event === "REQUEST_CHANGES") {
1509
+ throw new Error("GitLab does not support REQUEST_CHANGES reviews; post a comment or reject the merge request manually.");
1510
+ }
1511
+ }
1512
+ function parseGitlabDiffRefs(input) {
1513
+ let data;
1514
+ try {
1515
+ data = JSON.parse(input);
1516
+ } catch {
1517
+ throw new Error("Cannot create positioned GitLab draft notes: the merge request response was not valid JSON.");
1518
+ }
1519
+ const { base_sha, start_sha, head_sha } = data.diff_refs ?? {};
1520
+ if (!base_sha || !start_sha || !head_sha) {
1521
+ throw new Error("Cannot create positioned GitLab draft notes: merge request diff refs are unavailable.");
1522
+ }
1523
+ return { base_sha, start_sha, head_sha };
1524
+ }
1525
+ function hasGitlabDraftNotes(input) {
1526
+ try {
1527
+ const data = JSON.parse(input);
1528
+ return Array.isArray(data) && data.length > 0;
1529
+ } catch {
1530
+ throw new Error("Cannot publish the GitLab review: the draft notes response was not valid JSON.");
1531
+ }
1532
+ }
1533
+
1534
+ // src/review/local-review-backend.ts
1535
+ import { readFile as readFile6, writeFile as writeFile3 } from "node:fs/promises";
1536
+
1537
+ // src/extensions/tuicrx.ts
1538
+ import { readFile as readFile5, realpath as realpath2 } from "node:fs/promises";
1539
+ import { resolve as resolve2 } from "node:path";
1540
+ async function tuicrAvailable() {
1541
+ return Boolean(await findExecutable("tuicr"));
1542
+ }
1543
+ async function listSessions(repo = ".") {
1544
+ await requireTuicr();
1545
+ const result = await run("tuicr", ["review", "list", "--repo", repo]);
1546
+ if (result.code !== 0 || !result.stdout.trim())
1547
+ return [];
1548
+ try {
1549
+ const raw = JSON.parse(result.stdout);
1550
+ return raw.map((entry) => ({
1551
+ slug: entry.slug,
1552
+ kind: entry.kind,
1553
+ path: entry.path,
1554
+ updatedAt: entry.updated_at,
1555
+ commentCount: entry.comment_count,
1556
+ anchor: entry.anchor,
1557
+ active: entry.active
1558
+ }));
1559
+ } catch {
1560
+ throw new Error("Cannot parse tuicr review list output.");
1561
+ }
1562
+ }
1563
+ async function addComment(session, body, opts = {}) {
1564
+ await requireTuicr();
1565
+ const args = ["review", "add", "--session", session, body];
1566
+ if (opts.targetFile)
1567
+ args.push("--target-file", opts.targetFile);
1568
+ if (opts.line !== undefined)
1569
+ args.push("--line", String(opts.line));
1570
+ if (opts.side)
1571
+ args.push("--side", opts.side);
1572
+ if (opts.username)
1573
+ args.push("--username", opts.username);
1574
+ await runChecked("tuicr", args);
1575
+ }
1576
+ async function launch(cwd, pr, localBase) {
1577
+ let command;
1578
+ if (pr !== undefined)
1579
+ command = ["tuicr", "pr", String(pr)];
1580
+ else if (localBase)
1581
+ command = ["tuicr", "-w", "-r", `${localBase}..HEAD`];
1582
+ else
1583
+ throw new Error("Cannot launch a local tuicr review: no base branch could be determined.");
1584
+ if (!await tuicrAvailable()) {
1585
+ return {
1586
+ launched: false,
1587
+ via: "print",
1588
+ command: command.join(" "),
1589
+ reason: "tuicr is not installed or is not available on PATH.",
1590
+ instruction: `Install tuicr, then run: ${command.join(" ")}`
1591
+ };
1592
+ }
1593
+ const name = pr === undefined ? "diffpi: local review" : `diffpi: PR #${pr}`;
1594
+ return openInNewTab(command, { cwd, name });
1595
+ }
1596
+ async function resolveSession(cwd, branch) {
1597
+ const sessions = await listSessions(cwd);
1598
+ return findMatchingSession(sessions, cwd, branch);
1599
+ }
1600
+ async function resolveReviewSession(cwd, target) {
1601
+ if (!target.workingTree && target.owner && target.repo && target.number !== undefined) {
1602
+ return resolvePrSession(cwd, target.owner, target.repo, target.number);
1603
+ }
1604
+ return resolveSession(cwd, target.branch);
1605
+ }
1606
+ async function resolvePrSession(cwd, owner, repo, number) {
1607
+ const coordinate = `${owner}/${repo}`.toLowerCase();
1608
+ const sessions = await listSessions(cwd);
1609
+ return sessions.find((session) => {
1610
+ const slug = session.slug.toLowerCase();
1611
+ return session.kind === "pr" && slug.includes(coordinate) && (slug.endsWith(`/pr/${number}`) || slug.endsWith(`/mr/${number}`));
1612
+ });
1613
+ }
1614
+ async function findMatchingSession(sessions, cwd, branch) {
1615
+ const repository = await canonicalPath2(await gitToplevel(cwd));
1616
+ for (const session of sessions) {
1617
+ if (session.kind !== "local")
1618
+ continue;
1619
+ try {
1620
+ const data = await readSession(session.path);
1621
+ if (data.branch_name !== branch || !data.repo_path)
1622
+ continue;
1623
+ if (await canonicalPath2(data.repo_path) === repository)
1624
+ return session;
1625
+ } catch {}
1626
+ }
1627
+ return;
1628
+ }
1629
+ async function readSession(path) {
1630
+ const content = await readFile5(path, "utf8");
1631
+ try {
1632
+ return JSON.parse(content);
1633
+ } catch {
1634
+ throw new Error(`Cannot parse tuicr session JSON: ${path}`);
1635
+ }
1636
+ }
1637
+ function toFindings(session, options = {}) {
1638
+ const comments = [];
1639
+ const include = (comment) => (!options.agentOnly || commentAuthor(comment)?.startsWith("Agent: ")) && (!options.excludeLocalResponses || !isLocalResponse(comment.content));
1640
+ const bodyParts = (session.review_comments ?? []).flatMap((comment) => include(comment) ? [comment.content] : []);
1641
+ for (const [file, entry] of Object.entries(session.files ?? {})) {
1642
+ const fileComments = (entry.file_comments ?? []).flatMap((comment) => include(comment) ? [comment.content] : []);
1643
+ if (fileComments.length > 0)
1644
+ bodyParts.push(`File: ${file}
1645
+
1646
+ ${fileComments.join(`
1647
+
1648
+ `)}`);
1649
+ for (const [lineKey, lineComments] of Object.entries(entry.line_comments ?? {})) {
1650
+ const line = Number.parseInt(lineKey, 10);
1651
+ if (!Number.isFinite(line))
1652
+ continue;
1653
+ for (const lineComment of lineComments) {
1654
+ if (!include(lineComment))
1655
+ continue;
1656
+ const comment = {
1657
+ file,
1658
+ line,
1659
+ side: lineComment.side === "old" ? "LEFT" : "RIGHT",
1660
+ body: lineComment.content
1661
+ };
1662
+ const author = commentAuthor(lineComment);
1663
+ if (author)
1664
+ comment.author = author;
1665
+ comments.push(comment);
1666
+ }
1667
+ }
1668
+ }
1669
+ return { comments, body: bodyParts.join(`
1670
+
1671
+ `) };
1672
+ }
1673
+ async function requireTuicr() {
1674
+ if (!await tuicrAvailable())
1675
+ throw new Error("tuicr is not installed or is not available on PATH.");
1676
+ }
1677
+ function commentAuthor(comment) {
1678
+ return comment.username ?? comment.author;
1679
+ }
1680
+ async function canonicalPath2(path) {
1681
+ try {
1682
+ return await realpath2(path);
1683
+ } catch {
1684
+ return resolve2(path);
1685
+ }
1686
+ }
1687
+
1688
+ // src/review/local-review-backend.ts
1689
+ function LocalReviewBackend(options) {
1690
+ return {
1691
+ kind: "local",
1692
+ async stage(draft) {
1693
+ for (const comment of draft.comments) {
1694
+ await addComment(options.session, comment.body, {
1695
+ targetFile: comment.file,
1696
+ line: comment.line,
1697
+ side: comment.side === "LEFT" ? "old" : "new",
1698
+ username: options.author
1699
+ });
1700
+ }
1701
+ if (draft.body.trim())
1702
+ await addComment(options.session, draft.body, { username: options.author });
1703
+ },
1704
+ async readDraft() {
1705
+ return toFindings(await readSession(options.session), { excludeLocalResponses: true });
1706
+ },
1707
+ async listThreads() {
1708
+ try {
1709
+ return parseThreadArtifact(await readFile6(options.artifactPath, "utf8"));
1710
+ } catch (error) {
1711
+ if (error.code === "ENOENT")
1712
+ return [];
1713
+ throw error;
1714
+ }
1715
+ },
1716
+ async reply(input) {
1717
+ const content = await readFile6(options.artifactPath, "utf8");
1718
+ await writeFile3(options.artifactPath, upsertThreadReply(content, input.threadId, input.body, input.question, input.resolve), "utf8");
1719
+ },
1720
+ async publish() {
1721
+ throw new Error("Promote a local draft through a remote review backend before publishing it.");
1722
+ }
1723
+ };
1724
+ }
1725
+
1726
+ // src/review/review-backend.ts
1727
+ function createRemoteReviewBackend(vcs, number) {
1728
+ if (vcs.provider === "github")
1729
+ return GitHubReviewBackend(vcs, number);
1730
+ if (vcs.provider === "gitlab")
1731
+ return GitLabReviewBackend(vcs, number);
1732
+ throw new Error("A remote review backend requires a GitHub or GitLab repository.");
1733
+ }
1734
+ function createLocalReviewBackend(options) {
1735
+ return LocalReviewBackend(options);
1736
+ }
1737
+ // src/review/review-state.ts
1738
+ import { createHash as createHash2 } from "node:crypto";
1739
+ import { readFile as readFile7, rename, writeFile as writeFile4 } from "node:fs/promises";
1740
+ import { join as join9 } from "node:path";
1741
+ import { z as z4 } from "zod";
1742
+
1743
+ // src/store.ts
1744
+ import { createHash } from "node:crypto";
1745
+ import { lstat, mkdir as mkdir3, readlink, realpath as realpath3, symlink, unlink } from "node:fs/promises";
1746
+ import { homedir as homedir4 } from "node:os";
1747
+ import { dirname as dirname4, isAbsolute as isAbsolute2, join as join8, resolve as resolve3 } from "node:path";
1748
+ var STORE_LINK = ".diffpi";
1749
+ var LEGACY_STORE_LINK = join8(".pi", "diffpi");
1750
+ async function computeProjectSlug(cwd) {
1751
+ return projectSlug(await inspectGitRepository(cwd));
1752
+ }
1753
+ function storeGlobalRoot(homeDir = homedir4()) {
1754
+ return join8(homeDir, ".difflab", "diffpi", "projects");
1755
+ }
1756
+ async function ensureStore(cwd, homeDir = homedir4()) {
1757
+ const repository = await inspectGitRepository(cwd);
1758
+ const root = repository.root;
1759
+ const slug = projectSlug(repository);
1760
+ const dest = join8(storeGlobalRoot(homeDir), slug);
1761
+ const link = join8(root, STORE_LINK);
1762
+ await mkdir3(dest, { recursive: true });
1763
+ try {
1764
+ await assertStoreLink(link, dest);
1765
+ } catch (error) {
1766
+ if (error.code !== "ENOENT")
1767
+ throw error;
1768
+ await symlink(dest, link);
1769
+ }
1770
+ await removeLegacyStoreLink(join8(root, LEGACY_STORE_LINK), dest);
1771
+ return { slug, root, dest, link, linked: true };
1772
+ }
1773
+ async function storeDir(cwd, homeDir = homedir4()) {
1774
+ const store = await ensureStore(cwd, homeDir);
1775
+ return store.dest;
1776
+ }
1777
+ async function plansDir(cwd, homeDir = homedir4()) {
1778
+ const store = await ensureStore(cwd, homeDir);
1779
+ const dir = join8(store.link, "plan");
1780
+ await mkdir3(dir, { recursive: true });
1781
+ return dir;
1782
+ }
1783
+ async function reviewsDir(cwd, homeDir = homedir4()) {
1784
+ const store = await ensureStore(cwd, homeDir);
1785
+ const dir = join8(store.link, "review");
1786
+ await mkdir3(dir, { recursive: true });
1787
+ return dir;
1788
+ }
1789
+ async function sessionsDir(cwd, homeDir = homedir4()) {
1790
+ const store = await ensureStore(cwd, homeDir);
1791
+ const dir = join8(store.link, "sessions");
1792
+ await mkdir3(dir, { recursive: true });
1793
+ return dir;
1794
+ }
1795
+ async function assertStoreLink(path, dest) {
1796
+ const entry = await lstat(path);
1797
+ if (!entry.isSymbolicLink())
1798
+ throw new Error(`${path} exists and is not a symlink.`);
1799
+ const target = await symlinkTarget(path);
1800
+ if (target !== await canonicalPath3(dest))
1801
+ throw new Error(`${path} points to ${target}, not ${dest}.`);
1802
+ }
1803
+ async function removeLegacyStoreLink(path, dest) {
1804
+ try {
1805
+ const entry = await lstat(path);
1806
+ if (!entry.isSymbolicLink())
1807
+ return;
1808
+ const target = await symlinkTarget(path);
1809
+ if (target === await canonicalPath3(dest))
1810
+ await unlink(path);
1811
+ } catch (error) {
1812
+ if (error.code !== "ENOENT")
1813
+ throw error;
1814
+ }
1815
+ }
1816
+ async function symlinkTarget(path) {
1817
+ const target = await readlink(path);
1818
+ return canonicalPath3(isAbsolute2(target) ? target : resolve3(dirname4(path), target));
1819
+ }
1820
+ async function canonicalPath3(path) {
1821
+ try {
1822
+ return await realpath3(path);
1823
+ } catch {
1824
+ return resolve3(path);
1825
+ }
1826
+ }
1827
+ function projectSlug(repository) {
1828
+ const readable = repository.name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "unnamed";
1829
+ const digest = createHash("sha256").update(repository.identity).digest("hex").slice(0, 12);
1830
+ return `${readable}-${digest}`;
1831
+ }
1832
+
1833
+ // src/review/review-state.ts
1834
+ var reviewPublicationStateSchema = z4.object({
1835
+ target: z4.string().optional(),
1836
+ bodies: z4.array(z4.string()).default([]),
1837
+ comments: z4.array(z4.string()).default([]),
1838
+ replies: z4.array(z4.string()).default([]),
1839
+ overlayPath: z4.string().optional()
1840
+ });
1841
+ function reviewBodyFingerprint(body) {
1842
+ return digest(body);
1843
+ }
1844
+ function reviewCommentFingerprint(comment) {
1845
+ return digest([comment.file, String(comment.line), comment.side ?? "RIGHT", comment.body].join("\x00"));
1846
+ }
1847
+ function reviewReplyFingerprint(threadId, body) {
1848
+ return digest(`${threadId}\x00${body}`);
1849
+ }
1850
+ function unpublishedReviewComments(comments, knownFingerprints) {
1851
+ return comments.filter((comment) => !knownFingerprints.has(reviewCommentFingerprint(comment)));
1852
+ }
1853
+ async function loadReviewPublicationState(cwd, vcs, number, homeDir) {
1854
+ const target = `${vcs.provider}:${vcs.owner}/${vcs.repo}#${number}`;
1855
+ const path = join9(await sessionsDir(cwd, homeDir), `review-publish-${digest(target).slice(0, 16)}.json`);
1856
+ try {
1857
+ const parsed = reviewPublicationStateSchema.parse(JSON.parse(await readFile7(path, "utf8")));
1858
+ return { path, state: { ...parsed, target } };
1859
+ } catch (error) {
1860
+ if (error.code === "ENOENT") {
1861
+ return { path, state: { target, bodies: [], comments: [], replies: [] } };
1862
+ }
1863
+ if (error instanceof SyntaxError || error instanceof z4.ZodError) {
1864
+ throw new Error(`Cannot parse review publication state: ${path}`);
1865
+ }
1866
+ throw error;
1867
+ }
1868
+ }
1869
+ async function saveReviewPublicationState(path, state) {
1870
+ const temp = `${path}.${process.pid}.tmp`;
1871
+ await writeFile4(temp, `${JSON.stringify(state, null, 2)}
1872
+ `, "utf8");
1873
+ await rename(temp, path);
1874
+ }
1875
+ function digest(value) {
1876
+ return createHash2("sha256").update(value).digest("hex");
1877
+ }
1878
+ // src/mcp.ts
1879
+ import { mkdir as mkdir4, readFile as readFile8, writeFile as writeFile5 } from "node:fs/promises";
1880
+ import { homedir as homedir5 } from "node:os";
1881
+ import { dirname as dirname5, join as join10 } from "node:path";
1882
+ var mcp = {
1883
+ globalConfigPath(homeDir = homedir5()) {
1884
+ return join10(homeDir, ".config", "mcp", "mcp.json");
1885
+ },
1886
+ async serversEnsure(servers, options = {}) {
1887
+ const path = options.path ?? mcp.globalConfigPath();
1888
+ const currentText = await getOptionalFile2(path);
1889
+ const current = getParsedConfig(currentText, path);
1890
+ const nextServers = { ...current.mcpServers };
1891
+ for (const [name, entry] of Object.entries(servers)) {
1892
+ nextServers[name] = mergeEntry(nextServers[name], entry);
1893
+ }
1894
+ const next = { ...current, mcpServers: nextServers };
1895
+ const changed = JSON.stringify(current) !== JSON.stringify(next);
1896
+ if (changed && !options.dryRun) {
1897
+ await mkdir4(dirname5(path), { recursive: true });
1898
+ await writeFile5(path, `${JSON.stringify(next, null, 2)}
1899
+ `, "utf8");
1900
+ }
1901
+ return { path, changed, existed: currentText !== undefined, planned: changed && options.dryRun === true };
1902
+ }
1903
+ };
1904
+ function mergeEntry(current, required) {
1905
+ const merged = { ...current, ...required };
1906
+ if (current?.env || required.env)
1907
+ merged.env = { ...current?.env, ...required.env };
1908
+ return merged;
1909
+ }
1910
+ function getParsedConfig(content, path) {
1911
+ if (!content?.trim())
1912
+ return { mcpServers: {} };
1913
+ try {
1914
+ const value = JSON.parse(content);
1915
+ if (!isRecord(value))
1916
+ throw new Error("not an object");
1917
+ const servers = value.mcpServers;
1918
+ if (servers !== undefined && !isRecord(servers))
1919
+ throw new Error("mcpServers is not an object");
1920
+ return { ...value, mcpServers: servers ?? {} };
1921
+ } catch {
1922
+ throw new Error(`Expected a valid pi-mcp-adapter configuration in ${path}.`);
1923
+ }
1924
+ }
1925
+ async function getOptionalFile2(path) {
1926
+ try {
1927
+ return await readFile8(path, "utf8");
1928
+ } catch (error) {
1929
+ if (error instanceof Error && "code" in error && error.code === "ENOENT")
1930
+ return;
1931
+ throw error;
1932
+ }
1933
+ }
1934
+ function isRecord(value) {
1935
+ return value !== null && typeof value === "object" && !Array.isArray(value);
1936
+ }
405
1937
  // src/modes.ts
1938
+ import {
1939
+ getAgentDir,
1940
+ parseFrontmatter as parseFrontmatter2
1941
+ } from "@earendil-works/pi-coding-agent";
1942
+ import { readFile as readFile9 } from "node:fs/promises";
1943
+ import { homedir as homedir6 } from "node:os";
1944
+ import { basename as basename4, extname, join as join11 } from "node:path";
406
1945
  async function discoverAgentModes(options) {
407
1946
  const agentDir = options.agentDir ?? getAgentDir();
408
- const homeDir = options.homeDir ?? homedir4();
1947
+ const homeDir = options.homeDir ?? homedir6();
409
1948
  const modes = new Map;
410
1949
  const diagnostics = [];
411
1950
  await loadAgentModes(options.bundledAgentsDir ?? BUNDLED_AGENTS_DIR, "diffpi agent", modes, diagnostics);
412
- await loadAgentModes(join6(agentDir, "agents"), "user agent", modes, diagnostics);
1951
+ await loadAgentModes(join11(agentDir, "agents"), "user agent", modes, diagnostics);
413
1952
  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);
1953
+ await loadSkillModes(join11(homeDir, ".agents", "skills"), "user skill", modes, diagnostics);
1954
+ await loadSkillModes(join11(agentDir, "skills"), "pi user skill", modes, diagnostics);
416
1955
  }
417
1956
  if (options.projectTrusted === true) {
418
1957
  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);
1958
+ await loadSkillModes(join11(options.cwd, ".agents", "skills"), "project skill", modes, diagnostics);
1959
+ await loadSkillModes(join11(options.cwd, ".pi", "skills"), "pi project skill", modes, diagnostics);
421
1960
  }
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);
1961
+ await loadAgentModes(join11(options.cwd, ".agents", "agents"), "project agent", modes, diagnostics);
1962
+ await loadAgentModes(join11(options.cwd, ".pi", "agents"), "pi project agent", modes, diagnostics);
424
1963
  }
425
1964
  const userConfig = await loadDiffpiConfig({ homeDir });
426
1965
  const configuredModes = [...modes.values()].map((mode) => ({
@@ -457,7 +1996,11 @@ function createModeController(pi, options = {}) {
457
1996
  let active;
458
1997
  let baseline;
459
1998
  const updateStatus = (ctx) => {
460
- ctx.ui.setStatus(MODE_STATUS_KEY, active ? `mode: ${active.id}` : undefined);
1999
+ const model = ctx.model ? ` · ${ctx.model.provider}/${ctx.model.id}` : "";
2000
+ const label = active?.label.toUpperCase() ?? "DEFAULT";
2001
+ const badge = `AGENT ${label}${model}`;
2002
+ ctx.ui.setStatus(MODE_STATUS_KEY, undefined);
2003
+ ctx.ui.setWidget?.(MODE_WIDGET_KEY, [badge], { placement: "belowEditor" });
461
2004
  };
462
2005
  const list = (ctx, listOptions = {}) => discoverAgentModes({
463
2006
  cwd: ctx.cwd,
@@ -512,6 +2055,9 @@ function createModeController(pi, options = {}) {
512
2055
  }
513
2056
  updateStatus(ctx);
514
2057
  },
2058
+ refresh(ctx) {
2059
+ updateStatus(ctx);
2060
+ },
515
2061
  apply(systemPrompt) {
516
2062
  if (!active)
517
2063
  return systemPrompt;
@@ -530,6 +2076,7 @@ ${active.systemPrompt}`;
530
2076
  }
531
2077
  var MODE_STATE_ENTRY = "diffpi-mode-state";
532
2078
  var MODE_STATUS_KEY = "diffpi-mode";
2079
+ var MODE_WIDGET_KEY = "diffpi-mode-badge";
533
2080
  var MODE_CONTROL_TOOLS = ["ask_user_question", "diffpi_modes_list", "diffpi_modes_set", "diffpi_modes_unset"];
534
2081
  var BUNDLED_AGENTS_DIR = resolveBundledAgentsDir();
535
2082
  var THINKING_LEVELS = new Set(["off", "minimal", "low", "medium", "high", "xhigh", "max"]);
@@ -583,19 +2130,19 @@ function captureRuntime(pi, ctx) {
583
2130
  async function loadSkillModes(skillsDir, source, modes, diagnostics) {
584
2131
  const entries = await readDirectoryIfExists(skillsDir);
585
2132
  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);
2133
+ await loadAgentModes(join11(skillsDir, entry.name, "agents"), `${source} ${entry.name}`, modes, diagnostics, entry.name);
587
2134
  }
588
2135
  }
589
2136
  async function loadAgentModes(directory, source, modes, diagnostics, skillName) {
590
2137
  const entries = await readDirectoryIfExists(directory);
591
2138
  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);
2139
+ const path = join11(directory, entry.name);
593
2140
  try {
594
- const content = await readFile4(path, "utf8");
2141
+ const content = await readFile9(path, "utf8");
595
2142
  const { frontmatter, body } = parseFrontmatter2(content.startsWith("\uFEFF") ? content.slice(1) : content);
596
2143
  if (frontmatter.enabled === false || frontmatter.inline === false)
597
2144
  continue;
598
- const name = getFrontmatterText(frontmatter.name) ?? basename2(path, extname(path));
2145
+ const name = getFrontmatterText(frontmatter.name) ?? basename4(path, extname(path));
599
2146
  const systemPrompt = body.trim();
600
2147
  if (!name || name.includes(":") || !systemPrompt) {
601
2148
  diagnostics.push(`Skipped ${path}: agent name must not contain ":" and prompt body is required.`);
@@ -605,7 +2152,7 @@ async function loadAgentModes(directory, source, modes, diagnostics, skillName)
605
2152
  modes.set(id, {
606
2153
  id,
607
2154
  label: getFrontmatterText(frontmatter.display_name) ?? name,
608
- description: getFrontmatterText(frontmatter.description) ?? `Inline agent from ${basename2(path)}`,
2155
+ description: getFrontmatterText(frontmatter.description) ?? `Inline agent from ${basename4(path)}`,
609
2156
  systemPrompt,
610
2157
  promptStrategy: frontmatter.prompt_mode === "append" ? "append" : "replace",
611
2158
  modelPreferences: [
@@ -647,9 +2194,9 @@ function isModeBaseline(value) {
647
2194
  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
2195
  }
649
2196
  // 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";
2197
+ import { mkdir as mkdir5, writeFile as writeFile6 } from "node:fs/promises";
2198
+ import { homedir as homedir7 } from "node:os";
2199
+ import { dirname as dirname6, join as join12 } from "node:path";
653
2200
  var pi = {
654
2201
  executableCheck: findPiExecutable,
655
2202
  packageList: listPiPackages,
@@ -662,19 +2209,19 @@ var pi = {
662
2209
  configEnsure: ensurePiConfig
663
2210
  };
664
2211
  async function ensurePiAgent(filename, content, agentDir = resolvePiAgentDir(), dryRun = false) {
665
- const path = join7(agentDir, "agents", filename);
2212
+ const path = join12(agentDir, "agents", filename);
666
2213
  const currentText = await readTextIfExists(path);
667
2214
  const changed = currentText !== content;
668
2215
  if (changed && !dryRun) {
669
- await mkdir3(dirname4(path), { recursive: true });
670
- await writeFile3(path, content, "utf8");
2216
+ await mkdir5(dirname6(path), { recursive: true });
2217
+ await writeFile6(path, content, "utf8");
671
2218
  }
672
2219
  return { path, changed, existed: currentText !== undefined, planned: changed && dryRun };
673
2220
  }
674
- async function checkGlobalPiSkill(name, agentDir = resolvePiAgentDir(), sharedSkillsDir = join7(homedir5(), ".agents", "skills")) {
675
- const roots = [join7(agentDir, "skills"), sharedSkillsDir];
2221
+ async function checkGlobalPiSkill(name, agentDir = resolvePiAgentDir(), sharedSkillsDir = join12(homedir7(), ".agents", "skills")) {
2222
+ const roots = [join12(agentDir, "skills"), sharedSkillsDir];
676
2223
  for (const root of roots) {
677
- if (await readTextIfExists(join7(root, name, "SKILL.md")) !== undefined)
2224
+ if (await readTextIfExists(join12(root, name, "SKILL.md")) !== undefined)
678
2225
  return true;
679
2226
  }
680
2227
  return false;
@@ -703,8 +2250,8 @@ async function ensurePiConfig(path, update, dryRun = false) {
703
2250
  const next = update(current);
704
2251
  const changed = JSON.stringify(current) !== JSON.stringify(next);
705
2252
  if (changed && !dryRun) {
706
- await mkdir3(dirname4(path), { recursive: true });
707
- await writeFile3(path, `${JSON.stringify(next, null, 2)}
2253
+ await mkdir5(dirname6(path), { recursive: true });
2254
+ await writeFile6(path, `${JSON.stringify(next, null, 2)}
708
2255
  `, "utf8");
709
2256
  }
710
2257
  return { path, changed, existed: currentText !== undefined, planned: changed && dryRun };
@@ -723,8 +2270,8 @@ function hasPiPackage(listOutput, source) {
723
2270
  async function installPiPackage(executable, source) {
724
2271
  await runChecked(executable, ["install", source]);
725
2272
  }
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"));
2273
+ function resolvePiAgentDir(homeDir = homedir7()) {
2274
+ return process.env.PI_CODING_AGENT_DIR ?? (process.env.XDG_CONFIG_HOME ? join12(process.env.XDG_CONFIG_HOME, "pi") : join12(homeDir, ".pi", "agent"));
728
2275
  }
729
2276
  function parseJsonObject(content, path) {
730
2277
  if (!content?.trim())
@@ -736,11 +2283,975 @@ function parseJsonObject(content, path) {
736
2283
  } catch {}
737
2284
  throw new Error(`Expected valid JSON object in ${path}.`);
738
2285
  }
2286
+ // src/plan/annotations.ts
2287
+ import { spawn as spawn2 } from "node:child_process";
2288
+ import { readFile as readFile12, rename as rename2, writeFile as writeFile8 } from "node:fs/promises";
2289
+ import { basename as basename5, dirname as dirname7, join as join14 } from "node:path";
2290
+
2291
+ // src/plan/log.ts
2292
+ import { randomUUID } from "node:crypto";
2293
+ import { appendFile, readFile as readFile10 } from "node:fs/promises";
2294
+ async function appendPlanLog(logPath, event) {
2295
+ const entry = {
2296
+ ...event,
2297
+ version: 1,
2298
+ eventId: event.eventId ?? randomUUID(),
2299
+ timestamp: event.timestamp ?? new Date().toISOString()
2300
+ };
2301
+ await appendFile(logPath, `${JSON.stringify(entry)}
2302
+ `, { encoding: "utf8", mode: 384 });
2303
+ return entry;
2304
+ }
2305
+ async function readPlanLog(logPath) {
2306
+ let source;
2307
+ try {
2308
+ source = await readFile10(logPath, "utf8");
2309
+ } catch (error) {
2310
+ if (error.code === "ENOENT")
2311
+ return [];
2312
+ throw error;
2313
+ }
2314
+ return source.split(`
2315
+ `).filter(Boolean).map((line, index) => {
2316
+ try {
2317
+ return JSON.parse(line);
2318
+ } catch {
2319
+ throw new Error(`Malformed plan log entry at line ${index + 1}.`);
2320
+ }
2321
+ });
2322
+ }
2323
+
2324
+ // src/plan/lock.ts
2325
+ import { randomUUID as randomUUID2 } from "node:crypto";
2326
+ import { mkdir as mkdir6, readFile as readFile11, rm, writeFile as writeFile7 } from "node:fs/promises";
2327
+ import { hostname } from "node:os";
2328
+ import { join as join13 } from "node:path";
2329
+ async function withPlanLock(planDir, operation, options = {}) {
2330
+ const lockDir = join13(planDir, ".lock");
2331
+ const waitMs = options.waitMs ?? 5000;
2332
+ const pollMs = options.pollMs ?? 25;
2333
+ const owner = {
2334
+ token: randomUUID2(),
2335
+ pid: process.pid,
2336
+ hostname: hostname(),
2337
+ operation: options.operation ?? "plan mutation",
2338
+ acquiredAt: new Date().toISOString()
2339
+ };
2340
+ const started = Date.now();
2341
+ while (true) {
2342
+ try {
2343
+ await mkdir6(lockDir);
2344
+ await writeFile7(join13(lockDir, "owner.json"), `${JSON.stringify(owner)}
2345
+ `, { mode: 384 });
2346
+ break;
2347
+ } catch (error) {
2348
+ if (error.code !== "EEXIST")
2349
+ throw error;
2350
+ if (Date.now() - started >= waitMs) {
2351
+ let existing = "unknown owner";
2352
+ try {
2353
+ existing = (await readFile11(join13(lockDir, "owner.json"), "utf8")).trim();
2354
+ } catch {}
2355
+ throw new Error(`Timed out waiting for plan lock ${lockDir}; owner: ${existing}. Remove it only after confirming the owner is stale.`);
2356
+ }
2357
+ await new Promise((resolve) => setTimeout(resolve, pollMs));
2358
+ }
2359
+ }
2360
+ try {
2361
+ return await operation();
2362
+ } finally {
2363
+ let current;
2364
+ try {
2365
+ current = JSON.parse(await readFile11(join13(lockDir, "owner.json"), "utf8"));
2366
+ } catch {}
2367
+ if (current?.token === owner.token)
2368
+ await rm(lockDir, { recursive: true, force: true });
2369
+ }
2370
+ }
2371
+
2372
+ // src/plan/annotations.ts
2373
+ async function annotatePlan(record, runtime = {}) {
2374
+ const execute = runtime.execute ?? executeProcess;
2375
+ const result = await execute("tuicr", ["--file", record.planPath], dirname7(record.planPath), true);
2376
+ const sessionSlug = [...result.stderr.matchAll(/^tuicr-session:\s*(\S+)\s*$/gm)].at(-1)?.[1];
2377
+ if (result.code !== 0)
2378
+ throw new Error(result.stderr.trim() || `tuicr exited with status ${result.code}.`);
2379
+ if (!sessionSlug)
2380
+ throw new Error("tuicr did not report a tuicr-session marker.");
2381
+ const state = await withPlanLock(record.dir, async () => {
2382
+ const previous = await readAnnotationState(record).catch(() => {
2383
+ return;
2384
+ });
2385
+ const next = {
2386
+ schemaVersion: 1,
2387
+ sessionSlug,
2388
+ updatedAt: new Date().toISOString(),
2389
+ appliedCommentIds: previous?.appliedCommentIds ?? []
2390
+ };
2391
+ await atomicJson(annotationStatePath(record), next);
2392
+ return next;
2393
+ }, { operation: "save annotation session" });
2394
+ return { sessionSlug, code: result.code, state };
2395
+ }
2396
+ async function readPlanAnnotations(record, options = {}) {
2397
+ const state = await readAnnotationState(record).catch((error) => {
2398
+ if (error.code === "ENOENT")
2399
+ return;
2400
+ throw error;
2401
+ });
2402
+ if (!state)
2403
+ return { comments: [], pending: [] };
2404
+ const execute = options.runtime?.execute ?? executeProcess;
2405
+ const response = await execute("tuicr", ["review", "comments", "--session", state.sessionSlug], record.dir, false);
2406
+ if (response.code !== 0) {
2407
+ if (/not found|no session|deleted/i.test(response.stderr))
2408
+ return { state, comments: [], pending: [] };
2409
+ throw new Error(response.stderr.trim() || "Cannot read tuicr comments.");
2410
+ }
2411
+ let raw;
2412
+ try {
2413
+ raw = JSON.parse(response.stdout);
2414
+ } catch {
2415
+ throw new Error("tuicr returned malformed comment JSON.");
2416
+ }
2417
+ if (!Array.isArray(raw))
2418
+ throw new Error("tuicr comment output must be a JSON array.");
2419
+ const lines = record.source.split(`
2420
+ `);
2421
+ const applied = new Set(state.appliedCommentIds);
2422
+ const comments = raw.map((value, index) => normalizeComment(value, index, lines, applied));
2423
+ const pending = comments.filter((comment) => !comment.applied);
2424
+ return { state, comments: options.includeApplied ? comments : pending, pending };
2425
+ }
2426
+ async function acknowledgePlanAnnotations(record, commentIds, summary) {
2427
+ if (!summary.trim())
2428
+ throw new Error("Annotation acknowledgement summary is required.");
2429
+ return withPlanLock(record.dir, async () => {
2430
+ const state = await readAnnotationState(record);
2431
+ const next = {
2432
+ ...state,
2433
+ updatedAt: new Date().toISOString(),
2434
+ appliedCommentIds: [...new Set([...state.appliedCommentIds, ...commentIds])]
2435
+ };
2436
+ await atomicJson(annotationStatePath(record), next);
2437
+ await appendPlanLog(record.logPath, {
2438
+ planRevision: record.document.revision,
2439
+ kind: "annotation",
2440
+ actor: "planner",
2441
+ message: summary,
2442
+ data: { commentIds: [...commentIds], sessionSlug: state.sessionSlug }
2443
+ });
2444
+ return next;
2445
+ }, { operation: "acknowledge annotations" });
2446
+ }
2447
+ function annotationStatePath(record) {
2448
+ return join14(record.dir, "annotations.json");
2449
+ }
2450
+ async function readAnnotationState(record) {
2451
+ const value = JSON.parse(await readFile12(annotationStatePath(record), "utf8"));
2452
+ if (value.schemaVersion !== 1 || typeof value.sessionSlug !== "string" || !Array.isArray(value.appliedCommentIds)) {
2453
+ throw new Error(`Malformed annotation state: ${annotationStatePath(record)}.`);
2454
+ }
2455
+ return value;
2456
+ }
2457
+ function normalizeComment(value, index, lines, applied) {
2458
+ if (!value || typeof value !== "object")
2459
+ throw new Error(`Malformed tuicr comment at index ${index}.`);
2460
+ const raw = value;
2461
+ if (typeof raw.id !== "string" || typeof raw.content !== "string")
2462
+ throw new Error(`Malformed tuicr comment at index ${index}.`);
2463
+ const line = integer(raw.start_line) ?? integer(raw.line);
2464
+ const endLine = integer(raw.end_line) ?? line;
2465
+ const targetPath = typeof raw.path === "string" ? raw.path : undefined;
2466
+ const appliesToPlan = !targetPath || basename5(targetPath) === basename5("PLAN.md");
2467
+ const validAnchor = appliesToPlan && line !== undefined && line > 0 && line <= lines.length;
2468
+ return {
2469
+ id: raw.id,
2470
+ body: raw.content,
2471
+ file: targetPath,
2472
+ line,
2473
+ endLine,
2474
+ context: validAnchor ? lines.slice(line - 1, Math.min(endLine ?? line, lines.length)).join(`
2475
+ `) : undefined,
2476
+ stale: line !== undefined && !validAnchor,
2477
+ applied: applied.has(raw.id)
2478
+ };
2479
+ }
2480
+ async function executeProcess(command, args, cwd, interactive) {
2481
+ if (!interactive) {
2482
+ const result = await run(command, args, { cwd, capture: "unbounded" });
2483
+ return { code: result.code, stdout: result.stdout, stderr: result.stderr };
2484
+ }
2485
+ return new Promise((resolve, reject) => {
2486
+ const child = spawn2(command, args, { cwd, stdio: ["inherit", "inherit", "pipe"] });
2487
+ let stderr = "";
2488
+ child.stderr.on("data", (chunk) => {
2489
+ const text = chunk.toString();
2490
+ stderr += text;
2491
+ process.stderr.write(text);
2492
+ });
2493
+ child.on("error", reject);
2494
+ child.on("close", (code) => resolve({ code: code ?? 1, stdout: "", stderr }));
2495
+ });
2496
+ }
2497
+ async function atomicJson(path, value) {
2498
+ const temp = join14(dirname7(path), `.${basename5(path)}.${crypto.randomUUID()}.tmp`);
2499
+ await writeFile8(temp, `${JSON.stringify(value, null, 2)}
2500
+ `, { mode: 384, flag: "wx" });
2501
+ await rename2(temp, path);
2502
+ }
2503
+ function integer(value) {
2504
+ return typeof value === "number" && Number.isInteger(value) ? value : undefined;
2505
+ }
2506
+ // src/plan/escalation.ts
2507
+ import { z as z5 } from "zod";
2508
+ var escalationSchema = z5.object({
2509
+ planId: z5.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),
2510
+ executionId: z5.string().min(1),
2511
+ phaseId: z5.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),
2512
+ taskId: z5.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/).optional(),
2513
+ blocker: z5.string().min(1),
2514
+ attempts: z5.array(z5.string()),
2515
+ evidence: z5.array(z5.string()),
2516
+ needsUserDecision: z5.boolean()
2517
+ }).strict();
2518
+ var OPEN = "<diffpi-planner-escalation>";
2519
+ var CLOSE = "</diffpi-planner-escalation>";
2520
+ function renderPlannerEscalation(value) {
2521
+ return `${OPEN}${JSON.stringify(escalationSchema.parse(value))}${CLOSE}`;
2522
+ }
2523
+ function parsePlannerEscalation(output) {
2524
+ const start = output.indexOf(OPEN);
2525
+ const end = output.indexOf(CLOSE, start + OPEN.length);
2526
+ if (start < 0 || end < 0 || output.indexOf(OPEN, start + OPEN.length) >= 0)
2527
+ throw new Error("Missing or ambiguous planner escalation payload.");
2528
+ const payload = output.slice(start + OPEN.length, end);
2529
+ try {
2530
+ return escalationSchema.parse(JSON.parse(payload));
2531
+ } catch (error) {
2532
+ throw new Error(`Malformed planner escalation payload: ${error instanceof Error ? error.message : String(error)}`);
2533
+ }
2534
+ }
2535
+ // src/plan/execution.ts
2536
+ function createExecutionPacket(plan, coordinator) {
2537
+ if (!plan.execution?.active)
2538
+ throw new Error(`Plan ${plan.id} has no active execution.`);
2539
+ return {
2540
+ version: 1,
2541
+ planId: plan.id,
2542
+ executionId: plan.execution.id,
2543
+ policy: plan.execution.policy,
2544
+ cwd: plan.execution.cwd,
2545
+ branch: plan.execution.branch,
2546
+ coordinator
2547
+ };
2548
+ }
2549
+ function renderExecutionPrompt(packet) {
2550
+ return `Execute the durable Diffpi plan using this packet: ${JSON.stringify(packet)}. Call plan_context first. Coordinate eligible work, persist every transition and progress event, run phase gates, and honor the commit policy. Do not edit PLAN.md directly. Delegated workers never commit or restructure the plan.`;
2551
+ }
2552
+ function eligiblePlanTasks(plan) {
2553
+ const completedPhases = new Set(plan.phases.filter((phase) => phase.status === "completed" || phase.status === "skipped").map((phase) => phase.id));
2554
+ const completedTasks = new Set(plan.phases.flatMap((phase) => phase.tasks).filter((task) => task.status === "completed" || task.status === "skipped").map((task) => task.id));
2555
+ for (const phase of plan.phases) {
2556
+ if (phase.status === "completed" || phase.status === "skipped")
2557
+ continue;
2558
+ if (!phase.dependencies.every((dependency) => completedPhases.has(dependency)))
2559
+ continue;
2560
+ return phase.tasks.filter((task) => task.status === "pending" && task.dependencies.every((dependency) => completedTasks.has(dependency))).map((task) => ({ phase, task }));
2561
+ }
2562
+ return [];
2563
+ }
2564
+ function tasksMayRunInParallel(left, right) {
2565
+ if (!left.fileScopes.length || !right.fileScopes.length)
2566
+ return false;
2567
+ if (left.dependencies.includes(right.id) || right.dependencies.includes(left.id))
2568
+ return false;
2569
+ return left.fileScopes.every((scope) => right.fileScopes.every((other) => !scopesOverlap(scope, other)));
2570
+ }
2571
+ function phaseCommitCommand(policy) {
2572
+ return policy === "commit-per-phase" ? "/git commit --yes --no-push" : undefined;
2573
+ }
2574
+ function scopesOverlap(left, right) {
2575
+ const normalize = (value) => value.replace(/^\.\//, "").replace(/\*.*$/, "").replace(/\/$/, "");
2576
+ const a = normalize(left);
2577
+ const b = normalize(right);
2578
+ return !a || !b || a === b || a.startsWith(`${b}/`) || b.startsWith(`${a}/`);
2579
+ }
2580
+ // src/plan/transitions.ts
2581
+ var STABLE_ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
2582
+ var PLAN_TRANSITIONS = {
2583
+ draft: ["ready"],
2584
+ ready: ["draft", "in_progress"],
2585
+ in_progress: ["blocked", "completed"],
2586
+ blocked: ["draft", "ready", "in_progress"],
2587
+ completed: []
2588
+ };
2589
+ var WORK_TRANSITIONS = {
2590
+ pending: ["in_progress", "skipped"],
2591
+ in_progress: ["blocked", "completed"],
2592
+ blocked: ["pending", "in_progress", "skipped"],
2593
+ completed: [],
2594
+ skipped: []
2595
+ };
2596
+ function isStableId(value) {
2597
+ return STABLE_ID.test(value) && value.length <= 80;
2598
+ }
2599
+ function assertStableId(value, label = "identifier") {
2600
+ if (!isStableId(value))
2601
+ throw new Error(`${label} must be a lowercase stable slug, not a path: ${value}`);
2602
+ }
2603
+ function assertPlanTransition(from, to) {
2604
+ if (from === to)
2605
+ return;
2606
+ if (!PLAN_TRANSITIONS[from].includes(to))
2607
+ throw new Error(`Invalid plan status transition: ${from} -> ${to}.`);
2608
+ }
2609
+ function assertPhaseTransition(from, to) {
2610
+ if (from === to)
2611
+ return;
2612
+ if (!WORK_TRANSITIONS[from].includes(to))
2613
+ throw new Error(`Invalid phase status transition: ${from} -> ${to}.`);
2614
+ }
2615
+ function assertTaskTransition(task, to, executionId, actor) {
2616
+ if (task.status !== to && !WORK_TRANSITIONS[task.status].includes(to)) {
2617
+ throw new Error(`Invalid task status transition: ${task.status} -> ${to}.`);
2618
+ }
2619
+ if (task.status === "in_progress" && task.executionId && executionId !== task.executionId) {
2620
+ throw new Error(`Task ${task.id} is owned by execution ${task.executionId}.`);
2621
+ }
2622
+ if (task.status === "in_progress" && task.owner && actor && actor !== task.owner) {
2623
+ throw new Error(`Task ${task.id} is owned by ${task.owner}.`);
2624
+ }
2625
+ }
2626
+ function assertUniqueIds(ids, label) {
2627
+ const seen = new Set;
2628
+ for (const id of ids) {
2629
+ assertStableId(id, label);
2630
+ if (seen.has(id))
2631
+ throw new Error(`Duplicate ${label}: ${id}.`);
2632
+ seen.add(id);
2633
+ }
2634
+ }
2635
+
2636
+ // src/plan/markdown.ts
2637
+ var PLAN_MARKER = /<!-- diffpi-plan: (\{[^\n]+\}) -->/;
2638
+ var PHASE_MARKER = /<!-- diffpi-phase: (\{[^\n]+\}) -->/g;
2639
+ var TASK_MARKER = /<!-- diffpi-task: (\{[^\n]+\}) -->/g;
2640
+ var SUPPORTED_SCHEMA = 1;
2641
+ function countDesignWords(plan) {
2642
+ return [plan.design.bigIdeas, plan.design.keyApiUpdates, plan.design.consequences].join(" ").trim().split(/\s+/).filter(Boolean).length;
2643
+ }
2644
+ function validatePlanDocument(plan, options = {}) {
2645
+ const issues = [];
2646
+ const add = (code, severity, message, path) => issues.push({ code, severity, message, path });
2647
+ try {
2648
+ assertStableId(plan.id, "plan id");
2649
+ assertUniqueIds(plan.phases.map((phase) => phase.id), "phase id");
2650
+ assertUniqueIds(plan.phases.flatMap((phase) => phase.tasks.map((task) => task.id)), "task id");
2651
+ } catch (error) {
2652
+ add("invalid-id", "error", error.message);
2653
+ }
2654
+ if (plan.schemaVersion !== SUPPORTED_SCHEMA)
2655
+ add("schema", "error", `Unsupported plan schema: ${plan.schemaVersion}.`);
2656
+ const phaseIds = new Set(plan.phases.map((phase) => phase.id));
2657
+ const taskIds = new Set(plan.phases.flatMap((phase) => phase.tasks.map((task) => task.id)));
2658
+ for (const phase of plan.phases) {
2659
+ for (const dependency of phase.dependencies) {
2660
+ if (!phaseIds.has(dependency))
2661
+ add("dangling-dependency", "error", `Phase ${phase.id} depends on missing ${dependency}.`);
2662
+ }
2663
+ for (const task of phase.tasks) {
2664
+ for (const dependency of task.dependencies) {
2665
+ if (!taskIds.has(dependency))
2666
+ add("dangling-dependency", "error", `Task ${task.id} depends on missing ${dependency}.`);
2667
+ }
2668
+ if (options.strict && task.acceptanceCriteria.length === 0)
2669
+ add("acceptance", "error", `Task ${task.id} has no acceptance criteria.`, task.id);
2670
+ }
2671
+ if (options.strict && phase.tasks.length === 0)
2672
+ add("empty-phase", "error", `Phase ${phase.id} has no tasks.`, phase.id);
2673
+ }
2674
+ for (const cycle of dependencyCycles(plan))
2675
+ add("dependency-cycle", "error", `Dependency cycle: ${cycle.join(" -> ")}.`);
2676
+ const designWords = countDesignWords(plan);
2677
+ if (designWords > 300)
2678
+ add("design-length", "warning", `Design is ${designWords} words; prefer 300 or fewer.`);
2679
+ if (options.strict && designWords > 800)
2680
+ add("design-too-long", "error", `Design is ${designWords} words; finalization allows at most 800.`);
2681
+ if (options.strict && plan.phases.length === 0)
2682
+ add("no-phases", "error", "Finalization requires at least one phase.");
2683
+ if (options.strict && (options.pendingAnnotations ?? 0) > 0)
2684
+ add("pending-annotations", "error", `${options.pendingAnnotations} annotations remain pending.`);
2685
+ return issues;
2686
+ }
2687
+ function renderPlanDocument(plan, previousSource) {
2688
+ if (previousSource && canPatch(previousSource, plan))
2689
+ return patchMarkers(previousSource, plan);
2690
+ const marker = {
2691
+ schemaVersion: plan.schemaVersion,
2692
+ id: plan.id,
2693
+ revision: plan.revision,
2694
+ branch: plan.branch,
2695
+ status: plan.status,
2696
+ execution: plan.execution,
2697
+ createdAt: plan.createdAt,
2698
+ updatedAt: plan.updatedAt
2699
+ };
2700
+ const requirements = plan.requirements.length ? plan.requirements.map((item) => `- ${item}`).join(`
2701
+ `) : "<!-- Add requirements. -->";
2702
+ const phases = plan.phases.map(renderPhase).join(`
2703
+
2704
+ `);
2705
+ const references = plan.references.length ? plan.references.map((reference) => `- <!-- diffpi-reference: ${json(reference)} --> ${reference.value}`).join(`
2706
+ `) : "<!-- Add references. -->";
2707
+ return `<!-- diffpi-plan: ${json(marker)} -->
2708
+ # ${plan.title}
2709
+
2710
+ - **Plan ID:** ${plan.id}
2711
+ - **Branch:** ${plan.branch}
2712
+ - **Status:** ${plan.status}
2713
+ - **Revision:** ${plan.revision}
2714
+
2715
+ ## Intent
2716
+
2717
+ ${plan.intent || "<!-- Describe the intended outcome. -->"}
2718
+
2719
+ ## Requirements
2720
+
2721
+ ${requirements}
2722
+
2723
+ ## Design
2724
+
2725
+ ### Big Ideas
2726
+
2727
+ ${plan.design.bigIdeas || "<!-- Describe the main approach. -->"}
2728
+
2729
+ ### Key API Addition/Updates
2730
+
2731
+ ${plan.design.keyApiUpdates || "<!-- Describe public API changes. -->"}
2732
+
2733
+ ### Consequences
2734
+
2735
+ ${plan.design.consequences || "<!-- Describe trade-offs and limitations. -->"}
2736
+
2737
+ ## Implementation
2738
+
2739
+ ${phases || "<!-- Add phases with plan_add_phase. -->"}
2740
+
2741
+ ## References
2742
+
2743
+ ${references}
2744
+ `;
2745
+ }
2746
+ function parsePlanDocument(source, ref = "PLAN.md") {
2747
+ const planMatch = source.match(PLAN_MARKER);
2748
+ if (!planMatch)
2749
+ throw new Error(`${ref}: missing or malformed diffpi-plan marker.`);
2750
+ if ((source.match(new RegExp(PLAN_MARKER.source, "g")) ?? []).length !== 1)
2751
+ throw new Error(`${ref}: duplicate diffpi-plan marker.`);
2752
+ const marker = parseMarker(planMatch[1], `${ref} plan`);
2753
+ if (marker.schemaVersion !== SUPPORTED_SCHEMA)
2754
+ throw new Error(`${ref}: unsupported plan schema ${String(marker.schemaVersion)}.`);
2755
+ assertStableId(marker.id, "plan id");
2756
+ const title = source.match(/^# (.+)$/m)?.[1]?.trim();
2757
+ if (!title)
2758
+ throw new Error(`${ref}: missing plan title.`);
2759
+ const phases = parsePhases(section(source, "Implementation"), ref);
2760
+ const document = {
2761
+ ...marker,
2762
+ schemaVersion: 1,
2763
+ title,
2764
+ intent: cleanPlaceholder(section(source, "Intent")),
2765
+ requirements: parseBullets(section(source, "Requirements")),
2766
+ design: {
2767
+ bigIdeas: cleanPlaceholder(subsection(source, "Design", "Big Ideas")),
2768
+ keyApiUpdates: cleanPlaceholder(subsection(source, "Design", "Key API Addition/Updates")),
2769
+ consequences: cleanPlaceholder(subsection(source, "Design", "Consequences"))
2770
+ },
2771
+ phases,
2772
+ references: parseReferences(section(source, "References"))
2773
+ };
2774
+ const errors = validatePlanDocument(document).filter((issue) => issue.severity === "error");
2775
+ if (errors.length)
2776
+ throw new Error(`${ref}: ${errors.map((issue) => issue.message).join(" ")}`);
2777
+ return document;
2778
+ }
2779
+ function renderPhase(phase) {
2780
+ const marker = {
2781
+ id: phase.id,
2782
+ revision: phase.revision,
2783
+ status: phase.status,
2784
+ gate: phase.gate,
2785
+ commit: phase.commit,
2786
+ blocker: phase.blocker
2787
+ };
2788
+ const dependencies = phase.dependencies.length ? phase.dependencies.join(", ") : "none";
2789
+ return `<!-- diffpi-phase: ${json(marker)} -->
2790
+ ### Phase: ${phase.title}
2791
+
2792
+ **Objective:** ${phase.objective}
2793
+
2794
+ **Dependencies:** ${dependencies}
2795
+
2796
+ ${phase.tasks.map(renderTask).join(`
2797
+
2798
+ `)}
2799
+
2800
+ <!-- /diffpi-phase -->`;
2801
+ }
2802
+ function renderTask(task) {
2803
+ const marker = {
2804
+ id: task.id,
2805
+ revision: task.revision,
2806
+ status: task.status,
2807
+ owner: task.owner,
2808
+ executionId: task.executionId,
2809
+ blocker: task.blocker
2810
+ };
2811
+ const checked = task.status === "completed" || task.status === "skipped" ? "x" : " ";
2812
+ return `<!-- diffpi-task: ${json(marker)} -->
2813
+ - [${checked}] **${task.title}**
2814
+ - Steps: ${list(task.steps ?? [])}
2815
+ - Dependencies: ${list(task.dependencies)}
2816
+ - File scopes: ${list(task.fileScopes)}
2817
+ - Acceptance criteria: ${list(task.acceptanceCriteria)}
2818
+ <!-- /diffpi-task -->`;
2819
+ }
2820
+ function parsePhases(input, ref) {
2821
+ const starts = [...input.matchAll(PHASE_MARKER)];
2822
+ const phases = starts.map((match, index) => {
2823
+ const start = match.index;
2824
+ const end = input.indexOf("<!-- /diffpi-phase -->", start);
2825
+ if (end < 0)
2826
+ throw new Error(`${ref}: phase marker has no closing marker.`);
2827
+ const next = starts[index + 1]?.index;
2828
+ if (next !== undefined && next < end)
2829
+ throw new Error(`${ref}: nested or unclosed phase marker.`);
2830
+ const body = input.slice(start + match[0].length, end);
2831
+ const marker = parseMarker(match[1], `${ref} phase`);
2832
+ assertStableId(marker.id, "phase id");
2833
+ const title = body.match(/^### Phase: (.+)$/m)?.[1]?.trim();
2834
+ const objective = body.match(/^\*\*Objective:\*\*\s*(.*)$/m)?.[1]?.trim();
2835
+ if (!title || !objective)
2836
+ throw new Error(`${ref}: phase ${marker.id} is missing title or objective.`);
2837
+ return {
2838
+ ...marker,
2839
+ title,
2840
+ objective,
2841
+ dependencies: parseCsv(body.match(/^\*\*Dependencies:\*\*\s*(.*)$/m)?.[1]),
2842
+ tasks: parseTasks(body, ref)
2843
+ };
2844
+ });
2845
+ assertUniqueIds(phases.map((phase) => phase.id), "phase id");
2846
+ return phases;
2847
+ }
2848
+ function parseTasks(input, ref) {
2849
+ const starts = [...input.matchAll(TASK_MARKER)];
2850
+ const tasks = starts.map((match, index) => {
2851
+ const start = match.index;
2852
+ const end = input.indexOf("<!-- /diffpi-task -->", start);
2853
+ if (end < 0)
2854
+ throw new Error(`${ref}: task marker has no closing marker.`);
2855
+ const next = starts[index + 1]?.index;
2856
+ if (next !== undefined && next < end)
2857
+ throw new Error(`${ref}: nested or unclosed task marker.`);
2858
+ const body = input.slice(start + match[0].length, end);
2859
+ const marker = parseMarker(match[1], `${ref} task`);
2860
+ assertStableId(marker.id, "task id");
2861
+ const title = body.match(/^- \[[ xX]\] \*\*(.+)\*\*$/m)?.[1]?.trim();
2862
+ if (!title)
2863
+ throw new Error(`${ref}: task ${marker.id} is missing its checkbox title.`);
2864
+ return {
2865
+ ...marker,
2866
+ title,
2867
+ steps: parseListValue(body, "Steps"),
2868
+ dependencies: parseListValue(body, "Dependencies"),
2869
+ fileScopes: parseListValue(body, "File scopes"),
2870
+ acceptanceCriteria: parseListValue(body, "Acceptance criteria")
2871
+ };
2872
+ });
2873
+ assertUniqueIds(tasks.map((task) => task.id), "task id");
2874
+ return tasks;
2875
+ }
2876
+ function parseReferences(input) {
2877
+ return [...input.matchAll(/^- <!-- diffpi-reference: (\{[^\n]+\}) -->\s*(.*)$/gm)].map((match) => {
2878
+ const marker = parseMarker(match[1], "reference");
2879
+ assertStableId(marker.id, "reference id");
2880
+ return { id: marker.id, value: match[2].trim() || marker.value };
2881
+ });
2882
+ }
2883
+ function canPatch(source, next) {
2884
+ try {
2885
+ const old = parsePlanDocument(source);
2886
+ return JSON.stringify(contentShape(old)) === JSON.stringify(contentShape(next));
2887
+ } catch {
2888
+ return false;
2889
+ }
2890
+ }
2891
+ function patchMarkers(source, plan) {
2892
+ const planMarker = {
2893
+ schemaVersion: plan.schemaVersion,
2894
+ id: plan.id,
2895
+ revision: plan.revision,
2896
+ branch: plan.branch,
2897
+ status: plan.status,
2898
+ execution: plan.execution,
2899
+ createdAt: plan.createdAt,
2900
+ updatedAt: plan.updatedAt
2901
+ };
2902
+ let output = source.replace(PLAN_MARKER, `<!-- diffpi-plan: ${json(planMarker)} -->`);
2903
+ output = output.replace(/^- \*\*Status:\*\* .*$/m, `- **Status:** ${plan.status}`);
2904
+ output = output.replace(/^- \*\*Revision:\*\* .*$/m, `- **Revision:** ${plan.revision}`);
2905
+ for (const phase of plan.phases) {
2906
+ const marker = {
2907
+ id: phase.id,
2908
+ revision: phase.revision,
2909
+ status: phase.status,
2910
+ gate: phase.gate,
2911
+ commit: phase.commit,
2912
+ blocker: phase.blocker
2913
+ };
2914
+ output = replaceMarkerById(output, "phase", phase.id, marker);
2915
+ for (const task of phase.tasks) {
2916
+ const taskMarker = {
2917
+ id: task.id,
2918
+ revision: task.revision,
2919
+ status: task.status,
2920
+ owner: task.owner,
2921
+ executionId: task.executionId,
2922
+ blocker: task.blocker
2923
+ };
2924
+ output = replaceMarkerById(output, "task", task.id, taskMarker);
2925
+ const checked = task.status === "completed" || task.status === "skipped" ? "x" : " ";
2926
+ const escaped = escapeRegExp(task.title);
2927
+ output = output.replace(new RegExp(`^- \\[[ xX]\\] \\*\\*${escaped}\\*\\*$`, "m"), `- [${checked}] **${task.title}**`);
2928
+ }
2929
+ }
2930
+ return output;
2931
+ }
2932
+ function replaceMarkerById(source, kind, id, marker) {
2933
+ const pattern = new RegExp(`<!-- diffpi-${kind}: \\{[^\\n]*"id":"${escapeRegExp(id)}"[^\\n]*\\} -->`);
2934
+ if (!pattern.test(source))
2935
+ throw new Error(`Cannot update missing ${kind} marker ${id}.`);
2936
+ return source.replace(pattern, `<!-- diffpi-${kind}: ${json(marker)} -->`);
2937
+ }
2938
+ function contentShape(plan) {
2939
+ return {
2940
+ title: plan.title,
2941
+ branch: plan.branch,
2942
+ intent: plan.intent,
2943
+ requirements: plan.requirements,
2944
+ design: plan.design,
2945
+ references: plan.references,
2946
+ phases: plan.phases.map(({ id, title, objective, dependencies, tasks }) => ({
2947
+ id,
2948
+ title,
2949
+ objective,
2950
+ dependencies,
2951
+ tasks: tasks.map(({ id: taskId, title: taskTitle, steps, dependencies: taskDependencies, fileScopes, acceptanceCriteria }) => ({
2952
+ id: taskId,
2953
+ title: taskTitle,
2954
+ steps,
2955
+ dependencies: taskDependencies,
2956
+ fileScopes,
2957
+ acceptanceCriteria
2958
+ }))
2959
+ }))
2960
+ };
2961
+ }
2962
+ function dependencyCycles(plan) {
2963
+ const graph = new Map;
2964
+ for (const phase of plan.phases)
2965
+ graph.set(phase.id, phase.dependencies);
2966
+ for (const task of plan.phases.flatMap((phase) => phase.tasks))
2967
+ graph.set(task.id, task.dependencies);
2968
+ const cycles = [];
2969
+ const visiting = new Set;
2970
+ const visited = new Set;
2971
+ const walk = (id, path) => {
2972
+ if (visiting.has(id)) {
2973
+ cycles.push([...path.slice(path.indexOf(id)), id]);
2974
+ return;
2975
+ }
2976
+ if (visited.has(id))
2977
+ return;
2978
+ visiting.add(id);
2979
+ for (const dependency of graph.get(id) ?? [])
2980
+ walk(dependency, [...path, id]);
2981
+ visiting.delete(id);
2982
+ visited.add(id);
2983
+ };
2984
+ for (const id of graph.keys())
2985
+ walk(id, []);
2986
+ return cycles;
2987
+ }
2988
+ function section(source, heading) {
2989
+ const match = source.match(new RegExp(`^## ${escapeRegExp(heading)}\\s*$`, "m"));
2990
+ if (!match?.index) {
2991
+ if (match?.index === 0)
2992
+ return "";
2993
+ throw new Error(`Missing required heading: ${heading}.`);
2994
+ }
2995
+ const start = match.index + match[0].length;
2996
+ const rest = source.slice(start);
2997
+ const end = rest.search(/^## /m);
2998
+ return (end < 0 ? rest : rest.slice(0, end)).trim();
2999
+ }
3000
+ function subsection(source, parent, heading) {
3001
+ const body = section(source, parent);
3002
+ const match = body.match(new RegExp(`^### ${escapeRegExp(heading)}\\s*$`, "m"));
3003
+ if (match?.index === undefined)
3004
+ throw new Error(`Missing required heading: ${parent}/${heading}.`);
3005
+ const rest = body.slice(match.index + match[0].length);
3006
+ const end = rest.search(/^### /m);
3007
+ return (end < 0 ? rest : rest.slice(0, end)).trim();
3008
+ }
3009
+ function parseBullets(input) {
3010
+ return [...input.matchAll(/^- (?!<!--)(.+)$/gm)].map((match) => match[1].trim());
3011
+ }
3012
+ function parseListValue(body, label) {
3013
+ const value = body.match(new RegExp(`^ - ${escapeRegExp(label)}:\\s*(.*)$`, "m"))?.[1];
3014
+ return parseCsv(value);
3015
+ }
3016
+ function parseCsv(value) {
3017
+ if (!value || value.trim().toLowerCase() === "none")
3018
+ return [];
3019
+ return value.split(",").map((item) => item.trim()).filter(Boolean);
3020
+ }
3021
+ function cleanPlaceholder(value) {
3022
+ return value.replace(/<!--[^]*?-->/g, "").trim();
3023
+ }
3024
+ function parseMarker(value, label) {
3025
+ try {
3026
+ return JSON.parse(value);
3027
+ } catch {
3028
+ throw new Error(`Malformed ${label} marker JSON.`);
3029
+ }
3030
+ }
3031
+ function list(values) {
3032
+ return values.length ? values.join(", ") : "none";
3033
+ }
3034
+ function json(value) {
3035
+ return JSON.stringify(value, (_key, entry) => entry === undefined ? undefined : entry);
3036
+ }
3037
+ function escapeRegExp(value) {
3038
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
3039
+ }
3040
+ // src/plan/store.ts
3041
+ import { open, mkdir as mkdir7, readdir as readdir2, readFile as readFile14, realpath as realpath4, rename as rename3, rm as rm2 } from "node:fs/promises";
3042
+ import { basename as basename6, dirname as dirname8, join as join16, resolve as resolve4, sep } from "node:path";
3043
+
3044
+ // src/templates.ts
3045
+ import { readFile as readFile13 } from "node:fs/promises";
3046
+ import { homedir as homedir8 } from "node:os";
3047
+ import { join as join15, normalize } from "node:path";
3048
+ async function loadTemplate(name, options = {}) {
3049
+ const relative = templateRelativePath(name);
3050
+ const userPath = join15(options.homeDir ?? homedir8(), ".difflab", "diffpi", "templates", relative);
3051
+ const bundledPath = join15(options.bundledDir ?? resolveBundledTemplatesDir(), relative);
3052
+ const user = await readOptionalFile(userPath);
3053
+ if (user !== undefined)
3054
+ return { name, path: userPath, source: "user", content: user };
3055
+ const bundled = await readOptionalFile(bundledPath);
3056
+ if (bundled !== undefined)
3057
+ return { name, path: bundledPath, source: "bundled", content: bundled };
3058
+ throw new Error(`Template "${name}" was not found at ${userPath} or ${bundledPath}.`);
3059
+ }
3060
+ function renderTemplate(content, variables) {
3061
+ return content.replace(/\{\{\s*([a-zA-Z0-9_.-]+)\s*\}\}/g, (match, key) => variables[key] ?? match);
3062
+ }
3063
+ function templateRelativePath(name) {
3064
+ const normalized = normalize(name.replaceAll("\\", "/")).replace(/^\.\//, "");
3065
+ if (!normalized || normalized.startsWith("../") || normalized.includes("/../") || normalized.startsWith("/")) {
3066
+ throw new Error(`Invalid template name: ${name}`);
3067
+ }
3068
+ return normalized.endsWith(".md") ? normalized : `${normalized}.md`;
3069
+ }
3070
+ async function readOptionalFile(path) {
3071
+ try {
3072
+ return await readFile13(path, "utf8");
3073
+ } catch (error) {
3074
+ if (error.code === "ENOENT")
3075
+ return;
3076
+ throw error;
3077
+ }
3078
+ }
3079
+
3080
+ // src/plan/store.ts
3081
+ function planRecordName(shortSlug, date = new Date) {
3082
+ const slug = normalizeSlug(shortSlug);
3083
+ return `${date.toISOString().slice(2, 10).replaceAll("-", "")}-${slug}`;
3084
+ }
3085
+ async function resolvePlan(cwd, query, filters = {}, options = {}) {
3086
+ const root = await plansDir(cwd, options.homeDir);
3087
+ const entries = await readdir2(root, { withFileTypes: true });
3088
+ const normalizedQuery = query ? normalizeQuery(query) : undefined;
3089
+ const records = [];
3090
+ for (const entry of entries) {
3091
+ if (!entry.isDirectory() || entry.name === ".tmp" || entry.name === ".lock")
3092
+ continue;
3093
+ if (normalizedQuery && entry.name !== normalizedQuery && stripDate(entry.name) !== normalizedQuery)
3094
+ continue;
3095
+ const dir = join16(root, entry.name);
3096
+ await assertContained(root, dir);
3097
+ try {
3098
+ const record = await readRecord(dir);
3099
+ if (filters.branch && record.document.branch !== filters.branch)
3100
+ continue;
3101
+ if (filters.statuses && !filters.statuses.includes(record.document.status))
3102
+ continue;
3103
+ records.push(record);
3104
+ } catch (error) {
3105
+ if (error.code !== "ENOENT")
3106
+ throw error;
3107
+ }
3108
+ }
3109
+ records.sort((left, right) => left.id.localeCompare(right.id));
3110
+ return { candidates: records, record: records.length === 1 ? records[0] : undefined, ambiguous: records.length > 1 };
3111
+ }
3112
+ function createPlanStore(options = {}) {
3113
+ const now = options.now ?? (() => new Date);
3114
+ return {
3115
+ context(cwd, query, filters) {
3116
+ return resolvePlan(cwd, query, filters, options);
3117
+ },
3118
+ async init(input) {
3119
+ const root = await plansDir(input.cwd, options.homeDir);
3120
+ const id = planRecordName(input.shortSlug, now());
3121
+ const dir = join16(root, id);
3122
+ await assertContained(root, dirname8(dir));
3123
+ try {
3124
+ await mkdir7(dir);
3125
+ } catch (error) {
3126
+ if (error.code === "EEXIST")
3127
+ throw new Error(`Plan ${id} already exists.`);
3128
+ throw error;
3129
+ }
3130
+ try {
3131
+ const timestamp = now().toISOString();
3132
+ const template = await loadTemplate("plan/PLAN", {
3133
+ homeDir: options.homeDir,
3134
+ bundledDir: options.bundledTemplatesDir
3135
+ });
3136
+ const source = renderTemplate(template.content, {
3137
+ id,
3138
+ branch: input.branch,
3139
+ title: input.title?.trim() || titleFromSlug(input.shortSlug),
3140
+ intent: input.intent?.trim() || "<!-- Describe the intended outcome. -->",
3141
+ created_at: timestamp,
3142
+ updated_at: timestamp
3143
+ });
3144
+ const document = parsePlanDocument(source, join16(dir, "PLAN.md"));
3145
+ await atomicWrite(join16(dir, "PLAN.md"), source);
3146
+ await atomicWrite(join16(dir, "logs.txt"), "");
3147
+ await appendPlanLog(join16(dir, "logs.txt"), {
3148
+ planRevision: document.revision,
3149
+ kind: "created",
3150
+ actor: "diffpi",
3151
+ message: `Created plan ${id}.`
3152
+ });
3153
+ return { id, dir, planPath: join16(dir, "PLAN.md"), logPath: join16(dir, "logs.txt"), document, source };
3154
+ } catch (error) {
3155
+ await rm2(dir, { recursive: true, force: true });
3156
+ throw error;
3157
+ }
3158
+ },
3159
+ async read(cwd, query, filters) {
3160
+ const result = await resolvePlan(cwd, query, filters, options);
3161
+ if (!result.record) {
3162
+ if (result.ambiguous)
3163
+ throw new Error(`Plan query "${query}" is ambiguous: ${result.candidates.map((item) => item.id).join(", ")}.`);
3164
+ throw new Error(`Plan "${query}" was not found.`);
3165
+ }
3166
+ return result.record;
3167
+ },
3168
+ async mutate(cwd, query, operation, update) {
3169
+ const initial = await this.read(cwd, query);
3170
+ return withPlanLock(initial.dir, async () => {
3171
+ const current = await readRecord(initial.dir);
3172
+ const next = await update(structuredClone(current.document));
3173
+ if (next.id !== current.id)
3174
+ throw new Error("A plan mutation cannot change the plan ID.");
3175
+ const document = {
3176
+ ...next,
3177
+ revision: current.document.revision + 1,
3178
+ updatedAt: now().toISOString()
3179
+ };
3180
+ const source = renderPlanDocument(document, current.source);
3181
+ parsePlanDocument(source, current.planPath);
3182
+ await atomicWrite(current.planPath, source);
3183
+ return { ...current, document, source };
3184
+ }, { operation });
3185
+ },
3186
+ async log(cwd, query, event) {
3187
+ const record = await this.read(cwd, query);
3188
+ return withPlanLock(record.dir, () => appendPlanLog(record.logPath, event), { operation: "append log" });
3189
+ }
3190
+ };
3191
+ }
3192
+ async function readRecord(dir) {
3193
+ const planPath = join16(dir, "PLAN.md");
3194
+ const logPath = join16(dir, "logs.txt");
3195
+ const source = await readFile14(planPath, "utf8");
3196
+ const document = parsePlanDocument(source, planPath);
3197
+ return { id: basename6(dir), dir, planPath, logPath, document, source };
3198
+ }
3199
+ async function atomicWrite(path, content) {
3200
+ const temp = join16(dirname8(path), `.${basename6(path)}.${process.pid}.${crypto.randomUUID()}.tmp`);
3201
+ const file = await open(temp, "wx", 384);
3202
+ try {
3203
+ await file.writeFile(content, "utf8");
3204
+ await file.sync();
3205
+ } finally {
3206
+ await file.close();
3207
+ }
3208
+ await rename3(temp, path);
3209
+ try {
3210
+ const directory = await open(dirname8(path), "r");
3211
+ try {
3212
+ await directory.sync();
3213
+ } finally {
3214
+ await directory.close();
3215
+ }
3216
+ } catch {}
3217
+ }
3218
+ async function assertContained(root, candidate) {
3219
+ const canonicalRoot = await realpath4(root);
3220
+ let canonicalCandidate;
3221
+ try {
3222
+ canonicalCandidate = await realpath4(candidate);
3223
+ } catch {
3224
+ canonicalCandidate = resolve4(candidate);
3225
+ }
3226
+ if (canonicalCandidate !== canonicalRoot && !canonicalCandidate.startsWith(`${canonicalRoot}${sep}`)) {
3227
+ throw new Error(`Plan path escapes the shared store: ${candidate}.`);
3228
+ }
3229
+ }
3230
+ function normalizeSlug(value) {
3231
+ if (!value || value.includes("\x00") || value.includes("/") || value.includes("\\") || value.includes("..")) {
3232
+ throw new Error(`Invalid plan slug: ${value}.`);
3233
+ }
3234
+ const slug = value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
3235
+ assertStableId(slug, "plan slug");
3236
+ return slug;
3237
+ }
3238
+ function normalizeQuery(value) {
3239
+ if (!value || value.includes("\x00") || value.includes("/") || value.includes("\\") || value.includes("..")) {
3240
+ throw new Error(`Invalid plan query: ${value}.`);
3241
+ }
3242
+ return value.toLowerCase();
3243
+ }
3244
+ function stripDate(value) {
3245
+ return value.replace(/^\d{6}-/, "");
3246
+ }
3247
+ function titleFromSlug(value) {
3248
+ return normalizeSlug(value).replace(/^(?:[a-z]+-\d+-)/, "").split("-").map((part) => part[0].toUpperCase() + part.slice(1)).join(" ");
3249
+ }
739
3250
  // src/setup.ts
740
3251
  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";
3252
+ import { readdir as readdir3, readFile as readFile15 } from "node:fs/promises";
3253
+ import { homedir as homedir9 } from "node:os";
3254
+ import { basename as basename7, join as join17 } from "node:path";
744
3255
  var MISE_DEPENDENCIES = [
745
3256
  { name: "node", tool: "node", spec: "node@22", minimumVersion: "22.19.0" },
746
3257
  { name: "zellij", tool: "zellij", spec: "zellij@latest", minimumVersion: undefined },
@@ -768,13 +3279,18 @@ var PI_PACKAGES = [
768
3279
  ];
769
3280
  var PI_SKILL_SOURCES = [
770
3281
  { repository: "arabold/docs-mcp-server", skills: ["docs-manage", "docs-search", "fetch-url"] },
771
- { repository: "AminBlg/SimpleEnglish", skills: ["simple-english"] }
3282
+ { repository: "AminBlg/SimpleEnglish", skills: ["simple-english"] },
3283
+ { repository: "cloudvoyant/codevoyant", skills: ["git"] }
772
3284
  ];
773
3285
  var MCP_ADAPTER_PACKAGE = "npm:pi-mcp-adapter";
774
3286
  var BUNDLED_AGENTS_DIR2 = resolveBundledAgentsDir();
3287
+ var FORGE_DEPENDENCIES = {
3288
+ github: { name: "gh", tool: "gh", spec: "gh@latest", minimumVersion: undefined },
3289
+ gitlab: { name: "glab", tool: "glab", spec: "glab@latest", minimumVersion: undefined }
3290
+ };
775
3291
  async function ensureMise(options = {}) {
776
- const homeDir = options.homeDir ?? homedir6();
777
- const current = await mise.executableCheck() ?? await mise.executableCheck(join8(homeDir, ".local", "bin", "mise"));
3292
+ const homeDir = options.homeDir ?? homedir9();
3293
+ const current = await mise.executableCheck() ?? await mise.executableCheck(join17(homeDir, ".local", "bin", "mise"));
778
3294
  if (current)
779
3295
  return { executable: current, action: createSetupAction("mise", "ready", current) };
780
3296
  reportProgress(options, "Installing mise");
@@ -803,7 +3319,11 @@ async function ensureMiseHooks(miseExecutable, options = {}) {
803
3319
  async function ensureMiseDeps(miseExecutable, options = {}) {
804
3320
  const canRunMise = Boolean(await mise.executableCheck(miseExecutable));
805
3321
  const actions = [];
806
- for (const dependency of MISE_DEPENDENCIES) {
3322
+ const dependencies = [
3323
+ ...MISE_DEPENDENCIES,
3324
+ ...configuredForges(options).map((forge) => FORGE_DEPENDENCIES[forge])
3325
+ ];
3326
+ for (const dependency of dependencies) {
807
3327
  const installed = canRunMise && await mise.toolCheckGlobal(miseExecutable, dependency.tool, dependency.minimumVersion);
808
3328
  if (installed) {
809
3329
  actions.push(createSetupAction(dependency.name, "ready", dependency.spec));
@@ -819,9 +3339,9 @@ async function ensureMiseDeps(miseExecutable, options = {}) {
819
3339
  async function ensurePiPlugins(options = {}) {
820
3340
  const actions = await ensurePiPackages(PI_PACKAGES, options);
821
3341
  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);
3342
+ const webSearch = await pi.configEnsure(join17(agentDir, "web-search.json"), (config) => ({ ...config, workflow: "auto-summary" }), options.dryRun);
823
3343
  actions.push(getConfigSetupAction("web search settings", webSearch));
824
- const lsp = await pi.configEnsure(join8(agentDir, "pi-lsp.json"), (config) => ({
3344
+ const lsp = await pi.configEnsure(join17(agentDir, "pi-lsp.json"), (config) => ({
825
3345
  ...config,
826
3346
  progressive: { ...getRecord(config.progressive), enabled: true, inject: "none" }
827
3347
  }), options.dryRun);
@@ -832,11 +3352,11 @@ async function ensurePiAgents(options = {}) {
832
3352
  const agentDir = options.agentDir ?? pi.agentDir(options.homeDir);
833
3353
  const bundledAgentsDir = options.bundledAgentsDir ?? BUNDLED_AGENTS_DIR2;
834
3354
  const userConfig = await loadDiffpiConfig({ homeDir: options.homeDir });
835
- 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));
3355
+ const entries = (await readdir3(bundledAgentsDir, { withFileTypes: true })).filter((entry) => entry.isFile() && entry.name.startsWith("diffpi-") && entry.name.endsWith(".md")).sort((left, right) => left.name.localeCompare(right.name));
836
3356
  const actions = [];
837
3357
  for (const entry of entries) {
838
- const id = basename3(entry.name, ".md").replace(/^diffpi-/, "");
839
- const source = await readFile5(join8(bundledAgentsDir, entry.name), "utf8");
3358
+ const id = basename7(entry.name, ".md").replace(/^diffpi-/, "");
3359
+ const source = await readFile15(join17(bundledAgentsDir, entry.name), "utf8");
840
3360
  const content = materializeAgentModels(source, id, userConfig.config, options.availableModels);
841
3361
  const result = await pi.agentEnsure(entry.name, content, agentDir, options.dryRun);
842
3362
  actions.push(getConfigSetupAction(`pi agent ${id}`, result));
@@ -845,7 +3365,7 @@ async function ensurePiAgents(options = {}) {
845
3365
  }
846
3366
  async function ensurePiSkills(miseExecutable, options = {}) {
847
3367
  const agentDir = options.agentDir ?? pi.agentDir(options.homeDir);
848
- const sharedSkillsDir = join8(options.homeDir ?? homedir6(), ".agents", "skills");
3368
+ const sharedSkillsDir = join17(options.homeDir ?? homedir9(), ".agents", "skills");
849
3369
  const actions = [];
850
3370
  for (const source of PI_SKILL_SOURCES) {
851
3371
  const missing = [];
@@ -889,6 +3409,14 @@ async function ensureMcpAdapters(miseExecutable, options = {}) {
889
3409
  } else if (options.issueTracker === "jira") {
890
3410
  servers.atlassian = { url: "https://mcp.atlassian.com/v1/mcp", auth: "oauth", protocolVersion: "auto" };
891
3411
  }
3412
+ for (const forge of configuredForges(options)) {
3413
+ if (forge === "github") {
3414
+ servers.github = { url: "https://api.githubcopilot.com/mcp/", auth: "oauth", protocolVersion: "auto" };
3415
+ continue;
3416
+ }
3417
+ const host = await gitlabMcpHost(projectDir);
3418
+ servers.gitlab = { url: `https://${host}/api/v4/mcp`, auth: "oauth", protocolVersion: "auto" };
3419
+ }
892
3420
  const result = await mcp.serversEnsure(servers, {
893
3421
  dryRun: options.dryRun,
894
3422
  path: mcp.globalConfigPath(options.homeDir)
@@ -905,6 +3433,8 @@ async function setupPi(options = {}) {
905
3433
  actions.push(...await ensurePiAgents(options));
906
3434
  actions.push(...await ensurePiSkills(miseResult.executable, options));
907
3435
  actions.push(...await ensureMcpAdapters(miseResult.executable, options));
3436
+ if (options.bindZedKey)
3437
+ actions.push(...await ensureZedIntegration(options));
908
3438
  return {
909
3439
  actions,
910
3440
  restartPi: setupRequiresRestart(actions)
@@ -913,6 +3443,43 @@ async function setupPi(options = {}) {
913
3443
  function setupRequiresRestart(actions) {
914
3444
  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
3445
  }
3446
+ async function ensureZedIntegration(options = {}) {
3447
+ if (options.dryRun) {
3448
+ const actions = [
3449
+ createSetupAction("Zed review tasks", "planned", "global static runtime-resolver tasks in tasks.json"),
3450
+ createSetupAction("Zed plan task", "planned", "pinned package CLI task in tasks.json")
3451
+ ];
3452
+ if (options.bindZedKey)
3453
+ actions.push(createSetupAction("Zed review keybinding", "planned", "keymap.json"));
3454
+ return actions;
3455
+ }
3456
+ const actions = [];
3457
+ try {
3458
+ const tasks = await ensureZedReviewTask(options.homeDir);
3459
+ actions.push(createSetupAction("Zed review tasks", tasks.changed ? "installed" : "ready", tasks.path));
3460
+ const planTask = await ensureZedPlanTask(await packageVersion2(), options.homeDir);
3461
+ actions.push(createSetupAction("Zed plan task", planTask.changed ? "installed" : "ready", planTask.path));
3462
+ } catch (error) {
3463
+ actions.push(createSetupAction("Zed review tasks", "skipped", error instanceof Error ? error.message : String(error)));
3464
+ }
3465
+ if (options.bindZedKey) {
3466
+ try {
3467
+ const key = await ensureZedReviewKeybinding(options.homeDir);
3468
+ actions.push(createSetupAction("Zed review keybinding", key.changed ? "installed" : "ready", key.path));
3469
+ } catch (error) {
3470
+ actions.push(createSetupAction("Zed review keybinding", "skipped", error instanceof Error ? error.message : String(error)));
3471
+ }
3472
+ }
3473
+ return actions;
3474
+ }
3475
+ async function gitlabMcpHost(projectDir) {
3476
+ try {
3477
+ const vcs = await detectVcs(projectDir);
3478
+ return vcs.provider === "gitlab" && vcs.host ? vcs.host : "gitlab.com";
3479
+ } catch {
3480
+ return "gitlab.com";
3481
+ }
3482
+ }
916
3483
  function materializeAgentModels(content, agentId, config, availableModels) {
917
3484
  const { frontmatter } = parseFrontmatter3(content.startsWith("\uFEFF") ? content.slice(1) : content);
918
3485
  const profilePreferences = [...getTextList(frontmatter.model), ...getTextList(frontmatter.model_fallbacks)];
@@ -972,6 +3539,10 @@ ${source}`;
972
3539
  }
973
3540
  return actions;
974
3541
  }
3542
+ function configuredForges(options) {
3543
+ const selected = options.forges ?? (options.forge && options.forge !== "none" ? [options.forge] : []);
3544
+ return [...new Set(selected)];
3545
+ }
975
3546
  function getTextList(value) {
976
3547
  const values = Array.isArray(value) ? value : typeof value === "string" ? value.split(",") : [];
977
3548
  return values.filter((item) => typeof item === "string").map((item) => item.trim()).filter(Boolean);
@@ -989,13 +3560,55 @@ function createSetupAction(name, status, detail) {
989
3560
  function reportProgress(options, message) {
990
3561
  options.onProgress?.(message);
991
3562
  }
3563
+ async function packageVersion2() {
3564
+ const source = await readFile15(join17(resolveBundledAgentsDir(), "..", "package.json"), "utf8");
3565
+ const value = JSON.parse(source);
3566
+ if (typeof value.version !== "string")
3567
+ throw new Error("Cannot resolve the installed @difflab/pi version.");
3568
+ return value.version;
3569
+ }
992
3570
  function getRecord(value) {
993
3571
  return value !== null && typeof value === "object" && !Array.isArray(value) ? value : {};
994
3572
  }
995
3573
  export {
3574
+ CONVENTIONAL_COMMIT,
3575
+ GitHubVcsBackend,
3576
+ GitLabVcsBackend,
3577
+ ZED_LOCAL_REVIEW_TASK_NAME,
3578
+ ZED_PLAN_ANNOTATE_TASK_NAME,
3579
+ ZED_PR_REVIEW_TASK_NAME,
3580
+ ZED_REVIEW_TASK_NAME,
3581
+ acknowledgePlanAnnotations,
3582
+ addComment,
3583
+ annotatePlan,
3584
+ annotationStatePath,
3585
+ appendPlanLog,
3586
+ assertGitHubMergeReady,
3587
+ assertPhaseTransition,
3588
+ assertPlanTransition,
3589
+ assertReviewEventSupported,
3590
+ assertStableId,
3591
+ assertTaskTransition,
3592
+ checkConventionalSubject,
3593
+ ciGate,
3594
+ computeProjectSlug,
3595
+ countDesignWords,
3596
+ createExecutionPacket,
3597
+ createForge,
3598
+ createForgeBackend,
3599
+ createLocalReviewBackend,
996
3600
  createModeController,
3601
+ createPlanStore,
3602
+ createRemoteReviewBackend,
3603
+ createVcsBackend,
3604
+ dedupeFindings,
3605
+ detectIde,
3606
+ detectMux,
3607
+ detectShell,
3608
+ detectVcs,
997
3609
  diffpiConfigPaths,
998
3610
  discoverAgentModes,
3611
+ eligiblePlanTasks,
999
3612
  ensureMcpAdapters,
1000
3613
  ensureMise,
1001
3614
  ensureMiseDeps,
@@ -1003,12 +3616,74 @@ export {
1003
3616
  ensurePiAgents,
1004
3617
  ensurePiPlugins,
1005
3618
  ensurePiSkills,
3619
+ ensureStore,
3620
+ ensureZedPlanTask,
3621
+ ensureZedReviewKeybinding,
3622
+ ensureZedReviewTask,
1006
3623
  findPreferredModel,
3624
+ findingSchema,
3625
+ findingsSchema,
3626
+ gitToplevel,
3627
+ githubReviewSubmissionEndpoint,
3628
+ hasGitlabDraftNotes,
3629
+ launch,
3630
+ listSessions,
1007
3631
  loadDiffpiConfig,
3632
+ loadReviewPublicationState,
3633
+ loadTemplate,
3634
+ localReviewAuthor,
1008
3635
  mcp,
1009
3636
  mise,
3637
+ openInNewTab,
3638
+ parseGitlabDiffRefs,
3639
+ parsePlanDocument,
3640
+ parsePlannerEscalation,
3641
+ parseRemote,
3642
+ parseThreadArtifact,
3643
+ phaseCommitCommand,
1010
3644
  pi,
3645
+ planRecordName,
3646
+ plansDir,
3647
+ readPlanAnnotations,
3648
+ readPlanLog,
3649
+ readSession,
3650
+ renderExecutionPrompt,
3651
+ renderPlanDocument,
3652
+ renderPlannerEscalation,
3653
+ renderReviewDoc,
3654
+ renderTemplate,
3655
+ renderThreadArtifact,
1011
3656
  resolveAgentMode,
1012
3657
  resolveAgentModelPreferences,
1013
- setupPi
3658
+ resolvePlan,
3659
+ resolvePrSession,
3660
+ resolveReviewSession,
3661
+ resolveSession,
3662
+ reviewBodyFingerprint,
3663
+ reviewCommentFingerprint,
3664
+ reviewRecordName,
3665
+ reviewReplyFingerprint,
3666
+ reviewSlug,
3667
+ reviewsDir,
3668
+ runMiseGates,
3669
+ saveReviewPublicationState,
3670
+ sessionsDir,
3671
+ setupPi,
3672
+ severitySchema,
3673
+ storeDir,
3674
+ storeGlobalRoot,
3675
+ tasksMayRunInParallel,
3676
+ templateRelativePath,
3677
+ toFindings,
3678
+ toReviewComments,
3679
+ tuicrAvailable,
3680
+ unpublishedReviewComments,
3681
+ upsertThreadReply,
3682
+ validatePlanDocument,
3683
+ withPlanLock,
3684
+ withRemoteProvenance,
3685
+ yymmdd,
3686
+ zedKeymapPath,
3687
+ zedReviewTaskName,
3688
+ zedTasksPath
1014
3689
  };