@difflab/pi 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/README.md +8 -10
  2. package/agents/diffpi-orchestrator.md +1 -1
  3. package/agents/diffpi-planner.md +2 -2
  4. package/agents/diffpi-reviewer.md +2 -2
  5. package/agents/diffpi-worker.md +6 -6
  6. package/dist/environment.d.ts +0 -1
  7. package/dist/environment.d.ts.map +1 -1
  8. package/dist/extensions/index.js +319 -1495
  9. package/dist/index.d.ts +5 -9
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +140 -759
  12. package/dist/log.d.ts +6 -11
  13. package/dist/log.d.ts.map +1 -1
  14. package/dist/plan/verify.d.ts.map +1 -1
  15. package/dist/review/index.d.ts +2 -3
  16. package/dist/review/index.d.ts.map +1 -1
  17. package/dist/review/review-backend.d.ts +2 -3
  18. package/dist/review/review-backend.d.ts.map +1 -1
  19. package/dist/setup.d.ts +0 -2
  20. package/dist/setup.d.ts.map +1 -1
  21. package/dist/tools/index.js +319 -1495
  22. package/dist/tools/log.d.ts.map +1 -1
  23. package/dist/tools/review.d.ts +1 -7
  24. package/dist/tools/review.d.ts.map +1 -1
  25. package/dist/tools/setup.d.ts.map +1 -1
  26. package/package.json +1 -5
  27. package/skills/diffpi-setup/SKILL.md +3 -3
  28. package/skills/plan/SKILL.md +3 -4
  29. package/skills/plan/references/design-quality.md +1 -1
  30. package/skills/plan/references/workflows/finalize.md +2 -2
  31. package/skills/plan/references/workflows/go.md +7 -5
  32. package/skills/plan/references/workflows/help.md +1 -2
  33. package/skills/plan/references/workflows/init.md +2 -2
  34. package/skills/plan/references/workflows/inline-fallback.md +3 -3
  35. package/skills/plan/references/workflows/new.md +3 -3
  36. package/skills/plan/references/workflows/update.md +6 -4
  37. package/skills/plan/references/workflows/validate.md +5 -5
  38. package/skills/review/SKILL.md +5 -5
  39. package/skills/review/references/review-standards.md +9 -1
  40. package/skills/review/references/workflows/address.md +9 -15
  41. package/skills/review/references/workflows/auto.md +9 -17
  42. package/skills/review/references/workflows/complete.md +2 -2
  43. package/skills/review/references/workflows/edit.md +7 -5
  44. package/skills/review/references/workflows/help.md +12 -20
  45. package/skills/review/references/workflows/new.md +14 -13
  46. package/skills/review/references/workflows/publish.md +2 -2
  47. package/skills/review/references/workflows/status.md +1 -1
  48. package/skills/review/templates/REVIEW.md +39 -0
  49. package/templates/plan/PLAN.md +9 -1
  50. package/templates/review/draft-pr.md +3 -9
  51. package/dist/cli/plan.d.ts +0 -17
  52. package/dist/cli/plan.d.ts.map +0 -1
  53. package/dist/cli.d.ts +0 -4
  54. package/dist/cli.d.ts.map +0 -1
  55. package/dist/cli.js +0 -2258
  56. package/dist/extensions/tuicrx.d.ts +0 -66
  57. package/dist/extensions/tuicrx.d.ts.map +0 -1
  58. package/dist/extensions/zedx.d.ts +0 -18
  59. package/dist/extensions/zedx.d.ts.map +0 -1
  60. package/dist/review/local-review-backend.d.ts +0 -3
  61. package/dist/review/local-review-backend.d.ts.map +0 -1
  62. package/dist/review/local-reviews.d.ts +0 -31
  63. package/dist/review/local-reviews.d.ts.map +0 -1
  64. package/dist/review/review-markdown.d.ts +0 -6
  65. package/dist/review/review-markdown.d.ts.map +0 -1
  66. package/skills/plan/references/workflows/annotate.md +0 -13
  67. package/skills/review/references/workflows/open.md +0 -11
@@ -3,30 +3,13 @@ import { defineTool } from "@earendil-works/pi-coding-agent";
3
3
  import { z as z2 } from "zod";
4
4
 
5
5
  // src/environment.ts
6
- import { readFile as readFile2 } from "node:fs/promises";
7
- import { basename, join as join4 } from "node:path";
8
-
9
- // src/assets.ts
10
- import { existsSync } from "node:fs";
11
- import { dirname, join } from "node:path";
12
- import { fileURLToPath } from "node:url";
13
- function resolveBundledAssetDir(name, moduleUrl = import.meta.url) {
14
- const moduleDir = dirname(fileURLToPath(moduleUrl));
15
- const candidates = [join(moduleDir, name), join(moduleDir, "..", name), join(moduleDir, "..", "..", name)];
16
- return candidates.find((path) => existsSync(path)) ?? candidates[1];
17
- }
18
- function resolveBundledAgentsDir(moduleUrl = import.meta.url) {
19
- return resolveBundledAssetDir("agents", moduleUrl);
20
- }
21
- function resolveBundledTemplatesDir(moduleUrl = import.meta.url) {
22
- return resolveBundledAssetDir("templates", moduleUrl);
23
- }
6
+ import { basename } from "node:path";
24
7
 
25
8
  // src/extensions/processx.ts
26
9
  import { spawn } from "node:child_process";
27
10
  import { constants } from "node:fs";
28
11
  import { access } from "node:fs/promises";
