@difflab/pi 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/README.md +28 -4
  2. package/agents/diffpi-orchestrator.md +7 -5
  3. package/agents/diffpi-reviewer.md +36 -0
  4. package/dist/assets.d.ts +2 -0
  5. package/dist/assets.d.ts.map +1 -1
  6. package/dist/commands/index.d.ts +8 -0
  7. package/dist/commands/index.d.ts.map +1 -0
  8. package/dist/commands/mode.d.ts +5 -0
  9. package/dist/commands/mode.d.ts.map +1 -0
  10. package/dist/commands/reload.d.ts +4 -0
  11. package/dist/commands/reload.d.ts.map +1 -0
  12. package/dist/commands/review.d.ts +5 -0
  13. package/dist/commands/review.d.ts.map +1 -0
  14. package/dist/environment.d.ts +41 -0
  15. package/dist/environment.d.ts.map +1 -0
  16. package/dist/extensions/ghx.d.ts +5 -0
  17. package/dist/extensions/ghx.d.ts.map +1 -0
  18. package/dist/extensions/gitx.d.ts +10 -0
  19. package/dist/extensions/gitx.d.ts.map +1 -0
  20. package/dist/extensions/glabx.d.ts +5 -0
  21. package/dist/extensions/glabx.d.ts.map +1 -0
  22. package/dist/extensions/index.js +2732 -189
  23. package/dist/{mise.d.ts → extensions/misex.d.ts} +3 -1
  24. package/dist/extensions/misex.d.ts.map +1 -0
  25. package/dist/{process.d.ts → extensions/processx.d.ts} +3 -1
  26. package/dist/extensions/processx.d.ts.map +1 -0
  27. package/dist/extensions/tuicrx.d.ts +59 -0
  28. package/dist/extensions/tuicrx.d.ts.map +1 -0
  29. package/dist/extensions/zedx.d.ts +16 -0
  30. package/dist/extensions/zedx.d.ts.map +1 -0
  31. package/dist/gates.d.ts +12 -0
  32. package/dist/gates.d.ts.map +1 -0
  33. package/dist/index.d.ts +24 -2
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +1799 -137
  36. package/dist/modes.d.ts +1 -0
  37. package/dist/modes.d.ts.map +1 -1
  38. package/dist/review/github-review-backend.d.ts +5 -0
  39. package/dist/review/github-review-backend.d.ts.map +1 -0
  40. package/dist/review/gitlab-review-backend.d.ts +12 -0
  41. package/dist/review/gitlab-review-backend.d.ts.map +1 -0
  42. package/dist/review/index.d.ts +8 -0
  43. package/dist/review/index.d.ts.map +1 -0
  44. package/dist/review/local-review-backend.d.ts +3 -0
  45. package/dist/review/local-review-backend.d.ts.map +1 -0
  46. package/dist/review/review-backend.d.ts +8 -0
  47. package/dist/review/review-backend.d.ts.map +1 -0
  48. package/dist/review/review-markdown.d.ts +6 -0
  49. package/dist/review/review-markdown.d.ts.map +1 -0
  50. package/dist/review/review-state.d.ts +19 -0
  51. package/dist/review/review-state.d.ts.map +1 -0
  52. package/dist/review/types.d.ts +107 -0
  53. package/dist/review/types.d.ts.map +1 -0
  54. package/dist/setup.d.ts +7 -0
  55. package/dist/setup.d.ts.map +1 -1
  56. package/dist/store.d.ts +15 -0
  57. package/dist/store.d.ts.map +1 -0
  58. package/dist/templates.d.ts +14 -0
  59. package/dist/templates.d.ts.map +1 -0
  60. package/dist/tools/index.d.ts +2 -0
  61. package/dist/tools/index.d.ts.map +1 -1
  62. package/dist/tools/index.js +2722 -299
  63. package/dist/tools/review.d.ts +7 -0
  64. package/dist/tools/review.d.ts.map +1 -0
  65. package/dist/tools/setup.d.ts.map +1 -1
  66. package/dist/tools/templates.d.ts +3 -0
  67. package/dist/tools/templates.d.ts.map +1 -0
  68. package/dist/vcs/forge-backend.d.ts +7 -0
  69. package/dist/vcs/forge-backend.d.ts.map +1 -0
  70. package/dist/vcs/github.d.ts +5 -0
  71. package/dist/vcs/github.d.ts.map +1 -0
  72. package/dist/vcs/gitlab.d.ts +4 -0
  73. package/dist/vcs/gitlab.d.ts.map +1 -0
  74. package/dist/vcs/helpers.d.ts +7 -0
  75. package/dist/vcs/helpers.d.ts.map +1 -0
  76. package/dist/vcs/index.d.ts +6 -0
  77. package/dist/vcs/index.d.ts.map +1 -0
  78. package/dist/vcs/types.d.ts +32 -0
  79. package/dist/vcs/types.d.ts.map +1 -0
  80. package/package.json +3 -2
  81. package/skills/diffpi-setup/SKILL.md +3 -1
  82. package/skills/review/SKILL.md +17 -0
  83. package/skills/review/references/workflows/address.md +11 -0
  84. package/skills/review/references/workflows/auto.md +18 -0
  85. package/skills/review/references/workflows/complete.md +7 -0
  86. package/skills/review/references/workflows/edit.md +7 -0
  87. package/skills/review/references/workflows/help.md +30 -0
  88. package/skills/review/references/workflows/merge.md +6 -0
  89. package/skills/review/references/workflows/new.md +19 -0
  90. package/skills/review/references/workflows/open.md +3 -0
  91. package/skills/review/references/workflows/publish.md +8 -0
  92. package/templates/review/draft-pr.md +25 -0
  93. package/dist/mise.d.ts.map +0 -1
  94. package/dist/process.d.ts.map +0 -1
@@ -13336,15 +13336,17 @@ import { basename, extname, join as join5 } from "node:path";
13336
13336
  import { existsSync as existsSync2 } from "node:fs";
13337
13337
  import { dirname as dirname2, join as join3 } from "node:path";
13338
13338
  import { fileURLToPath } from "node:url";