29
- import { delimiter, join as join2 } from "node:path";
12
+ import { delimiter, join } from "node:path";
30
13
  async function findExecutable(name) {
31
14
  if (name.includes("/")) {
32
15
  try {
@@ -39,7 +22,7 @@ async function findExecutable(name) {
39
22
  for (const directory of (process.env.PATH ?? "").split(delimiter)) {
40
23
  if (!directory)
41
24
  continue;
42
- const candidate = join2(directory, name);
25
+ const candidate = join(directory, name);
43
26
  try {
44
27
  await access(candidate, constants.X_OK);
45
28
  return candidate;
@@ -103,174 +86,6 @@ function appendBounded(current, next) {
103
86
  return combined.length <= MAX_CAPTURED_OUTPUT_LENGTH ? combined : combined.slice(-MAX_CAPTURED_OUTPUT_LENGTH);
104
87
  }
105
88
 
106
- // src/extensions/zedx.ts
107
- import { mkdir, readFile, writeFile } from "node:fs/promises";
108
- import { homedir } from "node:os";
109
- import { dirname as dirname2, join as join3 } from "node:path";
110
- var ZED_LOCAL_REVIEW_TASK_NAME = "diffpi: tuicr local review";
111
- var ZED_PR_REVIEW_TASK_NAME = "diffpi: tuicr PR review";
112
- var ZED_PLAN_ANNOTATE_TASK_NAME = "diffpi: annotate plan";
113
- var LEGACY_ZED_REVIEW_TASK_NAME = "diffpi: tuicr review";
114
- var REVIEW_KEYBINDING = "cmd-alt-r";
115
- function zedTasksPath(homeDir = homedir()) {
116
- return join3(homeDir, ".config", "zed", "tasks.json");
117
- }
118
- function zedKeymapPath(homeDir = homedir()) {
119
- return join3(homeDir, ".config", "zed", "keymap.json");
120
- }
121
- async function ensureZedReviewTask(homeDir = homedir(), _command = ["tuicr", "-w", "-r", "main..HEAD"]) {
122
- const path = zedTasksPath(homeDir);
123
- const currentText = await readOptional(path);
124
- const tasks = parseJsonArray(currentText, path);
125
- const migrated = tasks.filter((task) => task.label !== LEGACY_ZED_REVIEW_TASK_NAME);
126
- const next = [...migrated];
127
- for (const task of [localReviewTask(), prReviewTask()]) {
128
- const index = next.findIndex((existing) => existing.label === task.label);
129
- if (index >= 0)
130
- next[index] = { ...next[index], ...task };
131
- else
132
- next.push(task);
133
- }
134
- const changed = JSON.stringify(tasks) !== JSON.stringify(next);
135
- if (changed)
136
- await writeJson(path, next);
137
- return { path, changed, existed: currentText !== undefined };
138
- }
139
- async function ensureZedPlanTask(packageVersion, homeDir = homedir()) {
140
- if (!/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(packageVersion)) {
141
- throw new Error(`Invalid @difflab/pi package version: ${packageVersion}.`);
142
- }
143
- const path = zedTasksPath(homeDir);
144
- const currentText = await readOptional(path);
145
- const tasks = parseJsonArray(currentText, path);
146
- const task = {
147
- label: ZED_PLAN_ANNOTATE_TASK_NAME,
148
- command: "npx",
149
- args: ["--yes", `@difflab/pi@${packageVersion}`, "plan", "annotate", "$ZED_FILE", "--cwd", "$ZED_WORKTREE_ROOT"],
150
- cwd: "$ZED_WORKTREE_ROOT",
151
- use_new_terminal: true,
152
- reveal: "always",
153
- reveal_target: "center"
154
- };
155
- const next = [...tasks];
156
- const index = next.findIndex((candidate) => candidate.label === task.label);
157
- if (index >= 0)
158
- next[index] = { ...next[index], ...task };
159
- else
160
- next.push(task);
161
- const changed = JSON.stringify(tasks) !== JSON.stringify(next);
162
- if (changed)
163
- await writeJson(path, next);
164
- return { path, changed, existed: currentText !== undefined };
165
- }
166
- async function ensureZedReviewKeybinding(homeDir = homedir()) {
167
- const path = zedKeymapPath(homeDir);
168
- const currentText = await readOptional(path);
169
- const entries = parseJsonArray(currentText, path);
170
- const migrated = entries.map(migrateReviewKeymapEntry);
171
- const alreadyBound = migrated.some((entry) => Object.values(entry.bindings ?? {}).some((action) => Array.isArray(action) && action[0] === "task::Spawn" && bindsReviewTask(action[1])));
172
- const next = alreadyBound ? migrated : [
173
- ...migrated,
174
- {
175
- context: "Workspace",
176
- bindings: { [REVIEW_KEYBINDING]: ["task::Spawn", { task_name: ZED_LOCAL_REVIEW_TASK_NAME }] }
177
- }
178
- ];
179
- const changed = JSON.stringify(entries) !== JSON.stringify(next);
180
- if (changed)
181
- await writeJson(path, next);
182
- return { path, changed, existed: currentText !== undefined };
183
- }
184
- function zedReviewTaskName(command) {
185
- if (command.some((value, index) => value === "plan" && command[index + 1] === "annotate")) {
186
- return ZED_PLAN_ANNOTATE_TASK_NAME;
187
- }
188
- return command[0] === "tuicr" && command[1] === "pr" ? ZED_PR_REVIEW_TASK_NAME : ZED_LOCAL_REVIEW_TASK_NAME;
189
- }
190
- var LOCAL_REVIEW_SCRIPT = `set -eu
191
- remote=$(git remote get-url origin 2>/dev/null || true)
192
- branch=$(git branch --show-current)
193
- base=
194
- case "$remote" in
195
- *github.com*)
196
- 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)
197
- [ -n "$base" ] || base=$(gh repo view --json defaultBranchRef --jq .defaultBranchRef.name 2>/dev/null || true)
198
- ;;
199
- *gitlab*)
200
- mr=$(glab mr list --source-branch "$branch" --order created_at --sort desc --per-page 100 --output json 2>/dev/null || true)
201
- 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{}})')
202
- [ -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)
203
- ;;
204
- esac
205
- [ -n "$base" ] || { echo 'diffpi: could not resolve a GitHub/GitLab review base' >&2; exit 1; }
206
- exec tuicr -w -r "$base..HEAD"`;
207
- var PR_REVIEW_SCRIPT = `set -eu
208
- remote=$(git remote get-url origin 2>/dev/null || true)
209
- branch=$(git branch --show-current)
210
- case "$remote" in
211
- *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) ;;
212
- *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) ;;
213
- *) number= ;;
214
- esac
215
- [ -n "$number" ] || { echo 'diffpi: could not resolve the current GitHub PR or GitLab MR' >&2; exit 1; }
216
- exec tuicr pr "$number"`;
217
- function staticReviewTask(label, script) {
218
- return {
219
- label,
220
- command: "sh",
221
- args: ["-lc", script],
222
- cwd: "$ZED_WORKTREE_ROOT",
223
- use_new_terminal: true,
224
- reveal: "always",
225
- reveal_target: "center"
226
- };
227
- }
228
- function localReviewTask() {
229
- return staticReviewTask(ZED_LOCAL_REVIEW_TASK_NAME, LOCAL_REVIEW_SCRIPT);
230
- }
231
- function prReviewTask() {
232
- return staticReviewTask(ZED_PR_REVIEW_TASK_NAME, PR_REVIEW_SCRIPT);
233
- }
234
- function bindsReviewTask(payload) {
235
- return typeof payload === "object" && payload !== null && payload.task_name === ZED_LOCAL_REVIEW_TASK_NAME;
236
- }
237
- function migrateReviewKeymapEntry(entry) {
238
- const bindings = Object.fromEntries(Object.entries(entry.bindings ?? {}).map(([key, action]) => {
239
- if (Array.isArray(action) && action[0] === "task::Spawn" && typeof action[1] === "object" && action[1] !== null && action[1].task_name === LEGACY_ZED_REVIEW_TASK_NAME) {
240
- return [key, ["task::Spawn", { ...action[1], task_name: ZED_LOCAL_REVIEW_TASK_NAME }]];
241
- }
242
- return [key, action];
243
- }));
244
- return entry.bindings ? { ...entry, bindings } : entry;
245
- }
246
- async function readOptional(path) {
247
- try {
248
- return await readFile(path, "utf8");
249
- } catch (error) {
250
- if (error instanceof Error && "code" in error && error.code === "ENOENT")
251
- return;
252
- throw error;
253
- }
254
- }
255
- function parseJsonArray(content, path) {
256
- if (!content?.trim())
257
- return [];
258
- let value;
259
- try {
260
- value = JSON.parse(content);
261
- } catch {
262
- throw new Error(`Cannot safely edit ${path}: not strict JSON (it may contain JSONC comments).`);
263
- }
264
- if (!Array.isArray(value))
265
- throw new Error(`Expected a JSON array in ${path}.`);
266
- return value;
267
- }
268
- async function writeJson(path, value) {
269
- await mkdir(dirname2(path), { recursive: true });
270
- await writeFile(path, `${JSON.stringify(value, null, 2)}
271
- `, "utf8");
272
- }
273
-
274
89
  // src/environment.ts
275
90
  function detectIde(env = process.env) {
276
91
  const program = (env.TERM_PROGRAM ?? "").toLowerCase();
@@ -322,73 +137,6 @@ function parseRemote(remote) {
322
137
  const provider = /github/i.test(host) ? "github" : /gitlab/i.test(host) ? "gitlab" : "none";
323
138
  return { provider, host, owner, repo };
324
139
  }
325
- function diffpiLaunchName(cwd, workflow) {
326
- return `diffpi: ${basename(cwd)} / ${workflow}`;
327
- }
328
- async function openInNewTab(command, opts) {
329
- const env = opts.env ?? process.env;
330
- const name = opts.name ?? "review";
331
- const printable = command.join(" ");
332
- const mux = detectMux(env);
333
- if (mux !== "none") {
334
- const opened = await openMuxTab(mux, command, opts.cwd, name, printable);
335
- if (opened)
336
- return opened;
337
- }
338
- if (detectIde(env) === "zed") {
339
- try {
340
- const taskName = zedReviewTaskName(command);
341
- if (taskName === ZED_PLAN_ANNOTATE_TASK_NAME)
342
- await ensureZedPlanTask(await packageVersion(), opts.homeDir);
343
- else
344
- await ensureZedReviewTask(opts.homeDir, command);
345
- return {
346
- launched: false,
347
- configured: true,
348
- via: "zed-task",
349
- command: printable,
350
- taskName,
351
- instruction: `Run the Zed task "${taskName}".`
352
- };
353
- } catch {}
354
- }
355
- return { launched: false, via: "print", command: printable };
356
- }
357
- function screenWindowArgs(command, cwd, name) {
358
- return ["-X", "screen", "-t", name, "sh", "-lc", 'cd -- "$1" && shift && exec "$@"', "sh", cwd, ...command];
359
- }
360
- async function packageVersion() {
361
- let value;
362
- try {
363
- value = JSON.parse(await readFile2(join4(resolveBundledAgentsDir(), "..", "package.json"), "utf8"));
364
- } catch (error) {
365
- throw new Error("Cannot parse the installed @difflab/pi package metadata.", { cause: error });
366
- }
367
- if (typeof value.version !== "string")
368
- throw new Error("Cannot resolve the installed @difflab/pi version.");
369
- return value.version;
370
- }
371
- async function openMuxTab(mux, command, cwd, name, printable, tmuxCommand) {
372
- if (mux === "zellij" && await findExecutable("zellij")) {
373
- const result = await run("zellij", ["action", "new-tab", "--cwd", cwd, "--name", name, "--", ...command]);
374
- if (result.code === 0)
375
- return { launched: true, via: "zellij", command: printable };
376
- const fallback = await run("zellij", ["run", "--cwd", cwd, "--name", name, "--", ...command]);
377
- if (fallback.code === 0)
378
- return { launched: true, via: "zellij-run", command: printable };
379
- }
380
- if (mux === "tmux" && await findExecutable("tmux")) {
381
- const result = tmuxCommand ? await run("tmux", ["new-window", "-c", cwd, "-n", name, ...tmuxCommand]) : await run("tmux", ["new-window", "-c", cwd, "-n", name, printable]);
382
- if (result.code === 0)
383
- return { launched: true, via: "tmux", command: printable };
384
- }
385
- if (mux === "screen" && await findExecutable("screen")) {
386
- const result = await run("screen", screenWindowArgs(command, cwd, name));
387
- if (result.code === 0)
388
- return { launched: true, via: "screen", command: printable };
389
- }
390
- return;
391
- }
392
140
 
393
141
  // src/extensions/zodx.ts
394
142
  import { z } from "zod";
@@ -757,33 +505,21 @@ function result(text, status, detail = text) {
757
505
 
758
506
  // src/tools/log.ts
759
507
  import { defineTool as defineTool2 } from "@earendil-works/pi-coding-agent";
760
- import { join as join7 } from "node:path";
761
508
  import { z as z3 } from "zod";
762
509
 
763
510
  // src/log.ts
764
- import { randomUUID } from "node:crypto";
765
- import { appendFile, readFile as readFile3 } from "node:fs/promises";
766
- async function appendLogEntry(logPath, event) {
767
- const entry = {
768
- ...event,
769
- version: 1,
770
- eventId: event.eventId ?? randomUUID(),
771
- timestamp: event.timestamp ?? new Date().toISOString()
772
- };
773
- await appendFile(logPath, `${JSON.stringify(entry)}
774
- `, { encoding: "utf8", mode: 384 });
775
- return entry;
776
- }
511
+ import { appendFile, lstat as lstat2, mkdir as mkdir2, readFile, realpath as realpath3 } from "node:fs/promises";
512
+ import { dirname as dirname2, isAbsolute as isAbsolute3, join as join4, relative, resolve as resolve3, sep } from "node:path";
777
513
 
778
514
  // src/store.ts
779
515
  import { createHash } from "node:crypto";
780
- import { lstat, mkdir as mkdir2, readlink, realpath as realpath2, symlink, unlink } from "node:fs/promises";
781
- import { homedir as homedir2 } from "node:os";
782
- import { dirname as dirname3, isAbsolute as isAbsolute2, join as join6, resolve as resolve2 } from "node:path";
516
+ import { lstat, mkdir, readlink, realpath as realpath2, symlink, unlink } from "node:fs/promises";
517
+ import { homedir } from "node:os";
518
+ import { dirname, isAbsolute as isAbsolute2, join as join3, resolve as resolve2 } from "node:path";
783
519
 
784
520
  // src/extensions/gitx.ts
785
521
  import { realpath } from "node:fs/promises";
786
- import { basename as basename2, isAbsolute, join as join5, resolve } from "node:path";
522
+ import { basename as basename2, isAbsolute, join as join2, resolve } from "node:path";
787
523
  async function gitToplevel(cwd) {
788
524
  const result = await run("git", ["-C", cwd, "rev-parse", "--show-toplevel"]);
789
525
  const top = result.stdout.trim();
@@ -795,7 +531,7 @@ async function inspectGitRepository(cwd) {
795
531
  const remote = remoteResult.code === 0 ? remoteResult.stdout.trim() : "";
796
532
  const commonResult = await run("git", ["-C", root, "rev-parse", "--git-common-dir"]);
797
533
  const common = commonResult.stdout.trim();
798
- const commonPath = commonResult.code === 0 && common ? resolve(isAbsolute(common) ? common : join5(root, common)) : root;
534
+ const commonPath = commonResult.code === 0 && common ? resolve(isAbsolute(common) ? common : join2(root, common)) : root;
799
535
  const commonDir = await canonicalPath(commonPath);
800
536
  return {
801
537
  root,
@@ -822,17 +558,17 @@ async function canonicalPath(path) {
822
558
 
823
559
  // src/store.ts
824
560
  var STORE_LINK = ".diffpi";
825
- var LEGACY_STORE_LINK = join6(".pi", "diffpi");
826
- function storeGlobalRoot(homeDir = homedir2()) {
827
- return join6(homeDir, ".difflab", "diffpi", "projects");
561
+ var LEGACY_STORE_LINK = join3(".pi", "diffpi");
562
+ function storeGlobalRoot(homeDir = homedir()) {
563
+ return join3(homeDir, ".difflab", "diffpi", "projects");
828
564
  }
829
- async function ensureStore(cwd, homeDir = homedir2()) {
565
+ async function ensureStore(cwd, homeDir = homedir()) {
830
566
  const repository = await inspectGitRepository(cwd);
831
567
  const root = repository.root;
832
568
  const slug = projectSlug(repository);
833
- const dest = join6(storeGlobalRoot(homeDir), slug);
834
- const link = join6(root, STORE_LINK);
835
- await mkdir2(dest, { recursive: true });
569
+ const dest = join3(storeGlobalRoot(homeDir), slug);
570
+ const link = join3(root, STORE_LINK);
571
+ await mkdir(dest, { recursive: true });
836
572
  try {
837
573
  await assertStoreLink(link, dest);
838
574
  } catch (error) {
@@ -840,32 +576,12 @@ async function ensureStore(cwd, homeDir = homedir2()) {
840
576
  throw error;
841
577
  await symlink(dest, link);
842
578
  }
843
- await removeLegacyStoreLink(join6(root, LEGACY_STORE_LINK), dest);
579
+ await removeLegacyStoreLink(join3(root, LEGACY_STORE_LINK), dest);
844
580
  return { slug, root, dest, link, linked: true };
845
581
  }
846
- async function logsDir(cwd, homeDir = homedir2()) {
582
+ async function storeDir(cwd, homeDir = homedir()) {
847
583
  const store = await ensureStore(cwd, homeDir);
848
- const dir = join6(store.link, "logs");
849
- await mkdir2(dir, { recursive: true });
850
- return dir;
851
- }
852
- async function reviewsDir(cwd, homeDir = homedir2()) {
853
- const store = await ensureStore(cwd, homeDir);
854
- const dir = join6(store.link, "review");
855
- await mkdir2(dir, { recursive: true });
856
- return dir;
857
- }
858
- async function completedReviewsDir(cwd, homeDir = homedir2()) {
859
- const store = await ensureStore(cwd, homeDir);
860
- const dir = join6(store.link, "reviews");
861
- await mkdir2(dir, { recursive: true });
862
- return dir;
863
- }
864
- async function sessionsDir(cwd, homeDir = homedir2()) {
865
- const store = await ensureStore(cwd, homeDir);
866
- const dir = join6(store.link, "sessions");
867
- await mkdir2(dir, { recursive: true });
868
- return dir;
584
+ return store.dest;
869
585
  }
870
586
  async function assertStoreLink(path, dest) {
871
587
  const entry = await lstat(path);
@@ -890,7 +606,7 @@ async function removeLegacyStoreLink(path, dest) {
890
606
  }
891
607
  async function symlinkTarget(path) {
892
608
  const target = await readlink(path);
893
- return canonicalPath2(isAbsolute2(target) ? target : resolve2(dirname3(path), target));
609
+ return canonicalPath2(isAbsolute2(target) ? target : resolve2(dirname(path), target));
894
610
  }
895
611
  async function canonicalPath2(path) {
896
612
  try {
@@ -905,41 +621,94 @@ function projectSlug(repository) {
905
621
  return `${readable}-${digest}`;
906
622
  }
907
623
 
624
+ // src/log.ts
625
+ async function appendLogEntry(logPath, entry) {
626
+ await appendFile(logPath, `${JSON.stringify(entry)}
627
+ `, { encoding: "utf8", mode: 384 });
628
+ return entry;
629
+ }
630
+ async function appendLog(cwd, filename, message, label) {
631
+ const entry = label === undefined ? { message } : { message, label };
632
+ return appendLogEntry(await resolveLogPath(cwd, filename), entry);
633
+ }
634
+ async function resolveLogPath(cwd, filename) {
635
+ if (!filename || isAbsolute3(filename))
636
+ throw new Error("Log filename must be a non-empty relative path.");
637
+ const root = await realpath3(cwd);
638
+ const lexicalTarget = resolve3(root, filename);
639
+ assertContained(root, lexicalTarget);
640
+ let safeRoot = root;
641
+ let target = lexicalTarget;
642
+ if (filename.startsWith(`.diffpi${sep}`)) {
643
+ const insideStore = filename.slice(`.diffpi${sep}`.length);
644
+ if (insideStore.split(sep).includes(".."))
645
+ throw new Error("Log filename must stay within cwd.");
646
+ safeRoot = await realpath3(await storeDir(root));
647
+ target = resolve3(safeRoot, insideStore);
648
+ assertContained(safeRoot, target);
649
+ if (target === safeRoot)
650
+ throw new Error("Log filename must name a file within the store.");
651
+ }
652
+ await ensureSafeParent(safeRoot, dirname2(target));
653
+ try {
654
+ const entry = await lstat2(target);
655
+ if (entry.isSymbolicLink())
656
+ throw new Error("Log filename must not point through a symlink.");
657
+ } catch (error) {
658
+ if (error.code !== "ENOENT")
659
+ throw error;
660
+ }
661
+ return target;
662
+ }
663
+ async function ensureSafeParent(root, parent) {
664
+ assertContained(root, parent);
665
+ let current = root;
666
+ const suffix = relative(root, parent);
667
+ if (!suffix)
668
+ return;
669
+ for (const part of suffix.split(sep)) {
670
+ current = join4(current, part);
671
+ try {
672
+ await mkdir2(current);
673
+ } catch (error) {
674
+ if (error.code !== "EEXIST")
675
+ throw error;
676
+ }
677
+ const entry = await lstat2(current);
678
+ if (!entry.isDirectory() || entry.isSymbolicLink())
679
+ throw new Error("Log filename must not pass through a symlink or non-directory.");
680
+ }
681
+ }
682
+ function assertContained(root, target) {
683
+ const targetRelative = relative(root, target);
684
+ if (targetRelative === ".." || targetRelative.startsWith(`..${sep}`) || isAbsolute3(targetRelative))
685
+ throw new Error("Log filename must stay within cwd.");
686
+ }
687
+
908
688
  // src/tools/log.ts
909
689
  var schema = z3.object({
910
690
  cwd: zx.cwd,
911
- channel: zx.id,
912
- kind: z3.enum(["progress", "issue", "deviation"]),
913
- actor: zx.text,
914
- message: zx.text,
915
- correlation: z3.record(z3.string(), z3.string()).optional(),
916
- evidence: z3.array(zx.text).optional(),
917
- data: z3.record(z3.string(), z3.unknown()).optional()
691
+ filename: z3.string().trim().min(1),
692
+ label: z3.string().trim().min(1).optional(),
693
+ message: zx.text
918
694
  }).strict();
919
695
  var diffpiLogTool = defineTool2({
920
696
  name: "diffpi_log",
921
697
  label: "diffpi log",
922
- description: "Append a reusable progress, issue, or deviation event to a project-scoped Diffpi JSONL channel.",
923
- promptSnippet: "Use diffpi_log when a skill or workflow needs durable progress, issue, or deviation events.",
698
+ description: "Append a message to a caller-selected relative project JSONL file.",
699
+ promptSnippet: "Use diffpi_log to append durable progress messages to the supplied relative JSONL filename.",
924
700
  promptGuidelines: [
925
- "Use stable channel names shared by one workflow, such as plan, flow, or session.",
926
- "Keep plan status and task checkboxes in live PLAN.md files; diffpi_log is a general activity log, not another task system."
701
+ "For /plan go, use cwd at the canonical Git root and filename .diffpi/plan/<plan-id>/logs.jsonl, directly beside PLAN.md.",
702
+ "Use label only when a reusable category helps readers filter messages."
927
703
  ],
928
704
  parameters: z3.toJSONSchema(schema, { io: "input" }),
929
705
  executionMode: "sequential",
930
706
  async execute(_id, input) {
931
707
  const params = schema.parse(input);
932
- const entry = await appendLogEntry(join7(await logsDir(params.cwd ?? process.cwd()), `${params.channel}.jsonl`), {
933
- kind: params.kind,
934
- actor: params.actor,
935
- message: params.message,
936
- correlation: params.correlation,
937
- evidence: params.evidence,
938
- data: params.data
939
- });
708
+ const entry = await appendLog(params.cwd ?? process.cwd(), params.filename, params.message, params.label);
940
709
  return {
941
- content: [{ type: "text", text: `Logged ${entry.eventId} to ${params.channel}.` }],
942
- details: { entry, channel: params.channel }
710
+ content: [{ type: "text", text: `Logged message to ${params.filename}.` }],
711
+ details: { entry, filename: params.filename }
943
712
  };
944
713
  }
945
714
  });
@@ -950,19 +719,35 @@ import { z as z5 } from "zod";
950
719
 
951
720
  // src/doctor.ts
952
721
  import { createHash as createHash2 } from "node:crypto";
953
- import { lstat as lstat2, mkdir as mkdir6, readFile as readFile8, readdir as readdir3, writeFile as writeFile5 } from "node:fs/promises";
954
- import { basename as basename5, join as join13 } from "node:path";
722
+ import { lstat as lstat3, mkdir as mkdir6, readFile as readFile6, readdir as readdir3, writeFile as writeFile4 } from "node:fs/promises";
723
+ import { basename as basename5, join as join11 } from "node:path";
724
+
725
+ // src/assets.ts
726
+ import { existsSync } from "node:fs";
727
+ import { dirname as dirname3, join as join5 } from "node:path";
728
+ import { fileURLToPath } from "node:url";
729
+ function resolveBundledAssetDir(name, moduleUrl = import.meta.url) {
730
+ const moduleDir = dirname3(fileURLToPath(moduleUrl));
731
+ const candidates = [join5(moduleDir, name), join5(moduleDir, "..", name), join5(moduleDir, "..", "..", name)];
732
+ return candidates.find((path) => existsSync(path)) ?? candidates[1];
733
+ }
734
+ function resolveBundledAgentsDir(moduleUrl = import.meta.url) {
735
+ return resolveBundledAssetDir("agents", moduleUrl);
736
+ }
737
+ function resolveBundledTemplatesDir(moduleUrl = import.meta.url) {
738
+ return resolveBundledAssetDir("templates", moduleUrl);
739
+ }
955
740
 
956
741
  // src/pi.ts
957
- import { mkdir as mkdir3, writeFile as writeFile2 } from "node:fs/promises";
958
- import { homedir as homedir3 } from "node:os";
959
- import { dirname as dirname4, join as join8 } from "node:path";
742
+ import { mkdir as mkdir3, writeFile } from "node:fs/promises";
743
+ import { homedir as homedir2 } from "node:os";
744
+ import { dirname as dirname4, join as join6 } from "node:path";
960
745
 
961
746
  // src/fsx.ts
962
- import { readdir, readFile as readFile4 } from "node:fs/promises";
747
+ import { readdir, readFile as readFile2 } from "node:fs/promises";
963
748
  async function readTextIfExists(path) {
964
749
  try {
965
- return await readFile4(path, "utf8");
750
+ return await readFile2(path, "utf8");
966
751
  } catch (error) {
967
752
  if (isMissingPath(error))
968
753
  return;
@@ -986,19 +771,19 @@ var pi = {
986
771
  configEnsure: ensurePiConfig
987
772
  };
988
773
  async function ensurePiAgent(filename, content, agentDir = resolvePiAgentDir(), dryRun = false) {
989
- const path = join8(agentDir, "agents", filename);
774
+ const path = join6(agentDir, "agents", filename);
990
775
  const currentText = await readTextIfExists(path);
991
776
  const changed = currentText !== content;
992
777
  if (changed && !dryRun) {
993
778
  await mkdir3(dirname4(path), { recursive: true });
994
- await writeFile2(path, content, "utf8");
779
+ await writeFile(path, content, "utf8");
995
780
  }
996
781
  return { path, changed, existed: currentText !== undefined, planned: changed && dryRun };
997
782
  }
998
- async function checkGlobalPiSkill(name, agentDir = resolvePiAgentDir(), sharedSkillsDir = join8(homedir3(), ".agents", "skills")) {
999
- const roots = [join8(agentDir, "skills"), sharedSkillsDir];
783
+ async function checkGlobalPiSkill(name, agentDir = resolvePiAgentDir(), sharedSkillsDir = join6(homedir2(), ".agents", "skills")) {
784
+ const roots = [join6(agentDir, "skills"), sharedSkillsDir];
1000
785
  for (const root of roots) {
1001
- if (await readTextIfExists(join8(root, name, "SKILL.md")) !== undefined)
786
+ if (await readTextIfExists(join6(root, name, "SKILL.md")) !== undefined)
1002
787
  return true;
1003
788
  }
1004
789
  return false;
@@ -1028,7 +813,7 @@ async function ensurePiConfig(path, update, dryRun = false) {
1028
813
  const changed = JSON.stringify(current) !== JSON.stringify(next);
1029
814
  if (changed && !dryRun) {
1030
815
  await mkdir3(dirname4(path), { recursive: true });
1031
- await writeFile2(path, `${JSON.stringify(next, null, 2)}
816
+ await writeFile(path, `${JSON.stringify(next, null, 2)}
1032
817
  `, "utf8");
1033
818
  }
1034
819
  return { path, changed, existed: currentText !== undefined, planned: changed && dryRun };
@@ -1047,8 +832,8 @@ function hasPiPackage(listOutput, source) {
1047
832
  async function installPiPackage(executable, source) {
1048
833
  await runChecked(executable, ["install", source]);
1049
834
  }
1050
- function resolvePiAgentDir(homeDir = homedir3()) {
1051
- return process.env.PI_CODING_AGENT_DIR ?? (process.env.XDG_CONFIG_HOME ? join8(process.env.XDG_CONFIG_HOME, "pi") : join8(homeDir, ".pi", "agent"));
835
+ function resolvePiAgentDir(homeDir = homedir2()) {
836
+ return process.env.PI_CODING_AGENT_DIR ?? (process.env.XDG_CONFIG_HOME ? join6(process.env.XDG_CONFIG_HOME, "pi") : join6(homeDir, ".pi", "agent"));
1052
837
  }
1053
838
  function parseJsonObject(content, path) {
1054
839
  if (!content?.trim())
@@ -1063,15 +848,15 @@ function parseJsonObject(content, path) {
1063
848
 
1064
849
  // src/setup.ts
1065
850
  import { parseFrontmatter as parseFrontmatter2 } from "@earendil-works/pi-coding-agent";
1066
- import { readdir as readdir2, readFile as readFile7 } from "node:fs/promises";
1067
- import { homedir as homedir7 } from "node:os";
1068
- import { basename as basename4, join as join12 } from "node:path";
851
+ import { readdir as readdir2, readFile as readFile5 } from "node:fs/promises";
852
+ import { homedir as homedir6 } from "node:os";
853
+ import { basename as basename4, join as join10 } from "node:path";
1069
854
 
1070
855
  // src/config.ts
1071
856
  import { parseFrontmatter } from "@earendil-works/pi-coding-agent";
1072
857
  import { z as z4 } from "zod";
1073
- import { homedir as homedir4 } from "node:os";
1074
- import { join as join9 } from "node:path";
858
+ import { homedir as homedir3 } from "node:os";
859
+ import { join as join7 } from "node:path";
1075
860
  var modelReferenceSchema = zx.text;
1076
861
  var agentConfigSchema = z4.object({
1077
862
  models: z4.array(modelReferenceSchema).optional()
@@ -1079,11 +864,11 @@ var agentConfigSchema = z4.object({
1079
864
  var diffpiConfigSchema = z4.object({
1080
865
  agents: z4.record(z4.string(), agentConfigSchema).optional()
1081
866
  }).strict();
1082
- function diffpiConfigPaths(homeDir = homedir4()) {
1083
- const directory = join9(homeDir, ".difflab", "diffpi");
867
+ function diffpiConfigPaths(homeDir = homedir3()) {
868
+ const directory = join7(homeDir, ".difflab", "diffpi");
1084
869
  return {
1085
- yaml: join9(directory, "config.yaml"),
1086
- json: join9(directory, "config.json")
870
+ yaml: join7(directory, "config.yaml"),
871
+ json: join7(directory, "config.json")
1087
872
  };
1088
873
  }
1089
874
  async function loadDiffpiConfig(options = {}) {
@@ -1139,12 +924,12 @@ function normalizeModelReference(value) {
1139
924
  }
1140
925
 
1141
926
  // src/mcp.ts
1142
- import { mkdir as mkdir4, readFile as readFile5, writeFile as writeFile3 } from "node:fs/promises";
1143
- import { homedir as homedir5 } from "node:os";
1144
- import { dirname as dirname5, join as join10 } from "node:path";
927
+ import { mkdir as mkdir4, readFile as readFile3, writeFile as writeFile2 } from "node:fs/promises";
928
+ import { homedir as homedir4 } from "node:os";
929
+ import { dirname as dirname5, join as join8 } from "node:path";
1145
930
  var mcp = {
1146
- globalConfigPath(homeDir = homedir5()) {
1147
- return join10(homeDir, ".config", "mcp", "mcp.json");
931
+ globalConfigPath(homeDir = homedir4()) {
932
+ return join8(homeDir, ".config", "mcp", "mcp.json");
1148
933
  },
1149
934
  async serversEnsure(servers, options = {}) {
1150
935
  const path = options.path ?? mcp.globalConfigPath();
@@ -1158,7 +943,7 @@ var mcp = {
1158
943
  const changed = JSON.stringify(current) !== JSON.stringify(next);
1159
944
  if (changed && !options.dryRun) {
1160
945
  await mkdir4(dirname5(path), { recursive: true });
1161
- await writeFile3(path, `${JSON.stringify(next, null, 2)}
946
+ await writeFile2(path, `${JSON.stringify(next, null, 2)}
1162
947
  `, "utf8");
1163
948
  }
1164
949
  return { path, changed, existed: currentText !== undefined, planned: changed && options.dryRun === true };
@@ -1187,7 +972,7 @@ function getParsedConfig(content, path) {
1187
972
  }
1188
973
  async function getOptionalFile(path) {
1189
974
  try {
1190
- return await readFile5(path, "utf8");
975
+ return await readFile3(path, "utf8");
1191
976
  } catch (error) {
1192
977
  if (error instanceof Error && "code" in error && error.code === "ENOENT")
1193
978
  return;
@@ -1199,9 +984,9 @@ function isRecord(value) {
1199
984
  }
1200
985
 
1201
986
  // src/extensions/misex.ts
1202
- import { mkdir as mkdir5, readFile as readFile6, writeFile as writeFile4 } from "node:fs/promises";
1203
- import { homedir as homedir6 } from "node:os";
1204
- import { basename as basename3, dirname as dirname6, join as join11 } from "node:path";
987
+ import { mkdir as mkdir5, readFile as readFile4, writeFile as writeFile3 } from "node:fs/promises";
988
+ import { homedir as homedir5 } from "node:os";
989
+ import { basename as basename3, dirname as dirname6, join as join9 } from "node:path";
1205
990
  var MISE_HOOK_START = "# >>> @difflab/pi mise >>>";
1206
991
  var MISE_HOOK_END = "# <<< @difflab/pi mise <<<";
1207
992
  var mise = {
@@ -1212,11 +997,11 @@ var mise = {
1212
997
  return run("mise", args, options);
1213
998
  },
1214
999
  async install(options = {}) {
1215
- const homeDir = options.homeDir ?? homedir6();
1000
+ const homeDir = options.homeDir ?? homedir5();
1216
1001
  const platform = options.platform ?? process.platform;
1217
1002
  if (platform === "win32")
1218
1003
  throw new Error("Automatic mise installation supports macOS and Linux only.");
1219
- const installedPath = join11(homeDir, ".local", "bin", "mise");
1004
+ const installedPath = join9(homeDir, ".local", "bin", "mise");
1220
1005
  if (options.dryRun)
1221
1006
  return installedPath;
1222
1007
  await runChecked("sh", ["-c", "curl -fsSL https://mise.run | sh"]);
@@ -1226,7 +1011,7 @@ var mise = {
1226
1011
  return executable;
1227
1012
  },
1228
1013
  async hookEnsure(executable, options = {}) {
1229
- const homeDir = options.homeDir ?? homedir6();
1014
+ const homeDir = options.homeDir ?? homedir5();
1230
1015
  const hook = getShellHook(basename3(options.shell ?? process.env.SHELL ?? ""), executable, homeDir);
1231
1016
  const current = await getOptionalFile2(hook.path);
1232
1017
  if (current.includes(MISE_HOOK_START))
@@ -1237,7 +1022,7 @@ var mise = {
1237
1022
  `) ? "" : `
1238
1023
  `;
1239
1024
  await mkdir5(dirname6(hook.path), { recursive: true });
1240
- await writeFile4(hook.path, `${current}${separator}${hook.content}`, "utf8");
1025
+ await writeFile3(hook.path, `${current}${separator}${hook.content}`, "utf8");
1241
1026
  return { path: hook.path, changed: true, planned: false };
1242
1027
  },
1243
1028
  async toolCheckGlobal(executable, tool, minimumVersion) {
@@ -1254,7 +1039,7 @@ var mise = {
1254
1039
  async toolInstallLocal(executable, specification, cwd = process.cwd()) {
1255
1040
  await runChecked(executable, ["use", "--path", cwd, specification], { cwd });
1256
1041
  },
1257
- async toolUpdateAllGlobal(executable, homeDir = homedir6()) {
1042
+ async toolUpdateAllGlobal(executable, homeDir = homedir5()) {
1258
1043
  await runChecked(executable, ["upgrade"], { cwd: homeDir });
1259
1044
  }
1260
1045
  };
@@ -1263,7 +1048,7 @@ function getShellHook(shell, executable, homeDir) {
1263
1048
  switch (shell.toLowerCase()) {
1264
1049
  case "zsh":
1265
1050
  return {
1266
- path: join11(homeDir, ".zshrc"),
1051
+ path: join9(homeDir, ".zshrc"),
1267
1052
  content: `${MISE_HOOK_START}
1268
1053
  eval "$(${command} activate zsh)"
1269
1054
  ${MISE_HOOK_END}
@@ -1271,7 +1056,7 @@ ${MISE_HOOK_END}
1271
1056
  };
1272
1057
  case "fish":
1273
1058
  return {
1274
- path: join11(homeDir, ".config", "fish", "config.fish"),
1059
+ path: join9(homeDir, ".config", "fish", "config.fish"),
1275
1060
  content: `${MISE_HOOK_START}
1276
1061
  ${command} activate fish | source
1277
1062
  ${MISE_HOOK_END}
@@ -1280,7 +1065,7 @@ ${MISE_HOOK_END}
1280
1065
  case "nu":
1281
1066
  case "nushell":
1282
1067
  return {
1283
- path: join11(homeDir, ".config", "nushell", "config.nu"),
1068
+ path: join9(homeDir, ".config", "nushell", "config.nu"),
1284
1069
  content: `${MISE_HOOK_START}
1285
1070
  let mise_bin = ${command}
1286
1071
  let mise_path = $nu.default-config-dir | path join mise.nu
@@ -1291,7 +1076,7 @@ ${MISE_HOOK_END}
1291
1076
  };
1292
1077
  case "xonsh":
1293
1078
  return {
1294
- path: join11(homeDir, ".xonshrc"),
1079
+ path: join9(homeDir, ".xonshrc"),
1295
1080
  content: `${MISE_HOOK_START}
1296
1081
  execx($(${command} activate xonsh))
1297
1082
  ${MISE_HOOK_END}
@@ -1299,7 +1084,7 @@ ${MISE_HOOK_END}
1299
1084
  };
1300
1085
  case "elvish":
1301
1086
  return {
1302
- path: join11(homeDir, ".config", "elvish", "rc.elv"),
1087
+ path: join9(homeDir, ".config", "elvish", "rc.elv"),
1303
1088
  content: `${MISE_HOOK_START}
1304
1089
  var mise: = (ns [&])
1305
1090
  eval (${command} activate elvish | slurp) &ns=$mise: &on-end={|ns| set mise: = $ns }
@@ -1310,7 +1095,7 @@ ${MISE_HOOK_END}
1310
1095
  case "pwsh":
1311
1096
  case "powershell":
1312
1097
  return {
1313
- path: join11(homeDir, ".config", "powershell", "Microsoft.PowerShell_profile.ps1"),
1098
+ path: join9(homeDir, ".config", "powershell", "Microsoft.PowerShell_profile.ps1"),
1314
1099
  content: `${MISE_HOOK_START}
1315
1100
  (& ${command} activate pwsh) | Out-String | Invoke-Expression
1316
1101
  ${MISE_HOOK_END}
@@ -1319,7 +1104,7 @@ ${MISE_HOOK_END}
1319
1104
  case "bash":
1320
1105
  default:
1321
1106
  return {
1322
- path: join11(homeDir, ".bashrc"),
1107
+ path: join9(homeDir, ".bashrc"),
1323
1108
  content: `${MISE_HOOK_START}
1324
1109
  eval "$(${command} activate bash)"
1325
1110
  ${MISE_HOOK_END}
@@ -1358,7 +1143,7 @@ function isVersionAtLeast(version, minimumVersion) {
1358
1143
  }
1359
1144
  async function getOptionalFile2(path) {
1360
1145
  try {
1361
- return await readFile6(path, "utf8");
1146
+ return await readFile4(path, "utf8");
1362
1147
  } catch (error) {
1363
1148
  if (error instanceof Error && "code" in error && error.code === "ENOENT")
1364
1149
  return "";
@@ -1374,12 +1159,6 @@ var MISE_DEPENDENCIES = [
1374
1159
  { name: "node", tool: "node", spec: "node@22", minimumVersion: "22.19.0" },
1375
1160
  { name: "zellij", tool: "zellij", spec: "zellij@latest", minimumVersion: undefined },
1376
1161
  { name: "helix", tool: "helix", spec: "helix@latest", minimumVersion: undefined },
1377
- {
1378
- name: "tuicr",
1379
- tool: "github:agavra/tuicr",
1380
- spec: "github:agavra/tuicr@latest",
1381
- minimumVersion: undefined
1382
- },
1383
1162
  {
1384
1163
  name: "context-mode",
1385
1164
  tool: "npm:context-mode",
@@ -1408,8 +1187,8 @@ var FORGE_DEPENDENCIES = {
1408
1187
  gitlab: { name: "glab", tool: "glab", spec: "glab@latest", minimumVersion: undefined }
1409
1188
  };
1410
1189
  async function ensureMise(options = {}) {
1411
- const homeDir = options.homeDir ?? homedir7();
1412
- const current = await mise.executableCheck() ?? await mise.executableCheck(join12(homeDir, ".local", "bin", "mise"));
1190
+ const homeDir = options.homeDir ?? homedir6();
1191
+ const current = await mise.executableCheck() ?? await mise.executableCheck(join10(homeDir, ".local", "bin", "mise"));
1413
1192
  if (current)
1414
1193
  return { executable: current, action: createSetupAction("mise", "ready", current) };
1415
1194
  reportProgress(options, "Installing mise");
@@ -1458,9 +1237,9 @@ async function ensureMiseDeps(miseExecutable, options = {}) {
1458
1237
  async function ensurePiPlugins(options = {}) {
1459
1238
  const actions = await ensurePiPackages(PI_PACKAGES, options);
1460
1239
  const agentDir = options.agentDir ?? pi.agentDir(options.homeDir);
1461
- const webSearch = await pi.configEnsure(join12(agentDir, "web-search.json"), (config) => ({ ...config, workflow: "auto-summary" }), options.dryRun);
1240
+ const webSearch = await pi.configEnsure(join10(agentDir, "web-search.json"), (config) => ({ ...config, workflow: "auto-summary" }), options.dryRun);
1462
1241
  actions.push(getConfigSetupAction("web search settings", webSearch));
1463
- const lsp = await pi.configEnsure(join12(agentDir, "pi-lsp.json"), (config) => ({
1242
+ const lsp = await pi.configEnsure(join10(agentDir, "pi-lsp.json"), (config) => ({
1464
1243
  ...config,
1465
1244
  progressive: { ...getRecord(config.progressive), enabled: true, inject: "none" }
1466
1245
  }), options.dryRun);
@@ -1475,7 +1254,7 @@ async function ensurePiAgents(options = {}) {
1475
1254
  const actions = [];
1476
1255
  for (const entry of entries) {
1477
1256
  const id = basename4(entry.name, ".md").replace(/^diffpi-/, "");
1478
- const source = await readFile7(join12(bundledAgentsDir, entry.name), "utf8");
1257
+ const source = await readFile5(join10(bundledAgentsDir, entry.name), "utf8");
1479
1258
  const content = materializeAgentModels(source, id, userConfig.config, options.availableModels);
1480
1259
  const result = await pi.agentEnsure(entry.name, content, agentDir, options.dryRun);
1481
1260
  actions.push(getConfigSetupAction(`pi agent ${id}`, result));
@@ -1484,7 +1263,7 @@ async function ensurePiAgents(options = {}) {
1484
1263
  }
1485
1264
  async function ensurePiSkills(miseExecutable, options = {}) {
1486
1265
  const agentDir = options.agentDir ?? pi.agentDir(options.homeDir);
1487
- const sharedSkillsDir = join12(options.homeDir ?? homedir7(), ".agents", "skills");
1266
+ const sharedSkillsDir = join10(options.homeDir ?? homedir6(), ".agents", "skills");
1488
1267
  const actions = [];
1489
1268
  for (const source of PI_SKILL_SOURCES) {
1490
1269
  const missing = [];
@@ -1552,8 +1331,6 @@ async function setupPi(options = {}) {
1552
1331
  actions.push(...await ensurePiAgents(options));
1553
1332
  actions.push(...await ensurePiSkills(miseResult.executable, options));
1554
1333
  actions.push(...await ensureMcpAdapters(miseResult.executable, options));
1555
- if (options.bindZedKey)
1556
- actions.push(...await ensureZedIntegration(options));
1557
1334
  return {
1558
1335
  actions,
1559
1336
  restartPi: setupRequiresRestart(actions)
@@ -1562,35 +1339,6 @@ async function setupPi(options = {}) {
1562
1339
  function setupRequiresRestart(actions) {
1563
1340
  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"));
1564
1341
  }
1565
- async function ensureZedIntegration(options = {}) {
1566
- if (options.dryRun) {
1567
- const actions = [
1568
- createSetupAction("Zed review tasks", "planned", "global static runtime-resolver tasks in tasks.json"),
1569
- createSetupAction("Zed plan task", "planned", "pinned package CLI task in tasks.json")
1570
- ];
1571
- if (options.bindZedKey)
1572
- actions.push(createSetupAction("Zed review keybinding", "planned", "keymap.json"));
1573
- return actions;
1574
- }
1575
- const actions = [];
1576
- try {
1577
- const tasks = await ensureZedReviewTask(options.homeDir);
1578
- actions.push(createSetupAction("Zed review tasks", tasks.changed ? "installed" : "ready", tasks.path));
1579
- const planTask = await ensureZedPlanTask(await packageVersion2(), options.homeDir);
1580
- actions.push(createSetupAction("Zed plan task", planTask.changed ? "installed" : "ready", planTask.path));
1581
- } catch (error) {
1582
- actions.push(createSetupAction("Zed review tasks", "skipped", error instanceof Error ? error.message : String(error)));
1583
- }
1584
- if (options.bindZedKey) {
1585
- try {
1586
- const key = await ensureZedReviewKeybinding(options.homeDir);
1587
- actions.push(createSetupAction("Zed review keybinding", key.changed ? "installed" : "ready", key.path));
1588
- } catch (error) {
1589
- actions.push(createSetupAction("Zed review keybinding", "skipped", error instanceof Error ? error.message : String(error)));
1590
- }
1591
- }
1592
- return actions;
1593
- }
1594
1342
  async function gitlabMcpHost(projectDir) {
1595
1343
  try {
1596
1344
  const vcs = await detectVcs(projectDir);
@@ -1679,18 +1427,6 @@ function createSetupAction(name, status, detail) {
1679
1427
  function reportProgress(options, message) {
1680
1428
  options.onProgress?.(message);
1681
1429
  }
1682
- async function packageVersion2() {
1683
- const source = await readFile7(join12(resolveBundledAgentsDir(), "..", "package.json"), "utf8");
1684
- let value;
1685
- try {
1686
- value = JSON.parse(source);
1687
- } catch (error) {
1688
- throw new Error("Cannot parse the installed @difflab/pi package metadata.", { cause: error });
1689
- }
1690
- if (typeof value.version !== "string")
1691
- throw new Error("Cannot resolve the installed @difflab/pi version.");
1692
- return value.version;
1693
- }
1694
1430
  function getRecord(value) {
1695
1431
  return value !== null && typeof value === "object" && !Array.isArray(value) ? value : {};
1696
1432
  }
@@ -1706,13 +1442,13 @@ var REQUIRED_AGENTS = [
1706
1442
  async function doctorAgents(options = {}) {
1707
1443
  const bundledDir = options.bundledAgentsDir ?? resolveBundledAgentsDir();
1708
1444
  const agentDir = options.agentDir ?? pi.agentDir(options.homeDir);
1709
- const targetDir = join13(agentDir, "agents");
1445
+ const targetDir = join11(agentDir, "agents");
1710
1446
  const bundled = (await readdir3(bundledDir, { withFileTypes: true })).filter((entry) => entry.isFile() && entry.name.startsWith("diffpi-") && entry.name.endsWith(".md")).map((entry) => entry.name);
1711
1447
  for (const name of REQUIRED_AGENTS)
1712
1448
  if (!bundled.includes(name))
1713
1449
  throw new Error(`Installed @difflab/pi is missing bundled agent ${name}`);
1714
1450
  for (const name of bundled) {
1715
- const content = await readFile8(join13(bundledDir, name), "utf8");
1451
+ const content = await readFile6(join11(bundledDir, name), "utf8");
1716
1452
  if (content.includes("diffpi_modes_"))
1717
1453
  throw new Error(`Installed agent ${name} still requires removed mode tools`);
1718
1454
  }
@@ -1726,10 +1462,10 @@ async function doctorAgents(options = {}) {
1726
1462
  if (!bundled.includes(basename5(path)))
1727
1463
  throw new Error(`Unexpected agent destination: ${path}`);
1728
1464
  try {
1729
- const stat = await lstat2(path);
1465
+ const stat = await lstat3(path);
1730
1466
  if (!stat.isFile())
1731
1467
  throw new Error(`Refusing to overwrite non-file agent: ${path}`);
1732
- originals.push({ path, content: await readFile8(path) });
1468
+ originals.push({ path, content: await readFile6(path) });
1733
1469
  } catch (error) {
1734
1470
  if (error.code !== "ENOENT")
1735
1471
  throw error;
@@ -1737,17 +1473,17 @@ async function doctorAgents(options = {}) {
1737
1473
  }
1738
1474
  const backups = [];
1739
1475
  if (originals.length > 0) {
1740
- const backupDir = join13(targetDir, ".diffpi-doctor-backups");
1476
+ const backupDir = join11(targetDir, ".diffpi-doctor-backups");
1741
1477
  await mkdir6(backupDir, { recursive: true, mode: 448 });
1742
1478
  for (const original of originals) {
1743
1479
  const digest = createHash2("sha256").update(original.content).digest("hex").slice(0, 16);
1744
- const backupPath = join13(backupDir, `${basename5(original.path)}.${digest}.bak`);
1480
+ const backupPath = join11(backupDir, `${basename5(original.path)}.${digest}.bak`);
1745
1481
  try {
1746
- await writeFile5(backupPath, original.content, { flag: "wx", mode: 384 });
1482
+ await writeFile4(backupPath, original.content, { flag: "wx", mode: 384 });
1747
1483
  } catch (error) {
1748
1484
  if (error.code !== "EEXIST")
1749
1485
  throw error;
1750
- const existing = await readFile8(backupPath);
1486
+ const existing = await readFile6(backupPath);
1751
1487
  if (!existing.equals(original.content))
1752
1488
  throw new Error(`Backup hash collision: ${backupPath}`);
1753
1489
  }
@@ -1799,14 +1535,14 @@ import { defineTool as defineTool4 } from "@earendil-works/pi-coding-agent";
1799
1535
  import { z as z6 } from "zod";
1800
1536
 
1801
1537
  // src/plan/verify.ts
1802
- import { readFile as readFile9, readdir as readdir4, stat } from "node:fs/promises";
1803
- import { basename as basename6, dirname as dirname7, join as join14, resolve as resolve3 } from "node:path";
1538
+ import { lstat as lstat4, readFile as readFile7, readdir as readdir4, stat } from "node:fs/promises";
1539
+ import { basename as basename6, dirname as dirname7, join as join12, resolve as resolve4 } from "node:path";
1804
1540
  async function verifyLivePlan(requested, cwd = process.cwd()) {
1805
- const path = resolve3(cwd, requested);
1541
+ const path = resolve4(cwd, requested);
1806
1542
  let planPath = path;
1807
1543
  try {
1808
1544
  if ((await stat(path)).isDirectory())
1809
- planPath = join14(path, "PLAN.md");
1545
+ planPath = join12(path, "PLAN.md");
1810
1546
  } catch {}
1811
1547
  const issues = [];
1812
1548
  const briefPaths = [];
@@ -1819,7 +1555,7 @@ async function verifyLivePlan(requested, cwd = process.cwd()) {
1819
1555
  addIssue(issues, planPath, 0, "layout", "Place PLAN.md under .diffpi/plan/<YYMMDD[-ticket]-short-slug>/.");
1820
1556
  let source;
1821
1557
  try {
1822
- source = await readFile9(planPath, "utf8");
1558
+ source = await readFile7(planPath, "utf8");
1823
1559
  } catch {
1824
1560
  addIssue(issues, planPath, 0, "missing-plan", "PLAN.md is not readable.");
1825
1561
  return { planPath, briefPaths, ok: false, issues };
@@ -1833,7 +1569,7 @@ async function verifyLivePlan(requested, cwd = process.cwd()) {
1833
1569
  if (!planLines[line]?.startsWith(`### Phase ${index + 1}: `))
1834
1570
  addIssue(issues, planPath, line + 1, "phase-order", "Number phase headings from 1 without gaps.");
1835
1571
  }
1836
- const implementation = join14(planDir, "implementation");
1572
+ const implementation = join12(planDir, "implementation");
1837
1573
  let rootEntries = [];
1838
1574
  let briefEntries = [];
1839
1575
  try {
@@ -1841,19 +1577,28 @@ async function verifyLivePlan(requested, cwd = process.cwd()) {
1841
1577
  briefEntries = await readdir4(implementation);
1842
1578
  } catch {}
1843
1579
  for (const entry of rootEntries) {
1844
- if (entry !== "PLAN.md" && entry !== "implementation")
1845
- addIssue(issues, join14(planDir, entry), 0, "layout", "Only PLAN.md and implementation/ belong in the plan directory.");
1580
+ const directory = ["implementation", "revisions", "logs"].includes(entry);
1581
+ const logFile = entry === "logs.jsonl";
1582
+ let valid = entry === "PLAN.md";
1583
+ if (directory || logFile) {
1584
+ try {
1585
+ const details = await lstat4(join12(planDir, entry));
1586
+ valid = !details.isSymbolicLink() && (directory ? details.isDirectory() : details.isFile());
1587
+ } catch {}
1588
+ }
1589
+ if (!valid)
1590
+ addIssue(issues, join12(planDir, entry), 0, "layout", "Only PLAN.md, implementation/, revisions/, logs.jsonl, and historical logs/ belong in the plan directory.");
1846
1591
  }
1847
1592
  for (const entry of briefEntries) {
1848
1593
  const ordinal = /^phase-(\d+)\.md$/.exec(entry)?.[1];
1849
1594
  if (!ordinal || !Number(ordinal) || Number(ordinal) > phases.length || entry !== `phase-${Number(ordinal)}.md`)
1850
- addIssue(issues, join14(implementation, entry), 0, "extra-brief", "Only matching numbered phase briefs belong in implementation/.");
1595
+ addIssue(issues, join12(implementation, entry), 0, "extra-brief", "Only matching numbered phase briefs belong in implementation/.");
1851
1596
  }
1852
1597
  for (let index = 0;index < phases.length; index++) {
1853
- const brief = join14(implementation, `phase-${index + 1}.md`);
1598
+ const brief = join12(implementation, `phase-${index + 1}.md`);
1854
1599
  briefPaths.push(brief);
1855
1600
  try {
1856
- const lines = visibleLines(await readFile9(brief, "utf8"));
1601
+ const lines = visibleLines(await readFile7(brief, "utf8"));
1857
1602
  if (!/^# Phase \d+:\s*\S/.test(lines[0] ?? "") || !lines[0]?.startsWith(`# Phase ${index + 1}: `))
1858
1603
  addIssue(issues, brief, 1, "phase-title", `Start with # Phase ${index + 1}: <title>.`);
1859
1604
  checkHeadings(lines, brief, ["Objective", "Files Affected", "Tasks", "Implementation Constraints"], issues);
@@ -1934,11 +1679,8 @@ function createDiffpiReloadTool(pi) {
1934
1679
  }
1935
1680
 
1936
1681
  // src/tools/review.ts
1937
- import { existsSync as existsSync2 } from "node:fs";
1938
- import { mkdir as mkdir7, readdir as readdir5, readFile as readFile14, rename as rename2, stat as stat2, unlink as unlink2, writeFile as writeFile8 } from "node:fs/promises";
1939
- import { join as join17 } from "node:path";
1940
1682
  import { defineTool as defineTool6 } from "@earendil-works/pi-coding-agent";
1941
- import { z as z11 } from "zod";
1683
+ import { z as z10 } from "zod";
1942
1684
 
1943
1685
  // src/gates.ts
1944
1686
  var CONVENTIONAL_COMMIT = /^(feat|fix|perf|refactor|docs|chore|test|build|ci|style|revert)(\([^)]+\))?!?: .+/;
@@ -2010,33 +1752,6 @@ var findingSchema = z8.object({
2010
1752
  reference: z8.string().optional().default("")
2011
1753
  });
2012
1754
  var findingsSchema = z8.array(findingSchema);
2013
- function reviewSlug(input) {
2014
- return input.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 50);
2015
- }
2016
- function yymmdd(date = new Date) {
2017
- const yy = String(date.getFullYear() % 100).padStart(2, "0");
2018
- const mm = String(date.getMonth() + 1).padStart(2, "0");
2019
- const dd = String(date.getDate()).padStart(2, "0");
2020
- return `${yy}${mm}${dd}`;
2021
- }
2022
- function reviewRecordName(target, date = new Date) {
2023
- return `${yymmdd(date)}-${reviewSlug(target) || "uncommitted"}`;
2024
- }
2025
- function localResponseMarker(threadId) {
2026
- return `<!-- diffpi -->`;
2027
- }
2028
- function isLocalResponse(body) {
2029
- return /^<!-- diffpi(?:-local-response:[A-Za-z0-9_-]+)? -->\n/m.test(body);
2030
- }
2031
- function parseReviewThreadAction(body) {
2032
- const match = body.match(/^\[(REOPEN|RESOLVE|DELETE)\]\s*/i);
2033
- if (!match)
2034
- return { body };
2035
- return {
2036
- action: match[1].toLowerCase(),
2037
- body: body.slice(match[0].length)
2038
- };
2039
- }
2040
1755
  function dedupeFindings(findings) {
2041
1756
  const rank = { BLOCKING: 3, CONSIDER: 2, NOTE: 1 };
2042
1757
  const byKey = new Map;
@@ -2071,9 +1786,6 @@ function withRemoteProvenance(body, model) {
2071
1786
 
2072
1787
  Generated review by Diffpi using \`${model}\`.`;
2073
1788
  }
2074
- function localReviewAuthor(model) {
2075
- return `Agent: ${model}`;
2076
- }
2077
1789
  function renderCommentBody(finding) {
2078
1790
  const prefix = finding.severity === "BLOCKING" ? "**BLOCKING** " : "";
2079
1791
  const reference = finding.reference ? `
@@ -2081,142 +1793,6 @@ function renderCommentBody(finding) {
2081
1793
  > **Reference:** ${finding.reference}` : "";
2082
1794
  return `${prefix}${finding.body}${reference}`;
2083
1795
  }
2084
- // src/review/review-markdown.ts
2085
- import { z as z9 } from "zod";
2086
- var reviewThreadRecordSchema = z9.object({
2087
- id: z9.string().min(1),
2088
- file: z9.string().nullable().optional().transform((value) => value ?? undefined),
2089
- line: z9.number().int().positive().nullable().optional().transform((value) => value ?? undefined),
2090
- body: z9.string(),
2091
- author: z9.string().optional(),
2092
- resolved: z9.boolean(),
2093
- addressed: z9.boolean().optional(),
2094
- question: z9.boolean(),
2095
- replies: z9.array(z9.string()).optional()
2096
- });
2097
- function renderReviewDoc(input) {
2098
- const anchored = dedupeFindings(input.findings).filter((finding) => finding.line > 0);
2099
- const lines = [`# Review: ${input.title}`, "", "## Metadata"];
2100
- if (input.number !== undefined)
2101
- lines.push(`- **PR/MR**: #${input.number}${input.url ? ` — ${input.url}` : ""}`);
2102
- if (input.author)
2103
- lines.push(`- **Author**: ${input.author}`);
2104
- if (input.model)
2105
- lines.push(`- **Review agent**: ${input.model}`);
2106
- if (input.headRef && input.baseRef)
2107
- lines.push(`- **Branch**: ${input.headRef} → ${input.baseRef}`);
2108
- if (input.additions !== undefined)
2109
- lines.push(`- **Stats**: +${input.additions} -${input.deletions ?? 0} across ${input.changedFiles ?? 0} files`);
2110
- lines.push(`- **Reviewed**: ${input.timestamp ?? new Date().toISOString()}`, "");
2111
- if (input.overallIssues.length > 0) {
2112
- lines.push("## Overall issues", "");
2113
- for (const issue of input.overallIssues)
2114
- lines.push(`- ${issue}`);
2115
- lines.push("");
2116
- }
2117
- lines.push("## Verification", "");
2118
- for (const gate of input.gates)
2119
- lines.push(`- ${gate.name}: ${gate.status} — ${gate.detail}`);
2120
- lines.push("");
2121
- if (input.notVerified.length > 0) {
2122
- lines.push("## What was NOT verified", "");
2123
- for (const item of input.notVerified)
2124
- lines.push(`- ${item}`);
2125
- lines.push("");
2126
- }
2127
- lines.push("## Inline Comments", "");
2128
- for (const finding of anchored) {
2129
- lines.push(`### ${finding.file}:${finding.line} — ${finding.severity}`, "", finding.body, "");
2130
- if (finding.reference)
2131
- lines.push(`> **Reference:** ${finding.reference}`, "");
2132
- lines.push("---", "");
2133
- }
2134
- return `${lines.join(`
2135
- `).trimEnd()}
2136
- `;
2137
- }
2138
- function renderThreadArtifact(title, target, threads, options = {}) {
2139
- const records = threads.map((thread) => ({
2140
- id: thread.id,
2141
- file: thread.file,
2142
- line: thread.line,
2143
- body: thread.body,
2144
- author: thread.author,
2145
- resolved: thread.resolved,
2146
- addressed: thread.addressed,
2147
- question: thread.question,
2148
- replies: thread.replies
2149
- }));
2150
- const payload = Buffer.from(JSON.stringify(records), "utf8").toString("base64url");
2151
- const lines = [
2152
- `<!-- diffpi-threads:${payload} -->`,
2153
- `# Review threads: ${title}`,
2154
- "",
2155
- "## Metadata",
2156
- "",
2157
- `- Target: ${target}`,
2158
- `- Pulled: ${options.timestamp ?? new Date().toISOString()}`
2159
- ];
2160
- if (options.number !== undefined)
2161
- lines.push(`- PR/MR: #${options.number}${options.url ? ` — ${options.url}` : ""}`);
2162
- lines.push("", "## Replies", "");
2163
- for (const thread of threads) {
2164
- const id = Buffer.from(thread.id, "utf8").toString("base64url");
2165
- lines.push(`### ${thread.file ?? "review"}:${thread.line ?? "n/a"} (${thread.id})`, "", `<!-- diffpi-reply-start:${id} -->`, thread.reply ?? "", `<!-- diffpi-reply-end:${id} -->`, "");
2166
- }
2167
- lines.push("## Source comments", "");
2168
- for (const thread of threads) {
2169
- lines.push(`### ${thread.file ?? "review"}:${thread.line ?? "n/a"} — ${thread.author ?? "unknown"}`, "", `Thread: ${thread.id}`, `Status: ${thread.resolved ? "resolved" : thread.addressed ? "addressed" : "open"}`, "", "**Source comment**", "", thread.body, "", "**Recorded response**", "", thread.reply?.trim() || "_No response recorded._", "", "---", "");
2170
- }
2171
- return `${lines.join(`
2172
- `).trimEnd()}
2173
- `;
2174
- }
2175
- function parseThreadArtifact(content) {
2176
- const payload = content.match(/^<!-- diffpi-threads:([A-Za-z0-9_-]+) -->$/m)?.[1];
2177
- if (!payload)
2178
- throw new Error("This file is not a Diffpi thread artifact.");
2179
- let threads;
2180
- try {
2181
- const decoded = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
2182
- threads = z9.array(reviewThreadRecordSchema).parse(decoded);
2183
- } catch {
2184
- throw new Error("Cannot parse the Diffpi thread artifact payload.");
2185
- }
2186
- const replies = content.split(/^## Source comments$/m, 1)[0] ?? "";
2187
- return threads.map((thread) => {
2188
- const id = Buffer.from(thread.id, "utf8").toString("base64url");
2189
- const startMarker = `<!-- diffpi-reply-start:${id} -->
2190
- `;
2191
- const endMarker = `
2192
- <!-- diffpi-reply-end:${id} -->`;
2193
- const start = replies.indexOf(startMarker);
2194
- const end = start < 0 ? -1 : replies.indexOf(endMarker, start + startMarker.length);
2195
- const reply = start >= 0 && end >= 0 ? replies.slice(start + startMarker.length, end).trim() : "";
2196
- return reply ? { ...thread, reply } : thread;
2197
- });
2198
- }
2199
- function upsertThreadReply(content, threadId, body, question, resolved) {
2200
- const threads = parseThreadArtifact(content);
2201
- const thread = threads.find((candidate) => candidate.id === threadId);
2202
- if (!thread)
2203
- throw new Error(`Review thread ${threadId} was not found in the local artifact.`);
2204
- thread.reply = body;
2205
- thread.addressed = true;
2206
- if (question !== undefined)
2207
- thread.question = question;
2208
- if (resolved !== undefined)
2209
- thread.resolved = resolved;
2210
- const title = content.match(/^# Review threads: (.+)$/m)?.[1] ?? "review";
2211
- const target = content.match(/^- Target: (.+)$/m)?.[1] ?? "local";
2212
- const timestamp = content.match(/^- Pulled: (.+)$/m)?.[1];
2213
- const pr = content.match(/^- PR\/MR: #(\d+)(?: — (.+))?$/m);
2214
- return renderThreadArtifact(title, target, threads, {
2215
- timestamp,
2216
- number: pr ? Number.parseInt(pr[1], 10) : undefined,
2217
- url: pr?.[2]
2218
- });
2219
- }
2220
1796
  // src/review/github-review-backend.ts
2221
1797
  function GitHubReviewBackend(vcs, number) {
2222
1798
  const pendingReview = async () => {
@@ -2596,245 +2172,6 @@ function hasGitlabDraftNotes(input) {
2596
2172
  }
2597
2173
  }
2598
2174
 
2599
- // src/review/local-review-backend.ts
2600
- import { readFile as readFile11, writeFile as writeFile6 } from "node:fs/promises";
2601
-
2602
- // src/extensions/tuicrx.ts
2603
- import { readFile as readFile10, realpath as realpath3 } from "node:fs/promises";
2604
- import { resolve as resolve4 } from "node:path";
2605
- async function tuicrAvailable() {
2606
- return Boolean(await findExecutable("tuicr"));
2607
- }
2608
- async function listSessions(repo = ".") {
2609
- await requireTuicr();
2610
- const result = await run("tuicr", ["review", "list", "--repo", repo]);
2611
- if (result.code !== 0 || !result.stdout.trim())
2612
- return [];
2613
- try {
2614
- const raw = JSON.parse(result.stdout);
2615
- return raw.map((entry) => ({
2616
- slug: entry.slug,
2617
- kind: entry.kind,
2618
- path: entry.path,
2619
- updatedAt: entry.updated_at,
2620
- commentCount: entry.comment_count,
2621
- anchor: entry.anchor,
2622
- active: entry.active
2623
- }));
2624
- } catch {
2625
- throw new Error("Cannot parse tuicr review list output.");
2626
- }
2627
- }
2628
- async function addComment(session, body, opts = {}) {
2629
- await requireTuicr();
2630
- const args = ["review", "add", "--session", session, body];
2631
- if (opts.targetFile)
2632
- args.push("--target-file", opts.targetFile);
2633
- if (opts.line !== undefined)
2634
- args.push("--line", String(opts.line));
2635
- if (opts.side)
2636
- args.push("--side", opts.side);
2637
- if (opts.username)
2638
- args.push("--username", opts.username);
2639
- await runChecked("tuicr", args);
2640
- }
2641
- async function launch(cwd, pr, localBase) {
2642
- let command;
2643
- if (pr !== undefined)
2644
- command = ["tuicr", "pr", String(pr)];
2645
- else if (localBase)
2646
- command = ["tuicr", "-w", "-r", `${localBase}..HEAD`];
2647
- else
2648
- throw new Error("Cannot launch a local tuicr review: no base branch could be determined.");
2649
- if (!await tuicrAvailable()) {
2650
- return {
2651
- launched: false,
2652
- via: "print",
2653
- command: command.join(" "),
2654
- reason: "tuicr is not installed or is not available on PATH.",
2655
- instruction: `Install tuicr, then run: ${command.join(" ")}`
2656
- };
2657
- }
2658
- const name = diffpiLaunchName(cwd, pr === undefined ? "local review" : `PR #${pr}`);
2659
- return openInNewTab(command, { cwd, name });
2660
- }
2661
- async function resolveSession(cwd, branch) {
2662
- const sessions = await listSessions(cwd);
2663
- return findMatchingSession(sessions, cwd, branch);
2664
- }
2665
- async function resolveReviewSession(cwd, target) {
2666
- if (!target.workingTree && target.owner && target.repo && target.number !== undefined) {
2667
- return resolvePrSession(cwd, target.owner, target.repo, target.number);
2668
- }
2669
- return resolveSession(cwd, target.branch);
2670
- }
2671
- async function resolvePublishSession(cwd, target) {
2672
- if (target.owner && target.repo && target.number !== undefined)
2673
- return resolvePrSession(cwd, target.owner, target.repo, target.number);
2674
- return resolveSession(cwd, target.branch);
2675
- }
2676
- async function resolvePrSession(cwd, owner, repo, number) {
2677
- const coordinate = `${owner}/${repo}`.toLowerCase();
2678
- const sessions = await listSessions(cwd);
2679
- return sessions.find((session) => {
2680
- const slug = session.slug.toLowerCase();
2681
- return session.kind === "pr" && slug.includes(coordinate) && (slug.endsWith(`/pr/${number}`) || slug.endsWith(`/mr/${number}`));
2682
- });
2683
- }
2684
- async function findMatchingSession(sessions, cwd, branch) {
2685
- const repository = await canonicalPath3(await gitToplevel(cwd));
2686
- for (const session of sessions) {
2687
- if (session.kind !== "local")
2688
- continue;
2689
- try {
2690
- const data = await readSession(session.path);
2691
- if (data.branch_name !== branch || !data.repo_path)
2692
- continue;
2693
- if (await canonicalPath3(data.repo_path) === repository)
2694
- return session;
2695
- } catch {}
2696
- }
2697
- return;
2698
- }
2699
- async function readSession(path) {
2700
- const content = await readFile10(path, "utf8");
2701
- try {
2702
- return JSON.parse(content);
2703
- } catch {
2704
- throw new Error(`Cannot parse tuicr session JSON: ${path}`);
2705
- }
2706
- }
2707
- function toLocalReviewThreads(session) {
2708
- const threads = [];
2709
- const add = (comment, location = {}) => {
2710
- if (isLocalResponse(comment.content))
2711
- return;
2712
- const author = commentAuthor(comment);
2713
- threads.push({
2714
- id: comment.id ?? `local-${threads.length + 1}`,
2715
- ...location,
2716
- body: comment.content,
2717
- ...author ? { author } : {},
2718
- resolved: false,
2719
- question: /\?\s*$/.test(comment.content.trim())
2720
- });
2721
- };
2722
- for (const comment of session.review_comments ?? [])
2723
- add(comment);
2724
- for (const [file, entry] of Object.entries(session.files ?? {})) {
2725
- for (const comment of entry.file_comments ?? [])
2726
- add(comment, { file });
2727
- for (const [lineKey, lineComments] of Object.entries(entry.line_comments ?? {})) {
2728
- const line = Number.parseInt(lineKey, 10);
2729
- if (!Number.isFinite(line))
2730
- continue;
2731
- for (const comment of lineComments)
2732
- add(comment, { file, line });
2733
- }
2734
- }
2735
- return threads;
2736
- }
2737
- function toFindings(session, options = {}) {
2738
- const comments = [];
2739
- const include = (comment) => (!options.agentOnly || commentAuthor(comment)?.startsWith("Agent: ")) && (!options.excludeLocalResponses || !isLocalResponse(comment.content));
2740
- const publishableBody = (comment) => {
2741
- const author = commentAuthor(comment);
2742
- const route = author?.match(/^Agent:\s*(.+)$/)?.[1];
2743
- return route ? withRemoteProvenance(comment.content, route) : comment.content;
2744
- };
2745
- const bodyParts = (session.review_comments ?? []).flatMap((comment) => include(comment) ? [publishableBody(comment)] : []);
2746
- for (const [file, entry] of Object.entries(session.files ?? {})) {
2747
- for (const fileComment of entry.file_comments ?? []) {
2748
- if (!include(fileComment))
2749
- continue;
2750
- const comment = {
2751
- file,
2752
- body: fileComment.content,
2753
- ...fileComment.id ? { sourceCommentId: String(fileComment.id) } : {}
2754
- };
2755
- const author = commentAuthor(fileComment);
2756
- if (author)
2757
- comment.author = author;
2758
- comments.push(comment);
2759
- }
2760
- for (const [lineKey, lineComments] of Object.entries(entry.line_comments ?? {})) {
2761
- const line = Number.parseInt(lineKey, 10);
2762
- if (!Number.isFinite(line))
2763
- continue;
2764
- for (const lineComment of lineComments) {
2765
- if (!include(lineComment))
2766
- continue;
2767
- const comment = {
2768
- file,
2769
- line,
2770
- side: lineComment.side === "old" ? "LEFT" : "RIGHT",
2771
- body: lineComment.content,
2772
- ...lineComment.id ? { sourceCommentId: String(lineComment.id) } : {}
2773
- };
2774
- const author = commentAuthor(lineComment);
2775
- if (author)
2776
- comment.author = author;
2777
- comments.push(comment);
2778
- }
2779
- }
2780
- }
2781
- return { comments, body: bodyParts.join(`
2782
-
2783
- `) };
2784
- }
2785
- async function requireTuicr() {
2786
- if (!await tuicrAvailable())
2787
- throw new Error("tuicr is not installed or is not available on PATH.");
2788
- }
2789
- function commentAuthor(comment) {
2790
- return comment.username ?? comment.author;
2791
- }
2792
- async function canonicalPath3(path) {
2793
- try {
2794
- return await realpath3(path);
2795
- } catch {
2796
- return resolve4(path);
2797
- }
2798
- }
2799
-
2800
- // src/review/local-review-backend.ts
2801
- function LocalReviewBackend(options) {
2802
- return {
2803
- kind: "local",
2804
- async stage(draft) {
2805
- for (const comment of draft.comments) {
2806
- await addComment(options.session, comment.body, {
2807
- targetFile: comment.file,
2808
- line: comment.line,
2809
- side: comment.side === "LEFT" ? "old" : "new",
2810
- username: options.author
2811
- });
2812
- }
2813
- if (draft.body.trim())
2814
- await addComment(options.session, draft.body, { username: options.author });
2815
- },
2816
- async readDraft() {
2817
- return toFindings(await readSession(options.session), { excludeLocalResponses: true });
2818
- },
2819
- async listThreads() {
2820
- try {
2821
- return parseThreadArtifact(await readFile11(options.artifactPath, "utf8"));
2822
- } catch (error) {
2823
- if (error.code === "ENOENT")
2824
- return [];
2825
- throw error;
2826
- }
2827
- },
2828
- async reply(input) {
2829
- const content = await readFile11(options.artifactPath, "utf8");
2830
- await writeFile6(options.artifactPath, upsertThreadReply(content, input.threadId, input.body, input.question, input.resolve), "utf8");
2831
- },
2832
- async publish() {
2833
- throw new Error("Promote a local draft through a remote review backend before publishing it.");
2834
- }
2835
- };
2836
- }
2837
-
2838
2175
  // src/review/review-backend.ts
2839
2176
  function createRemoteReviewBackend(vcs, number) {
2840
2177
  if (vcs.provider === "github")
@@ -2843,67 +2180,24 @@ function createRemoteReviewBackend(vcs, number) {
2843
2180
  return GitLabReviewBackend(vcs, number);
2844
2181
  throw new Error("A remote review backend requires a GitHub or GitLab repository.");
2845
2182
  }
2846
- function createLocalReviewBackend(options) {
2847
- return LocalReviewBackend(options);
2848
- }
2849
2183
  // src/review/review-state.ts
2850
- import { createHash as createHash3 } from "node:crypto";
2851
- import { readFile as readFile12, rename, writeFile as writeFile7 } from "node:fs/promises";
2852
- import { join as join15 } from "node:path";
2853
- import { z as z10 } from "zod";
2854
- var reviewPublicationStateSchema = z10.object({
2855
- target: z10.string().optional(),
2856
- bodies: z10.array(z10.string()).default([]),
2857
- comments: z10.array(z10.string()).default([]),
2858
- stagedComments: z10.array(z10.string()).default([]),
2859
- replies: z10.array(z10.string()).default([]),
2860
- overlayPath: z10.string().optional()
2184
+ import { z as z9 } from "zod";
2185
+ var reviewPublicationStateSchema = z9.object({
2186
+ target: z9.string().optional(),
2187
+ bodies: z9.array(z9.string()).default([]),
2188
+ comments: z9.array(z9.string()).default([]),
2189
+ stagedComments: z9.array(z9.string()).default([]),
2190
+ replies: z9.array(z9.string()).default([]),
2191
+ overlayPath: z9.string().optional()
2861
2192
  });
2862
- function reviewBodyFingerprint(body) {
2863
- return digest(body);
2864
- }
2865
- function reviewCommentFingerprint(comment) {
2866
- return digest([comment.file, String(comment.line), comment.side ?? "RIGHT", comment.body].join("\x00"));
2867
- }
2868
- function reviewReplyFingerprint(threadId, body) {
2869
- return digest(`${threadId}\x00${body}`);
2870
- }
2871
- function unpublishedReviewComments(comments, knownFingerprints) {
2872
- return comments.filter((comment) => !knownFingerprints.has(reviewCommentFingerprint(comment)));
2873
- }
2874
- async function loadReviewPublicationState(cwd, vcs, number, homeDir) {
2875
- const target = `${vcs.provider}:${vcs.owner}/${vcs.repo}#${number}`;
2876
- const path = join15(await sessionsDir(cwd, homeDir), `review-publish-${digest(target).slice(0, 16)}.json`);
2877
- try {
2878
- const parsed = reviewPublicationStateSchema.parse(JSON.parse(await readFile12(path, "utf8")));
2879
- return { path, state: { ...parsed, target } };
2880
- } catch (error) {
2881
- if (error.code === "ENOENT") {
2882
- return { path, state: { target, bodies: [], comments: [], stagedComments: [], replies: [] } };
2883
- }
2884
- if (error instanceof SyntaxError || error instanceof z10.ZodError) {
2885
- throw new Error(`Cannot parse review publication state: ${path}`);
2886
- }
2887
- throw error;
2888
- }
2889
- }
2890
- async function saveReviewPublicationState(path, state) {
2891
- const temp = `${path}.${process.pid}.tmp`;
2892
- await writeFile7(temp, `${JSON.stringify(state, null, 2)}
2893
- `, "utf8");
2894
- await rename(temp, path);
2895
- }
2896
- function digest(value) {
2897
- return createHash3("sha256").update(value).digest("hex");
2898
- }
2899
2193
  // src/templates.ts
2900
- import { readFile as readFile13 } from "node:fs/promises";
2901
- import { homedir as homedir8 } from "node:os";
2902
- import { join as join16, normalize } from "node:path";
2194
+ import { readFile as readFile8 } from "node:fs/promises";
2195
+ import { homedir as homedir7 } from "node:os";
2196
+ import { join as join13, normalize } from "node:path";
2903
2197
  async function loadTemplate(name, options = {}) {
2904
2198
  const relative = templateRelativePath(name);
2905
- const userPath = join16(options.homeDir ?? homedir8(), ".difflab", "diffpi", "templates", relative);
2906
- const bundledPath = join16(options.bundledDir ?? resolveBundledTemplatesDir(), relative);
2199
+ const userPath = join13(options.homeDir ?? homedir7(), ".difflab", "diffpi", "templates", relative);
2200
+ const bundledPath = join13(options.bundledDir ?? resolveBundledTemplatesDir(), relative);
2907
2201
  const user = await readOptionalFile(userPath);
2908
2202
  if (user !== undefined)
2909
2203
  return { name, path: userPath, source: "user", content: user };
@@ -2924,7 +2218,7 @@ function templateRelativePath(name) {
2924
2218
  }
2925
2219
  async function readOptionalFile(path) {
2926
2220
  try {
2927
- return await readFile13(path, "utf8");
2221
+ return await readFile8(path, "utf8");
2928
2222
  } catch (error) {
2929
2223
  if (error.code === "ENOENT")
2930
2224
  return;
@@ -2933,94 +2227,64 @@ async function readOptionalFile(path) {
2933
2227
  }
2934
2228
 
2935
2229
  // src/tools/review.ts
2936
- var contextSchema = z11.object({
2937
- cwd: z11.string().optional(),
2938
- target: z11.string().optional(),
2939
- workingTree: z11.boolean().optional()
2230
+ var contextSchema = z10.object({
2231
+ cwd: z10.string().optional(),
2232
+ target: z10.string().optional(),
2233
+ workingTree: z10.boolean().optional()
2940
2234
  });
2941
- var localSchema = contextSchema.extend({ local: z11.boolean().optional() });
2235
+ var localSchema = contextSchema.extend({ local: z10.boolean().optional() });
2942
2236
  var openSchema = localSchema.extend({
2943
- title: z11.string().optional(),
2944
- intent: z11.string().optional(),
2945
- issueUrl: z11.string().url().optional(),
2946
- base: z11.string().optional()
2237
+ title: z10.string().optional(),
2238
+ intent: z10.string().optional(),
2239
+ issueUrl: z10.string().url().optional(),
2240
+ base: z10.string().optional(),
2241
+ body: z10.string().optional()
2947
2242
  });
2948
2243
  var submitSchema = localSchema.extend({
2949
2244
  findings: findingsSchema,
2950
- overallIssues: z11.array(z11.string()).optional(),
2951
- notVerified: z11.array(z11.string()).optional(),
2952
- title: z11.string().optional()
2245
+ overallIssues: z10.array(z10.string()).optional(),
2246
+ notVerified: z10.array(z10.string()).optional(),
2247
+ title: z10.string().optional()
2953
2248
  });
2954
2249
  var addCommentSchema = localSchema.extend({
2955
- body: z11.string().min(1),
2956
- file: z11.string().min(1),
2957
- line: z11.number().int().positive(),
2958
- side: z11.enum(["LEFT", "RIGHT"]).optional()
2250
+ body: z10.string().min(1),
2251
+ file: z10.string().min(1),
2252
+ line: z10.number().int().positive(),
2253
+ side: z10.enum(["LEFT", "RIGHT"]).optional()
2959
2254
  });
2960
2255
  var respondSchema = localSchema.extend({
2961
- threadId: z11.string().min(1),
2962
- body: z11.string().min(1),
2963
- question: z11.boolean().optional(),
2964
- resolve: z11.boolean().optional()
2256
+ threadId: z10.string().min(1),
2257
+ body: z10.string().min(1),
2258
+ question: z10.boolean().optional(),
2259
+ resolve: z10.boolean().optional()
2965
2260
  });
2966
2261
  var publishSchema = localSchema.extend({
2967
- status: z11.enum(["COMMENT", "APPROVE", "REQUEST_CHANGES", "CLOSE"]).optional()
2262
+ status: z10.enum(["COMMENT", "APPROVE", "REQUEST_CHANGES", "CLOSE"]).optional()
2968
2263
  });
2969
2264
  var completeSchema = localSchema.extend({
2970
- action: z11.enum(["approve", "reject", "abandon"]).optional()
2265
+ action: z10.enum(["approve", "reject", "close"]).optional()
2971
2266
  });
2972
2267
  function hasReviewDraft(comments, body) {
2973
2268
  return comments.length > 0 || body.trim().length > 0;
2974
2269
  }
2975
- function reviewBrowserCommand(url, platform = process.platform) {
2976
- return platform === "darwin" ? { command: "open", args: [url] } : platform === "win32" ? { command: "cmd", args: ["/c", "start", "", url] } : { command: "xdg-open", args: [url] };
2977
- }
2978
- async function openReviewUrl(url, execute = run, platform = process.platform) {
2979
- const launch = reviewBrowserCommand(url, platform);
2980
- try {
2981
- return (await execute(launch.command, launch.args)).code === 0;
2982
- } catch {
2983
- return false;
2984
- }
2985
- }
2986
- function partitionReviewComments(comments, knownFingerprints, model) {
2987
- const normalized = comments.map((comment) => {
2988
- const published = { ...comment, body: withCommentProvenance(comment, model) };
2989
- return { source: comment, published, fingerprint: reviewCommentFingerprint(published) };
2990
- });
2991
- return {
2992
- checkpointed: normalized.filter((comment) => knownFingerprints.has(comment.fingerprint)),
2993
- candidates: normalized.filter((comment) => !knownFingerprints.has(comment.fingerprint))
2994
- };
2995
- }
2996
2270
  function reviewResponseResolution(question, requested) {
2997
2271
  return question ? false : requested ?? false;
2998
2272
  }
2999
- function workingTreeReplyDraft(thread, body) {
3000
- if (!thread.file)
3001
- return { comments: [], body };
3002
- return {
3003
- comments: [
3004
- {
3005
- file: thread.file,
3006
- ...thread.line === undefined ? {} : { line: thread.line, side: "RIGHT" },
3007
- body
3008
- }
3009
- ],
3010
- body: ""
3011
- };
3012
- }
3013
- function resolveReviewThread(comment, threads) {
3014
- const sourceCommentId = comment.sourceCommentId;
3015
- if (sourceCommentId) {
3016
- const identified = threads.find((thread) => thread.id === sourceCommentId || thread.rootCommentId === sourceCommentId || thread.commentIds?.includes(sourceCommentId));
3017
- if (identified)
3018
- return identified;
2273
+ function assertRenderedReviewBody(body) {
2274
+ if (/{{[^{}]+}}/.test(body))
2275
+ throw new Error("Review body contains unresolved template placeholders.");
2276
+ if (/<!--/.test(body))
2277
+ throw new Error("Review body contains HTML comments.");
2278
+ const headings = [...body.matchAll(/^## (.+)$/gm)];
2279
+ for (const section of ["Intent", "Changes", "Validation", "References", "Further Work"]) {
2280
+ const index = headings.findIndex((match) => match[1].trim() === section);
2281
+ const current = headings[index];
2282
+ if (!current || current.index === undefined)
2283
+ throw new Error(`Review body requires a filled ${section} section.`);
2284
+ const content = body.slice(current.index + current[0].length, headings[index + 1]?.index).trim();
2285
+ if (!content)
2286
+ throw new Error(`Review body requires a filled ${section} section.`);
3019
2287
  }
3020
- const matches = threads.filter((thread) => thread.file === comment.file && (thread.line ?? undefined) === (comment.line ?? undefined));
3021
- if (matches.length > 1)
3022
- throw new Error(`Ambiguous remote review threads at ${comment.file}:${comment.line ?? "file"}: ${matches.map((thread) => thread.id).join(", ")}.`);
3023
- return matches[0];
3024
2288
  }
3025
2289
  async function workingTreeDiff(cwd) {
3026
2290
  const tracked = await run("git", ["-C", cwd, "diff", "HEAD"], { capture: "unbounded" });
@@ -3044,7 +2308,7 @@ function createReviewTools() {
3044
2308
  defineTool6({
3045
2309
  name: "review_context",
3046
2310
  label: "review context",
3047
- description: "Orient to the target, backend, forge, environment, shared store, PR/MR, and tuicr session.",
2311
+ description: "Orient to the target, forge, environment, shared store, and PR/MR.",
3048
2312
  promptSnippet: "Call review_context first",
3049
2313
  promptGuidelines: ["Call this before every review workflow."],
3050
2314
  parameters: parameters(localSchema),
@@ -3054,19 +2318,15 @@ function createReviewTools() {
3054
2318
  const review = await resolveReviewContext(resolveWorkingDirectory(params), params.target);
3055
2319
  const store = await ensureStore(review.cwd);
3056
2320
  const env = { ide: detectIde(), mux: detectMux(), shell: detectShell() };
3057
- const session = await resolveTuicrSession(review, Boolean(params.local || params.workingTree)).catch(() => {
3058
- return;
3059
- });
3060
2321
  const baseRef = review.pr?.baseRef ?? (review.forge ? await review.forge.defaultBranch() : "local");
3061
- const backend = params.local || params.workingTree ? "tuicr" : review.forge?.provider ?? "unsupported";
2322
+ const backend = review.forge?.provider ?? "unsupported";
3062
2323
  return result2([
3063
2324
  `Backend: ${backend}`,
3064
2325
  `Forge: ${review.vcs.provider}${review.vcs.provider === "none" ? "" : ` (${review.vcs.owner}/${review.vcs.repo})`}`,
3065
2326
  `Branch: ${review.vcs.branch} → ${baseRef}`,
3066
2327
  `Env: ide=${env.ide} mux=${env.mux} shell=${env.shell}`,
3067
2328
  `Store: ${store.link} → ${store.dest}`,
3068
- review.pr ? `PR/MR: #${review.pr.number} ${review.pr.url}` : "PR/MR: none",
3069
- session ? `tuicr: ${session.slug} (${session.commentCount} comments)` : "tuicr: none"
2329
+ review.pr ? `PR/MR: #${review.pr.number} ${review.pr.url}` : "PR/MR: none"
3070
2330
  ].join(`
3071
2331
  `), {
3072
2332
  cwd: review.cwd,
@@ -3074,7 +2334,6 @@ function createReviewTools() {
3074
2334
  pr: review.pr,
3075
2335
  env,
3076
2336
  store,
3077
- session,
3078
2337
  baseRef,
3079
2338
  backend
3080
2339
  });
@@ -3083,9 +2342,9 @@ function createReviewTools() {
3083
2342
  defineTool6({
3084
2343
  name: "review_status",
3085
2344
  label: "review status",
3086
- description: "Report local and remote review lifecycle status without requiring a forge or tuicr.",
2345
+ description: "Report remote review lifecycle status without requiring a forge.",
3087
2346
  promptSnippet: "Call review_status for review lifecycle state",
3088
- promptGuidelines: ["Use this for status even when no forge or tuicr binary is available."],
2347
+ promptGuidelines: ["Use this for status even when no forge is available."],
3089
2348
  parameters: parameters(localSchema),
3090
2349
  executionMode: "parallel",
3091
2350
  async execute(_id, input) {
@@ -3098,9 +2357,9 @@ function createReviewTools() {
3098
2357
  defineTool6({
3099
2358
  name: "review_new",
3100
2359
  label: "review new",
3101
- description: "Create a new local tuicr review or a remote draft PR/MR and open it in tuicr.",
2360
+ description: "Create a filled remote draft PR/MR.",
3102
2361
  promptSnippet: "Call review_new to start",
3103
- promptGuidelines: ["Use local to select tuicr as the review backend."],
2362
+ promptGuidelines: ["Use the skill workflow for direct-file local reviews."],
3104
2363
  parameters: parameters(openSchema),
3105
2364
  executionMode: "sequential",
3106
2365
  async execute(_id, input) {
@@ -3108,7 +2367,7 @@ function createReviewTools() {
3108
2367
  const review = await resolveReviewContext(resolveWorkingDirectory(params), params.target);
3109
2368
  await ensureStore(review.cwd);
3110
2369
  if (params.local || params.workingTree)
3111
- return launchLocalReview(review, true, params.base);
2370
+ return result2("Local reviews are direct Markdown files; follow the review new workflow instead.");
3112
2371
  if (!review.forge)
3113
2372
  return result2(unsupportedForgeMessage());
3114
2373
  if (review.pr)
@@ -3116,57 +2375,25 @@ function createReviewTools() {
3116
2375
  await assertRemoteBranchReady(review.cwd);
3117
2376
  const base = params.base ?? await review.forge.defaultBranch();
3118
2377
  const template = await loadTemplate("review/draft-pr");
3119
- const body = renderTemplate(template.content, {
3120
- intent: params.intent ?? "<!-- Describe why this change is needed. -->",
3121
- issue_url: params.issueUrl ?? "<!-- Add issue tracker URL. -->",
3122
- head: review.vcs.branch,
3123
- base
3124
- });
2378
+ if (!params.body)
2379
+ throw new Error("A fully rendered draft PR body is required.");
2380
+ assertRenderedReviewBody(params.body);
3125
2381
  const pr = await review.forge.createDraftPr({
3126
2382
  title: params.title ?? deriveTitle(review.vcs.branch),
3127
- body,
2383
+ body: params.body,
3128
2384
  base,
3129
2385
  head: review.vcs.branch
3130
2386
  });
3131
- const launched = await launch(review.cwd, pr.number);
3132
- const openMessage = launched.launched ? `Opened PR/MR #${pr.number} in tuicr (${launched.via}).` : launched.instruction ?? `Run: ${launched.command}`;
3133
- return result2(`Draft PR/MR created from ${template.source} template: ${pr.url}
3134
- ${openMessage}`, {
2387
+ return result2(`Draft PR/MR created from ${template.source} template: ${pr.url}`, {
3135
2388
  pr,
3136
- template,
3137
- launched
3138
- });
3139
- }
3140
- }),
3141
- defineTool6({
3142
- name: "review_open",
3143
- label: "review open",
3144
- description: "Open an existing remote PR/MR in the system browser; never creates one.",
3145
- promptSnippet: "Call review_open to open an existing PR/MR",
3146
- promptGuidelines: ["Direct users to review_new when no remote review exists."],
3147
- parameters: parameters(localSchema),
3148
- executionMode: "sequential",
3149
- async execute(_id, input) {
3150
- const params = localSchema.parse(input);
3151
- const review = await resolveReviewContext(resolveWorkingDirectory(params), params.target);
3152
- if (params.local || params.workingTree)
3153
- return launchLocalReview(review, true);
3154
- if (!review.forge)
3155
- return result2(`${unsupportedForgeMessage()} Use review_new with local=true for an offline review.`);
3156
- if (!review.pr)
3157
- return result2("No remote PR/MR exists. Use review_new to create a draft review first.");
3158
- const opened = await openReviewUrl(review.pr.url);
3159
- return result2(`${opened ? "Opened" : "Open this URL"} PR/MR #${review.pr.number}: ${review.pr.url}`, {
3160
- pr: review.pr,
3161
- opened,
3162
- url: review.pr.url
2389
+ template
3163
2390
  });
3164
2391
  }
3165
2392
  }),
3166
2393
  defineTool6({
3167
2394
  name: "review_edit",
3168
2395
  label: "review edit",
3169
- description: "Open an existing local tuicr session or remote PR/MR in tuicr without generating findings.",
2396
+ description: "Print the selected local review path or remote PR/MR URL without generating findings.",
3170
2397
  promptSnippet: "Call review_edit to continue an existing review",
3171
2398
  promptGuidelines: ["This tool never creates review findings or a PR/MR."],
3172
2399
  parameters: parameters(localSchema),
@@ -3175,16 +2402,13 @@ ${openMessage}`, {
3175
2402
  const params = localSchema.parse(input);
3176
2403
  const review = await resolveReviewContext(resolveWorkingDirectory(params), params.target);
3177
2404
  await ensureStore(review.cwd);
3178
- if (params.local || params.workingTree) {
3179
- if (!await resolveTuicrSession(review, true))
3180
- return result2("No local tuicr review exists. Use review_new with local=true to create one.");
3181
- return launchLocalReview(review, true);
3182
- }
2405
+ if (params.local || params.workingTree)
2406
+ return result2("Local reviews are direct Markdown files; print the selected REVIEW.md path from the workflow.");
3183
2407
  if (!review.forge)
3184
2408
  return result2(unsupportedForgeMessage());
3185
2409
  if (!review.pr)
3186
2410
  return result2("No remote PR/MR exists. Use review_new to create a draft review first.");
3187
- return launchLocalReview(review, false);
2411
+ return result2(`PR/MR #${review.pr.number}: ${review.pr.url}`, { pr: review.pr, url: review.pr.url });
3188
2412
  }
3189
2413
  }),
3190
2414
  defineTool6({
@@ -3252,52 +2476,21 @@ ${openMessage}`, {
3252
2476
  const params = submitSchema.parse(input);
3253
2477
  const review = await resolveReviewContext(resolveWorkingDirectory(params), params.target);
3254
2478
  const model = modelRoute(ctx);
3255
- const useLocalBackend = Boolean(params.local || params.workingTree);
3256
- if (!useLocalBackend && !review.forge)
2479
+ if (params.local || params.workingTree)
2480
+ return result2("Local reviews are direct Markdown files; follow the review address workflow instead.");
2481
+ if (!review.forge)
3257
2482
  return result2(unsupportedForgeMessage());
3258
2483
  const findings = dedupeFindings(params.findings);
3259
- const gates = await runMiseGates(review.cwd);
3260
- const baseRef = review.pr?.baseRef ?? (review.forge ? await review.forge.defaultBranch() : "local");
3261
- const artifact = await newReviewArtifactPath(review.cwd, params.local || params.workingTree || !review.pr ? "uncommitted" : await reviewTargetId(review));
3262
- await writeFile8(artifact, renderReviewDoc({
3263
- title: params.title ?? review.pr?.title ?? review.vcs.branch,
3264
- number: useLocalBackend ? undefined : review.pr?.number,
3265
- url: useLocalBackend ? undefined : review.pr?.url,
3266
- model,
3267
- headRef: review.vcs.branch,
3268
- baseRef,
3269
- findings,
3270
- overallIssues: params.overallIssues ?? [],
3271
- gates,
3272
- notVerified: params.notVerified ?? []
3273
- }), "utf8");
3274
- const comments = toReviewComments(findings, useLocalBackend ? undefined : model);
2484
+ const comments = toReviewComments(findings, model);
3275
2485
  const body = (params.overallIssues ?? []).join(`
3276
2486
  `);
3277
- if (useLocalBackend) {
3278
- const session = await resolveTuicrSession(review, Boolean(params.local || params.workingTree));
3279
- if (!session) {
3280
- return result2(`Review written: ${artifact}. Open tuicr, then call review_submit again to seed comments.`, {
3281
- artifact,
3282
- count: findings.length
3283
- });
3284
- }
3285
- const backend = createLocalReviewBackend({
3286
- session: session.path,
3287
- artifactPath: artifact,
3288
- author: localReviewAuthor(model)
3289
- });
3290
- await backend.stage({ comments, body });
3291
- return result2(`Local review staged in tuicr: ${artifact}`, { artifact, count: findings.length, session });
3292
- }
3293
2487
  if (!review.pr)
3294
- return result2(`Review written: ${artifact}. No PR/MR matches this remote target.`, { artifact });
2488
+ return result2("No PR/MR matches this remote target.");
3295
2489
  const hasDraft = hasReviewDraft(comments, body);
3296
2490
  if (hasDraft) {
3297
2491
  await createRemoteReviewBackend(review.vcs, review.pr.number).stage({ comments, body });
3298
2492
  }
3299
- return result2(`${hasDraft ? "Pending review staged" : "Clean review recorded"} on #${review.pr.number}. Artifact: ${artifact}`, {
3300
- artifact,
2493
+ return result2(`${hasDraft ? "Pending review staged" : "Clean review recorded"} on #${review.pr.number}.`, {
3301
2494
  pr: review.pr,
3302
2495
  count: findings.length
3303
2496
  });
@@ -3306,36 +2499,25 @@ ${openMessage}`, {
3306
2499
  defineTool6({
3307
2500
  name: "review_add_comment",
3308
2501
  label: "review add comment",
3309
- description: "Add one provenance-marked comment through tuicr or the remote pending-review backend.",
2502
+ description: "Add one provenance-marked comment through the remote pending-review backend.",
3310
2503
  promptSnippet: "Call review_add_comment for incremental comments",
3311
- promptGuidelines: ["Pass local=true when tuicr owns the draft."],
2504
+ promptGuidelines: ["Use the direct-file workflow for local reviews."],
3312
2505
  parameters: parameters(addCommentSchema),
3313
2506
  executionMode: "sequential",
3314
2507
  async execute(_id, input, _signal, _onUpdate, ctx) {
3315
2508
  const params = addCommentSchema.parse(input);
3316
2509
  const review = await resolveReviewContext(resolveWorkingDirectory(params), params.target);
3317
2510
  const model = modelRoute(ctx);
3318
- const useLocalBackend = Boolean(params.local || params.workingTree);
3319
- if (!useLocalBackend && !review.forge)
2511
+ if (params.local || params.workingTree)
2512
+ return result2("Local reviews are direct Markdown files; edit the selected REVIEW.md instead.");
2513
+ if (!review.forge)
3320
2514
  return result2(unsupportedForgeMessage());
3321
2515
  const comment = {
3322
2516
  file: params.file,
3323
2517
  line: params.line,
3324
2518
  side: params.side ?? "RIGHT",
3325
- body: useLocalBackend ? params.body : withRemoteProvenance(params.body, model)
2519
+ body: withRemoteProvenance(params.body, model)
3326
2520
  };
3327
- if (useLocalBackend) {
3328
- const session = await resolveTuicrSession(review, Boolean(params.local || params.workingTree));
3329
- if (!session)
3330
- return result2("No matching tuicr session. Open review_new or review_launch_ui first.");
3331
- const backend = createLocalReviewBackend({
3332
- session: session.path,
3333
- artifactPath: "",
3334
- author: localReviewAuthor(model)
3335
- });
3336
- await backend.stage({ comments: [comment], body: "" });
3337
- return result2(`Comment added to tuicr session ${session.slug}.`, { session, comment });
3338
- }
3339
2521
  if (!review.pr)
3340
2522
  return result2("No PR/MR matches this remote review target.");
3341
2523
  await createRemoteReviewBackend(review.vcs, review.pr.number).stage({ comments: [comment], body: "" });
@@ -3345,38 +2527,30 @@ ${openMessage}`, {
3345
2527
  defineTool6({
3346
2528
  name: "review_comments",
3347
2529
  label: "review comments",
3348
- description: "Pull review threads or local tuicr comments and write a target-named artifact.",
2530
+ description: "Pull remote review threads and return them for the direct-file workflow.",
3349
2531
  promptSnippet: "Call review_comments before addressing findings",
3350
- promptGuidelines: ["Pass local=true to prepare the local reply overlay."],
2532
+ promptGuidelines: ["Use the direct-file workflow for local review files."],
3351
2533
  parameters: parameters(localSchema),
3352
2534
  executionMode: "sequential",
3353
2535
  async execute(_id, input) {
3354
2536
  const params = localSchema.parse(input);
3355
2537
  const review = await resolveReviewContext(resolveWorkingDirectory(params), params.target);
3356
2538
  let threads;
3357
- let artifact;
3358
- if (params.local || params.workingTree) {
3359
- const session = await resolveTuicrSession(review, true);
3360
- if (!session)
3361
- return result2("No matching tuicr session found.");
3362
- artifact = await localThreadArtifactPath(review.cwd, session.slug);
3363
- threads = await syncLocalThreadArtifact(artifact, review.pr?.title ?? review.vcs.branch, session.slug, toLocalReviewThreads(await readSession(session.path)));
3364
- } else if (review.pr && review.forge) {
2539
+ if (params.local || params.workingTree)
2540
+ return result2("Local reviews are direct Markdown files; read the selected REVIEW.md instead.");
2541
+ if (review.pr && review.forge) {
3365
2542
  threads = await createRemoteReviewBackend(review.vcs, review.pr.number).listThreads();
3366
- const target = await reviewTargetId(review);
3367
- artifact = await newReviewArtifactPath(review.cwd, target);
3368
- await writeFile8(artifact, renderThreadArtifact(review.pr.title, target, threads, { number: review.pr.number, url: review.pr.url }), "utf8");
3369
2543
  } else {
3370
2544
  return result2(review.forge ? "No PR/MR matches this remote review target." : unsupportedForgeMessage());
3371
2545
  }
3372
2546
  return result2(threads.map((thread) => `${thread.id} ${thread.file ?? "review"}:${thread.line ?? "-"} — ${thread.body}`).join(`
3373
- `) || "No comments.", { artifact, threads });
2547
+ `) || "No comments.", { threads });
3374
2548
  }
3375
2549
  }),
3376
2550
  defineTool6({
3377
2551
  name: "review_respond",
3378
2552
  label: "review respond",
3379
- description: "Post local question replies to tuicr and record their publish overlay, or reply to remote threads.",
2553
+ description: "Reply to remote review threads.",
3380
2554
  promptSnippet: "Call review_respond after addressing a comment",
3381
2555
  promptGuidelines: ["Question replies remain unresolved."],
3382
2556
  parameters: parameters(respondSchema),
@@ -3385,36 +2559,8 @@ ${openMessage}`, {
3385
2559
  const params = respondSchema.parse(input);
3386
2560
  const review = await resolveReviewContext(resolveWorkingDirectory(params), params.target);
3387
2561
  const model = modelRoute(ctx);
3388
- if (params.local) {
3389
- const tuicrSession = await resolveTuicrSession(review, true);
3390
- if (!tuicrSession)
3391
- return result2("No matching tuicr session. Open review_new or review_launch_ui first.");
3392
- const artifact = await localThreadArtifactPath(review.cwd, tuicrSession.slug);
3393
- if (!existsSync2(artifact))
3394
- return result2("No local review artifact. Run review_comments with local=true first.");
3395
- const threads = await readThreadArtifact(artifact);
3396
- const known = threads.find((thread) => thread.id === params.threadId);
3397
- const question = known?.question === true || params.question === true || !known && params.question === undefined;
3398
- const awaitUserDeletion = !question && params.resolve === true;
3399
- await addComment(tuicrSession.path, `${localResponseMarker(params.threadId)}
3400
- ${params.body}`, {
3401
- targetFile: known?.file,
3402
- line: known?.line,
3403
- side: "new",
3404
- username: localReviewAuthor(model)
3405
- });
3406
- await createLocalReviewBackend({
3407
- session: tuicrSession.path,
3408
- artifactPath: artifact,
3409
- author: localReviewAuthor(model)
3410
- }).reply({
3411
- threadId: params.threadId,
3412
- body: params.body,
3413
- resolve: false,
3414
- question
3415
- });
3416
- return result2(`Local ${question ? "answer" : "response"} posted to tuicr and recorded in ${artifact}${awaitUserDeletion ? "; delete the source comment in tuicr to resolve it" : ""}.`, { artifact, question, awaitUserDeletion, session: tuicrSession });
3417
- }
2562
+ if (params.local || params.workingTree)
2563
+ return result2("Local reviews are direct Markdown files; append the response to REVIEW.md instead.");
3418
2564
  if (!review.pr || !review.forge)
3419
2565
  return result2("No remote PR/MR for this reply.");
3420
2566
  const remote = createRemoteReviewBackend(review.vcs, review.pr.number);
@@ -3437,7 +2583,7 @@ ${params.body}`, {
3437
2583
  defineTool6({
3438
2584
  name: "review_publish",
3439
2585
  label: "review publish",
3440
- description: "Publish pending remote draft PR/MR review comments and status, or promote a local tuicr draft before publishing.",
2586
+ description: "Publish pending remote draft PR/MR review comments and status.",
3441
2587
  promptSnippet: "Call review_publish to make remote review work public",
3442
2588
  promptGuidelines: ["Statuses are COMMENT, APPROVE, REQUEST_CHANGES, or CLOSE."],
3443
2589
  parameters: parameters(publishSchema),
@@ -3445,6 +2591,8 @@ ${params.body}`, {
3445
2591
  async execute(_id, input, _signal, _onUpdate, ctx) {
3446
2592
  const params = publishSchema.parse(input);
3447
2593
  const review = await resolveReviewContext(resolveWorkingDirectory(params), params.target);
2594
+ if (params.local || params.workingTree)
2595
+ return result2("Local publish is unsupported; select a remote PR/MR.");
3448
2596
  if (!review.pr || !review.forge)
3449
2597
  return result2("No remote PR/MR to publish.");
3450
2598
  return publishResolvedReview(review, params, modelRoute(ctx));
@@ -3453,34 +2601,25 @@ ${params.body}`, {
3453
2601
  defineTool6({
3454
2602
  name: "review_complete",
3455
2603
  label: "review complete",
3456
- description: "Approve, reject, or abandon a remote review, or archive a local review artifact.",
2604
+ description: "Approve, reject, or close a remote review.",
3457
2605
  promptSnippet: "Call review_complete to finish a review without merging it.",
3458
- promptGuidelines: ["A local completion archives the Diffpi artifact and deletes the matching tuicr session."],
2606
+ promptGuidelines: ["Close closes the remote PR/MR without publishing a review first."],
3459
2607
  parameters: parameters(completeSchema),
3460
2608
  executionMode: "sequential",
3461
2609
  async execute(_id, input, _signal, _onUpdate, ctx) {
3462
2610
  const params = completeSchema.parse(input);
3463
2611
  const review = await resolveReviewContext(resolveWorkingDirectory(params), params.target);
3464
- if (params.local) {
3465
- const session = await resolveTuicrSession(review, true);
3466
- if (!session)
3467
- return result2("No matching local tuicr session to close.");
3468
- const artifact = await localThreadArtifactPath(review.cwd, session.slug);
3469
- if (!existsSync2(artifact))
3470
- return result2("No local review artifact. Run review_comments with local=true first.");
3471
- const archived = await uniqueRecordPath(await completedReviewsDir(review.cwd), reviewSlug(session.slug));
3472
- await rename2(artifact, archived);
3473
- await unlink2(session.path);
3474
- return result2(`Archived ${archived} and deleted the tuicr session ${session.slug}.`, {
3475
- artifact: archived,
3476
- session
3477
- });
3478
- }
2612
+ if (params.local || params.workingTree)
2613
+ return result2("Local complete is unsupported; select a remote PR/MR.");
3479
2614
  if (!review.pr || !review.forge)
3480
2615
  return result2("No remote PR/MR to complete.");
3481
2616
  if (!params.action)
3482
- return result2("Choose a complete action: approve, reject, or abandon.");
3483
- const status = { approve: "APPROVE", reject: "REQUEST_CHANGES", abandon: "CLOSE" }[params.action];
2617
+ return result2("Choose a complete action: approve, reject, or close.");
2618
+ if (params.action === "close") {
2619
+ await review.forge.closePr(review.pr.number);
2620
+ return result2(`Closed #${review.pr.number}.`, { pr: review.pr });
2621
+ }
2622
+ const status = params.action === "approve" ? "APPROVE" : "REQUEST_CHANGES";
3484
2623
  return publishResolvedReview(review, { ...params, status }, modelRoute(ctx));
3485
2624
  }
3486
2625
  }),
@@ -3493,10 +2632,10 @@ ${params.body}`, {
3493
2632
  "Do not query GitHub through MCP or duplicate readiness checks before calling review_merge.",
3494
2633
  "This is intentionally GitHub-only until GitLab merge support is added."
3495
2634
  ],
3496
- parameters: parameters(contextSchema.extend({ subject: z11.string().optional() })),
2635
+ parameters: parameters(contextSchema.extend({ subject: z10.string().optional() })),
3497
2636
  executionMode: "sequential",
3498
2637
  async execute(_id, input) {
3499
- const params = contextSchema.extend({ subject: z11.string().optional() }).parse(input);
2638
+ const params = contextSchema.extend({ subject: z10.string().optional() }).parse(input);
3500
2639
  const review = await resolveReviewContext(resolveWorkingDirectory(params), params.target);
3501
2640
  if (review.vcs.provider !== "github" || !review.forge)
3502
2641
  return result2("review_merge currently supports GitHub only.");
@@ -3509,72 +2648,28 @@ ${params.body}`, {
3509
2648
  await review.forge.mergePr(review.pr.number, subject);
3510
2649
  return result2(`Merged #${review.pr.number} with subject: ${subject}.`, { pr: review.pr, guard });
3511
2650
  }
3512
- }),
3513
- defineTool6({
3514
- name: "review_launch_ui",
3515
- label: "review launch UI",
3516
- description: "Open a tuicr UI in a mux tab, configure Zed, or return the command to run.",
3517
- promptSnippet: "Call review_launch_ui for the interactive tuicr TUI",
3518
- promptGuidelines: ["Show the returned command when launch cannot open a tab."],
3519
- parameters: parameters(localSchema),
3520
- executionMode: "sequential",
3521
- async execute(_id, input) {
3522
- const params = localSchema.parse(input);
3523
- const review = await resolveReviewContext(resolveWorkingDirectory(params), params.target);
3524
- const workingTree = Boolean(params.local || params.workingTree);
3525
- if (!workingTree && !review.forge)
3526
- return result2(unsupportedForgeMessage());
3527
- return launchLocalReview(review, workingTree);
3528
- }
3529
2651
  })
3530
2652
  ];
3531
2653
  }
3532
- async function syncLocalThreadArtifact(path, title, sessionSlug, threads) {
3533
- let previous = [];
3534
- try {
3535
- previous = parseThreadArtifact(await readFile14(path, "utf8"));
3536
- } catch (error) {
3537
- if (error.code !== "ENOENT")
3538
- throw error;
3539
- }
3540
- const previousById = new Map(previous.map((thread) => [thread.id, thread]));
3541
- const currentIds = new Set(threads.map((thread) => thread.id));
3542
- const current = threads.map((thread) => {
3543
- const existing = previousById.get(thread.id);
3544
- return {
3545
- ...thread,
3546
- resolved: false,
3547
- addressed: existing?.addressed ?? Boolean(existing?.reply),
3548
- reply: existing?.reply
3549
- };
3550
- });
3551
- const removed = previous.filter((thread) => !currentIds.has(thread.id)).map((thread) => ({ ...thread, resolved: true }));
3552
- const merged = [...current, ...removed];
3553
- await writeFile8(path, renderThreadArtifact(title, sessionSlug, merged), "utf8");
3554
- return merged;
3555
- }
3556
2654
  function parameters(schema) {
3557
- return z11.toJSONSchema(schema, { io: "input" });
2655
+ return z10.toJSONSchema(schema, { io: "input" });
3558
2656
  }
3559
2657
  function resolveWorkingDirectory(params) {
3560
2658
  return params.cwd ?? process.cwd();
3561
2659
  }
2660
+ function deriveTitle(branch) {
2661
+ return branch.replace(/^(feature|feat|fix|bug|chore)\//, "").replace(/^eng-\d+-/i, "").replace(/[-_]+/g, " ").replace(/^\w/, (char) => char.toUpperCase());
2662
+ }
3562
2663
  function result2(text, details = {}) {
3563
2664
  return { content: [{ type: "text", text }], details };
3564
2665
  }
3565
2666
  function unsupportedForgeMessage() {
3566
- return "No supported GitHub or GitLab remote was detected. Use local=true for an offline tuicr review.";
2667
+ return "No supported GitHub or GitLab remote was detected.";
3567
2668
  }
3568
2669
  async function collectReviewStatus(review) {
3569
- const [porcelain, branch, localSession, remoteSession] = await Promise.all([
2670
+ const [porcelain, branch] = await Promise.all([
3570
2671
  runChecked("git", ["-C", review.cwd, "status", "--porcelain"]),
3571
- runChecked("git", ["-C", review.cwd, "branch", "--show-current"]),
3572
- resolveTuicrSession(review, true).catch(() => {
3573
- return;
3574
- }),
3575
- resolveTuicrSession(review, false).catch(() => {
3576
- return;
3577
- })
2672
+ runChecked("git", ["-C", review.cwd, "branch", "--show-current"])
3578
2673
  ]);
3579
2674
  const entries = porcelain.stdout.split("\\n").filter(Boolean);
3580
2675
  const staged = entries.filter((line) => !line.startsWith("??") && line[0] !== " ").length;
@@ -3585,10 +2680,8 @@ async function collectReviewStatus(review) {
3585
2680
  const text = [
3586
2681
  `Branch: ${branch.stdout.trim() || review.vcs.branch}`,
3587
2682
  `Worktree: ${entries.length ? "dirty" : "clean"} (staged=${staged}, unstaged=${unstaged}, untracked=${untracked})`,
3588
- `Local working-tree tuicr review: ${localSession ? `yes (${localSession.slug}, ${localSession.commentCount} comments)` : "no"}`,
3589
2683
  `Remote PR/MR: ${remote}`,
3590
- `Remote URL: ${url}`,
3591
- `Remote tuicr session: ${remoteSession ? `yes (${remoteSession.slug}, ${remoteSession.commentCount} comments)` : "no"}`
2684
+ `Remote URL: ${url}`
3592
2685
  ].join("\\n");
3593
2686
  return {
3594
2687
  text,
@@ -3597,8 +2690,6 @@ async function collectReviewStatus(review) {
3597
2690
  staged,
3598
2691
  unstaged,
3599
2692
  untracked,
3600
- localSession,
3601
- remoteSession,
3602
2693
  pr: review.pr,
3603
2694
  url
3604
2695
  }
@@ -3614,167 +2705,13 @@ async function publishResolvedReview(review, params, model) {
3614
2705
  const event = status === "CLOSE" ? "COMMENT" : status;
3615
2706
  assertReviewEventSupported(review.vcs.provider, event);
3616
2707
  const remote = createRemoteReviewBackend(review.vcs, review.pr.number);
3617
- const promotion = params.local ? await promoteLocalReview(review, remote, model) : undefined;
3618
2708
  if (status !== "CLOSE" && review.pr.isDraft)
3619
2709
  await review.forge.markReady(review.pr.number);
3620
2710
  await remote.publish(event);
3621
- if (promotion) {
3622
- promotion.publication.state.bodies = [
3623
- ...new Set([...promotion.publication.state.bodies, ...promotion.bodyFingerprints])
3624
- ];
3625
- promotion.publication.state.comments = [
3626
- ...new Set([...promotion.publication.state.comments, ...promotion.commentFingerprints])
3627
- ];
3628
- const published = new Set(promotion.commentFingerprints);
3629
- promotion.publication.state.stagedComments = promotion.publication.state.stagedComments.filter((fingerprint) => !published.has(fingerprint));
3630
- await saveReviewPublicationState(promotion.publication.path, promotion.publication.state);
3631
- }
3632
2711
  if (status === "CLOSE")
3633
2712
  await review.forge.closePr(review.pr.number);
3634
2713
  const finalPr = await review.forge.viewPr(String(review.pr.number));
3635
- const promotedBodies = promotion?.promotedBodies ?? 0;
3636
- const promotedComments = promotion?.promotedComments ?? 0;
3637
- const promotedReplies = promotion?.promotedReplies ?? 0;
3638
- return result2(`Published #${review.pr.number} (${status}); promoted ${promotedBodies} review bodies, ${promotedComments} comments, and ${promotedReplies} replies.`, { pr: finalPr ?? review.pr, status, promotedBodies, promotedComments, promotedReplies });
3639
- }
3640
- async function promoteLocalReview(review, remote, model) {
3641
- const publication = await loadReviewPublicationState(review.cwd, review.vcs, review.pr.number);
3642
- const session = await resolvePublishSession(review.cwd, {
3643
- branch: review.vcs.branch,
3644
- owner: review.vcs.provider === "none" ? undefined : review.vcs.owner,
3645
- repo: review.vcs.provider === "none" ? undefined : review.vcs.repo,
3646
- number: review.pr?.number
3647
- });
3648
- if (!session)
3649
- throw new Error("No matching tuicr session to publish.");
3650
- const sessionIsWorkingTree = session.kind === "local";
3651
- const local = createLocalReviewBackend({
3652
- session: session.path,
3653
- artifactPath: "",
3654
- author: localReviewAuthor(model)
3655
- });
3656
- const draft = await local.readDraft();
3657
- const remoteDraft = await remote.readDraft();
3658
- const knownRootComments = new Set([
3659
- ...publication.state.comments,
3660
- ...publication.state.stagedComments,
3661
- ...remoteDraft.comments.map(reviewCommentFingerprint)
3662
- ]);
3663
- const { checkpointed, candidates } = partitionReviewComments(draft.comments, knownRootComments, model);
3664
- const remoteThreads = await remote.listThreads();
3665
- const threadReplies = sessionIsWorkingTree ? [] : candidates.flatMap(({ source: comment }) => {
3666
- const thread = resolveReviewThread(comment, remoteThreads);
3667
- return thread ? [{ comment, thread }] : [];
3668
- });
3669
- const unmatchedActions = sessionIsWorkingTree ? [] : candidates.map(({ source }) => source).filter((comment) => parseReviewThreadAction(comment.body).action && !threadReplies.some((reply) => reply.comment === comment));
3670
- if (unmatchedActions.length > 0) {
3671
- const locations = unmatchedActions.map(({ file, line }) => `${file}:${line ?? "?"}`).join(", ");
3672
- throw new Error(`Cannot apply review thread action: no matching remote thread at ${locations}.`);
3673
- }
3674
- const comments = candidates.filter(({ source }) => !threadReplies.some((reply) => reply.comment === source)).map(({ published }) => published);
3675
- const body = draft.body.trim();
3676
- const bodyFingerprints = body ? [reviewBodyFingerprint(body)] : [];
3677
- const commentFingerprints = [
3678
- ...checkpointed.map(({ fingerprint }) => fingerprint),
3679
- ...comments.map(reviewCommentFingerprint)
3680
- ];
3681
- const knownBodies = new Set(publication.state.bodies);
3682
- if (remoteDraft.body.trim())
3683
- knownBodies.add(reviewBodyFingerprint(remoteDraft.body));
3684
- const unpublishedBody = body && !knownBodies.has(reviewBodyFingerprint(body)) ? body : "";
3685
- const unpublished = unpublishedReviewComments(comments, knownRootComments);
3686
- if (unpublishedBody)
3687
- await remote.stage({ comments: [], body: unpublishedBody });
3688
- let promotedComments = 0;
3689
- for (const comment of unpublished) {
3690
- await remote.stage({ comments: [comment], body: "" });
3691
- publication.state.stagedComments.push(reviewCommentFingerprint(comment));
3692
- await saveReviewPublicationState(publication.path, publication.state);
3693
- promotedComments += 1;
3694
- }
3695
- const promotedReplies = await promoteLocalReplies(review, remote, publication, model, sessionIsWorkingTree) + await promoteLocationReplies(remote, publication, threadReplies, model);
3696
- return {
3697
- publication,
3698
- bodyFingerprints,
3699
- commentFingerprints,
3700
- promotedBodies: unpublishedBody ? 1 : 0,
3701
- promotedComments,
3702
- promotedReplies
3703
- };
3704
- }
3705
- async function promoteLocationReplies(remote, publication, replies, model) {
3706
- let count = 0;
3707
- for (const { comment, thread } of replies) {
3708
- const parsed = parseReviewThreadAction(comment.body);
3709
- const body = parsed.body.trim();
3710
- const action = parsed.action;
3711
- const fingerprint = reviewReplyFingerprint(thread.id, `${action ?? "reply"}\x00${body}`);
3712
- if (publication.state.replies.includes(fingerprint))
3713
- continue;
3714
- if (action === "delete") {
3715
- if (!remote.deleteThread)
3716
- throw new Error(`The review backend cannot delete thread ${thread.id}.`);
3717
- await remote.deleteThread(thread.id);
3718
- publication.state.replies.push(fingerprint);
3719
- await saveReviewPublicationState(publication.path, publication.state);
3720
- count += 1;
3721
- continue;
3722
- }
3723
- const remoteBody = withCommentProvenance({ ...comment, body }, model);
3724
- if (body && !thread.replies?.some((reply) => reply === body || reply === remoteBody)) {
3725
- await remote.reply({
3726
- threadId: thread.id,
3727
- body: remoteBody,
3728
- resolve: false
3729
- });
3730
- }
3731
- if (action && remote.setResolved)
3732
- await remote.setResolved(thread.id, action === "resolve");
3733
- publication.state.replies.push(fingerprint);
3734
- await saveReviewPublicationState(publication.path, publication.state);
3735
- count += 1;
3736
- }
3737
- return count;
3738
- }
3739
- async function promoteLocalReplies(review, remote, publication, model, workingTree) {
3740
- const target = workingTree ? "uncommitted" : await reviewTargetId(review);
3741
- const artifact = workingTree ? await latestThreadArtifact(review.cwd, review, target) : publication.state.overlayPath ?? await latestThreadArtifact(review.cwd, review, target);
3742
- if (!artifact)
3743
- return 0;
3744
- if (!workingTree)
3745
- publication.state.overlayPath = artifact;
3746
- let count = 0;
3747
- for (const thread of await readThreadArtifact(artifact)) {
3748
- if (!thread.reply)
3749
- continue;
3750
- const body = withRemoteProvenance(thread.reply, model);
3751
- const fingerprint = reviewReplyFingerprint(thread.id, body);
3752
- if (publication.state.replies.includes(fingerprint))
3753
- continue;
3754
- if (workingTree) {
3755
- const draft = workingTreeReplyDraft(thread, body);
3756
- const comment = draft.comments[0];
3757
- if (comment) {
3758
- const commentFingerprint = reviewCommentFingerprint(comment);
3759
- if (!publication.state.stagedComments.includes(commentFingerprint)) {
3760
- await remote.stage(draft);
3761
- publication.state.stagedComments.push(commentFingerprint);
3762
- }
3763
- } else {
3764
- const bodyFingerprint = reviewBodyFingerprint(draft.body);
3765
- if (!publication.state.bodies.includes(bodyFingerprint)) {
3766
- await remote.stage(draft);
3767
- publication.state.bodies.push(bodyFingerprint);
3768
- }
3769
- }
3770
- } else {
3771
- await remote.reply({ threadId: thread.id, body, resolve: false, question: thread.question });
3772
- }
3773
- publication.state.replies.push(fingerprint);
3774
- await saveReviewPublicationState(publication.path, publication.state);
3775
- count += 1;
3776
- }
3777
- return count;
2714
+ return result2(`Published #${review.pr.number} (${status}).`, { pr: finalPr ?? review.pr, status });
3778
2715
  }
3779
2716
  async function resolveReviewContext(cwd, target) {
3780
2717
  const vcs = await detectVcs(cwd);
@@ -3786,21 +2723,6 @@ async function resolveReviewContext(cwd, target) {
3786
2723
  function normalizeTarget(target) {
3787
2724
  return target.match(/\/(?:pull|merge_requests)\/(\d+)(?:\/|$)/)?.[1] ?? target;
3788
2725
  }
3789
- function resolveTuicrSession(review, workingTree = false) {
3790
- return resolveReviewSession(review.cwd, {
3791
- branch: review.vcs.branch,
3792
- workingTree,
3793
- owner: review.vcs.provider === "none" ? undefined : review.vcs.owner,
3794
- repo: review.vcs.provider === "none" ? undefined : review.vcs.repo,
3795
- number: review.pr?.number
3796
- });
3797
- }
3798
- async function launchLocalReview(review, workingTree, requestedBase) {
3799
- const base = workingTree ? requestedBase ?? review.pr?.baseRef ?? (review.forge ? await review.forge.defaultBranch() : undefined) : undefined;
3800
- const launched = await launch(review.cwd, workingTree ? undefined : review.pr?.number, base);
3801
- const commandTarget = workingTree || !review.pr ? "full branch" : `PR/MR #${review.pr.number}`;
3802
- return result2(launched.launched ? `Opened ${commandTarget} in tuicr (${launched.via}).` : launched.instruction ?? `Run: ${launched.command}`, { launched, pr: review.pr, target: commandTarget });
3803
- }
3804
2726
  async function assertRemoteBranchReady(cwd) {
3805
2727
  const dirty = await runChecked("git", ["-C", cwd, "status", "--porcelain"]);
3806
2728
  if (dirty.stdout.trim())
@@ -3815,112 +2737,16 @@ async function assertRemoteBranchReady(cwd) {
3815
2737
  if (head.stdout.trim() !== pushed.stdout.trim())
3816
2738
  throw new Error("Push the current branch before opening a remote review.");
3817
2739
  }
3818
- function withCommentProvenance(comment, fallbackModel) {
3819
- const route = comment.author?.match(/^Agent:\s*(.+)$/)?.[1];
3820
- return route ? withRemoteProvenance(comment.body, route || fallbackModel) : comment.body;
3821
- }
3822
- async function reviewTargetId(review) {
3823
- if (!review.pr)
3824
- return "uncommitted";
3825
- if (review.pr.headSha)
3826
- return review.pr.headSha.slice(0, 12);
3827
- if (review.pr.headRef === review.vcs.branch)
3828
- return headSha(review.cwd);
3829
- const remote = await run("git", ["-C", review.cwd, "ls-remote", "origin", `refs/heads/${review.pr.headRef}`]);
3830
- return remote.stdout.trim().split(/\s+/)[0]?.slice(0, 12) || headSha(review.cwd);
3831
- }
3832
- async function headSha(cwd) {
3833
- const result = await runChecked("git", ["-C", cwd, "rev-parse", "--short=12", "HEAD"]);
3834
- return result.stdout.trim();
3835
- }
3836
- async function newReviewArtifactPath(cwd, target) {
3837
- const dir = await reviewsDir(cwd);
3838
- await mkdir7(dir, { recursive: true });
3839
- return uniqueRecordPath(dir, reviewRecordName(target));
3840
- }
3841
- async function localThreadArtifactPath(cwd, sessionSlug) {
3842
- const dir = await reviewsDir(cwd);
3843
- return join17(dir, `${reviewSlug(sessionSlug) || "local-review"}.md`);
3844
- }
3845
- async function readThreadArtifact(path) {
3846
- try {
3847
- return parseThreadArtifact(await readFile14(path, "utf8"));
3848
- } catch (error) {
3849
- if (error.code === "ENOENT") {
3850
- throw new Error(`Expected local reply overlay is missing: ${path}`);
3851
- }
3852
- throw error;
3853
- }
3854
- }
3855
- async function latestThreadArtifact(cwd, review, target) {
3856
- try {
3857
- return await findLatestThreadArtifact(cwd, review, target);
3858
- } catch (error) {
3859
- throw new Error(`Cannot locate the latest review thread artifact for ${target}.`, { cause: error });
3860
- }
3861
- }
3862
- async function findLatestThreadArtifact(cwd, review, target) {
3863
- const dir = await reviewsDir(cwd);
3864
- const suffix = reviewSlug(target) || "uncommitted";
3865
- const names = await listReviewArtifactNames(dir);
3866
- const candidates = await Promise.all(names.flatMap((name) => {
3867
- if (!name.endsWith(".md"))
3868
- return [];
3869
- return [
3870
- (async () => {
3871
- const path = join17(dir, name);
3872
- const info = await stat2(path);
3873
- return { path, name, content: await readFile14(path, "utf8"), modified: info.mtimeMs };
3874
- })()
3875
- ];
3876
- }));
3877
- return candidates.filter(({ name, content }) => {
3878
- if (!content.startsWith("<!-- diffpi-threads:"))
3879
- return false;
3880
- if (review.pr && target !== "local" && target !== "uncommitted")
3881
- return content.includes(`- PR/MR: #${review.pr.number}`);
3882
- return artifactNameMatches(name, suffix);
3883
- }).sort((a, b) => b.modified - a.modified)[0]?.path;
3884
- }
3885
- async function listReviewArtifactNames(dir) {
3886
- try {
3887
- return await readdir5(dir);
3888
- } catch (error) {
3889
- throw new Error(`Cannot read review artifacts in ${dir}.`, { cause: error });
3890
- }
3891
- }
3892
- function deriveTitle(branch) {
3893
- return branch.replace(/^(feature|feat|fix|bug|chore)\//, "").replace(/^eng-\d+-/i, "").replace(/[-_]+/g, " ").replace(/^\w/, (char) => char.toUpperCase());
3894
- }
3895
- function uniqueRecordPath(dir, base) {
3896
- let path = join17(dir, `${base}.md`);
3897
- let count = 2;
3898
- while (existsSync2(path))
3899
- path = join17(dir, `${base}-${count++}.md`);
3900
- return path;
3901
- }
3902
- function artifactNameMatches(name, suffix) {
3903
- if (!name.endsWith(".md"))
3904
- return false;
3905
- const stem = name.slice(0, -3);
3906
- const marker = `-${suffix}`;
3907
- const markerIndex = stem.lastIndexOf(marker);
3908
- if (markerIndex < 0)
3909
- return false;
3910
- const tail = stem.slice(markerIndex + marker.length);
3911
- return tail === "" || /^-\d+$/.test(tail);
3912
- }
3913
2740
 
3914
2741
  // src/tools/setup.ts
3915
2742
  import { defineTool as defineTool7 } from "@earendil-works/pi-coding-agent";
3916
- import { z as z12 } from "zod";
3917
- var setupParametersSchema = z12.object({
3918
- issueTracker: z12.enum(["none", "linear", "jira"]).default("none").describe("Issue tracker MCP server to configure. Use none unless the user explicitly selects Linear or Jira."),
3919
- forges: z12.array(z12.enum(["github", "gitlab"])).default([]).describe("Hosted VCS MCP servers to configure. Installs matching CLIs and registers each MCP server."),
3920
- forge: z12.enum(["none", "github", "gitlab"]).optional().describe("Deprecated single-host alias. Prefer forges for one or more VCS MCP servers."),
3921
- bindZedKey: z12.boolean().default(false).describe("Opt in to a Zed keybinding for the tuicr review task.")
2743
+ import { z as z11 } from "zod";
2744
+ var setupParametersSchema = z11.object({
2745
+ issueTracker: z11.enum(["none", "linear", "jira"]).default("none").describe("Issue tracker MCP server to configure. Use none unless the user explicitly selects Linear or Jira."),
2746
+ forges: z11.array(z11.enum(["github", "gitlab"])).default([]).describe("Hosted VCS MCP servers to configure. Installs matching CLIs and registers each MCP server."),
2747
+ forge: z11.enum(["none", "github", "gitlab"]).optional().describe("Deprecated single-host alias. Prefer forges for one or more VCS MCP servers.")
3922
2748
  });
3923
- var setupParameters = z12.toJSONSchema(setupParametersSchema, { io: "input" });
2749
+ var setupParameters = z11.toJSONSchema(setupParametersSchema, { io: "input" });
3924
2750
  var diffpiSetupTool = defineTool7({
3925
2751
  name: "diffpi_setup",
3926
2752
  label: "diffpi setup",
@@ -3940,7 +2766,6 @@ var diffpiSetupTool = defineTool7({
3940
2766
  issueTracker: params.issueTracker,
3941
2767
  forges: params.forges,
3942
2768
  forge: params.forge,
3943
- bindZedKey: params.bindZedKey,
3944
2769
  installMiseHook: true,
3945
2770
  availableModels: ctx.modelRegistry.getAvailable(),
3946
2771
  onProgress(message) {
@@ -3968,7 +2793,6 @@ var diffpiValidateTool = defineTool7({
3968
2793
  issueTracker: params.issueTracker,
3969
2794
  forges: params.forges,
3970
2795
  forge: params.forge,
3971
- bindZedKey: params.bindZedKey,
3972
2796
  installMiseHook: true,
3973
2797
  dryRun: true,
3974
2798
  availableModels: ctx.modelRegistry.getAvailable()
@@ -3993,11 +2817,11 @@ ${lines.join(`
3993
2817
 
3994
2818
  // src/tools/templates.ts
3995
2819
  import { defineTool as defineTool8 } from "@earendil-works/pi-coding-agent";
3996
- import { z as z13 } from "zod";
3997
- var templateSchema = z13.object({
3998
- name: z13.string().min(1),
3999
- variables: z13.record(z13.string(), z13.string()).optional(),
4000
- homeDir: z13.string().optional()
2820
+ import { z as z12 } from "zod";
2821
+ var templateSchema = z12.object({
2822
+ name: z12.string().min(1),
2823
+ variables: z12.record(z12.string(), z12.string()).optional(),
2824
+ homeDir: z12.string().optional()
4001
2825
  });
4002
2826
  var diffpiTemplateTool = defineTool8({
4003
2827
  name: "diffpi_template",
@@ -4005,7 +2829,7 @@ var diffpiTemplateTool = defineTool8({
4005
2829
  description: "Load a bundled Diffpi template or a user override and render named variables.",
4006
2830
  promptSnippet: "Use diffpi_template for package workflow templates",
4007
2831
  promptGuidelines: ["User overrides live under ~/.difflab/diffpi/templates."],
4008
- parameters: z13.toJSONSchema(templateSchema, { io: "input" }),
2832
+ parameters: z12.toJSONSchema(templateSchema, { io: "input" }),
4009
2833
  executionMode: "parallel",
4010
2834
  async execute(_id, input) {
4011
2835
  const params = templateSchema.parse(input);