13339
- function resolveBundledAgentsDir(moduleUrl = import.meta.url) {
13339
+ function resolveBundledAssetDir(name, moduleUrl = import.meta.url) {
13340
13340
  const moduleDir = dirname2(fileURLToPath(moduleUrl));
13341
- const candidates = [
13342
- join3(moduleDir, "agents"),
13343
- join3(moduleDir, "..", "agents"),
13344
- join3(moduleDir, "..", "..", "agents")
13345
- ];
13341
+ const candidates = [join3(moduleDir, name), join3(moduleDir, "..", name), join3(moduleDir, "..", "..", name)];
13346
13342
  return candidates.find((path) => existsSync2(path)) ?? candidates[1];
13347
13343
  }
13344
+ function resolveBundledAgentsDir(moduleUrl = import.meta.url) {
13345
+ return resolveBundledAssetDir("agents", moduleUrl);
13346
+ }
13347
+ function resolveBundledTemplatesDir(moduleUrl = import.meta.url) {
13348
+ return resolveBundledAssetDir("templates", moduleUrl);
13349
+ }
13348
13350
 
13349
13351
  // src/config.ts
13350
13352
  import { parseFrontmatter } from "@earendil-works/pi-coding-agent";
@@ -13498,7 +13500,11 @@ function createModeController(pi, options = {}) {
13498
13500
  let active;
13499
13501
  let baseline;
13500
13502
  const updateStatus = (ctx) => {
13501
- ctx.ui.setStatus(MODE_STATUS_KEY, active ? `mode: ${active.id}` : undefined);
13503
+ const model = ctx.model ? ` · ${ctx.model.provider}/${ctx.model.id}` : "";
13504
+ const label = active?.label.toUpperCase() ?? "DEFAULT";
13505
+ const badge = `AGENT ${label}${model}`;
13506
+ ctx.ui.setStatus(MODE_STATUS_KEY, undefined);
13507
+ ctx.ui.setWidget?.(MODE_WIDGET_KEY, [badge], { placement: "belowEditor" });
13502
13508
  };
13503
13509
  const list = (ctx, listOptions = {}) => discoverAgentModes({
13504
13510
  cwd: ctx.cwd,
@@ -13553,6 +13559,9 @@ function createModeController(pi, options = {}) {
13553
13559
  }
13554
13560
  updateStatus(ctx);
13555
13561
  },
13562
+ refresh(ctx) {
13563
+ updateStatus(ctx);
13564
+ },
13556
13565
  apply(systemPrompt) {
13557
13566
  if (!active)
13558
13567
  return systemPrompt;
@@ -13571,6 +13580,7 @@ ${active.systemPrompt}`;
13571
13580
  }
13572
13581
  var MODE_STATE_ENTRY = "diffpi-mode-state";
13573
13582
  var MODE_STATUS_KEY = "diffpi-mode";
13583
+ var MODE_WIDGET_KEY = "diffpi-mode-badge";
13574
13584
  var MODE_CONTROL_TOOLS = ["ask_user_question", "diffpi_modes_list", "diffpi_modes_set", "diffpi_modes_unset"];
13575
13585
  var BUNDLED_AGENTS_DIR = resolveBundledAgentsDir();
13576
13586
  var THINKING_LEVELS = new Set(["off", "minimal", "low", "medium", "high", "xhigh", "max"]);
@@ -13688,6 +13698,124 @@ function isModeBaseline(value) {
13688
13698
  return (model === undefined || typeof model.provider === "string" && typeof model.id === "string") && candidate.thinkingLevel !== undefined && THINKING_LEVELS.has(candidate.thinkingLevel) && Array.isArray(candidate.tools) && candidate.tools.every((tool) => typeof tool === "string");
13689
13699
  }
13690
13700
 
13701
+ // src/commands/mode.ts
13702
+ var MODE_USAGE = `/mode [agent|default|clear|reset]
13703
+
13704
+ Use /mode to pick a profile. Available profiles: copilot, orchestrator, reviewer, tutor, worker.`;
13705
+ function registerModeCommand(pi, modes) {
13706
+ pi.registerCommand("mode", {
13707
+ description: "Select or clear a Diffpi inline agent profile",
13708
+ handler: async (args, ctx) => handleModeCommand(args, ctx, modes)
13709
+ });
13710
+ }
13711
+ async function handleModeCommand(args, ctx, modes) {
13712
+ const requested = args.trim();
13713
+ if (requested === "help" || requested === "-h" || requested === "--help") {
13714
+ ctx.ui.notify(MODE_USAGE, "info");
13715
+ return;
13716
+ }
13717
+ if (!requested) {
13718
+ const catalog = await modes.list(ctx);
13719
+ const selected = await ctx.ui.select("Diffpi mode", ["default", ...catalog.modes.map((mode) => mode.id)]);
13720
+ if (!selected)
13721
+ return;
13722
+ const result = selected === "default" ? await modes.unset(ctx) : await modes.set(selected, ctx);
13723
+ ctx.ui.notify(`${result.message} Changes apply on the next turn.`, result.ok ? "info" : "error");
13724
+ return;
13725
+ }
13726
+ if (/\s/.test(requested)) {
13727
+ ctx.ui.notify(MODE_USAGE, "error");
13728
+ return;
13729
+ }
13730
+ const result = requested === "default" || requested === "clear" || requested === "reset" ? await modes.unset(ctx) : await modes.set(requested, ctx);
13731
+ ctx.ui.notify(`${result.message} Changes apply on the next turn.`, result.ok ? "info" : "error");
13732
+ }
13733
+
13734
+ // src/commands/reload.ts
13735
+ function registerReloadCommand(pi) {
13736
+ pi.registerCommand("diffpi-reload", {
13737
+ description: "Reload extensions, skills, prompts, themes, and context files",
13738
+ handler: async (_args, ctx) => {
13739
+ await ctx.reload();
13740
+ }
13741
+ });
13742
+ }
13743
+
13744
+ // src/commands/review.ts
13745
+ import { join as join6 } from "node:path";
13746
+ var REVIEWER_VERBS = new Set(["address", "auto", "launch"]);
13747
+ var ORCHESTRATOR_AGENT_PATH = join6(resolveBundledAgentsDir(), "diffpi-orchestrator.md");
13748
+ function registerReviewCommand(pi, modes) {
13749
+ pi.registerCommand("review", {
13750
+ description: "Code review: auto, new, edit, address, publish, complete, merge (add --local or --bg)",
13751
+ handler: async (args, ctx) => handleReviewCommand(args, ctx, pi, modes)
13752
+ });
13753
+ }
13754
+ async function handleReviewCommand(args, ctx, pi, modes) {
13755
+ const background = hasFlag(args, "--bg");
13756
+ const invocation = withoutFlag(args, "--bg") || "help";
13757
+ const verb = reviewVerb(invocation);
13758
+ if (background) {
13759
+ const name = `Review ${verb}`;
13760
+ const prompt = reviewPrompt(invocation, "orchestrator");
13761
+ const command = shellCommand([
13762
+ "pi",
13763
+ "--mode",
13764
+ "json",
13765
+ "--print",
13766
+ "--no-session",
13767
+ "--offline",
13768
+ "--approve",
13769
+ "--model",
13770
+ "openai-codex/gpt-5.6-luna",
13771
+ "--thinking",
13772
+ "medium",
13773
+ "--append-system-prompt",
13774
+ ORCHESTRATOR_AGENT_PATH,
13775
+ "--",
13776
+ prompt
13777
+ ]);
13778
+ await pi.sendUserMessage(`/bg --agent --name ${shellQuote(name)} -- ${command}`, {
13779
+ deliverAs: "followUp",
13780
+ expandPromptTemplates: true
13781
+ });
13782
+ return;
13783
+ }
13784
+ const agent = REVIEWER_VERBS.has(verb) ? "reviewer" : "orchestrator";
13785
+ const activation = await modes.set(agent, ctx);
13786
+ pi.sendMessage({
13787
+ customType: "diffpi-review-command",
13788
+ display: false,
13789
+ content: `${reviewPrompt(invocation, agent)} ${activation.message}`
13790
+ }, { triggerTurn: true });
13791
+ }
13792
+ function reviewVerb(invocation) {
13793
+ return invocation.split(/\s+/).find((token) => token && !token.startsWith("-")) ?? "help";
13794
+ }
13795
+ function reviewPrompt(invocation, agent) {
13796
+ const routing = agent === "reviewer" ? "Coordinate this auto/address workflow. For address, classify every thread and delegate bounded edits to lightweight worker agents before collecting outcomes." : "Own this lifecycle workflow. Delegate review judgment to the reviewer only if the selected workflow requires it.";
13797
+ return `The user ran /review ${invocation}. Active inline agent: ${agent}. ${routing} Follow the review skill dispatcher. Call review_context first, then the matching review_* tools. Do not perform unrelated work.`;
13798
+ }
13799
+ function hasFlag(input, flag) {
13800
+ return input.split(/\s+/).includes(flag);
13801
+ }
13802
+ function withoutFlag(input, flag) {
13803
+ return input.split(/\s+/).filter((token) => token && token !== flag).join(" ").trim();
13804
+ }
13805
+ function shellCommand(args) {
13806
+ return args.map(shellQuote).join(" ");
13807
+ }
13808
+ function shellQuote(value) {
13809
+ return "'" + value.replaceAll("'", `'"'"'`) + "'";
13810
+ }
13811
+
13812
+ // src/commands/index.ts
13813
+ function registerCommands(pi, modes) {
13814
+ registerReloadCommand(pi);
13815
+ registerModeCommand(pi, modes);
13816
+ registerReviewCommand(pi, modes);
13817
+ }
13818
+
13691
13819
  // src/tools/reload.ts
13692
13820
  import { defineTool } from "@earendil-works/pi-coding-agent";
13693
13821
  import { z as z2 } from "zod";
@@ -13814,87 +13942,21 @@ function sanitize(value) {
13814
13942
  return value.replace(/[\r\n\t]+/g, " ").replace(/\s+/g, " ").trim();
13815
13943
  }
13816
13944
 
13817
- // src/tools/setup.ts
13945
+ // src/tools/review.ts
13946
+ import { existsSync as existsSync3 } from "node:fs";
13947
+ import { mkdir as mkdir4, readdir as readdir2, readFile as readFile9, rename as rename2, stat, unlink as unlink2, writeFile as writeFile5 } from "node:fs/promises";
13948
+ import { join as join14 } from "node:path";
13818
13949
  import { defineTool as defineTool3 } from "@earendil-works/pi-coding-agent";
13819
- import { z as z4 } from "zod";
13820
-
13821
- // src/setup.ts
13822
- import { parseFrontmatter as parseFrontmatter3 } from "@earendil-works/pi-coding-agent";
13823
- import { readdir as readdir2, readFile as readFile5 } from "node:fs/promises";
13824
- import { homedir as homedir7 } from "node:os";
13825
- import { basename as basename3, join as join10 } from "node:path";
13826
-
13827
- // src/mcp.ts
13828
- import { mkdir, readFile as readFile3, writeFile } from "node:fs/promises";
13829
- import { homedir as homedir4 } from "node:os";
13830
- import { dirname as dirname3, join as join6 } from "node:path";
13831
- var mcp = {
13832
- globalConfigPath(homeDir = homedir4()) {
13833
- return join6(homeDir, ".config", "mcp", "mcp.json");
13834
- },
13835
- async serversEnsure(servers, options = {}) {
13836
- const path = options.path ?? mcp.globalConfigPath();
13837
- const currentText = await getOptionalFile(path);
13838
- const current = getParsedConfig(currentText, path);
13839
- const nextServers = { ...current.mcpServers };
13840
- for (const [name, entry] of Object.entries(servers)) {
13841
- nextServers[name] = mergeEntry(nextServers[name], entry);
13842
- }
13843
- const next = { ...current, mcpServers: nextServers };
13844
- const changed = JSON.stringify(current) !== JSON.stringify(next);
13845
- if (changed && !options.dryRun) {
13846
- await mkdir(dirname3(path), { recursive: true });
13847
- await writeFile(path, `${JSON.stringify(next, null, 2)}
13848
- `, "utf8");
13849
- }
13850
- return { path, changed, existed: currentText !== undefined, planned: changed && options.dryRun === true };
13851
- }
13852
- };
13853
- function mergeEntry(current, required) {
13854
- const merged = { ...current, ...required };
13855
- if (current?.env || required.env)
13856
- merged.env = { ...current?.env, ...required.env };
13857
- return merged;
13858
- }
13859
- function getParsedConfig(content, path) {
13860
- if (!content?.trim())
13861
- return { mcpServers: {} };
13862
- try {
13863
- const value = JSON.parse(content);
13864
- if (!isRecord(value))
13865
- throw new Error("not an object");
13866
- const servers = value.mcpServers;
13867
- if (servers !== undefined && !isRecord(servers))
13868
- throw new Error("mcpServers is not an object");
13869
- return { ...value, mcpServers: servers ?? {} };
13870
- } catch {
13871
- throw new Error(`Expected a valid pi-mcp-adapter configuration in ${path}.`);
13872
- }
13873
- }
13874
- async function getOptionalFile(path) {
13875
- try {
13876
- return await readFile3(path, "utf8");
13877
- } catch (error) {
13878
- if (error instanceof Error && "code" in error && error.code === "ENOENT")
13879
- return;
13880
- throw error;
13881
- }
13882
- }
13883
- function isRecord(value) {
13884
- return value !== null && typeof value === "object" && !Array.isArray(value);
13885
- }
13950
+ import { z as z7 } from "zod";
13886
13951
 
13887
- // src/mise.ts
13888
- import { mkdir as mkdir2, readFile as readFile4, writeFile as writeFile2 } from "node:fs/promises";
13889
- import { homedir as homedir5 } from "node:os";
13890
- import { basename as basename2, dirname as dirname4, join as join8 } from "node:path";
13952
+ // src/environment.ts
13953
+ import { basename as basename2 } from "node:path";
13891
13954
 
13892
- // src/process.ts
13955
+ // src/extensions/processx.ts
13956
+ import { spawn as spawn2 } from "node:child_process";
13893
13957
  import { constants } from "node:fs";
13894
13958
  import { access } from "node:fs/promises";
13895
13959
  import { delimiter, join as join7 } from "node:path";
13896
- import { spawn as spawn2 } from "node:child_process";
13897
- var MAX_CAPTURED_OUTPUT_LENGTH = 65536;
13898
13960
  async function findExecutable(name) {
13899
13961
  if (name.includes("/")) {
13900
13962
  try {
@@ -13920,18 +13982,35 @@ function run(command, args, options = {}) {
13920
13982
  const child = spawn2(command, args, {
13921
13983
  cwd: options.cwd,
13922
13984
  env: options.env ?? process.env,
13923
- stdio: ["ignore", "pipe", "pipe"]
13985
+ stdio: [options.input === undefined ? "ignore" : "pipe", "pipe", "pipe"]
13924
13986
  });
13925
13987
  let stdout = "";
13926
13988
  let stderr = "";
13927
- child.stdout.on("data", (chunk) => {
13928
- stdout = appendBounded(stdout, chunk.toString());
13989
+ const stdoutChunks = [];
13990
+ const stderrChunks = [];
13991
+ const unbounded = options.capture === "unbounded";
13992
+ child.stdout?.on("data", (chunk) => {
13993
+ const text = chunk.toString();
13994
+ if (unbounded)
13995
+ stdoutChunks.push(text);
13996
+ else
13997
+ stdout = appendBounded(stdout, text);
13929
13998
  });
13930
- child.stderr.on("data", (chunk) => {
13931
- stderr = appendBounded(stderr, chunk.toString());
13999
+ child.stderr?.on("data", (chunk) => {
14000
+ const text = chunk.toString();
14001
+ if (unbounded)
14002
+ stderrChunks.push(text);
14003
+ else
14004
+ stderr = appendBounded(stderr, text);
13932
14005
  });
13933
14006
  child.on("error", reject);
13934
- child.on("close", (code) => resolve({ code: code ?? 1, stdout, stderr }));
14007
+ child.on("close", (code) => resolve({
14008
+ code: code ?? 1,
14009
+ stdout: unbounded ? stdoutChunks.join("") : stdout,
14010
+ stderr: unbounded ? stderrChunks.join("") : stderr
14011
+ }));
14012
+ if (options.input !== undefined && child.stdin)
14013
+ child.stdin.end(options.input);
13935
14014
  });
13936
14015
  }
13937
14016
  async function runChecked(command, args, options = {}) {
@@ -13941,24 +14020,516 @@ async function runChecked(command, args, options = {}) {
13941
14020
  const detail = result.stderr.trim() || result.stdout.trim() || `exit code ${result.code}`;
13942
14021
  throw new Error(`${command} ${args.join(" ")} failed: ${detail}`);
13943
14022
  }
14023
+ var MAX_CAPTURED_OUTPUT_LENGTH = 65536;
13944
14024
  function appendBounded(current, next) {
13945
14025
  const combined = current + next;
13946
14026
  return combined.length <= MAX_CAPTURED_OUTPUT_LENGTH ? combined : combined.slice(-MAX_CAPTURED_OUTPUT_LENGTH);
13947
14027
  }
13948
14028
 
13949
- // src/mise.ts
14029
+ // src/extensions/zedx.ts
14030
+ import { mkdir, readFile as readFile3, writeFile } from "node:fs/promises";
14031
+ import { homedir as homedir4 } from "node:os";
14032
+ import { dirname as dirname3, join as join8 } from "node:path";
14033
+ var ZED_LOCAL_REVIEW_TASK_NAME = "diffpi: tuicr local review";
14034
+ var ZED_PR_REVIEW_TASK_NAME = "diffpi: tuicr PR review";
14035
+ var LEGACY_ZED_REVIEW_TASK_NAME = "diffpi: tuicr review";
14036
+ var REVIEW_KEYBINDING = "cmd-alt-r";
14037
+ function zedTasksPath(homeDir = homedir4()) {
14038
+ return join8(homeDir, ".config", "zed", "tasks.json");
14039
+ }
14040
+ function zedKeymapPath(homeDir = homedir4()) {
14041
+ return join8(homeDir, ".config", "zed", "keymap.json");
14042
+ }
14043
+ async function ensureZedReviewTask(homeDir = homedir4(), _command = ["tuicr", "-w", "-r", "main..HEAD"]) {
14044
+ const path = zedTasksPath(homeDir);
14045
+ const currentText = await readOptional(path);
14046
+ const tasks = parseJsonArray(currentText, path);
14047
+ const migrated = tasks.filter((task) => task.label !== LEGACY_ZED_REVIEW_TASK_NAME);
14048
+ const next = [...migrated];
14049
+ for (const task of [localReviewTask(), prReviewTask()]) {
14050
+ const index = next.findIndex((existing) => existing.label === task.label);
14051
+ if (index >= 0)
14052
+ next[index] = { ...next[index], ...task };
14053
+ else
14054
+ next.push(task);
14055
+ }
14056
+ const changed = JSON.stringify(tasks) !== JSON.stringify(next);
14057
+ if (changed)
14058
+ await writeJson(path, next);
14059
+ return { path, changed, existed: currentText !== undefined };
14060
+ }
14061
+ async function ensureZedReviewKeybinding(homeDir = homedir4()) {
14062
+ const path = zedKeymapPath(homeDir);
14063
+ const currentText = await readOptional(path);
14064
+ const entries = parseJsonArray(currentText, path);
14065
+ const migrated = entries.map(migrateReviewKeymapEntry);
14066
+ const alreadyBound = migrated.some((entry) => Object.values(entry.bindings ?? {}).some((action) => Array.isArray(action) && action[0] === "task::Spawn" && bindsReviewTask(action[1])));
14067
+ const next = alreadyBound ? migrated : [
14068
+ ...migrated,
14069
+ {
14070
+ context: "Workspace",
14071
+ bindings: { [REVIEW_KEYBINDING]: ["task::Spawn", { task_name: ZED_LOCAL_REVIEW_TASK_NAME }] }
14072
+ }
14073
+ ];
14074
+ const changed = JSON.stringify(entries) !== JSON.stringify(next);
14075
+ if (changed)
14076
+ await writeJson(path, next);
14077
+ return { path, changed, existed: currentText !== undefined };
14078
+ }
14079
+ function zedReviewTaskName(command) {
14080
+ return command[0] === "tuicr" && command[1] === "pr" ? ZED_PR_REVIEW_TASK_NAME : ZED_LOCAL_REVIEW_TASK_NAME;
14081
+ }
14082
+ var LOCAL_REVIEW_SCRIPT = `set -eu
14083
+ remote=$(git remote get-url origin 2>/dev/null || true)
14084
+ branch=$(git branch --show-current)
14085
+ base=
14086
+ case "$remote" in
14087
+ *github.com*)
14088
+ 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)
14089
+ [ -n "$base" ] || base=$(gh repo view --json defaultBranchRef --jq .defaultBranchRef.name 2>/dev/null || true)
14090
+ ;;
14091
+ *gitlab*)
14092
+ mr=$(glab mr list --source-branch "$branch" --order created_at --sort desc --per-page 100 --output json 2>/dev/null || true)
14093
+ 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{}})')
14094
+ [ -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)
14095
+ ;;
14096
+ esac
14097
+ [ -n "$base" ] || { echo 'diffpi: could not resolve a GitHub/GitLab review base' >&2; exit 1; }
14098
+ exec tuicr -w -r "$base..HEAD"`;
14099
+ var PR_REVIEW_SCRIPT = `set -eu
14100
+ remote=$(git remote get-url origin 2>/dev/null || true)
14101
+ branch=$(git branch --show-current)
14102
+ case "$remote" in
14103
+ *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) ;;
14104
+ *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) ;;
14105
+ *) number= ;;
14106
+ esac
14107
+ [ -n "$number" ] || { echo 'diffpi: could not resolve the current GitHub PR or GitLab MR' >&2; exit 1; }
14108
+ exec tuicr pr "$number"`;
14109
+ function staticReviewTask(label, script) {
14110
+ return {
14111
+ label,
14112
+ command: "sh",
14113
+ args: ["-lc", script],
14114
+ cwd: "$ZED_WORKTREE_ROOT",
14115
+ use_new_terminal: true,
14116
+ reveal: "always",
14117
+ reveal_target: "center"
14118
+ };
14119
+ }
14120
+ function localReviewTask() {
14121
+ return staticReviewTask(ZED_LOCAL_REVIEW_TASK_NAME, LOCAL_REVIEW_SCRIPT);
14122
+ }
14123
+ function prReviewTask() {
14124
+ return staticReviewTask(ZED_PR_REVIEW_TASK_NAME, PR_REVIEW_SCRIPT);
14125
+ }
14126
+ function bindsReviewTask(payload) {
14127
+ return typeof payload === "object" && payload !== null && payload.task_name === ZED_LOCAL_REVIEW_TASK_NAME;
14128
+ }
14129
+ function migrateReviewKeymapEntry(entry) {
14130
+ const bindings = Object.fromEntries(Object.entries(entry.bindings ?? {}).map(([key, action]) => {
14131
+ if (Array.isArray(action) && action[0] === "task::Spawn" && typeof action[1] === "object" && action[1] !== null && action[1].task_name === LEGACY_ZED_REVIEW_TASK_NAME) {
14132
+ return [key, ["task::Spawn", { ...action[1], task_name: ZED_LOCAL_REVIEW_TASK_NAME }]];
14133
+ }
14134
+ return [key, action];
14135
+ }));
14136
+ return entry.bindings ? { ...entry, bindings } : entry;
14137
+ }
14138
+ async function readOptional(path) {
14139
+ try {
14140
+ return await readFile3(path, "utf8");
14141
+ } catch (error) {
14142
+ if (error instanceof Error && "code" in error && error.code === "ENOENT")
14143
+ return;
14144
+ throw error;
14145
+ }
14146
+ }
14147
+ function parseJsonArray(content, path) {
14148
+ if (!content?.trim())
14149
+ return [];
14150
+ let value;
14151
+ try {
14152
+ value = JSON.parse(content);
14153
+ } catch {
14154
+ throw new Error(`Cannot safely edit ${path}: not strict JSON (it may contain JSONC comments).`);
14155
+ }
14156
+ if (!Array.isArray(value))
14157
+ throw new Error(`Expected a JSON array in ${path}.`);
14158
+ return value;
14159
+ }
14160
+ async function writeJson(path, value) {
14161
+ await mkdir(dirname3(path), { recursive: true });
14162
+ await writeFile(path, `${JSON.stringify(value, null, 2)}
14163
+ `, "utf8");
14164
+ }
14165
+
14166
+ // src/environment.ts
14167
+ function detectIde(env = process.env) {
14168
+ const program = (env.TERM_PROGRAM ?? "").toLowerCase();
14169
+ if (env.ZED_TERM === "true" || program === "zed")
14170
+ return "zed";
14171
+ if (env.CURSOR_TRACE_ID || program === "cursor")
14172
+ return "cursor";
14173
+ if (env.WINDSURF_ENV || program === "windsurf")
14174
+ return "windsurf";
14175
+ if (env.TERMINAL_EMULATOR?.toLowerCase().includes("jetbrains"))
14176
+ return "jetbrains";
14177
+ if (env.VSCODE_PID || env.VSCODE_GIT_IPC_HANDLE || program === "vscode")
14178
+ return "vscode";
14179
+ return "unknown";
14180
+ }
14181
+ function detectMux(env = process.env) {
14182
+ if (env.ZELLIJ || env.ZELLIJ_SESSION_NAME)
14183
+ return "zellij";
14184
+ if (env.TMUX)
14185
+ return "tmux";
14186
+ if (env.STY)
14187
+ return "screen";
14188
+ return "none";
14189
+ }
14190
+ function detectShell(env = process.env) {
14191
+ return env.SHELL ? basename2(env.SHELL) : "unknown";
14192
+ }
14193
+ async function detectVcs(cwd) {
14194
+ const root = (await run("git", ["-C", cwd, "rev-parse", "--show-toplevel"])).stdout.trim() || cwd;
14195
+ const branch = (await run("git", ["-C", root, "rev-parse", "--abbrev-ref", "HEAD"])).stdout.trim();
14196
+ const remote = (await run("git", ["-C", root, "remote", "get-url", "origin"])).stdout.trim();
14197
+ return { ...parseRemote(remote), branch, root };
14198
+ }
14199
+ function parseRemote(remote) {
14200
+ const empty = { provider: "none", host: "", owner: "", repo: "" };
14201
+ if (!remote)
14202
+ return empty;
14203
+ const scp = remote.match(/^[^@]+@([^:]+):(.+?)(?:\.git)?$/);
14204
+ const url = remote.match(/^[a-z]+:\/\/(?:[^@]+@)?([^/]+)\/(.+?)(?:\.git)?$/i);
14205
+ const match = scp ?? url;
14206
+ if (!match)
14207
+ return empty;
14208
+ const host = match[1];
14209
+ const segments = match[2].split("/").filter(Boolean);
14210
+ if (segments.length < 2)
14211
+ return { ...empty, host };
14212
+ const repo = segments.at(-1) ?? "";
14213
+ const owner = segments.slice(0, -1).join("/");
14214
+ const provider = /github/i.test(host) ? "github" : /gitlab/i.test(host) ? "gitlab" : "none";
14215
+ return { provider, host, owner, repo };
14216
+ }
14217
+ async function openInNewTab(command, opts) {
14218
+ const env = opts.env ?? process.env;
14219
+ const name = opts.name ?? "review";
14220
+ const printable = command.join(" ");
14221
+ const mux = detectMux(env);
14222
+ if (mux !== "none") {
14223
+ const opened = await openMuxTab(mux, command, opts.cwd, name, printable);
14224
+ if (opened)
14225
+ return opened;
14226
+ }
14227
+ if (detectIde(env) === "zed") {
14228
+ try {
14229
+ const taskName = zedReviewTaskName(command);
14230
+ await ensureZedReviewTask(opts.homeDir, command);
14231
+ return {
14232
+ launched: false,
14233
+ configured: true,
14234
+ via: "zed-task",
14235
+ command: printable,
14236
+ taskName,
14237
+ instruction: `Run the Zed task "${taskName}".`
14238
+ };
14239
+ } catch {}
14240
+ }
14241
+ return { launched: false, via: "print", command: printable };
14242
+ }
14243
+ function screenWindowArgs(command, cwd, name) {
14244
+ return ["-X", "screen", "-t", name, "sh", "-lc", 'cd -- "$1" && shift && exec "$@"', "sh", cwd, ...command];
14245
+ }
14246
+ async function openMuxTab(mux, command, cwd, name, printable) {
14247
+ if (mux === "zellij" && await findExecutable("zellij")) {
14248
+ const result = await run("zellij", ["action", "new-tab", "--cwd", cwd, "--name", name, "--", ...command]);
14249
+ if (result.code === 0)
14250
+ return { launched: true, via: "zellij", command: printable };
14251
+ const fallback = await run("zellij", ["run", "--cwd", cwd, "--name", name, "--", ...command]);
14252
+ if (fallback.code === 0)
14253
+ return { launched: true, via: "zellij-run", command: printable };
14254
+ }
14255
+ if (mux === "tmux" && await findExecutable("tmux")) {
14256
+ const result = await run("tmux", ["new-window", "-c", cwd, "-n", name, printable]);
14257
+ if (result.code === 0)
14258
+ return { launched: true, via: "tmux", command: printable };
14259
+ }
14260
+ if (mux === "screen" && await findExecutable("screen")) {
14261
+ const result = await run("screen", screenWindowArgs(command, cwd, name));
14262
+ if (result.code === 0)
14263
+ return { launched: true, via: "screen", command: printable };
14264
+ }
14265
+ return;
14266
+ }
14267
+
14268
+ // src/extensions/ghx.ts
14269
+ function gh(args, options) {
14270
+ return run("gh", args, options);
14271
+ }
14272
+ function ghChecked(args, options) {
14273
+ return runChecked("gh", args, options);
14274
+ }
14275
+
14276
+ // src/vcs/helpers.ts
14277
+ function isConfirmedMissingChange(provider, output) {
14278
+ const message = output.toLowerCase();
14279
+ return provider === "github" ? message.includes("no pull requests found for branch") || message.includes("could not find pull request") || message.includes("could not resolve to a pullrequest") : provider === "gitlab" ? message.includes("no open merge request") || /failed to get open merge request/.test(message) && /404(?: not found)?/.test(message) : false;
14280
+ }
14281
+ function parseJson(value, name) {
14282
+ try {
14283
+ return JSON.parse(value);
14284
+ } catch {
14285
+ throw new Error(`Cannot parse the ${name} response as JSON.`);
14286
+ }
14287
+ }
14288
+ function commandFailure(command, args, result) {
14289
+ return new Error(`${command} ${args.join(" ")} failed: ${result.stderr.trim() || result.stdout.trim() || `exit code ${result.code}`}`);
14290
+ }
14291
+ function requireBranchName(output, provider) {
14292
+ const branch = output.trim();
14293
+ if (!branch || branch === "null")
14294
+ throw new Error(`${provider} did not return a default branch.`);
14295
+ return branch;
14296
+ }
14297
+
14298
+ // src/vcs/github.ts
14299
+ function GitHubVcsBackend(vcs) {
14300
+ const repo = ["--repo", `${vcs.owner}/${vcs.repo}`];
14301
+ const viewPr = async (idOrBranch) => {
14302
+ const args = [
14303
+ "pr",
14304
+ "view",
14305
+ idOrBranch,
14306
+ ...repo,
14307
+ "--json",
14308
+ "number,title,url,isDraft,baseRefName,headRefName,headRefOid"
14309
+ ];
14310
+ const result = await gh(args);
14311
+ if (result.code !== 0) {
14312
+ if (isConfirmedMissingChange("github", result.stderr || result.stdout))
14313
+ return;
14314
+ throw commandFailure("gh", args, result);
14315
+ }
14316
+ if (!result.stdout.trim())
14317
+ throw new Error("GitHub returned an empty pull request response.");
14318
+ const data = parseJson(result.stdout, "GitHub pull request");
14319
+ if (typeof data.number !== "number" || typeof data.title !== "string" || typeof data.url !== "string" || typeof data.isDraft !== "boolean" || typeof data.baseRefName !== "string" || typeof data.headRefName !== "string")
14320
+ throw new Error("GitHub returned an invalid pull request response.");
14321
+ return {
14322
+ number: data.number,
14323
+ title: data.title,
14324
+ url: data.url,
14325
+ isDraft: data.isDraft,
14326
+ baseRef: data.baseRefName,
14327
+ headRef: data.headRefName,
14328
+ headSha: data.headRefOid
14329
+ };
14330
+ };
14331
+ return {
14332
+ provider: "github",
14333
+ async createDraftPr(options) {
14334
+ const args = [
14335
+ "pr",
14336
+ "create",
14337
+ ...repo,
14338
+ "--title",
14339
+ options.title,
14340
+ "--body",
14341
+ options.body,
14342
+ "--base",
14343
+ options.base,
14344
+ "--head",
14345
+ options.head
14346
+ ];
14347
+ if (options.draft !== false)
14348
+ args.push("--draft");
14349
+ await ghChecked(args);
14350
+ const ref = await viewPr(options.head);
14351
+ if (!ref)
14352
+ throw new Error("Draft PR created but could not be resolved.");
14353
+ return ref;
14354
+ },
14355
+ viewPr,
14356
+ async defaultBranch() {
14357
+ return requireBranchName((await ghChecked([
14358
+ "repo",
14359
+ "view",
14360
+ `${vcs.owner}/${vcs.repo}`,
14361
+ "--json",
14362
+ "defaultBranchRef",
14363
+ "--jq",
14364
+ ".defaultBranchRef.name"
14365
+ ])).stdout, "GitHub");
14366
+ },
14367
+ async prDiff(id) {
14368
+ return (await ghChecked(["pr", "diff", String(id), ...repo], { capture: "unbounded" })).stdout;
14369
+ },
14370
+ async prChecks(id) {
14371
+ return (await gh(["pr", "checks", String(id), ...repo])).stdout;
14372
+ },
14373
+ async markReady(id) {
14374
+ await ghChecked(["pr", "ready", String(id), ...repo]);
14375
+ },
14376
+ async closePr(id, comment) {
14377
+ const args = ["pr", "close", String(id), ...repo];
14378
+ if (comment)
14379
+ args.push("--comment", comment);
14380
+ await ghChecked(args);
14381
+ },
14382
+ async mergePr(id, subject) {
14383
+ const readiness = await ghChecked([
14384
+ "pr",
14385
+ "view",
14386
+ String(id),
14387
+ ...repo,
14388
+ "--json",
14389
+ "isDraft,state,reviewDecision,mergeStateStatus,statusCheckRollup"
14390
+ ]);
14391
+ assertGitHubMergeReady(readiness.stdout);
14392
+ await ghChecked(["pr", "merge", String(id), ...repo, "--squash", "--subject", subject]);
14393
+ }
14394
+ };
14395
+ }
14396
+ function assertGitHubMergeReady(input) {
14397
+ const data = parseJson(input, "GitHub readiness");
14398
+ const blockers = [];
14399
+ if (data.state !== "OPEN")
14400
+ blockers.push(`pull request state is ${data.state ?? "unknown"}`);
14401
+ if (data.isDraft)
14402
+ blockers.push("pull request is still a draft");
14403
+ if (data.reviewDecision !== "APPROVED")
14404
+ blockers.push(`review decision is ${data.reviewDecision || "not approved"}`);
14405
+ if (data.mergeStateStatus !== "CLEAN")
14406
+ blockers.push(`merge state is ${data.mergeStateStatus ?? "unknown"}`);
14407
+ for (const check of data.statusCheckRollup ?? []) {
14408
+ const name = check.name ?? check.context ?? "unnamed check";
14409
+ if (check.__typename === "CheckRun") {
14410
+ if (check.status !== "COMPLETED")
14411
+ blockers.push(`${name} is ${check.status?.toLowerCase() ?? "pending"}`);
14412
+ else if (!["SUCCESS", "SKIPPED", "NEUTRAL"].includes(check.conclusion ?? ""))
14413
+ blockers.push(`${name} concluded ${(check.conclusion ?? "unknown").toLowerCase()}`);
14414
+ } else if (check.state !== "SUCCESS")
14415
+ blockers.push(`${name} is ${(check.state ?? "pending").toLowerCase()}`);
14416
+ }
14417
+ if (blockers.length > 0)
14418
+ throw new Error(`Merge blocked: ${blockers.join("; ")}.`);
14419
+ }
14420
+
14421
+ // src/extensions/glabx.ts
14422
+ function glab(args, options) {
14423
+ return run("glab", args, options);
14424
+ }
14425
+ function glabChecked(args, options) {
14426
+ return runChecked("glab", args, options);
14427
+ }
14428
+
14429
+ // src/vcs/gitlab.ts
14430
+ function GitLabVcsBackend(vcs) {
14431
+ const project = `${vcs.owner}/${vcs.repo}`;
14432
+ const viewPr = async (idOrBranch) => {
14433
+ const args = ["mr", "view", idOrBranch, "--repo", project, "--output", "json"];
14434
+ const result = await glab(args);
14435
+ if (result.code !== 0) {
14436
+ if (isConfirmedMissingChange("gitlab", result.stderr || result.stdout))
14437
+ return;
14438
+ throw commandFailure("glab", args, result);
14439
+ }
14440
+ if (!result.stdout.trim())
14441
+ throw new Error("GitLab returned an empty merge request response.");
14442
+ const data = parseJson(result.stdout, "GitLab merge request");
14443
+ if (typeof data.iid !== "number" || typeof data.title !== "string" || typeof data.web_url !== "string" || typeof data.target_branch !== "string" || typeof data.source_branch !== "string")
14444
+ throw new Error("GitLab returned an invalid merge request response.");
14445
+ return {
14446
+ number: data.iid,
14447
+ title: data.title,
14448
+ url: data.web_url,
14449
+ isDraft: Boolean(data.draft ?? data.work_in_progress),
14450
+ baseRef: data.target_branch,
14451
+ headRef: data.source_branch,
14452
+ headSha: data.sha
14453
+ };
14454
+ };
14455
+ return {
14456
+ provider: "gitlab",
14457
+ async createDraftPr(options) {
14458
+ await glabChecked([
14459
+ "mr",
14460
+ "create",
14461
+ "--repo",
14462
+ project,
14463
+ "--title",
14464
+ `Draft: ${options.title}`,
14465
+ "--description",
14466
+ options.body,
14467
+ "--target-branch",
14468
+ options.base,
14469
+ "--source-branch",
14470
+ options.head,
14471
+ "--yes"
14472
+ ]);
14473
+ const ref = await viewPr(options.head);
14474
+ if (!ref)
14475
+ throw new Error("Draft MR created but could not be resolved.");
14476
+ return ref;
14477
+ },
14478
+ viewPr,
14479
+ async defaultBranch() {
14480
+ return requireBranchName((await glabChecked(["api", `projects/${encodeURIComponent(project)}`, "--jq", ".default_branch"])).stdout, "GitLab");
14481
+ },
14482
+ async prDiff(id) {
14483
+ return (await glabChecked(["mr", "diff", String(id), "--repo", project], { capture: "unbounded" })).stdout;
14484
+ },
14485
+ async prChecks(id) {
14486
+ return (await glabChecked([
14487
+ "api",
14488
+ `projects/${encodeURIComponent(project)}/merge_requests/${id}/pipelines?per_page=100`
14489
+ ])).stdout;
14490
+ },
14491
+ async markReady(id) {
14492
+ await glabChecked(["mr", "update", String(id), "--repo", project, "--ready"]);
14493
+ },
14494
+ async closePr(id, comment) {
14495
+ if (comment)
14496
+ await glabChecked(["mr", "note", String(id), "--repo", project, "--message", comment]);
14497
+ await glabChecked(["mr", "close", String(id), "--repo", project]);
14498
+ },
14499
+ async mergePr() {
14500
+ throw new Error("Merge is not supported by the GitLab forge adapter.");
14501
+ }
14502
+ };
14503
+ }
14504
+
14505
+ // src/vcs/forge-backend.ts
14506
+ function createForgeBackend(vcs) {
14507
+ if (vcs.provider === "github")
14508
+ return GitHubVcsBackend(vcs);
14509
+ if (vcs.provider === "gitlab")
14510
+ return GitLabVcsBackend(vcs);
14511
+ throw new Error("No supported forge detected from the git remote. Use --local for an offline review.");
14512
+ }
14513
+ var createVcsBackend = createForgeBackend;
14514
+ // src/extensions/misex.ts
14515
+ import { mkdir as mkdir2, readFile as readFile4, writeFile as writeFile2 } from "node:fs/promises";
14516
+ import { homedir as homedir5 } from "node:os";
14517
+ import { basename as basename3, dirname as dirname4, join as join9 } from "node:path";
13950
14518
  var MISE_HOOK_START = "# >>> @difflab/pi mise >>>";
13951
14519
  var MISE_HOOK_END = "# <<< @difflab/pi mise <<<";
13952
14520
  var mise = {
13953
14521
  async executableCheck(name = "mise") {
13954
14522
  return findExecutable(name);
13955
14523
  },
14524
+ async run(args, options = {}) {
14525
+ return run("mise", args, options);
14526
+ },
13956
14527
  async install(options = {}) {
13957
14528
  const homeDir = options.homeDir ?? homedir5();
13958
14529
  const platform = options.platform ?? process.platform;
13959
14530
  if (platform === "win32")
13960
14531
  throw new Error("Automatic mise installation supports macOS and Linux only.");
13961
- const installedPath = join8(homeDir, ".local", "bin", "mise");
14532
+ const installedPath = join9(homeDir, ".local", "bin", "mise");
13962
14533
  if (options.dryRun)
13963
14534
  return installedPath;
13964
14535
  await runChecked("sh", ["-c", "curl -fsSL https://mise.run | sh"]);
@@ -13969,8 +14540,8 @@ var mise = {
13969
14540
  },
13970
14541
  async hookEnsure(executable, options = {}) {
13971
14542
  const homeDir = options.homeDir ?? homedir5();
13972
- const hook = getShellHook(basename2(options.shell ?? process.env.SHELL ?? ""), executable, homeDir);
13973
- const current = await getOptionalFile2(hook.path);
14543
+ const hook = getShellHook(basename3(options.shell ?? process.env.SHELL ?? ""), executable, homeDir);
14544
+ const current = await getOptionalFile(hook.path);
13974
14545
  if (current.includes(MISE_HOOK_START))
13975
14546
  return { path: hook.path, changed: false, planned: false };
13976
14547
  if (options.dryRun)
@@ -14005,7 +14576,7 @@ function getShellHook(shell, executable, homeDir) {
14005
14576
  switch (shell.toLowerCase()) {
14006
14577
  case "zsh":
14007
14578
  return {
14008
- path: join8(homeDir, ".zshrc"),
14579
+ path: join9(homeDir, ".zshrc"),
14009
14580
  content: `${MISE_HOOK_START}
14010
14581
  eval "$(${command} activate zsh)"
14011
14582
  ${MISE_HOOK_END}
@@ -14013,7 +14584,7 @@ ${MISE_HOOK_END}
14013
14584
  };
14014
14585
  case "fish":
14015
14586
  return {
14016
- path: join8(homeDir, ".config", "fish", "config.fish"),
14587
+ path: join9(homeDir, ".config", "fish", "config.fish"),
14017
14588
  content: `${MISE_HOOK_START}
14018
14589
  ${command} activate fish | source
14019
14590
  ${MISE_HOOK_END}
@@ -14022,7 +14593,7 @@ ${MISE_HOOK_END}
14022
14593
  case "nu":
14023
14594
  case "nushell":
14024
14595
  return {
14025
- path: join8(homeDir, ".config", "nushell", "config.nu"),
14596
+ path: join9(homeDir, ".config", "nushell", "config.nu"),
14026
14597
  content: `${MISE_HOOK_START}
14027
14598
  let mise_bin = ${command}
14028
14599
  let mise_path = $nu.default-config-dir | path join mise.nu
@@ -14033,7 +14604,7 @@ ${MISE_HOOK_END}
14033
14604
  };
14034
14605
  case "xonsh":
14035
14606
  return {
14036
- path: join8(homeDir, ".xonshrc"),
14607
+ path: join9(homeDir, ".xonshrc"),
14037
14608
  content: `${MISE_HOOK_START}
14038
14609
  execx($(${command} activate xonsh))
14039
14610
  ${MISE_HOOK_END}
@@ -14041,7 +14612,7 @@ ${MISE_HOOK_END}
14041
14612
  };
14042
14613
  case "elvish":
14043
14614
  return {
14044
- path: join8(homeDir, ".config", "elvish", "rc.elv"),
14615
+ path: join9(homeDir, ".config", "elvish", "rc.elv"),
14045
14616
  content: `${MISE_HOOK_START}
14046
14617
  var mise: = (ns [&])
14047
14618
  eval (${command} activate elvish | slurp) &ns=$mise: &on-end={|ns| set mise: = $ns }
@@ -14052,7 +14623,7 @@ ${MISE_HOOK_END}
14052
14623
  case "pwsh":
14053
14624
  case "powershell":
14054
14625
  return {
14055
- path: join8(homeDir, ".config", "powershell", "Microsoft.PowerShell_profile.ps1"),
14626
+ path: join9(homeDir, ".config", "powershell", "Microsoft.PowerShell_profile.ps1"),
14056
14627
  content: `${MISE_HOOK_START}
14057
14628
  (& ${command} activate pwsh) | Out-String | Invoke-Expression
14058
14629
  ${MISE_HOOK_END}
@@ -14061,7 +14632,7 @@ ${MISE_HOOK_END}
14061
14632
  case "bash":
14062
14633
  default:
14063
14634
  return {
14064
- path: join8(homeDir, ".bashrc"),
14635
+ path: join9(homeDir, ".bashrc"),
14065
14636
  content: `${MISE_HOOK_START}
14066
14637
  eval "$(${command} activate bash)"
14067
14638
  ${MISE_HOOK_END}
@@ -14098,7 +14669,7 @@ function isVersionAtLeast(version, minimumVersion) {
14098
14669
  }
14099
14670
  return true;
14100
14671
  }
14101
- async function getOptionalFile2(path) {
14672
+ async function getOptionalFile(path) {
14102
14673
  try {
14103
14674
  return await readFile4(path, "utf8");
14104
14675
  } catch (error) {
@@ -14111,74 +14682,1948 @@ function getShellQuoted(value) {
14111
14682
  return `'${value.replaceAll("'", "'\\''")}'`;
14112
14683
  }
14113
14684
 
14114
- // src/pi.ts
14115
- import { mkdir as mkdir3, writeFile as writeFile3 } from "node:fs/promises";
14116
- import { homedir as homedir6 } from "node:os";
14117
- import { dirname as dirname5, join as join9 } from "node:path";
14118
- var pi = {
14119
- executableCheck: findPiExecutable,
14120
- packageList: listPiPackages,
14121
- packageCheck: hasPiPackage,
14122
- packageInstall: installPiPackage,
14123
- agentDir: resolvePiAgentDir,
14124
- agentEnsure: ensurePiAgent,
14125
- skillCheckGlobal: checkGlobalPiSkill,
14126
- skillInstallGlobal: installGlobalPiSkills,
14127
- configEnsure: ensurePiConfig
14128
- };
14129
- async function ensurePiAgent(filename, content, agentDir = resolvePiAgentDir(), dryRun = false) {
14130
- const path = join9(agentDir, "agents", filename);
14131
- const currentText = await readTextIfExists(path);
14132
- const changed = currentText !== content;
14133
- if (changed && !dryRun) {
14134
- await mkdir3(dirname5(path), { recursive: true });
14135
- await writeFile3(path, content, "utf8");
14136
- }
14137
- return { path, changed, existed: currentText !== undefined, planned: changed && dryRun };
14685
+ // src/gates.ts
14686
+ var CONVENTIONAL_COMMIT = /^(feat|fix|perf|refactor|docs|chore|test|build|ci|style|revert)(\([^)]+\))?!?: .+/;
14687
+ var MISE_GATES = ["format:check", "lint", "test"];
14688
+ function checkConventionalSubject(subject) {
14689
+ const trimmed = subject.trim();
14690
+ const ok = CONVENTIONAL_COMMIT.test(trimmed);
14691
+ return {
14692
+ name: "conventional-subject",
14693
+ status: ok ? "pass" : "warn",
14694
+ detail: ok ? trimmed : `not a conventional-commit subject: "${trimmed}"`
14695
+ };
14138
14696
  }
14139
- async function checkGlobalPiSkill(name, agentDir = resolvePiAgentDir(), sharedSkillsDir = join9(homedir6(), ".agents", "skills")) {
14140
- const roots = [join9(agentDir, "skills"), sharedSkillsDir];
14141
- for (const root of roots) {
14142
- if (await readTextIfExists(join9(root, name, "SKILL.md")) !== undefined)
14143
- return true;
14697
+ async function runMiseGates(cwd) {
14698
+ const tasks = await discoverMiseTasks(cwd);
14699
+ const results = [];
14700
+ for (const gate of MISE_GATES) {
14701
+ const targets = tasks.get(gate) ?? [];
14702
+ if (targets.length === 0) {
14703
+ results.push({ name: gate, status: "skip", detail: "no mise recipe" });
14704
+ continue;
14705
+ }
14706
+ const invocations = targets.flatMap((target, index) => index === 0 ? [target] : [":::", target]);
14707
+ const result = await mise.run(["run", ...invocations], { cwd });
14708
+ results.push({
14709
+ name: gate,
14710
+ status: result.code === 0 ? "pass" : "fail",
14711
+ detail: result.code === 0 ? "clean" : (result.stderr.trim() || result.stdout.trim()).slice(-400)
14712
+ });
14144
14713
  }
14145
- return false;
14714
+ return results;
14146
14715
  }
14147
- async function installGlobalPiSkills(miseExecutable, source, names) {
14148
- const selection = names.flatMap((name) => ["--skill", name]);
14149
- await runChecked(miseExecutable, [
14150
- "x",
14151
- "node@22",
14152
- "--",
14153
- "npx",
14154
- "-y",
14155
- "skills",
14156
- "add",
14157
- source,
14158
- ...selection,
14159
- "--global",
14160
- "--agent",
14161
- "pi",
14162
- "--yes"
14163
- ]);
14716
+ function ciGate(checksOutput) {
14717
+ const text = checksOutput.toLowerCase();
14718
+ if (!text.trim())
14719
+ return { name: "ci", status: "skip", detail: "no CI output" };
14720
+ if (/\bfail|error\b/.test(text))
14721
+ return { name: "ci", status: "warn", detail: "CI failing" };
14722
+ if (/\bpending|in progress|queued\b/.test(text))
14723
+ return { name: "ci", status: "warn", detail: "CI pending" };
14724
+ return { name: "ci", status: "pass", detail: "CI green" };
14164
14725
  }
14165
- async function ensurePiConfig(path, update, dryRun = false) {
14166
- const currentText = await readTextIfExists(path);
14167
- const current = parseJsonObject(currentText, path);
14168
- const next = update(current);
14169
- const changed = JSON.stringify(current) !== JSON.stringify(next);
14170
- if (changed && !dryRun) {
14171
- await mkdir3(dirname5(path), { recursive: true });
14172
- await writeFile3(path, `${JSON.stringify(next, null, 2)}
14173
- `, "utf8");
14726
+ function parseMiseTasks(input) {
14727
+ let tasks;
14728
+ try {
14729
+ tasks = JSON.parse(input);
14730
+ } catch {
14731
+ return new Map;
14732
+ }
14733
+ if (!Array.isArray(tasks))
14734
+ return new Map;
14735
+ const found = new Map;
14736
+ for (const gate of MISE_GATES) {
14737
+ const targets = tasks.flatMap((task) => {
14738
+ if (typeof task.name !== "string")
14739
+ return [];
14740
+ return task.name === gate || task.name.endsWith(`:${gate}`) || task.aliases?.includes(gate) ? [task.name] : [];
14741
+ });
14742
+ if (targets.length > 0)
14743
+ found.set(gate, [...new Set(targets)]);
14174
14744
  }
14175
- return { path, changed, existed: currentText !== undefined, planned: changed && dryRun };
14745
+ return found;
14176
14746
  }
14177
- async function findPiExecutable() {
14178
- return findExecutable("pi");
14747
+ async function discoverMiseTasks(cwd) {
14748
+ const result = await mise.run(["tasks", "--json", "--all"], { cwd });
14749
+ if (result.code !== 0)
14750
+ return new Map;
14751
+ return parseMiseTasks(result.stdout);
14179
14752
  }
14180
- async function listPiPackages(executable) {
14181
- return (await runChecked(executable, ["list"])).stdout;
14753
+
14754
+ // src/review/types.ts
14755
+ import { z as z4 } from "zod";
14756
+ var severitySchema = z4.enum(["BLOCKING", "CONSIDER", "NOTE"]);
14757
+ var findingSchema = z4.object({
14758
+ file: z4.string().min(1),
14759
+ line: z4.number().int().nonnegative(),
14760
+ severity: severitySchema,
14761
+ body: z4.string().min(1),
14762
+ reference: z4.string().optional().default("")
14763
+ });
14764
+ var findingsSchema = z4.array(findingSchema);
14765
+ function reviewSlug(input) {
14766
+ return input.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 50);
14767
+ }
14768
+ function yymmdd(date = new Date) {
14769
+ const yy = String(date.getFullYear() % 100).padStart(2, "0");
14770
+ const mm = String(date.getMonth() + 1).padStart(2, "0");
14771
+ const dd = String(date.getDate()).padStart(2, "0");
14772
+ return `${yy}${mm}${dd}`;
14773
+ }
14774
+ function reviewRecordName(target, date = new Date) {
14775
+ return `${yymmdd(date)}-${reviewSlug(target) || "uncommitted"}`;
14776
+ }
14777
+ function localResponseMarker(threadId) {
14778
+ return `<!-- diffpi -->`;
14779
+ }
14780
+ function isLocalResponse(body) {
14781
+ return /^<!-- diffpi(?:-local-response:[A-Za-z0-9_-]+)? -->\n/m.test(body);
14782
+ }
14783
+ function dedupeFindings(findings) {
14784
+ const rank = { BLOCKING: 3, CONSIDER: 2, NOTE: 1 };
14785
+ const byKey = new Map;
14786
+ for (const finding of findings) {
14787
+ const key = `${finding.file}:${finding.line}`;
14788
+ const existing = byKey.get(key);
14789
+ if (!existing || rank[finding.severity] > rank[existing.severity])
14790
+ byKey.set(key, finding);
14791
+ }
14792
+ return [...byKey.values()].sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line || rank[b.severity] - rank[a.severity]);
14793
+ }
14794
+ function toReviewComments(findings, model) {
14795
+ const comments = [];
14796
+ for (const finding of findings) {
14797
+ if (finding.line <= 0)
14798
+ continue;
14799
+ const body = renderCommentBody(finding);
14800
+ comments.push({
14801
+ file: finding.file,
14802
+ line: finding.line,
14803
+ side: "RIGHT",
14804
+ body: model ? withRemoteProvenance(body, model) : body
14805
+ });
14806
+ }
14807
+ return comments;
14808
+ }
14809
+ function withRemoteProvenance(body, model) {
14810
+ const normalized = body.trimEnd();
14811
+ if (/Generated review by Diffpi using `[^`]+`\.$/.test(normalized))
14812
+ return body;
14813
+ return `${normalized}
14814
+
14815
+ Generated review by Diffpi using \`${model}\`.`;
14816
+ }
14817
+ function localReviewAuthor(model) {
14818
+ return `Agent: ${model}`;
14819
+ }
14820
+ function renderCommentBody(finding) {
14821
+ const prefix = finding.severity === "BLOCKING" ? "**BLOCKING** " : "";
14822
+ const reference = finding.reference ? `
14823
+
14824
+ > **Reference:** ${finding.reference}` : "";
14825
+ return `${prefix}${finding.body}${reference}`;
14826
+ }
14827
+ // src/review/review-markdown.ts
14828
+ import { z as z5 } from "zod";
14829
+ var reviewThreadRecordSchema = z5.object({
14830
+ id: z5.string().min(1),
14831
+ file: z5.string().optional(),
14832
+ line: z5.number().int().positive().optional(),
14833
+ body: z5.string(),
14834
+ author: z5.string().optional(),
14835
+ resolved: z5.boolean(),
14836
+ addressed: z5.boolean().optional(),
14837
+ question: z5.boolean(),
14838
+ replies: z5.array(z5.string()).optional()
14839
+ });
14840
+ function renderReviewDoc(input) {
14841
+ const anchored = dedupeFindings(input.findings).filter((finding) => finding.line > 0);
14842
+ const lines = [`# Review: ${input.title}`, "", "## Metadata"];
14843
+ if (input.number !== undefined)
14844
+ lines.push(`- **PR/MR**: #${input.number}${input.url ? ` — ${input.url}` : ""}`);
14845
+ if (input.author)
14846
+ lines.push(`- **Author**: ${input.author}`);
14847
+ if (input.model)
14848
+ lines.push(`- **Review agent**: ${input.model}`);
14849
+ if (input.headRef && input.baseRef)
14850
+ lines.push(`- **Branch**: ${input.headRef} → ${input.baseRef}`);
14851
+ if (input.additions !== undefined)
14852
+ lines.push(`- **Stats**: +${input.additions} -${input.deletions ?? 0} across ${input.changedFiles ?? 0} files`);
14853
+ lines.push(`- **Reviewed**: ${input.timestamp ?? new Date().toISOString()}`, "");
14854
+ if (input.overallIssues.length > 0) {
14855
+ lines.push("## Overall issues", "");
14856
+ for (const issue of input.overallIssues)
14857
+ lines.push(`- ${issue}`);
14858
+ lines.push("");
14859
+ }
14860
+ lines.push("## Verification", "");
14861
+ for (const gate of input.gates)
14862
+ lines.push(`- ${gate.name}: ${gate.status} — ${gate.detail}`);
14863
+ lines.push("");
14864
+ if (input.notVerified.length > 0) {
14865
+ lines.push("## What was NOT verified", "");
14866
+ for (const item of input.notVerified)
14867
+ lines.push(`- ${item}`);
14868
+ lines.push("");
14869
+ }
14870
+ lines.push("## Inline Comments", "");
14871
+ for (const finding of anchored) {
14872
+ lines.push(`### ${finding.file}:${finding.line} — ${finding.severity}`, "", finding.body, "");
14873
+ if (finding.reference)
14874
+ lines.push(`> **Reference:** ${finding.reference}`, "");
14875
+ lines.push("---", "");
14876
+ }
14877
+ return `${lines.join(`
14878
+ `).trimEnd()}
14879
+ `;
14880
+ }
14881
+ function renderThreadArtifact(title, target, threads, options = {}) {
14882
+ const records = threads.map((thread) => ({
14883
+ id: thread.id,
14884
+ file: thread.file,
14885
+ line: thread.line,
14886
+ body: thread.body,
14887
+ author: thread.author,
14888
+ resolved: thread.resolved,
14889
+ addressed: thread.addressed,
14890
+ question: thread.question,
14891
+ replies: thread.replies
14892
+ }));
14893
+ const payload = Buffer.from(JSON.stringify(records), "utf8").toString("base64url");
14894
+ const lines = [
14895
+ `<!-- diffpi-threads:${payload} -->`,
14896
+ `# Review threads: ${title}`,
14897
+ "",
14898
+ "## Metadata",
14899
+ "",
14900
+ `- Target: ${target}`,
14901
+ `- Pulled: ${options.timestamp ?? new Date().toISOString()}`
14902
+ ];
14903
+ if (options.number !== undefined)
14904
+ lines.push(`- PR/MR: #${options.number}${options.url ? ` — ${options.url}` : ""}`);
14905
+ lines.push("", "## Replies", "");
14906
+ for (const thread of threads) {
14907
+ const id = Buffer.from(thread.id, "utf8").toString("base64url");
14908
+ lines.push(`### ${thread.file ?? "review"}:${thread.line ?? "n/a"} (${thread.id})`, "", `<!-- diffpi-reply-start:${id} -->`, thread.reply ?? "", `<!-- diffpi-reply-end:${id} -->`, "");
14909
+ }
14910
+ lines.push("## Source comments", "");
14911
+ for (const thread of threads) {
14912
+ lines.push(`### ${thread.file ?? "review"}:${thread.line ?? "n/a"} — ${thread.author ?? "unknown"}`, "", `Thread: ${thread.id}`, `Status: ${thread.resolved ? "resolved" : thread.addressed ? "addressed" : "open"}`, "", thread.body, "", "---", "");
14913
+ }
14914
+ return `${lines.join(`
14915
+ `).trimEnd()}
14916
+ `;
14917
+ }
14918
+ function parseThreadArtifact(content) {
14919
+ const payload = content.match(/^<!-- diffpi-threads:([A-Za-z0-9_-]+) -->$/m)?.[1];
14920
+ if (!payload)
14921
+ throw new Error("This file is not a Diffpi thread artifact.");
14922
+ let threads;
14923
+ try {
14924
+ const decoded = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
14925
+ threads = z5.array(reviewThreadRecordSchema).parse(decoded);
14926
+ } catch {
14927
+ throw new Error("Cannot parse the Diffpi thread artifact payload.");
14928
+ }
14929
+ const replies = content.split(/^## Source comments$/m, 1)[0] ?? "";
14930
+ return threads.map((thread) => {
14931
+ const id = Buffer.from(thread.id, "utf8").toString("base64url");
14932
+ const startMarker = `<!-- diffpi-reply-start:${id} -->
14933
+ `;
14934
+ const endMarker = `
14935
+ <!-- diffpi-reply-end:${id} -->`;
14936
+ const start = replies.indexOf(startMarker);
14937
+ const end = start < 0 ? -1 : replies.indexOf(endMarker, start + startMarker.length);
14938
+ const reply = start >= 0 && end >= 0 ? replies.slice(start + startMarker.length, end).trim() : "";
14939
+ return reply ? { ...thread, reply } : thread;
14940
+ });
14941
+ }
14942
+ function upsertThreadReply(content, threadId, body, question, resolved) {
14943
+ const threads = parseThreadArtifact(content);
14944
+ const thread = threads.find((candidate) => candidate.id === threadId);
14945
+ if (!thread)
14946
+ throw new Error(`Review thread ${threadId} was not found in the local artifact.`);
14947
+ thread.reply = body;
14948
+ thread.addressed = true;
14949
+ if (question !== undefined)
14950
+ thread.question = question;
14951
+ if (resolved !== undefined)
14952
+ thread.resolved = resolved;
14953
+ const title = content.match(/^# Review threads: (.+)$/m)?.[1] ?? "review";
14954
+ const target = content.match(/^- Target: (.+)$/m)?.[1] ?? "local";
14955
+ const timestamp = content.match(/^- Pulled: (.+)$/m)?.[1];
14956
+ const pr = content.match(/^- PR\/MR: #(\d+)(?: — (.+))?$/m);
14957
+ return renderThreadArtifact(title, target, threads, {
14958
+ timestamp,
14959
+ number: pr ? Number.parseInt(pr[1], 10) : undefined,
14960
+ url: pr?.[2]
14961
+ });
14962
+ }
14963
+ // src/review/github-review-backend.ts
14964
+ function GitHubReviewBackend(vcs, number) {
14965
+ const pendingReview = async () => {
14966
+ const result = await ghChecked([
14967
+ "api",
14968
+ `/repos/${vcs.owner}/${vcs.repo}/pulls/${number}/reviews`,
14969
+ "--jq",
14970
+ '[.[] | select(.state=="PENDING")] | last | {id: (.id | tostring), nodeId: .node_id}'
14971
+ ]);
14972
+ if (!result.stdout.trim())
14973
+ return;
14974
+ let pending;
14975
+ try {
14976
+ pending = JSON.parse(result.stdout);
14977
+ } catch {
14978
+ throw new Error("Cannot parse the pending GitHub review identifier as JSON.");
14979
+ }
14980
+ if (!pending.id || !pending.nodeId)
14981
+ return;
14982
+ return { id: pending.id, nodeId: pending.nodeId };
14983
+ };
14984
+ const listThreads = async () => {
14985
+ const threads = [];
14986
+ let cursor;
14987
+ do {
14988
+ const args = [
14989
+ "api",
14990
+ "graphql",
14991
+ "-f",
14992
+ `query=${GITHUB_THREADS_QUERY}`,
14993
+ "-f",
14994
+ `owner=${vcs.owner}`,
14995
+ "-f",
14996
+ `repo=${vcs.repo}`,
14997
+ "-F",
14998
+ `number=${number}`
14999
+ ];
15000
+ if (cursor)
15001
+ args.push("-f", `after=${cursor}`);
15002
+ const result = await ghChecked(args);
15003
+ let data;
15004
+ try {
15005
+ data = JSON.parse(result.stdout);
15006
+ } catch {
15007
+ throw new Error("Cannot parse GitHub review threads as JSON.");
15008
+ }
15009
+ const connection = data.data?.repository?.pullRequest?.reviewThreads;
15010
+ threads.push(...connection?.nodes ?? []);
15011
+ cursor = connection?.pageInfo?.hasNextPage ? connection.pageInfo.endCursor : undefined;
15012
+ if (connection?.pageInfo?.hasNextPage && !cursor) {
15013
+ throw new Error("GitHub review thread pagination did not return an end cursor.");
15014
+ }
15015
+ } while (cursor);
15016
+ return threads.map((thread) => {
15017
+ const nodes = thread.comments?.nodes ?? [];
15018
+ const comment = nodes[0];
15019
+ const body = comment?.body ?? "";
15020
+ return {
15021
+ id: thread.id,
15022
+ file: thread.path,
15023
+ line: thread.line,
15024
+ body,
15025
+ author: comment?.author?.login,
15026
+ resolved: thread.isResolved,
15027
+ question: /\?\s*$/.test(body.trim()),
15028
+ replies: nodes.slice(1).map((node) => node.body)
15029
+ };
15030
+ });
15031
+ };
15032
+ return {
15033
+ kind: "remote",
15034
+ async stage(draft) {
15035
+ const pending = await pendingReview();
15036
+ if (!pending) {
15037
+ const payload = {
15038
+ body: draft.body,
15039
+ comments: draft.comments.map((comment) => ({
15040
+ path: comment.file,
15041
+ line: comment.line,
15042
+ side: comment.side ?? "RIGHT",
15043
+ body: comment.body
15044
+ }))
15045
+ };
15046
+ await ghChecked(["api", "--method", "POST", `/repos/${vcs.owner}/${vcs.repo}/pulls/${number}/reviews`, "--input", "-"], { input: JSON.stringify(payload) });
15047
+ return;
15048
+ }
15049
+ if (draft.body.trim()) {
15050
+ await ghChecked([
15051
+ "api",
15052
+ "--method",
15053
+ "PUT",
15054
+ `/repos/${vcs.owner}/${vcs.repo}/pulls/${number}/reviews/${pending.id}`,
15055
+ "-f",
15056
+ `body=${draft.body}`
15057
+ ]);
15058
+ }
15059
+ for (const comment of draft.comments) {
15060
+ await ghChecked([
15061
+ "api",
15062
+ "graphql",
15063
+ "-f",
15064
+ `query=${GITHUB_ADD_THREAD_MUTATION}`,
15065
+ "-f",
15066
+ `reviewId=${pending.nodeId}`,
15067
+ "-f",
15068
+ `body=${comment.body}`,
15069
+ "-f",
15070
+ `path=${comment.file}`,
15071
+ "-F",
15072
+ `line=${comment.line}`,
15073
+ "-f",
15074
+ `side=${comment.side ?? "RIGHT"}`
15075
+ ]);
15076
+ }
15077
+ },
15078
+ async readDraft() {
15079
+ const pending = await pendingReview();
15080
+ if (!pending)
15081
+ return { comments: [], body: "" };
15082
+ const review = await ghChecked(["api", `/repos/${vcs.owner}/${vcs.repo}/pulls/${number}/reviews/${pending.id}`]);
15083
+ const comments = await ghChecked([
15084
+ "api",
15085
+ `/repos/${vcs.owner}/${vcs.repo}/pulls/${number}/reviews/${pending.id}/comments`
15086
+ ]);
15087
+ let reviewData;
15088
+ let commentData;
15089
+ try {
15090
+ reviewData = JSON.parse(review.stdout);
15091
+ commentData = JSON.parse(comments.stdout);
15092
+ } catch {
15093
+ throw new Error("Cannot parse the pending GitHub review as JSON.");
15094
+ }
15095
+ return {
15096
+ body: reviewData.body ?? "",
15097
+ comments: commentData.map((comment) => ({
15098
+ file: comment.path,
15099
+ line: comment.line ?? comment.original_line ?? 1,
15100
+ side: comment.side,
15101
+ body: comment.body
15102
+ }))
15103
+ };
15104
+ },
15105
+ listThreads,
15106
+ async reply(input) {
15107
+ const threads = await listThreads();
15108
+ const existing = threads.find((thread) => thread.id === input.threadId);
15109
+ if (!existing?.replies?.includes(input.body)) {
15110
+ await ghChecked([
15111
+ "api",
15112
+ "graphql",
15113
+ "-f",
15114
+ `query=${GITHUB_REPLY_MUTATION}`,
15115
+ "-f",
15116
+ `threadId=${input.threadId}`,
15117
+ "-f",
15118
+ `body=${input.body}`
15119
+ ]);
15120
+ }
15121
+ if (input.resolve) {
15122
+ await ghChecked([
15123
+ "api",
15124
+ "graphql",
15125
+ "-f",
15126
+ `query=${GITHUB_RESOLVE_MUTATION}`,
15127
+ "-f",
15128
+ `threadId=${input.threadId}`
15129
+ ]);
15130
+ }
15131
+ },
15132
+ async publish(event) {
15133
+ const pending = await pendingReview();
15134
+ if (!pending && event === "COMMENT")
15135
+ return;
15136
+ if (!pending && event === "REQUEST_CHANGES") {
15137
+ throw new Error("GitHub requires pending comments before publishing a request-changes review without a body.");
15138
+ }
15139
+ const endpoint = githubReviewSubmissionEndpoint(vcs.owner, vcs.repo, number, pending?.id ?? "");
15140
+ await ghChecked(["api", "--method", "POST", endpoint, "-f", `event=${event}`]);
15141
+ }
15142
+ };
15143
+ }
15144
+ function githubReviewSubmissionEndpoint(owner, repo, id, pendingReviewId) {
15145
+ return pendingReviewId ? `/repos/${owner}/${repo}/pulls/${id}/reviews/${pendingReviewId}/events` : `/repos/${owner}/${repo}/pulls/${id}/reviews`;
15146
+ }
15147
+ var GITHUB_THREADS_QUERY = `query($owner:String!,$repo:String!,$number:Int!,$after:String){repository(owner:$owner,name:$repo){pullRequest(number:$number){reviewThreads(first:100,after:$after){nodes{id,isResolved,path,line,comments(first:100){nodes{body,author{login}}}}pageInfo{hasNextPage,endCursor}}}}}`;
15148
+ var GITHUB_ADD_THREAD_MUTATION = `mutation($reviewId:ID!,$body:String!,$path:String!,$line:Int!,$side:DiffSide!){addPullRequestReviewThread(input:{pullRequestReviewId:$reviewId,body:$body,path:$path,line:$line,side:$side}){thread{id}}}`;
15149
+ var GITHUB_REPLY_MUTATION = `mutation($threadId:ID!,$body:String!){addPullRequestReviewThreadReply(input:{pullRequestReviewThreadId:$threadId,body:$body}){comment{id}}}`;
15150
+ var GITHUB_RESOLVE_MUTATION = `mutation($threadId:ID!){resolveReviewThread(input:{threadId:$threadId}){thread{isResolved}}}`;
15151
+
15152
+ // src/review/gitlab-review-backend.ts
15153
+ function GitLabReviewBackend(vcs, number) {
15154
+ const project = () => `${vcs.owner}/${vcs.repo}`;
15155
+ const mergeRequestEndpoint = () => `projects/${encodeURIComponent(project())}/merge_requests/${number}`;
15156
+ const listThreads = async () => {
15157
+ const discussions = [];
15158
+ for (let page = 1;; page += 1) {
15159
+ const result = await glabChecked(["api", `${mergeRequestEndpoint()}/discussions?per_page=100&page=${page}`]);
15160
+ let batch;
15161
+ try {
15162
+ batch = JSON.parse(result.stdout);
15163
+ } catch {
15164
+ throw new Error("Cannot parse GitLab review discussions as JSON.");
15165
+ }
15166
+ discussions.push(...batch);
15167
+ if (batch.length < 100)
15168
+ break;
15169
+ }
15170
+ return discussions.map((discussion) => {
15171
+ const notes = discussion.notes ?? [];
15172
+ const note = notes[0];
15173
+ const body = note?.body ?? "";
15174
+ return {
15175
+ id: discussion.id,
15176
+ file: note?.position?.new_path ?? note?.position?.old_path,
15177
+ line: note?.position?.new_line ?? note?.position?.old_line,
15178
+ body,
15179
+ author: note?.author?.username,
15180
+ resolved: Boolean(discussion.resolved),
15181
+ question: /\?\s*$/.test(body.trim()),
15182
+ replies: notes.slice(1).map((reply) => reply.body)
15183
+ };
15184
+ });
15185
+ };
15186
+ return {
15187
+ kind: "remote",
15188
+ async stage(draft) {
15189
+ const endpoint = `${mergeRequestEndpoint()}/draft_notes`;
15190
+ if (draft.body.trim()) {
15191
+ await glabChecked(["api", "--method", "POST", endpoint, "--input", "-"], {
15192
+ input: JSON.stringify({ note: draft.body })
15193
+ });
15194
+ }
15195
+ if (draft.comments.length === 0)
15196
+ return;
15197
+ const response = await glabChecked(["api", mergeRequestEndpoint()]);
15198
+ const diffRefs = parseGitlabDiffRefs(response.stdout);
15199
+ for (const comment of draft.comments) {
15200
+ const payload = {
15201
+ note: comment.body,
15202
+ position: {
15203
+ ...diffRefs,
15204
+ position_type: "text",
15205
+ new_path: comment.file,
15206
+ old_path: comment.file,
15207
+ new_line: comment.side === "LEFT" ? undefined : comment.line,
15208
+ old_line: comment.side === "LEFT" ? comment.line : undefined
15209
+ }
15210
+ };
15211
+ await glabChecked(["api", "--method", "POST", endpoint, "--input", "-"], {
15212
+ input: JSON.stringify(payload)
15213
+ });
15214
+ }
15215
+ },
15216
+ async readDraft() {
15217
+ const result = await glabChecked(["api", `${mergeRequestEndpoint()}/draft_notes`]);
15218
+ let notes;
15219
+ try {
15220
+ notes = JSON.parse(result.stdout);
15221
+ } catch {
15222
+ throw new Error("Cannot parse GitLab draft notes as JSON.");
15223
+ }
15224
+ const comments = [];
15225
+ const body = [];
15226
+ for (const note of notes) {
15227
+ if (!note.note)
15228
+ continue;
15229
+ const file = note.position?.new_path ?? note.position?.old_path;
15230
+ const line = note.position?.new_line ?? note.position?.old_line;
15231
+ if (file && line) {
15232
+ comments.push({
15233
+ file,
15234
+ line,
15235
+ side: note.position?.new_line ? "RIGHT" : "LEFT",
15236
+ body: note.note
15237
+ });
15238
+ } else {
15239
+ body.push(note.note);
15240
+ }
15241
+ }
15242
+ return { comments, body: body.join(`
15243
+
15244
+ `) };
15245
+ },
15246
+ listThreads,
15247
+ async reply(input) {
15248
+ const endpoint = `${mergeRequestEndpoint()}/discussions/${encodeURIComponent(input.threadId)}`;
15249
+ const threads = await listThreads();
15250
+ const existing = threads.find((thread) => thread.id === input.threadId);
15251
+ if (!existing?.replies?.includes(input.body)) {
15252
+ await glabChecked(["api", "--method", "POST", `${endpoint}/notes`, "--input", "-"], {
15253
+ input: JSON.stringify({ body: input.body })
15254
+ });
15255
+ }
15256
+ if (input.resolve)
15257
+ await glabChecked(["api", "--method", "PUT", `${endpoint}?resolved=true`]);
15258
+ },
15259
+ async publish(event) {
15260
+ assertReviewEventSupported(vcs.provider, event);
15261
+ const drafts = await glabChecked(["api", `${mergeRequestEndpoint()}/draft_notes`]);
15262
+ if (hasGitlabDraftNotes(drafts.stdout)) {
15263
+ await glabChecked(["api", "--method", "POST", `${mergeRequestEndpoint()}/draft_notes/bulk_publish`]);
15264
+ }
15265
+ if (event === "APPROVE")
15266
+ await glabChecked(["mr", "approve", String(number), "--repo", project()]);
15267
+ }
15268
+ };
15269
+ }
15270
+ function assertReviewEventSupported(provider, event) {
15271
+ if (provider === "gitlab" && event === "REQUEST_CHANGES") {
15272
+ throw new Error("GitLab does not support REQUEST_CHANGES reviews; post a comment or reject the merge request manually.");
15273
+ }
15274
+ }
15275
+ function parseGitlabDiffRefs(input) {
15276
+ let data;
15277
+ try {
15278
+ data = JSON.parse(input);
15279
+ } catch {
15280
+ throw new Error("Cannot create positioned GitLab draft notes: the merge request response was not valid JSON.");
15281
+ }
15282
+ const { base_sha, start_sha, head_sha } = data.diff_refs ?? {};
15283
+ if (!base_sha || !start_sha || !head_sha) {
15284
+ throw new Error("Cannot create positioned GitLab draft notes: merge request diff refs are unavailable.");
15285
+ }
15286
+ return { base_sha, start_sha, head_sha };
15287
+ }
15288
+ function hasGitlabDraftNotes(input) {
15289
+ try {
15290
+ const data = JSON.parse(input);
15291
+ return Array.isArray(data) && data.length > 0;
15292
+ } catch {
15293
+ throw new Error("Cannot publish the GitLab review: the draft notes response was not valid JSON.");
15294
+ }
15295
+ }
15296
+
15297
+ // src/review/local-review-backend.ts
15298
+ import { readFile as readFile6, writeFile as writeFile3 } from "node:fs/promises";
15299
+
15300
+ // src/extensions/tuicrx.ts
15301
+ import { readFile as readFile5, realpath as realpath2 } from "node:fs/promises";
15302
+ import { resolve as resolve3 } from "node:path";
15303
+
15304
+ // src/extensions/gitx.ts
15305
+ import { realpath } from "node:fs/promises";
15306
+ import { basename as basename4, isAbsolute as isAbsolute2, join as join10, resolve as resolve2 } from "node:path";
15307
+ async function gitToplevel(cwd) {
15308
+ const result = await run("git", ["-C", cwd, "rev-parse", "--show-toplevel"]);
15309
+ const top = result.stdout.trim();
15310
+ return result.code === 0 && top ? top : resolve2(cwd);
15311
+ }
15312
+ async function inspectGitRepository(cwd) {
15313
+ const root = await gitToplevel(cwd);
15314
+ const remoteResult = await run("git", ["-C", root, "remote", "get-url", "origin"]);
15315
+ const remote = remoteResult.code === 0 ? remoteResult.stdout.trim() : "";
15316
+ const commonResult = await run("git", ["-C", root, "rev-parse", "--git-common-dir"]);
15317
+ const common = commonResult.stdout.trim();
15318
+ const commonPath = commonResult.code === 0 && common ? resolve2(isAbsolute2(common) ? common : join10(root, common)) : root;
15319
+ const commonDir = await canonicalPath(commonPath);
15320
+ return {
15321
+ root,
15322
+ commonDir,
15323
+ ...remote ? { remote } : {},
15324
+ name: remote ? repositoryName(remote) : basename4(resolve2(commonDir, "..")) || basename4(root),
15325
+ identity: remote ? `remote:${normalizeRemote(remote)}` : `git-common-dir:${commonDir}`
15326
+ };
15327
+ }
15328
+ function normalizeRemote(remote) {
15329
+ return remote.trim().replace(/\.git\/?$/i, "").replace(/\/+$/, "").toLowerCase();
15330
+ }
15331
+ function repositoryName(remote) {
15332
+ const normalized = remote.trim().replace(/\.git\/?$/i, "").replace(/\/+$/, "");
15333
+ return normalized.split(/[/\\:]/).filter(Boolean).at(-1) ?? "";
15334
+ }
15335
+ async function canonicalPath(path) {
15336
+ try {
15337
+ return await realpath(path);
15338
+ } catch {
15339
+ return resolve2(path);
15340
+ }
15341
+ }
15342
+
15343
+ // src/extensions/tuicrx.ts
15344
+ async function tuicrAvailable() {
15345
+ return Boolean(await findExecutable("tuicr"));
15346
+ }
15347
+ async function listSessions(repo = ".") {
15348
+ await requireTuicr();
15349
+ const result = await run("tuicr", ["review", "list", "--repo", repo]);
15350
+ if (result.code !== 0 || !result.stdout.trim())
15351
+ return [];
15352
+ try {
15353
+ const raw = JSON.parse(result.stdout);
15354
+ return raw.map((entry) => ({
15355
+ slug: entry.slug,
15356
+ kind: entry.kind,
15357
+ path: entry.path,
15358
+ updatedAt: entry.updated_at,
15359
+ commentCount: entry.comment_count,
15360
+ anchor: entry.anchor,
15361
+ active: entry.active
15362
+ }));
15363
+ } catch {
15364
+ throw new Error("Cannot parse tuicr review list output.");
15365
+ }
15366
+ }
15367
+ async function addComment(session, body, opts = {}) {
15368
+ await requireTuicr();
15369
+ const args = ["review", "add", "--session", session, body];
15370
+ if (opts.targetFile)
15371
+ args.push("--target-file", opts.targetFile);
15372
+ if (opts.line !== undefined)
15373
+ args.push("--line", String(opts.line));
15374
+ if (opts.side)
15375
+ args.push("--side", opts.side);
15376
+ if (opts.username)
15377
+ args.push("--username", opts.username);
15378
+ await runChecked("tuicr", args);
15379
+ }
15380
+ async function launch(cwd, pr, localBase) {
15381
+ let command;
15382
+ if (pr !== undefined)
15383
+ command = ["tuicr", "pr", String(pr)];
15384
+ else if (localBase)
15385
+ command = ["tuicr", "-w", "-r", `${localBase}..HEAD`];
15386
+ else
15387
+ throw new Error("Cannot launch a local tuicr review: no base branch could be determined.");
15388
+ if (!await tuicrAvailable()) {
15389
+ return {
15390
+ launched: false,
15391
+ via: "print",
15392
+ command: command.join(" "),
15393
+ reason: "tuicr is not installed or is not available on PATH.",
15394
+ instruction: `Install tuicr, then run: ${command.join(" ")}`
15395
+ };
15396
+ }
15397
+ return openInNewTab(command, { cwd, name: "tuicr" });
15398
+ }
15399
+ async function resolveSession(cwd, branch) {
15400
+ const sessions = await listSessions(cwd);
15401
+ return findMatchingSession(sessions, cwd, branch);
15402
+ }
15403
+ async function resolveReviewSession(cwd, target) {
15404
+ if (!target.workingTree && target.owner && target.repo && target.number !== undefined) {
15405
+ return resolvePrSession(cwd, target.owner, target.repo, target.number);
15406
+ }
15407
+ return resolveSession(cwd, target.branch);
15408
+ }
15409
+ async function resolvePrSession(cwd, owner, repo, number) {
15410
+ const coordinate = `${owner}/${repo}`.toLowerCase();
15411
+ const sessions = await listSessions(cwd);
15412
+ return sessions.find((session) => {
15413
+ const slug = session.slug.toLowerCase();
15414
+ return session.kind === "pr" && slug.includes(coordinate) && (slug.endsWith(`/pr/${number}`) || slug.endsWith(`/mr/${number}`));
15415
+ });
15416
+ }
15417
+ async function findMatchingSession(sessions, cwd, branch) {
15418
+ const repository = await canonicalPath2(await gitToplevel(cwd));
15419
+ for (const session of sessions) {
15420
+ if (session.kind !== "local")
15421
+ continue;
15422
+ try {
15423
+ const data = await readSession(session.path);
15424
+ if (data.branch_name !== branch || !data.repo_path)
15425
+ continue;
15426
+ if (await canonicalPath2(data.repo_path) === repository)
15427
+ return session;
15428
+ } catch {}
15429
+ }
15430
+ return;
15431
+ }
15432
+ async function readSession(path) {
15433
+ const content = await readFile5(path, "utf8");
15434
+ try {
15435
+ return JSON.parse(content);
15436
+ } catch {
15437
+ throw new Error(`Cannot parse tuicr session JSON: ${path}`);
15438
+ }
15439
+ }
15440
+ function toLocalReviewThreads(session) {
15441
+ const threads = [];
15442
+ const add = (comment, location = {}) => {
15443
+ if (isLocalResponse(comment.content))
15444
+ return;
15445
+ const author = commentAuthor(comment);
15446
+ threads.push({
15447
+ id: comment.id ?? `local-${threads.length + 1}`,
15448
+ ...location,
15449
+ body: comment.content,
15450
+ ...author ? { author } : {},
15451
+ resolved: false,
15452
+ question: /\?\s*$/.test(comment.content.trim())
15453
+ });
15454
+ };
15455
+ for (const comment of session.review_comments ?? [])
15456
+ add(comment);
15457
+ for (const [file, entry] of Object.entries(session.files ?? {})) {
15458
+ for (const comment of entry.file_comments ?? [])
15459
+ add(comment, { file });
15460
+ for (const [lineKey, lineComments] of Object.entries(entry.line_comments ?? {})) {
15461
+ const line = Number.parseInt(lineKey, 10);
15462
+ if (!Number.isFinite(line))
15463
+ continue;
15464
+ for (const comment of lineComments)
15465
+ add(comment, { file, line });
15466
+ }
15467
+ }
15468
+ return threads;
15469
+ }
15470
+ function toFindings(session, options = {}) {
15471
+ const comments = [];
15472
+ const include = (comment) => (!options.agentOnly || commentAuthor(comment)?.startsWith("Agent: ")) && (!options.excludeLocalResponses || !isLocalResponse(comment.content));
15473
+ const bodyParts = (session.review_comments ?? []).flatMap((comment) => include(comment) ? [comment.content] : []);
15474
+ for (const [file, entry] of Object.entries(session.files ?? {})) {
15475
+ const fileComments = (entry.file_comments ?? []).flatMap((comment) => include(comment) ? [comment.content] : []);
15476
+ if (fileComments.length > 0)
15477
+ bodyParts.push(`File: ${file}
15478
+
15479
+ ${fileComments.join(`
15480
+
15481
+ `)}`);
15482
+ for (const [lineKey, lineComments] of Object.entries(entry.line_comments ?? {})) {
15483
+ const line = Number.parseInt(lineKey, 10);
15484
+ if (!Number.isFinite(line))
15485
+ continue;
15486
+ for (const lineComment of lineComments) {
15487
+ if (!include(lineComment))
15488
+ continue;
15489
+ const comment = {
15490
+ file,
15491
+ line,
15492
+ side: lineComment.side === "old" ? "LEFT" : "RIGHT",
15493
+ body: lineComment.content
15494
+ };
15495
+ const author = commentAuthor(lineComment);
15496
+ if (author)
15497
+ comment.author = author;
15498
+ comments.push(comment);
15499
+ }
15500
+ }
15501
+ }
15502
+ return { comments, body: bodyParts.join(`
15503
+
15504
+ `) };
15505
+ }
15506
+ async function requireTuicr() {
15507
+ if (!await tuicrAvailable())
15508
+ throw new Error("tuicr is not installed or is not available on PATH.");
15509
+ }
15510
+ function commentAuthor(comment) {
15511
+ return comment.username ?? comment.author;
15512
+ }
15513
+ async function canonicalPath2(path) {
15514
+ try {
15515
+ return await realpath2(path);
15516
+ } catch {
15517
+ return resolve3(path);
15518
+ }
15519
+ }
15520
+
15521
+ // src/review/local-review-backend.ts
15522
+ function LocalReviewBackend(options) {
15523
+ return {
15524
+ kind: "local",
15525
+ async stage(draft) {
15526
+ for (const comment of draft.comments) {
15527
+ await addComment(options.session, comment.body, {
15528
+ targetFile: comment.file,
15529
+ line: comment.line,
15530
+ side: comment.side === "LEFT" ? "old" : "new",
15531
+ username: options.author
15532
+ });
15533
+ }
15534
+ if (draft.body.trim())
15535
+ await addComment(options.session, draft.body, { username: options.author });
15536
+ },
15537
+ async readDraft() {
15538
+ return toFindings(await readSession(options.session), { agentOnly: true, excludeLocalResponses: true });
15539
+ },
15540
+ async listThreads() {
15541
+ try {
15542
+ return parseThreadArtifact(await readFile6(options.artifactPath, "utf8"));
15543
+ } catch (error) {
15544
+ if (error.code === "ENOENT")
15545
+ return [];
15546
+ throw error;
15547
+ }
15548
+ },
15549
+ async reply(input) {
15550
+ const content = await readFile6(options.artifactPath, "utf8");
15551
+ await writeFile3(options.artifactPath, upsertThreadReply(content, input.threadId, input.body, input.question, input.resolve), "utf8");
15552
+ },
15553
+ async publish() {
15554
+ throw new Error("Promote a local draft through a remote review backend before publishing it.");
15555
+ }
15556
+ };
15557
+ }
15558
+
15559
+ // src/review/review-backend.ts
15560
+ function createRemoteReviewBackend(vcs, number) {
15561
+ if (vcs.provider === "github")
15562
+ return GitHubReviewBackend(vcs, number);
15563
+ if (vcs.provider === "gitlab")
15564
+ return GitLabReviewBackend(vcs, number);
15565
+ throw new Error("A remote review backend requires a GitHub or GitLab repository.");
15566
+ }
15567
+ function createLocalReviewBackend(options) {
15568
+ return LocalReviewBackend(options);
15569
+ }
15570
+ // src/review/review-state.ts
15571
+ import { createHash as createHash2 } from "node:crypto";
15572
+ import { readFile as readFile7, rename, writeFile as writeFile4 } from "node:fs/promises";
15573
+ import { join as join12 } from "node:path";
15574
+ import { z as z6 } from "zod";
15575
+
15576
+ // src/store.ts
15577
+ import { createHash } from "node:crypto";
15578
+ import { lstat, mkdir as mkdir3, readlink, realpath as realpath3, symlink, unlink } from "node:fs/promises";
15579
+ import { homedir as homedir6 } from "node:os";
15580
+ import { dirname as dirname5, isAbsolute as isAbsolute3, join as join11, resolve as resolve4 } from "node:path";
15581
+ var STORE_LINK = ".diffpi";
15582
+ var LEGACY_STORE_LINK = join11(".pi", "diffpi");
15583
+ function storeGlobalRoot(homeDir = homedir6()) {
15584
+ return join11(homeDir, ".difflab", "diffpi", "projects");
15585
+ }
15586
+ async function ensureStore(cwd, homeDir = homedir6()) {
15587
+ const repository = await inspectGitRepository(cwd);
15588
+ const root = repository.root;
15589
+ const slug = projectSlug(repository);
15590
+ const dest = join11(storeGlobalRoot(homeDir), slug);
15591
+ const link = join11(root, STORE_LINK);
15592
+ await mkdir3(dest, { recursive: true });
15593
+ try {
15594
+ await assertStoreLink(link, dest);
15595
+ } catch (error) {
15596
+ if (error.code !== "ENOENT")
15597
+ throw error;
15598
+ await symlink(dest, link);
15599
+ }
15600
+ await removeLegacyStoreLink(join11(root, LEGACY_STORE_LINK), dest);
15601
+ return { slug, root, dest, link, linked: true };
15602
+ }
15603
+ async function reviewsDir(cwd, homeDir = homedir6()) {
15604
+ const store = await ensureStore(cwd, homeDir);
15605
+ const dir = join11(store.link, "review");
15606
+ await mkdir3(dir, { recursive: true });
15607
+ return dir;
15608
+ }
15609
+ async function completedReviewsDir(cwd, homeDir = homedir6()) {
15610
+ const store = await ensureStore(cwd, homeDir);
15611
+ const dir = join11(store.link, "reviews");
15612
+ await mkdir3(dir, { recursive: true });
15613
+ return dir;
15614
+ }
15615
+ async function sessionsDir(cwd, homeDir = homedir6()) {
15616
+ const store = await ensureStore(cwd, homeDir);
15617
+ const dir = join11(store.link, "sessions");
15618
+ await mkdir3(dir, { recursive: true });
15619
+ return dir;
15620
+ }
15621
+ async function assertStoreLink(path, dest) {
15622
+ const entry = await lstat(path);
15623
+ if (!entry.isSymbolicLink())
15624
+ throw new Error(`${path} exists and is not a symlink.`);
15625
+ const target = await symlinkTarget(path);
15626
+ if (target !== await canonicalPath3(dest))
15627
+ throw new Error(`${path} points to ${target}, not ${dest}.`);
15628
+ }
15629
+ async function removeLegacyStoreLink(path, dest) {
15630
+ try {
15631
+ const entry = await lstat(path);
15632
+ if (!entry.isSymbolicLink())
15633
+ return;
15634
+ const target = await symlinkTarget(path);
15635
+ if (target === await canonicalPath3(dest))
15636
+ await unlink(path);
15637
+ } catch (error) {
15638
+ if (error.code !== "ENOENT")
15639
+ throw error;
15640
+ }
15641
+ }
15642
+ async function symlinkTarget(path) {
15643
+ const target = await readlink(path);
15644
+ return canonicalPath3(isAbsolute3(target) ? target : resolve4(dirname5(path), target));
15645
+ }
15646
+ async function canonicalPath3(path) {
15647
+ try {
15648
+ return await realpath3(path);
15649
+ } catch {
15650
+ return resolve4(path);
15651
+ }
15652
+ }
15653
+ function projectSlug(repository) {
15654
+ const readable = repository.name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "unnamed";
15655
+ const digest = createHash("sha256").update(repository.identity).digest("hex").slice(0, 12);
15656
+ return `${readable}-${digest}`;
15657
+ }
15658
+
15659
+ // src/review/review-state.ts
15660
+ var reviewPublicationStateSchema = z6.object({
15661
+ target: z6.string().optional(),
15662
+ bodies: z6.array(z6.string()).default([]),
15663
+ comments: z6.array(z6.string()).default([]),
15664
+ replies: z6.array(z6.string()).default([]),
15665
+ overlayPath: z6.string().optional()
15666
+ });
15667
+ function reviewBodyFingerprint(body) {
15668
+ return digest(body);
15669
+ }
15670
+ function reviewCommentFingerprint(comment) {
15671
+ return digest([comment.file, String(comment.line), comment.side ?? "RIGHT", comment.body].join("\x00"));
15672
+ }
15673
+ function reviewReplyFingerprint(threadId, body) {
15674
+ return digest(`${threadId}\x00${body}`);
15675
+ }
15676
+ function unpublishedReviewComments(comments, knownFingerprints) {
15677
+ return comments.filter((comment) => !knownFingerprints.has(reviewCommentFingerprint(comment)));
15678
+ }
15679
+ async function loadReviewPublicationState(cwd, vcs, number, homeDir) {
15680
+ const target = `${vcs.provider}:${vcs.owner}/${vcs.repo}#${number}`;
15681
+ const path = join12(await sessionsDir(cwd, homeDir), `review-publish-${digest(target).slice(0, 16)}.json`);
15682
+ try {
15683
+ const parsed = reviewPublicationStateSchema.parse(JSON.parse(await readFile7(path, "utf8")));
15684
+ return { path, state: { ...parsed, target } };
15685
+ } catch (error) {
15686
+ if (error.code === "ENOENT") {
15687
+ return { path, state: { target, bodies: [], comments: [], replies: [] } };
15688
+ }
15689
+ if (error instanceof SyntaxError || error instanceof z6.ZodError) {
15690
+ throw new Error(`Cannot parse review publication state: ${path}`);
15691
+ }
15692
+ throw error;
15693
+ }
15694
+ }
15695
+ async function saveReviewPublicationState(path, state) {
15696
+ const temp = `${path}.${process.pid}.tmp`;
15697
+ await writeFile4(temp, `${JSON.stringify(state, null, 2)}
15698
+ `, "utf8");
15699
+ await rename(temp, path);
15700
+ }
15701
+ function digest(value) {
15702
+ return createHash2("sha256").update(value).digest("hex");
15703
+ }
15704
+ // src/templates.ts
15705
+ import { readFile as readFile8 } from "node:fs/promises";
15706
+ import { homedir as homedir7 } from "node:os";
15707
+ import { join as join13, normalize } from "node:path";
15708
+ async function loadTemplate(name, options = {}) {
15709
+ const relative = templateRelativePath(name);
15710
+ const userPath = join13(options.homeDir ?? homedir7(), ".difflab", "diffpi", "templates", relative);
15711
+ const bundledPath = join13(options.bundledDir ?? resolveBundledTemplatesDir(), relative);
15712
+ const user = await readOptionalFile(userPath);
15713
+ if (user !== undefined)
15714
+ return { name, path: userPath, source: "user", content: user };
15715
+ const bundled = await readOptionalFile(bundledPath);
15716
+ if (bundled !== undefined)
15717
+ return { name, path: bundledPath, source: "bundled", content: bundled };
15718
+ throw new Error(`Template "${name}" was not found at ${userPath} or ${bundledPath}.`);
15719
+ }
15720
+ function renderTemplate(content, variables) {
15721
+ return content.replace(/\{\{\s*([a-zA-Z0-9_.-]+)\s*\}\}/g, (match, key) => variables[key] ?? match);
15722
+ }
15723
+ function templateRelativePath(name) {
15724
+ const normalized = normalize(name.replaceAll("\\", "/")).replace(/^\.\//, "");
15725
+ if (!normalized || normalized.startsWith("../") || normalized.includes("/../") || normalized.startsWith("/")) {
15726
+ throw new Error(`Invalid template name: ${name}`);
15727
+ }
15728
+ return normalized.endsWith(".md") ? normalized : `${normalized}.md`;
15729
+ }
15730
+ async function readOptionalFile(path) {
15731
+ try {
15732
+ return await readFile8(path, "utf8");
15733
+ } catch (error) {
15734
+ if (error.code === "ENOENT")
15735
+ return;
15736
+ throw error;
15737
+ }
15738
+ }
15739
+
15740
+ // src/tools/review.ts
15741
+ var contextSchema = z7.object({
15742
+ cwd: z7.string().optional(),
15743
+ target: z7.string().optional(),
15744
+ workingTree: z7.boolean().optional()
15745
+ });
15746
+ var localSchema = contextSchema.extend({ local: z7.boolean().optional() });
15747
+ var openSchema = localSchema.extend({
15748
+ title: z7.string().optional(),
15749
+ intent: z7.string().optional(),
15750
+ issueUrl: z7.string().url().optional(),
15751
+ base: z7.string().optional()
15752
+ });
15753
+ var submitSchema = localSchema.extend({
15754
+ findings: findingsSchema,
15755
+ overallIssues: z7.array(z7.string()).optional(),
15756
+ notVerified: z7.array(z7.string()).optional(),
15757
+ title: z7.string().optional()
15758
+ });
15759
+ var addCommentSchema = localSchema.extend({
15760
+ body: z7.string().min(1),
15761
+ file: z7.string().min(1),
15762
+ line: z7.number().int().positive(),
15763
+ side: z7.enum(["LEFT", "RIGHT"]).optional()
15764
+ });
15765
+ var respondSchema = localSchema.extend({
15766
+ threadId: z7.string().min(1),
15767
+ body: z7.string().min(1),
15768
+ question: z7.boolean().optional(),
15769
+ resolve: z7.boolean().optional()
15770
+ });
15771
+ var publishSchema = localSchema.extend({
15772
+ status: z7.enum(["COMMENT", "APPROVE", "REQUEST_CHANGES", "CLOSE"]).optional()
15773
+ });
15774
+ var completeSchema = localSchema.extend({
15775
+ action: z7.enum(["approve", "reject", "abandon"]).optional()
15776
+ });
15777
+ function hasReviewDraft(comments, body) {
15778
+ return comments.length > 0 || body.trim().length > 0;
15779
+ }
15780
+ async function workingTreeDiff(cwd) {
15781
+ const tracked = await run("git", ["-C", cwd, "diff", "HEAD"], { capture: "unbounded" });
15782
+ if (tracked.code !== 0)
15783
+ throw new Error(tracked.stderr || "Cannot read tracked working-tree changes.");
15784
+ const untracked = await runChecked("git", ["-C", cwd, "ls-files", "--others", "--exclude-standard", "-z"]);
15785
+ const patches = [tracked.stdout];
15786
+ for (const file of untracked.stdout.split("\x00").filter(Boolean)) {
15787
+ const patch = await run("git", ["-C", cwd, "diff", "--no-index", "--", "/dev/null", file], {
15788
+ capture: "unbounded"
15789
+ });
15790
+ if (patch.code > 1)
15791
+ throw new Error(patch.stderr || `Cannot read untracked file diff: ${file}`);
15792
+ patches.push(patch.stdout);
15793
+ }
15794
+ return patches.filter(Boolean).join(`
15795
+ `);
15796
+ }
15797
+ function createReviewTools() {
15798
+ return [
15799
+ defineTool3({
15800
+ name: "review_context",
15801
+ label: "review context",
15802
+ description: "Orient to the target, backend, forge, environment, shared store, PR/MR, and tuicr session.",
15803
+ promptSnippet: "Call review_context first",
15804
+ promptGuidelines: ["Call this before every review workflow."],
15805
+ parameters: parameters5(localSchema),
15806
+ executionMode: "parallel",
15807
+ async execute(_id, input) {
15808
+ const params = localSchema.parse(input);
15809
+ const review = await resolveReviewContext(resolveWorkingDirectory(params), params.target);
15810
+ const store = await ensureStore(review.cwd);
15811
+ const env = { ide: detectIde(), mux: detectMux(), shell: detectShell() };
15812
+ const session = await resolveTuicrSession(review, Boolean(params.local || params.workingTree));
15813
+ const baseRef = review.pr?.baseRef ?? (review.forge ? await review.forge.defaultBranch() : "local");
15814
+ const backend = params.local || params.workingTree ? "tuicr" : review.forge?.provider ?? "unsupported";
15815
+ return result([
15816
+ `Backend: ${backend}`,
15817
+ `Forge: ${review.vcs.provider}${review.vcs.provider === "none" ? "" : ` (${review.vcs.owner}/${review.vcs.repo})`}`,
15818
+ `Branch: ${review.vcs.branch} → ${baseRef}`,
15819
+ `Env: ide=${env.ide} mux=${env.mux} shell=${env.shell}`,
15820
+ `Store: ${store.link} → ${store.dest}`,
15821
+ review.pr ? `PR/MR: #${review.pr.number} ${review.pr.url}` : "PR/MR: none",
15822
+ session ? `tuicr: ${session.slug} (${session.commentCount} comments)` : "tuicr: none"
15823
+ ].join(`
15824
+ `), { ...review, env, store, session, baseRef, backend });
15825
+ }
15826
+ }),
15827
+ defineTool3({
15828
+ name: "review_new",
15829
+ label: "review new",
15830
+ description: "Create a new local tuicr review or a remote draft PR/MR and open it in tuicr.",
15831
+ promptSnippet: "Call review_new to start",
15832
+ promptGuidelines: ["Use local to select tuicr as the review backend."],
15833
+ parameters: parameters5(openSchema),
15834
+ executionMode: "sequential",
15835
+ async execute(_id, input) {
15836
+ const params = openSchema.parse(input);
15837
+ const review = await resolveReviewContext(resolveWorkingDirectory(params), params.target);
15838
+ await ensureStore(review.cwd);
15839
+ if (params.local || params.workingTree)
15840
+ return launchLocalReview(review, true, params.base);
15841
+ if (!review.forge)
15842
+ return result(unsupportedForgeMessage());
15843
+ if (review.pr)
15844
+ return result(`A remote review already exists for this branch: ${review.pr.url}. Use review_edit to open it.`);
15845
+ await assertRemoteBranchReady(review.cwd);
15846
+ const base = params.base ?? await review.forge.defaultBranch();
15847
+ const template = await loadTemplate("review/draft-pr");
15848
+ const body = renderTemplate(template.content, {
15849
+ intent: params.intent ?? "<!-- Describe why this change is needed. -->",
15850
+ issue_url: params.issueUrl ?? "<!-- Add issue tracker URL. -->",
15851
+ head: review.vcs.branch,
15852
+ base
15853
+ });
15854
+ const pr = await review.forge.createDraftPr({
15855
+ title: params.title ?? deriveTitle(review.vcs.branch),
15856
+ body,
15857
+ base,
15858
+ head: review.vcs.branch
15859
+ });
15860
+ const launched = await launch(review.cwd, pr.number);
15861
+ const openMessage = launched.launched ? `Opened PR/MR #${pr.number} in tuicr (${launched.via}).` : launched.instruction ?? `Run: ${launched.command}`;
15862
+ return result(`Draft PR/MR created from ${template.source} template: ${pr.url}
15863
+ ${openMessage}`, {
15864
+ pr,
15865
+ template,
15866
+ launched
15867
+ });
15868
+ }
15869
+ }),
15870
+ defineTool3({
15871
+ name: "review_edit",
15872
+ label: "review edit",
15873
+ description: "Open an existing local tuicr session or remote PR/MR in tuicr without generating findings.",
15874
+ promptSnippet: "Call review_edit to continue an existing review",
15875
+ promptGuidelines: ["This tool never creates review findings or a PR/MR."],
15876
+ parameters: parameters5(localSchema),
15877
+ executionMode: "sequential",
15878
+ async execute(_id, input) {
15879
+ const params = localSchema.parse(input);
15880
+ const review = await resolveReviewContext(resolveWorkingDirectory(params), params.target);
15881
+ await ensureStore(review.cwd);
15882
+ if (params.local || params.workingTree) {
15883
+ if (!await resolveTuicrSession(review, true))
15884
+ return result("No local tuicr review exists. Use review_new with local=true to create one.");
15885
+ return launchLocalReview(review, true);
15886
+ }
15887
+ if (!review.forge)
15888
+ return result(unsupportedForgeMessage());
15889
+ if (!review.pr)
15890
+ return result("No remote PR/MR exists. Use review_new to create a draft review first.");
15891
+ return launchLocalReview(review, false);
15892
+ }
15893
+ }),
15894
+ defineTool3({
15895
+ name: "review_diff",
15896
+ label: "review diff",
15897
+ description: "Fetch the target PR/MR diff or auto-detected local working-tree diff.",
15898
+ promptSnippet: "Call review_diff for the code under review",
15899
+ promptGuidelines: ["Ground findings in this diff."],
15900
+ parameters: parameters5(localSchema),
15901
+ executionMode: "parallel",
15902
+ async execute(_id, input) {
15903
+ const params = localSchema.parse(input);
15904
+ const review = await resolveReviewContext(resolveWorkingDirectory(params), params.target);
15905
+ if (params.local || params.workingTree) {
15906
+ const diff = await workingTreeDiff(review.cwd);
15907
+ return result(diff || "No working-tree changes.", { diff, target: "local" });
15908
+ }
15909
+ if (!review.forge)
15910
+ return result(unsupportedForgeMessage());
15911
+ if (!review.pr)
15912
+ return result("No PR/MR matches this remote review target.");
15913
+ const diff = await review.forge.prDiff(review.pr.number);
15914
+ return result(diff || "Empty diff.", { diff, pr: review.pr });
15915
+ }
15916
+ }),
15917
+ defineTool3({
15918
+ name: "review_gates",
15919
+ label: "review gates",
15920
+ description: "Run format, lint, test, conventional-subject, and available CI checks.",
15921
+ promptSnippet: "Call review_gates before submitting findings",
15922
+ promptGuidelines: ["Report skipped gates as skipped."],
15923
+ parameters: parameters5(localSchema),
15924
+ executionMode: "parallel",
15925
+ async execute(_id, input) {
15926
+ const params = localSchema.parse(input);
15927
+ const review = await resolveReviewContext(resolveWorkingDirectory(params), params.target);
15928
+ const gates = await runMiseGates(review.cwd);
15929
+ const commit = await run("git", ["-C", review.cwd, "log", "-1", "--format=%s"]);
15930
+ const subject = commit.stdout.trim();
15931
+ if (subject)
15932
+ gates.push(checkConventionalSubject(subject));
15933
+ if (!params.local && review.pr && review.forge)
15934
+ gates.push(ciGate(await review.forge.prChecks(review.pr.number)));
15935
+ return result(gates.map((gate) => `- ${gate.name}: ${gate.status} — ${gate.detail}`).join(`
15936
+ `), {
15937
+ results: gates
15938
+ });
15939
+ }
15940
+ }),
15941
+ defineTool3({
15942
+ name: "review_submit",
15943
+ label: "review submit",
15944
+ description: "Write the review artifact and stage comments in the selected local or remote backend.",
15945
+ promptSnippet: "Call review_submit with the findings JSON",
15946
+ promptGuidelines: ["Remote comments remain pending until review_publish."],
15947
+ parameters: parameters5(submitSchema),
15948
+ executionMode: "sequential",
15949
+ async execute(_id, input, _signal, _onUpdate, ctx) {
15950
+ const params = submitSchema.parse(input);
15951
+ const review = await resolveReviewContext(resolveWorkingDirectory(params), params.target);
15952
+ const model = modelRoute(ctx);
15953
+ const useLocalBackend = Boolean(params.local || params.workingTree);
15954
+ if (!useLocalBackend && !review.forge)
15955
+ return result(unsupportedForgeMessage());
15956
+ const findings = dedupeFindings(params.findings);
15957
+ const gates = await runMiseGates(review.cwd);
15958
+ const baseRef = review.pr?.baseRef ?? (review.forge ? await review.forge.defaultBranch() : "local");
15959
+ const artifact = await newReviewArtifactPath(review.cwd, params.local || params.workingTree || !review.pr ? "uncommitted" : await reviewTargetId(review));
15960
+ await writeFile5(artifact, renderReviewDoc({
15961
+ title: params.title ?? review.pr?.title ?? review.vcs.branch,
15962
+ number: useLocalBackend ? undefined : review.pr?.number,
15963
+ url: useLocalBackend ? undefined : review.pr?.url,
15964
+ model,
15965
+ headRef: review.vcs.branch,
15966
+ baseRef,
15967
+ findings,
15968
+ overallIssues: params.overallIssues ?? [],
15969
+ gates,
15970
+ notVerified: params.notVerified ?? []
15971
+ }), "utf8");
15972
+ const comments = toReviewComments(findings, useLocalBackend ? undefined : model);
15973
+ const body = (params.overallIssues ?? []).join(`
15974
+ `);
15975
+ if (useLocalBackend) {
15976
+ const session = await resolveTuicrSession(review, Boolean(params.local || params.workingTree));
15977
+ if (!session) {
15978
+ return result(`Review written: ${artifact}. Open tuicr, then call review_submit again to seed comments.`, {
15979
+ artifact,
15980
+ count: findings.length
15981
+ });
15982
+ }
15983
+ const backend = createLocalReviewBackend({
15984
+ session: session.path,
15985
+ artifactPath: artifact,
15986
+ author: localReviewAuthor(model)
15987
+ });
15988
+ await backend.stage({ comments, body });
15989
+ return result(`Local review staged in tuicr: ${artifact}`, { artifact, count: findings.length, session });
15990
+ }
15991
+ if (!review.pr)
15992
+ return result(`Review written: ${artifact}. No PR/MR matches this remote target.`, { artifact });
15993
+ const hasDraft = hasReviewDraft(comments, body);
15994
+ if (hasDraft) {
15995
+ await createRemoteReviewBackend(review.vcs, review.pr.number).stage({ comments, body });
15996
+ }
15997
+ return result(`${hasDraft ? "Pending review staged" : "Clean review recorded"} on #${review.pr.number}. Artifact: ${artifact}`, {
15998
+ artifact,
15999
+ pr: review.pr,
16000
+ count: findings.length
16001
+ });
16002
+ }
16003
+ }),
16004
+ defineTool3({
16005
+ name: "review_add_comment",
16006
+ label: "review add comment",
16007
+ description: "Add one provenance-marked comment through tuicr or the remote pending-review backend.",
16008
+ promptSnippet: "Call review_add_comment for incremental comments",
16009
+ promptGuidelines: ["Pass local=true when tuicr owns the draft."],
16010
+ parameters: parameters5(addCommentSchema),
16011
+ executionMode: "sequential",
16012
+ async execute(_id, input, _signal, _onUpdate, ctx) {
16013
+ const params = addCommentSchema.parse(input);
16014
+ const review = await resolveReviewContext(resolveWorkingDirectory(params), params.target);
16015
+ const model = modelRoute(ctx);
16016
+ const useLocalBackend = Boolean(params.local || params.workingTree);
16017
+ if (!useLocalBackend && !review.forge)
16018
+ return result(unsupportedForgeMessage());
16019
+ const comment = {
16020
+ file: params.file,
16021
+ line: params.line,
16022
+ side: params.side ?? "RIGHT",
16023
+ body: useLocalBackend ? params.body : withRemoteProvenance(params.body, model)
16024
+ };
16025
+ if (useLocalBackend) {
16026
+ const session = await resolveTuicrSession(review, Boolean(params.local || params.workingTree));
16027
+ if (!session)
16028
+ return result("No matching tuicr session. Open review_new or review_launch_ui first.");
16029
+ const backend = createLocalReviewBackend({
16030
+ session: session.path,
16031
+ artifactPath: "",
16032
+ author: localReviewAuthor(model)
16033
+ });
16034
+ await backend.stage({ comments: [comment], body: "" });
16035
+ return result(`Comment added to tuicr session ${session.slug}.`, { session, comment });
16036
+ }
16037
+ if (!review.pr)
16038
+ return result("No PR/MR matches this remote review target.");
16039
+ await createRemoteReviewBackend(review.vcs, review.pr.number).stage({ comments: [comment], body: "" });
16040
+ return result(`Draft comment added to #${review.pr.number}.`, { pr: review.pr, comment });
16041
+ }
16042
+ }),
16043
+ defineTool3({
16044
+ name: "review_comments",
16045
+ label: "review comments",
16046
+ description: "Pull review threads or local tuicr comments and write a target-named artifact.",
16047
+ promptSnippet: "Call review_comments before addressing findings",
16048
+ promptGuidelines: ["Pass local=true to prepare the local reply overlay."],
16049
+ parameters: parameters5(localSchema),
16050
+ executionMode: "sequential",
16051
+ async execute(_id, input) {
16052
+ const params = localSchema.parse(input);
16053
+ const review = await resolveReviewContext(resolveWorkingDirectory(params), params.target);
16054
+ let threads;
16055
+ let artifact;
16056
+ if (params.local || params.workingTree) {
16057
+ const session = await resolveTuicrSession(review, true);
16058
+ if (!session)
16059
+ return result("No matching tuicr session found.");
16060
+ artifact = await localThreadArtifactPath(review.cwd, session.slug);
16061
+ threads = await syncLocalThreadArtifact(artifact, review.pr?.title ?? review.vcs.branch, session.slug, toLocalReviewThreads(await readSession(session.path)));
16062
+ } else if (review.pr && review.forge) {
16063
+ threads = await createRemoteReviewBackend(review.vcs, review.pr.number).listThreads();
16064
+ const target = await reviewTargetId(review);
16065
+ artifact = await newReviewArtifactPath(review.cwd, target);
16066
+ await writeFile5(artifact, renderThreadArtifact(review.pr.title, target, threads, { number: review.pr.number, url: review.pr.url }), "utf8");
16067
+ } else {
16068
+ return result(review.forge ? "No PR/MR matches this remote review target." : unsupportedForgeMessage());
16069
+ }
16070
+ return result(threads.map((thread) => `${thread.id} ${thread.file ?? "review"}:${thread.line ?? "-"} — ${thread.body}`).join(`
16071
+ `) || "No comments.", { artifact, threads });
16072
+ }
16073
+ }),
16074
+ defineTool3({
16075
+ name: "review_respond",
16076
+ label: "review respond",
16077
+ description: "Post local question replies to tuicr and record their publish overlay, or reply to remote threads.",
16078
+ promptSnippet: "Call review_respond after addressing a comment",
16079
+ promptGuidelines: ["Question replies remain unresolved."],
16080
+ parameters: parameters5(respondSchema),
16081
+ executionMode: "sequential",
16082
+ async execute(_id, input, _signal, _onUpdate, ctx) {
16083
+ const params = respondSchema.parse(input);
16084
+ const review = await resolveReviewContext(resolveWorkingDirectory(params), params.target);
16085
+ const model = modelRoute(ctx);
16086
+ if (params.local) {
16087
+ const tuicrSession = await resolveTuicrSession(review, true);
16088
+ if (!tuicrSession)
16089
+ return result("No matching tuicr session. Open review_new or review_launch_ui first.");
16090
+ const artifact = await localThreadArtifactPath(review.cwd, tuicrSession.slug);
16091
+ if (!existsSync3(artifact))
16092
+ return result("No local review artifact. Run review_comments with local=true first.");
16093
+ const threads = await readThreadArtifact(artifact);
16094
+ const known = threads.find((thread) => thread.id === params.threadId);
16095
+ const question = known?.question === true || params.question === true || !known && params.question === undefined;
16096
+ const awaitUserDeletion = !question && params.resolve === true;
16097
+ await addComment(tuicrSession.path, `${localResponseMarker(params.threadId)}
16098
+ ${params.body}`, {
16099
+ targetFile: known?.file,
16100
+ line: known?.line,
16101
+ side: "new",
16102
+ username: localReviewAuthor(model)
16103
+ });
16104
+ await createLocalReviewBackend({
16105
+ session: tuicrSession.path,
16106
+ artifactPath: artifact,
16107
+ author: localReviewAuthor(model)
16108
+ }).reply({
16109
+ threadId: params.threadId,
16110
+ body: params.body,
16111
+ resolve: false,
16112
+ question
16113
+ });
16114
+ return result(`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 });
16115
+ }
16116
+ if (!review.pr || !review.forge)
16117
+ return result("No remote PR/MR for this reply.");
16118
+ const remote = createRemoteReviewBackend(review.vcs, review.pr.number);
16119
+ const remoteThreads = await remote.listThreads();
16120
+ const known = remoteThreads.find((thread) => thread.id === params.threadId);
16121
+ const question = known?.question === true || params.question === true || !known && params.question === undefined;
16122
+ const resolve = question ? false : params.resolve ?? true;
16123
+ await remote.reply({
16124
+ threadId: params.threadId,
16125
+ body: withRemoteProvenance(params.body, model),
16126
+ resolve,
16127
+ question
16128
+ });
16129
+ return result(`Replied to ${params.threadId}${resolve ? " and resolved it" : " and left it open"}.`, {
16130
+ pr: review.pr,
16131
+ resolve
16132
+ });
16133
+ }
16134
+ }),
16135
+ defineTool3({
16136
+ name: "review_publish",
16137
+ label: "review publish",
16138
+ description: "Publish pending remote draft PR/MR review comments and status, or promote a local tuicr draft before publishing.",
16139
+ promptSnippet: "Call review_publish to make remote review work public",
16140
+ promptGuidelines: ["Statuses are COMMENT, APPROVE, REQUEST_CHANGES, or CLOSE."],
16141
+ parameters: parameters5(publishSchema),
16142
+ executionMode: "sequential",
16143
+ async execute(_id, input, _signal, _onUpdate, ctx) {
16144
+ const params = publishSchema.parse(input);
16145
+ const review = await resolveReviewContext(resolveWorkingDirectory(params), params.target);
16146
+ if (!review.pr || !review.forge)
16147
+ return result("No remote PR/MR to publish.");
16148
+ return publishResolvedReview(review, params, modelRoute(ctx));
16149
+ }
16150
+ }),
16151
+ defineTool3({
16152
+ name: "review_complete",
16153
+ label: "review complete",
16154
+ description: "Approve, reject, or abandon a remote review, or archive a local review artifact.",
16155
+ promptSnippet: "Call review_complete to finish a review without merging it.",
16156
+ promptGuidelines: ["A local completion archives the Diffpi artifact and deletes the matching tuicr session."],
16157
+ parameters: parameters5(completeSchema),
16158
+ executionMode: "sequential",
16159
+ async execute(_id, input, _signal, _onUpdate, ctx) {
16160
+ const params = completeSchema.parse(input);
16161
+ const review = await resolveReviewContext(resolveWorkingDirectory(params), params.target);
16162
+ if (params.local) {
16163
+ const session = await resolveTuicrSession(review, true);
16164
+ if (!session)
16165
+ return result("No matching local tuicr session to close.");
16166
+ const artifact = await localThreadArtifactPath(review.cwd, session.slug);
16167
+ if (!existsSync3(artifact))
16168
+ return result("No local review artifact. Run review_comments with local=true first.");
16169
+ const archived = await uniqueRecordPath(await completedReviewsDir(review.cwd), reviewSlug(session.slug));
16170
+ await rename2(artifact, archived);
16171
+ await unlink2(session.path);
16172
+ return result(`Archived ${archived} and deleted the tuicr session ${session.slug}.`, {
16173
+ artifact: archived,
16174
+ session
16175
+ });
16176
+ }
16177
+ if (!review.pr || !review.forge)
16178
+ return result("No remote PR/MR to complete.");
16179
+ if (!params.action)
16180
+ return result("Choose a complete action: approve, reject, or abandon.");
16181
+ const status = { approve: "APPROVE", reject: "REQUEST_CHANGES", abandon: "CLOSE" }[params.action];
16182
+ return publishResolvedReview(review, { ...params, status }, modelRoute(ctx));
16183
+ }
16184
+ }),
16185
+ defineTool3({
16186
+ name: "review_merge",
16187
+ label: "review merge",
16188
+ description: "Squash-merge an approved GitHub PR after checking its conventional subject.",
16189
+ promptSnippet: "Call review_merge only after review_publish APPROVE",
16190
+ promptGuidelines: ["This is intentionally GitHub-only until GitLab merge support is added."],
16191
+ parameters: parameters5(contextSchema.extend({ subject: z7.string().optional() })),
16192
+ executionMode: "sequential",
16193
+ async execute(_id, input) {
16194
+ const params = contextSchema.extend({ subject: z7.string().optional() }).parse(input);
16195
+ const review = await resolveReviewContext(resolveWorkingDirectory(params), params.target);
16196
+ if (review.vcs.provider !== "github" || !review.forge)
16197
+ return result("review_merge currently supports GitHub only.");
16198
+ if (!review.pr)
16199
+ return result("No open PR/MR for this branch.");
16200
+ const subject = params.subject ?? review.pr.title;
16201
+ const guard = checkConventionalSubject(subject);
16202
+ if (guard.status !== "pass")
16203
+ return result(`Merge blocked: ${guard.detail}`, { pr: review.pr, guard });
16204
+ await review.forge.mergePr(review.pr.number, subject);
16205
+ return result(`Merged #${review.pr.number} with subject: ${subject}.`, { pr: review.pr, guard });
16206
+ }
16207
+ }),
16208
+ defineTool3({
16209
+ name: "review_launch_ui",
16210
+ label: "review launch UI",
16211
+ description: "Open a tuicr UI in a mux tab, configure Zed, or return the command to run.",
16212
+ promptSnippet: "Call review_launch_ui for the interactive tuicr TUI",
16213
+ promptGuidelines: ["Show the returned command when launch cannot open a tab."],
16214
+ parameters: parameters5(localSchema),
16215
+ executionMode: "sequential",
16216
+ async execute(_id, input) {
16217
+ const params = localSchema.parse(input);
16218
+ const review = await resolveReviewContext(resolveWorkingDirectory(params), params.target);
16219
+ const workingTree = Boolean(params.local || params.workingTree);
16220
+ if (!workingTree && !review.forge)
16221
+ return result(unsupportedForgeMessage());
16222
+ return launchLocalReview(review, workingTree);
16223
+ }
16224
+ })
16225
+ ];
16226
+ }
16227
+ async function syncLocalThreadArtifact(path, title, sessionSlug, threads) {
16228
+ let previous = [];
16229
+ try {
16230
+ previous = parseThreadArtifact(await readFile9(path, "utf8"));
16231
+ } catch (error) {
16232
+ if (error.code !== "ENOENT")
16233
+ throw error;
16234
+ }
16235
+ const previousById = new Map(previous.map((thread) => [thread.id, thread]));
16236
+ const currentIds = new Set(threads.map((thread) => thread.id));
16237
+ const current = threads.map((thread) => {
16238
+ const existing = previousById.get(thread.id);
16239
+ return {
16240
+ ...thread,
16241
+ resolved: false,
16242
+ addressed: existing?.addressed ?? Boolean(existing?.reply),
16243
+ reply: existing?.reply
16244
+ };
16245
+ });
16246
+ const removed = previous.filter((thread) => !currentIds.has(thread.id)).map((thread) => ({ ...thread, resolved: true }));
16247
+ const merged = [...current, ...removed];
16248
+ await writeFile5(path, renderThreadArtifact(title, sessionSlug, merged), "utf8");
16249
+ return merged;
16250
+ }
16251
+ function parameters5(schema) {
16252
+ return z7.toJSONSchema(schema, { io: "input" });
16253
+ }
16254
+ function resolveWorkingDirectory(params) {
16255
+ return params.cwd ?? process.cwd();
16256
+ }
16257
+ function result(text, details = {}) {
16258
+ return { content: [{ type: "text", text }], details };
16259
+ }
16260
+ function unsupportedForgeMessage() {
16261
+ return "No supported GitHub or GitLab remote was detected. Use local=true for an offline tuicr review.";
16262
+ }
16263
+ function modelRoute(ctx) {
16264
+ if (!ctx.model)
16265
+ throw new Error("Cannot record review provenance because Pi did not provide an active model route.");
16266
+ return `${ctx.model.provider}/${ctx.model.id}`;
16267
+ }
16268
+ async function publishResolvedReview(review, params, model) {
16269
+ const status = params.status ?? "COMMENT";
16270
+ const event = status === "CLOSE" ? "COMMENT" : status;
16271
+ assertReviewEventSupported(review.vcs.provider, event);
16272
+ const remote = createRemoteReviewBackend(review.vcs, review.pr.number);
16273
+ const promotion = params.local ? await promoteLocalReview(review, remote, model, true) : undefined;
16274
+ if (status !== "CLOSE" && review.pr.isDraft)
16275
+ await review.forge.markReady(review.pr.number);
16276
+ await remote.publish(event);
16277
+ if (promotion) {
16278
+ promotion.publication.state.bodies = [
16279
+ ...new Set([...promotion.publication.state.bodies, ...promotion.bodyFingerprints])
16280
+ ];
16281
+ promotion.publication.state.comments = [
16282
+ ...new Set([...promotion.publication.state.comments, ...promotion.commentFingerprints])
16283
+ ];
16284
+ await saveReviewPublicationState(promotion.publication.path, promotion.publication.state);
16285
+ }
16286
+ if (status === "CLOSE")
16287
+ await review.forge.closePr(review.pr.number);
16288
+ const finalPr = await review.forge.viewPr(String(review.pr.number));
16289
+ const promotedBodies = promotion?.promotedBodies ?? 0;
16290
+ const promotedComments = promotion?.promotedComments ?? 0;
16291
+ const promotedReplies = promotion?.promotedReplies ?? 0;
16292
+ return result(`Published #${review.pr.number} (${status}); promoted ${promotedBodies} review bodies, ${promotedComments} comments, and ${promotedReplies} replies.`, { pr: finalPr ?? review.pr, status, promotedBodies, promotedComments, promotedReplies });
16293
+ }
16294
+ async function promoteLocalReview(review, remote, model, workingTree) {
16295
+ const publication = await loadReviewPublicationState(review.cwd, review.vcs, review.pr.number);
16296
+ const session = await resolveTuicrSession(review, workingTree);
16297
+ if (!session)
16298
+ throw new Error("No matching tuicr session to publish.");
16299
+ const local = createLocalReviewBackend({
16300
+ session: session.path,
16301
+ artifactPath: "",
16302
+ author: localReviewAuthor(model)
16303
+ });
16304
+ const draft = await local.readDraft();
16305
+ const comments = draft.comments.map((comment) => ({
16306
+ ...comment,
16307
+ body: withRemoteProvenance(comment.body, comment.author?.replace(/^Agent:\s*/, "") || model)
16308
+ }));
16309
+ const body = draft.body.trim() ? withRemoteProvenance(draft.body, model) : "";
16310
+ const bodyFingerprints = body ? [reviewBodyFingerprint(body)] : [];
16311
+ const commentFingerprints = comments.map(reviewCommentFingerprint);
16312
+ const remoteDraft = await remote.readDraft();
16313
+ const knownBodies = new Set(publication.state.bodies);
16314
+ if (remoteDraft.body.trim())
16315
+ knownBodies.add(reviewBodyFingerprint(remoteDraft.body));
16316
+ const unpublishedBody = body && !knownBodies.has(reviewBodyFingerprint(body)) ? body : "";
16317
+ const known = new Set([...publication.state.comments, ...remoteDraft.comments.map(reviewCommentFingerprint)]);
16318
+ const unpublished = unpublishedReviewComments(comments, known);
16319
+ if (unpublished.length > 0 || unpublishedBody)
16320
+ await remote.stage({ comments: unpublished, body: unpublishedBody });
16321
+ const promotedReplies = await promoteLocalReplies(review, remote, publication, model, workingTree);
16322
+ return {
16323
+ publication,
16324
+ bodyFingerprints,
16325
+ commentFingerprints,
16326
+ promotedBodies: unpublishedBody ? 1 : 0,
16327
+ promotedComments: unpublished.length,
16328
+ promotedReplies
16329
+ };
16330
+ }
16331
+ async function promoteLocalReplies(review, remote, publication, model, workingTree) {
16332
+ const target = workingTree ? "uncommitted" : await reviewTargetId(review);
16333
+ const artifact = workingTree ? await latestThreadArtifact(review.cwd, review, target) : publication.state.overlayPath ?? await latestThreadArtifact(review.cwd, review, target);
16334
+ if (!artifact)
16335
+ return 0;
16336
+ if (!workingTree)
16337
+ publication.state.overlayPath = artifact;
16338
+ let count = 0;
16339
+ for (const thread of await readThreadArtifact(artifact)) {
16340
+ if (!thread.reply)
16341
+ continue;
16342
+ const body = withRemoteProvenance(thread.reply, model);
16343
+ const fingerprint = reviewReplyFingerprint(thread.id, body);
16344
+ if (publication.state.replies.includes(fingerprint))
16345
+ continue;
16346
+ await remote.reply({
16347
+ threadId: thread.id,
16348
+ body,
16349
+ resolve: !thread.question,
16350
+ question: thread.question
16351
+ });
16352
+ publication.state.replies.push(fingerprint);
16353
+ await saveReviewPublicationState(publication.path, publication.state);
16354
+ count += 1;
16355
+ }
16356
+ return count;
16357
+ }
16358
+ async function resolveReviewContext(cwd, target) {
16359
+ const vcs = await detectVcs(cwd);
16360
+ const forge = vcs.provider === "none" ? undefined : createVcsBackend(vcs);
16361
+ const requested = target ? normalizeTarget(target) : vcs.branch;
16362
+ const pr = forge ? await forge.viewPr(requested) : undefined;
16363
+ return { cwd, vcs, forge, pr };
16364
+ }
16365
+ function normalizeTarget(target) {
16366
+ return target.match(/\/(?:pull|merge_requests)\/(\d+)(?:\/|$)/)?.[1] ?? target;
16367
+ }
16368
+ function resolveTuicrSession(review, workingTree = false) {
16369
+ return resolveReviewSession(review.cwd, {
16370
+ branch: review.vcs.branch,
16371
+ workingTree,
16372
+ owner: review.vcs.provider === "none" ? undefined : review.vcs.owner,
16373
+ repo: review.vcs.provider === "none" ? undefined : review.vcs.repo,
16374
+ number: review.pr?.number
16375
+ });
16376
+ }
16377
+ async function launchLocalReview(review, workingTree, requestedBase) {
16378
+ const base = workingTree ? requestedBase ?? review.pr?.baseRef ?? (review.forge ? await review.forge.defaultBranch() : undefined) : undefined;
16379
+ const launched = await launch(review.cwd, workingTree ? undefined : review.pr?.number, base);
16380
+ const commandTarget = workingTree || !review.pr ? "full branch" : `PR/MR #${review.pr.number}`;
16381
+ return result(launched.launched ? `Opened ${commandTarget} in tuicr (${launched.via}).` : launched.instruction ?? `Run: ${launched.command}`, { launched, pr: review.pr, target: commandTarget });
16382
+ }
16383
+ async function assertRemoteBranchReady(cwd) {
16384
+ const dirty = await runChecked("git", ["-C", cwd, "status", "--porcelain"]);
16385
+ if (dirty.stdout.trim())
16386
+ throw new Error("Commit and push all changes before opening a remote review.");
16387
+ const upstream = await run("git", ["-C", cwd, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"]);
16388
+ if (upstream.code !== 0 || !upstream.stdout.trim())
16389
+ throw new Error("Push the current branch and set its upstream before opening a remote review.");
16390
+ const [head, pushed] = await Promise.all([
16391
+ runChecked("git", ["-C", cwd, "rev-parse", "HEAD"]),
16392
+ runChecked("git", ["-C", cwd, "rev-parse", "@{upstream}"])
16393
+ ]);
16394
+ if (head.stdout.trim() !== pushed.stdout.trim())
16395
+ throw new Error("Push the current branch before opening a remote review.");
16396
+ }
16397
+ async function reviewTargetId(review) {
16398
+ if (!review.pr)
16399
+ return "uncommitted";
16400
+ if (review.pr.headSha)
16401
+ return review.pr.headSha.slice(0, 12);
16402
+ if (review.pr.headRef === review.vcs.branch)
16403
+ return headSha(review.cwd);
16404
+ const remote = await run("git", ["-C", review.cwd, "ls-remote", "origin", `refs/heads/${review.pr.headRef}`]);
16405
+ return remote.stdout.trim().split(/\s+/)[0]?.slice(0, 12) || headSha(review.cwd);
16406
+ }
16407
+ async function headSha(cwd) {
16408
+ const result = await runChecked("git", ["-C", cwd, "rev-parse", "--short=12", "HEAD"]);
16409
+ return result.stdout.trim();
16410
+ }
16411
+ async function newReviewArtifactPath(cwd, target) {
16412
+ const dir = await reviewsDir(cwd);
16413
+ await mkdir4(dir, { recursive: true });
16414
+ return uniqueRecordPath(dir, reviewRecordName(target));
16415
+ }
16416
+ async function localThreadArtifactPath(cwd, sessionSlug) {
16417
+ const dir = await reviewsDir(cwd);
16418
+ return join14(dir, `${reviewSlug(sessionSlug) || "local-review"}.md`);
16419
+ }
16420
+ async function readThreadArtifact(path) {
16421
+ try {
16422
+ return parseThreadArtifact(await readFile9(path, "utf8"));
16423
+ } catch (error) {
16424
+ if (error.code === "ENOENT") {
16425
+ throw new Error(`Expected local reply overlay is missing: ${path}`);
16426
+ }
16427
+ throw error;
16428
+ }
16429
+ }
16430
+ async function latestThreadArtifact(cwd, review, target) {
16431
+ try {
16432
+ return await findLatestThreadArtifact(cwd, review, target);
16433
+ } catch (error) {
16434
+ throw new Error(`Cannot locate the latest review thread artifact for ${target}.`, { cause: error });
16435
+ }
16436
+ }
16437
+ async function findLatestThreadArtifact(cwd, review, target) {
16438
+ const dir = await reviewsDir(cwd);
16439
+ const suffix = reviewSlug(target) || "uncommitted";
16440
+ const names = await listReviewArtifactNames(dir);
16441
+ const candidates = await Promise.all(names.flatMap((name) => {
16442
+ if (!name.endsWith(".md"))
16443
+ return [];
16444
+ return [
16445
+ (async () => {
16446
+ const path = join14(dir, name);
16447
+ const info = await stat(path);
16448
+ return { path, name, content: await readFile9(path, "utf8"), modified: info.mtimeMs };
16449
+ })()
16450
+ ];
16451
+ }));
16452
+ return candidates.filter(({ name, content }) => {
16453
+ if (!content.startsWith("<!-- diffpi-threads:"))
16454
+ return false;
16455
+ if (review.pr && target !== "local" && target !== "uncommitted")
16456
+ return content.includes(`- PR/MR: #${review.pr.number}`);
16457
+ return artifactNameMatches(name, suffix);
16458
+ }).sort((a, b) => b.modified - a.modified)[0]?.path;
16459
+ }
16460
+ async function listReviewArtifactNames(dir) {
16461
+ try {
16462
+ return await readdir2(dir);
16463
+ } catch (error) {
16464
+ throw new Error(`Cannot read review artifacts in ${dir}.`, { cause: error });
16465
+ }
16466
+ }
16467
+ function deriveTitle(branch) {
16468
+ return branch.replace(/^(feature|feat|fix|bug|chore)\//, "").replace(/^eng-\d+-/i, "").replace(/[-_]+/g, " ").replace(/^\w/, (char) => char.toUpperCase());
16469
+ }
16470
+ function uniqueRecordPath(dir, base) {
16471
+ let path = join14(dir, `${base}.md`);
16472
+ let count = 2;
16473
+ while (existsSync3(path))
16474
+ path = join14(dir, `${base}-${count++}.md`);
16475
+ return path;
16476
+ }
16477
+ function artifactNameMatches(name, suffix) {
16478
+ if (!name.endsWith(".md"))
16479
+ return false;
16480
+ const stem = name.slice(0, -3);
16481
+ const marker = `-${suffix}`;
16482
+ const markerIndex = stem.lastIndexOf(marker);
16483
+ if (markerIndex < 0)
16484
+ return false;
16485
+ const tail = stem.slice(markerIndex + marker.length);
16486
+ return tail === "" || /^-\d+$/.test(tail);
16487
+ }
16488
+
16489
+ // src/tools/setup.ts
16490
+ import { defineTool as defineTool4 } from "@earendil-works/pi-coding-agent";
16491
+ import { z as z8 } from "zod";
16492
+
16493
+ // src/setup.ts
16494
+ import { parseFrontmatter as parseFrontmatter3 } from "@earendil-works/pi-coding-agent";
16495
+ import { readdir as readdir3, readFile as readFile11 } from "node:fs/promises";
16496
+ import { homedir as homedir10 } from "node:os";
16497
+ import { basename as basename5, join as join17 } from "node:path";
16498
+
16499
+ // src/mcp.ts
16500
+ import { mkdir as mkdir5, readFile as readFile10, writeFile as writeFile6 } from "node:fs/promises";
16501
+ import { homedir as homedir8 } from "node:os";
16502
+ import { dirname as dirname6, join as join15 } from "node:path";
16503
+ var mcp = {
16504
+ globalConfigPath(homeDir = homedir8()) {
16505
+ return join15(homeDir, ".config", "mcp", "mcp.json");
16506
+ },
16507
+ async serversEnsure(servers, options = {}) {
16508
+ const path = options.path ?? mcp.globalConfigPath();
16509
+ const currentText = await getOptionalFile2(path);
16510
+ const current = getParsedConfig(currentText, path);
16511
+ const nextServers = { ...current.mcpServers };
16512
+ for (const [name, entry] of Object.entries(servers)) {
16513
+ nextServers[name] = mergeEntry(nextServers[name], entry);
16514
+ }
16515
+ const next = { ...current, mcpServers: nextServers };
16516
+ const changed = JSON.stringify(current) !== JSON.stringify(next);
16517
+ if (changed && !options.dryRun) {
16518
+ await mkdir5(dirname6(path), { recursive: true });
16519
+ await writeFile6(path, `${JSON.stringify(next, null, 2)}
16520
+ `, "utf8");
16521
+ }
16522
+ return { path, changed, existed: currentText !== undefined, planned: changed && options.dryRun === true };
16523
+ }
16524
+ };
16525
+ function mergeEntry(current, required) {
16526
+ const merged = { ...current, ...required };
16527
+ if (current?.env || required.env)
16528
+ merged.env = { ...current?.env, ...required.env };
16529
+ return merged;
16530
+ }
16531
+ function getParsedConfig(content, path) {
16532
+ if (!content?.trim())
16533
+ return { mcpServers: {} };
16534
+ try {
16535
+ const value = JSON.parse(content);
16536
+ if (!isRecord(value))
16537
+ throw new Error("not an object");
16538
+ const servers = value.mcpServers;
16539
+ if (servers !== undefined && !isRecord(servers))
16540
+ throw new Error("mcpServers is not an object");
16541
+ return { ...value, mcpServers: servers ?? {} };
16542
+ } catch {
16543
+ throw new Error(`Expected a valid pi-mcp-adapter configuration in ${path}.`);
16544
+ }
16545
+ }
16546
+ async function getOptionalFile2(path) {
16547
+ try {
16548
+ return await readFile10(path, "utf8");
16549
+ } catch (error) {
16550
+ if (error instanceof Error && "code" in error && error.code === "ENOENT")
16551
+ return;
16552
+ throw error;
16553
+ }
16554
+ }
16555
+ function isRecord(value) {
16556
+ return value !== null && typeof value === "object" && !Array.isArray(value);
16557
+ }
16558
+
16559
+ // src/pi.ts
16560
+ import { mkdir as mkdir6, writeFile as writeFile7 } from "node:fs/promises";
16561
+ import { homedir as homedir9 } from "node:os";
16562
+ import { dirname as dirname7, join as join16 } from "node:path";
16563
+ var pi = {
16564
+ executableCheck: findPiExecutable,
16565
+ packageList: listPiPackages,
16566
+ packageCheck: hasPiPackage,
16567
+ packageInstall: installPiPackage,
16568
+ agentDir: resolvePiAgentDir,
16569
+ agentEnsure: ensurePiAgent,
16570
+ skillCheckGlobal: checkGlobalPiSkill,
16571
+ skillInstallGlobal: installGlobalPiSkills,
16572
+ configEnsure: ensurePiConfig
16573
+ };
16574
+ async function ensurePiAgent(filename, content, agentDir = resolvePiAgentDir(), dryRun = false) {
16575
+ const path = join16(agentDir, "agents", filename);
16576
+ const currentText = await readTextIfExists(path);
16577
+ const changed = currentText !== content;
16578
+ if (changed && !dryRun) {
16579
+ await mkdir6(dirname7(path), { recursive: true });
16580
+ await writeFile7(path, content, "utf8");
16581
+ }
16582
+ return { path, changed, existed: currentText !== undefined, planned: changed && dryRun };
16583
+ }
16584
+ async function checkGlobalPiSkill(name, agentDir = resolvePiAgentDir(), sharedSkillsDir = join16(homedir9(), ".agents", "skills")) {
16585
+ const roots = [join16(agentDir, "skills"), sharedSkillsDir];
16586
+ for (const root of roots) {
16587
+ if (await readTextIfExists(join16(root, name, "SKILL.md")) !== undefined)
16588
+ return true;
16589
+ }
16590
+ return false;
16591
+ }
16592
+ async function installGlobalPiSkills(miseExecutable, source, names) {
16593
+ const selection = names.flatMap((name) => ["--skill", name]);
16594
+ await runChecked(miseExecutable, [
16595
+ "x",
16596
+ "node@22",
16597
+ "--",
16598
+ "npx",
16599
+ "-y",
16600
+ "skills",
16601
+ "add",
16602
+ source,
16603
+ ...selection,
16604
+ "--global",
16605
+ "--agent",
16606
+ "pi",
16607
+ "--yes"
16608
+ ]);
16609
+ }
16610
+ async function ensurePiConfig(path, update, dryRun = false) {
16611
+ const currentText = await readTextIfExists(path);
16612
+ const current = parseJsonObject(currentText, path);
16613
+ const next = update(current);
16614
+ const changed = JSON.stringify(current) !== JSON.stringify(next);
16615
+ if (changed && !dryRun) {
16616
+ await mkdir6(dirname7(path), { recursive: true });
16617
+ await writeFile7(path, `${JSON.stringify(next, null, 2)}
16618
+ `, "utf8");
16619
+ }
16620
+ return { path, changed, existed: currentText !== undefined, planned: changed && dryRun };
16621
+ }
16622
+ async function findPiExecutable() {
16623
+ return findExecutable("pi");
16624
+ }
16625
+ async function listPiPackages(executable) {
16626
+ return (await runChecked(executable, ["list"])).stdout;
14182
16627
  }
14183
16628
  function hasPiPackage(listOutput, source) {
14184
16629
  if (listOutput.includes(source))
@@ -14188,8 +16633,8 @@ function hasPiPackage(listOutput, source) {
14188
16633
  async function installPiPackage(executable, source) {
14189
16634
  await runChecked(executable, ["install", source]);
14190
16635
  }
14191
- function resolvePiAgentDir(homeDir = homedir6()) {
14192
- return process.env.PI_CODING_AGENT_DIR ?? (process.env.XDG_CONFIG_HOME ? join9(process.env.XDG_CONFIG_HOME, "pi") : join9(homeDir, ".pi", "agent"));
16636
+ function resolvePiAgentDir(homeDir = homedir9()) {
16637
+ return process.env.PI_CODING_AGENT_DIR ?? (process.env.XDG_CONFIG_HOME ? join16(process.env.XDG_CONFIG_HOME, "pi") : join16(homeDir, ".pi", "agent"));
14193
16638
  }
14194
16639
  function parseJsonObject(content, path) {
14195
16640
  if (!content?.trim())
@@ -14230,13 +16675,18 @@ var PI_PACKAGES = [
14230
16675
  ];
14231
16676
  var PI_SKILL_SOURCES = [
14232
16677
  { repository: "arabold/docs-mcp-server", skills: ["docs-manage", "docs-search", "fetch-url"] },
14233
- { repository: "AminBlg/SimpleEnglish", skills: ["simple-english"] }
16678
+ { repository: "AminBlg/SimpleEnglish", skills: ["simple-english"] },
16679
+ { repository: "cloudvoyant/codevoyant", skills: ["git"] }
14234
16680
  ];
14235
16681
  var MCP_ADAPTER_PACKAGE = "npm:pi-mcp-adapter";
14236
16682
  var BUNDLED_AGENTS_DIR2 = resolveBundledAgentsDir();
16683
+ var FORGE_DEPENDENCIES = {
16684
+ github: { name: "gh", tool: "gh", spec: "gh@latest", minimumVersion: undefined },
16685
+ gitlab: { name: "glab", tool: "glab", spec: "glab@latest", minimumVersion: undefined }
16686
+ };
14237
16687
  async function ensureMise(options = {}) {
14238
- const homeDir = options.homeDir ?? homedir7();
14239
- const current = await mise.executableCheck() ?? await mise.executableCheck(join10(homeDir, ".local", "bin", "mise"));
16688
+ const homeDir = options.homeDir ?? homedir10();
16689
+ const current = await mise.executableCheck() ?? await mise.executableCheck(join17(homeDir, ".local", "bin", "mise"));
14240
16690
  if (current)
14241
16691
  return { executable: current, action: createSetupAction("mise", "ready", current) };
14242
16692
  reportProgress(options, "Installing mise");
@@ -14265,7 +16715,11 @@ async function ensureMiseHooks(miseExecutable, options = {}) {
14265
16715
  async function ensureMiseDeps(miseExecutable, options = {}) {
14266
16716
  const canRunMise = Boolean(await mise.executableCheck(miseExecutable));
14267
16717
  const actions = [];
14268
- for (const dependency of MISE_DEPENDENCIES) {
16718
+ const dependencies = [
16719
+ ...MISE_DEPENDENCIES,
16720
+ ...configuredForges(options).map((forge) => FORGE_DEPENDENCIES[forge])
16721
+ ];
16722
+ for (const dependency of dependencies) {
14269
16723
  const installed = canRunMise && await mise.toolCheckGlobal(miseExecutable, dependency.tool, dependency.minimumVersion);
14270
16724
  if (installed) {
14271
16725
  actions.push(createSetupAction(dependency.name, "ready", dependency.spec));
@@ -14281,9 +16735,9 @@ async function ensureMiseDeps(miseExecutable, options = {}) {
14281
16735
  async function ensurePiPlugins(options = {}) {
14282
16736
  const actions = await ensurePiPackages(PI_PACKAGES, options);
14283
16737
  const agentDir = options.agentDir ?? pi.agentDir(options.homeDir);
14284
- const webSearch = await pi.configEnsure(join10(agentDir, "web-search.json"), (config) => ({ ...config, workflow: "auto-summary" }), options.dryRun);
16738
+ const webSearch = await pi.configEnsure(join17(agentDir, "web-search.json"), (config) => ({ ...config, workflow: "auto-summary" }), options.dryRun);
14285
16739
  actions.push(getConfigSetupAction("web search settings", webSearch));
14286
- const lsp = await pi.configEnsure(join10(agentDir, "pi-lsp.json"), (config) => ({
16740
+ const lsp = await pi.configEnsure(join17(agentDir, "pi-lsp.json"), (config) => ({
14287
16741
  ...config,
14288
16742
  progressive: { ...getRecord(config.progressive), enabled: true, inject: "none" }
14289
16743
  }), options.dryRun);
@@ -14294,11 +16748,11 @@ async function ensurePiAgents(options = {}) {
14294
16748
  const agentDir = options.agentDir ?? pi.agentDir(options.homeDir);
14295
16749
  const bundledAgentsDir = options.bundledAgentsDir ?? BUNDLED_AGENTS_DIR2;
14296
16750
  const userConfig = await loadDiffpiConfig({ homeDir: options.homeDir });
14297
- const entries = (await readdir2(bundledAgentsDir, { withFileTypes: true })).filter((entry) => entry.isFile() && entry.name.startsWith("diffpi-") && entry.name.endsWith(".md")).sort((left, right) => left.name.localeCompare(right.name));
16751
+ const entries = (await readdir3(bundledAgentsDir, { withFileTypes: true })).filter((entry) => entry.isFile() && entry.name.startsWith("diffpi-") && entry.name.endsWith(".md")).sort((left, right) => left.name.localeCompare(right.name));
14298
16752
  const actions = [];
14299
16753
  for (const entry of entries) {
14300
- const id = basename3(entry.name, ".md").replace(/^diffpi-/, "");
14301
- const source = await readFile5(join10(bundledAgentsDir, entry.name), "utf8");
16754
+ const id = basename5(entry.name, ".md").replace(/^diffpi-/, "");
16755
+ const source = await readFile11(join17(bundledAgentsDir, entry.name), "utf8");
14302
16756
  const content = materializeAgentModels(source, id, userConfig.config, options.availableModels);
14303
16757
  const result = await pi.agentEnsure(entry.name, content, agentDir, options.dryRun);
14304
16758
  actions.push(getConfigSetupAction(`pi agent ${id}`, result));
@@ -14307,7 +16761,7 @@ async function ensurePiAgents(options = {}) {
14307
16761
  }
14308
16762
  async function ensurePiSkills(miseExecutable, options = {}) {
14309
16763
  const agentDir = options.agentDir ?? pi.agentDir(options.homeDir);
14310
- const sharedSkillsDir = join10(options.homeDir ?? homedir7(), ".agents", "skills");
16764
+ const sharedSkillsDir = join17(options.homeDir ?? homedir10(), ".agents", "skills");
14311
16765
  const actions = [];
14312
16766
  for (const source of PI_SKILL_SOURCES) {
14313
16767
  const missing = [];
@@ -14351,6 +16805,14 @@ async function ensureMcpAdapters(miseExecutable, options = {}) {
14351
16805
  } else if (options.issueTracker === "jira") {
14352
16806
  servers.atlassian = { url: "https://mcp.atlassian.com/v1/mcp", auth: "oauth", protocolVersion: "auto" };
14353
16807
  }
16808
+ for (const forge of configuredForges(options)) {
16809
+ if (forge === "github") {
16810
+ servers.github = { url: "https://api.githubcopilot.com/mcp/", auth: "oauth", protocolVersion: "auto" };
16811
+ continue;
16812
+ }
16813
+ const host = await gitlabMcpHost(projectDir);
16814
+ servers.gitlab = { url: `https://${host}/api/v4/mcp`, auth: "oauth", protocolVersion: "auto" };
16815
+ }
14354
16816
  const result = await mcp.serversEnsure(servers, {
14355
16817
  dryRun: options.dryRun,
14356
16818
  path: mcp.globalConfigPath(options.homeDir)
@@ -14367,6 +16829,8 @@ async function setupPi(options = {}) {
14367
16829
  actions.push(...await ensurePiAgents(options));
14368
16830
  actions.push(...await ensurePiSkills(miseResult.executable, options));
14369
16831
  actions.push(...await ensureMcpAdapters(miseResult.executable, options));
16832
+ if (options.bindZedKey)
16833
+ actions.push(...await ensureZedIntegration(options));
14370
16834
  return {
14371
16835
  actions,
14372
16836
  restartPi: setupRequiresRestart(actions)
@@ -14375,6 +16839,40 @@ async function setupPi(options = {}) {
14375
16839
  function setupRequiresRestart(actions) {
14376
16840
  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"));
14377
16841
  }
16842
+ async function ensureZedIntegration(options = {}) {
16843
+ if (options.dryRun) {
16844
+ const actions = [
16845
+ createSetupAction("Zed review tasks", "planned", "global static runtime-resolver tasks in tasks.json")
16846
+ ];
16847
+ if (options.bindZedKey)
16848
+ actions.push(createSetupAction("Zed review keybinding", "planned", "keymap.json"));
16849
+ return actions;
16850
+ }
16851
+ const actions = [];
16852
+ try {
16853
+ const tasks = await ensureZedReviewTask(options.homeDir);
16854
+ actions.push(createSetupAction("Zed review tasks", tasks.changed ? "installed" : "ready", tasks.path));
16855
+ } catch (error) {
16856
+ actions.push(createSetupAction("Zed review tasks", "skipped", error instanceof Error ? error.message : String(error)));
16857
+ }
16858
+ if (options.bindZedKey) {
16859
+ try {
16860
+ const key = await ensureZedReviewKeybinding(options.homeDir);
16861
+ actions.push(createSetupAction("Zed review keybinding", key.changed ? "installed" : "ready", key.path));
16862
+ } catch (error) {
16863
+ actions.push(createSetupAction("Zed review keybinding", "skipped", error instanceof Error ? error.message : String(error)));
16864
+ }
16865
+ }
16866
+ return actions;
16867
+ }
16868
+ async function gitlabMcpHost(projectDir) {
16869
+ try {
16870
+ const vcs = await detectVcs(projectDir);
16871
+ return vcs.provider === "gitlab" && vcs.host ? vcs.host : "gitlab.com";
16872
+ } catch {
16873
+ return "gitlab.com";
16874
+ }
16875
+ }
14378
16876
  function materializeAgentModels(content, agentId, config, availableModels) {
14379
16877
  const { frontmatter } = parseFrontmatter3(content.startsWith("\uFEFF") ? content.slice(1) : content);
14380
16878
  const profilePreferences = [...getTextList(frontmatter.model), ...getTextList(frontmatter.model_fallbacks)];
@@ -14434,6 +16932,10 @@ ${source}`;
14434
16932
  }
14435
16933
  return actions;
14436
16934
  }
16935
+ function configuredForges(options) {
16936
+ const selected = options.forges ?? (options.forge && options.forge !== "none" ? [options.forge] : []);
16937
+ return [...new Set(selected)];
16938
+ }
14437
16939
  function getTextList(value) {
14438
16940
  const values = Array.isArray(value) ? value : typeof value === "string" ? value.split(",") : [];
14439
16941
  return values.filter((item) => typeof item === "string").map((item) => item.trim()).filter(Boolean);
@@ -14456,11 +16958,14 @@ function getRecord(value) {
14456
16958
  }
14457
16959
 
14458
16960
  // src/tools/setup.ts
14459
- var setupParametersSchema = z4.object({
14460
- issueTracker: z4.enum(["none", "linear", "jira"]).default("none").describe("Issue tracker MCP server to configure. Use none unless the user explicitly selects Linear or Jira.")
14461
- });
14462
- var setupParameters = z4.toJSONSchema(setupParametersSchema, { io: "input" });
14463
- var diffpiSetupTool = defineTool3({
16961
+ var setupParametersSchema = z8.object({
16962
+ issueTracker: z8.enum(["none", "linear", "jira"]).default("none").describe("Issue tracker MCP server to configure. Use none unless the user explicitly selects Linear or Jira."),
16963
+ forges: z8.array(z8.enum(["github", "gitlab"])).default([]).describe("Hosted VCS MCP servers to configure. Installs matching CLIs and registers each MCP server."),
16964
+ forge: z8.enum(["none", "github", "gitlab"]).optional().describe("Deprecated single-host alias. Prefer forges for one or more VCS MCP servers."),
16965
+ bindZedKey: z8.boolean().default(false).describe("Opt in to a Zed keybinding for the tuicr review task.")
16966
+ });
16967
+ var setupParameters = z8.toJSONSchema(setupParametersSchema, { io: "input" });
16968
+ var diffpiSetupTool = defineTool4({
14464
16969
  name: "diffpi_setup",
14465
16970
  label: "diffpi setup",
14466
16971
  description: "Install or repair the @difflab/pi environment. This mutates user-level tool installations and configuration files.",
@@ -14477,6 +16982,9 @@ var diffpiSetupTool = defineTool3({
14477
16982
  const params = setupParametersSchema.parse(input);
14478
16983
  const result = await setupPi({
14479
16984
  issueTracker: params.issueTracker,
16985
+ forges: params.forges,
16986
+ forge: params.forge,
16987
+ bindZedKey: params.bindZedKey,
14480
16988
  installMiseHook: true,
14481
16989
  availableModels: ctx.modelRegistry.getAvailable(),
14482
16990
  onProgress(message) {
@@ -14486,7 +16994,7 @@ var diffpiSetupTool = defineTool3({
14486
16994
  return formatResult(result, "Setup complete.");
14487
16995
  }
14488
16996
  });
14489
- var diffpiValidateTool = defineTool3({
16997
+ var diffpiValidateTool = defineTool4({
14490
16998
  name: "diffpi_validate",
14491
16999
  label: "diffpi validate",
14492
17000
  description: "Inspect the @difflab/pi environment without installing software or changing configuration files.",
@@ -14502,6 +17010,9 @@ var diffpiValidateTool = defineTool3({
14502
17010
  const params = setupParametersSchema.parse(input);
14503
17011
  const result = await setupPi({
14504
17012
  issueTracker: params.issueTracker,
17013
+ forges: params.forges,
17014
+ forge: params.forge,
17015
+ bindZedKey: params.bindZedKey,
14505
17016
  installMiseHook: true,
14506
17017
  dryRun: true,
14507
17018
  availableModels: ctx.modelRegistry.getAvailable()
@@ -14524,13 +17035,46 @@ ${lines.join(`
14524
17035
  };
14525
17036
  }
14526
17037
 
17038
+ // src/tools/templates.ts
17039
+ import { defineTool as defineTool5 } from "@earendil-works/pi-coding-agent";
17040
+ import { z as z9 } from "zod";
17041
+ var templateSchema = z9.object({
17042
+ name: z9.string().min(1),
17043
+ variables: z9.record(z9.string(), z9.string()).optional(),
17044
+ homeDir: z9.string().optional()
17045
+ });
17046
+ var diffpiTemplateTool = defineTool5({
17047
+ name: "diffpi_template",
17048
+ label: "diffpi template",
17049
+ description: "Load a bundled Diffpi template or a user override and render named variables.",
17050
+ promptSnippet: "Use diffpi_template for package workflow templates",
17051
+ promptGuidelines: ["User overrides live under ~/.difflab/diffpi/templates."],
17052
+ parameters: z9.toJSONSchema(templateSchema, { io: "input" }),
17053
+ executionMode: "parallel",
17054
+ async execute(_id, input) {
17055
+ const params = templateSchema.parse(input);
17056
+ const template = await loadTemplate(params.name, { homeDir: params.homeDir });
17057
+ const content = renderTemplate(template.content, params.variables ?? {});
17058
+ return {
17059
+ content: [{ type: "text", text: content }],
17060
+ details: { name: params.name, path: template.path, source: template.source }
17061
+ };
17062
+ }
17063
+ });
17064
+
14527
17065
  // src/tools/index.ts
14528
17066
  function createPiTools(pi, modes) {
14529
- return [diffpiSetupTool, diffpiValidateTool, createDiffpiReloadTool(pi), ...createModeTools(modes)];
17067
+ return [
17068
+ diffpiSetupTool,
17069
+ diffpiValidateTool,
17070
+ createDiffpiReloadTool(pi),
17071
+ diffpiTemplateTool,
17072
+ ...createModeTools(modes),
17073
+ ...createReviewTools()
17074
+ ];
14530
17075
  }
14531
17076
 
14532
17077
  // extensions/index.ts
14533
- var RELOAD_COMMAND = "diffpi-reload";
14534
17078
  var SKILL_ROUTING_GUIDANCE = `## Skill and tool routing
14535
17079
  Use the docs-search skill before web search for library or API documentation.
14536
17080
  Use docs-manage when the required documentation is absent or stale.
@@ -14538,18 +17082,17 @@ Use fetch-url for a one-time read that does not belong in the documentation inde
14538
17082
  Use the context-mode skill for commands, tests, builds, logs, API responses, and other output that can be large.
14539
17083
  Use ctx_execute or ctx_execute_file to analyze that output, and use ctx_fetch_and_index with ctx_search for external documentation.`;
14540
17084
  function difflabPiExtension(pi) {
14541
- rpiv_ask_user_question_default(pi);
14542
17085
  const modes = createModeController(pi);
14543
- pi.registerCommand(RELOAD_COMMAND, {
14544
- description: "Reload extensions, skills, prompts, themes, and context files",
14545
- handler: async (_args, ctx) => {
14546
- await ctx.reload();
14547
- }
14548
- });
17086
+ registerCommands(pi, modes);
14549
17087
  for (const tool of createPiTools(pi, modes))
14550
17088
  pi.registerTool(tool);
14551
- pi.on("session_start", async (_event, ctx) => modes.restore(ctx));
17089
+ pi.on("session_start", async (_event, ctx) => {
17090
+ if (!pi.getAllTools().some((tool) => tool.name === "ask_user_question"))
17091
+ rpiv_ask_user_question_default(pi);
17092
+ await modes.restore(ctx);
17093
+ });
14552
17094
  pi.on("session_tree", async (_event, ctx) => modes.restore(ctx));
17095
+ pi.on("model_select", (_event, ctx) => modes.refresh(ctx));
14553
17096
  pi.on("before_agent_start", (event) => {
14554
17097
  const defaultPrompt = `${event.systemPrompt}
14555
17098