@difflab/pi 0.5.0 → 0.6.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 (53) hide show
  1. package/README.md +5 -5
  2. package/agents/diffpi-copilot.md +6 -3
  3. package/agents/diffpi-reviewer.md +3 -3
  4. package/dist/commands/review.d.ts +0 -1
  5. package/dist/commands/review.d.ts.map +1 -1
  6. package/dist/extensions/index.js +344 -810
  7. package/dist/extensions/tuicrx.d.ts +1 -8
  8. package/dist/extensions/tuicrx.d.ts.map +1 -1
  9. package/dist/index.d.ts +7 -8
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +197 -458
  12. package/dist/review/github-review-backend.d.ts.map +1 -1
  13. package/dist/review/gitlab-review-backend.d.ts.map +1 -1
  14. package/dist/review/index.d.ts +6 -6
  15. package/dist/review/index.d.ts.map +1 -1
  16. package/dist/review/local-reviews.d.ts +31 -0
  17. package/dist/review/local-reviews.d.ts.map +1 -0
  18. package/dist/review/review-backend.d.ts +2 -3
  19. package/dist/review/review-backend.d.ts.map +1 -1
  20. package/dist/review/review-markdown.d.ts +1 -4
  21. package/dist/review/review-markdown.d.ts.map +1 -1
  22. package/dist/review/types.d.ts +0 -28
  23. package/dist/review/types.d.ts.map +1 -1
  24. package/dist/store.d.ts +0 -2
  25. package/dist/store.d.ts.map +1 -1
  26. package/dist/tools/index.js +185 -783
  27. package/dist/tools/review.d.ts +0 -30
  28. package/dist/tools/review.d.ts.map +1 -1
  29. package/dist/workflow.d.ts +47 -0
  30. package/dist/workflow.d.ts.map +1 -1
  31. package/package.json +1 -1
  32. package/skills/review/SKILL.md +6 -8
  33. package/workflows/review/address.md +8 -0
  34. package/workflows/review/auto.md +8 -0
  35. package/workflows/review/complete.md +7 -0
  36. package/workflows/review/edit.md +7 -0
  37. package/workflows/review/help.md +14 -0
  38. package/workflows/review/merge.md +6 -0
  39. package/workflows/review/new.md +7 -0
  40. package/workflows/review/publish.md +6 -0
  41. package/dist/review/local-review-backend.d.ts +0 -3
  42. package/dist/review/local-review-backend.d.ts.map +0 -1
  43. package/dist/review/review-state.d.ts +0 -20
  44. package/dist/review/review-state.d.ts.map +0 -1
  45. package/skills/review/references/workflows/address.md +0 -11
  46. package/skills/review/references/workflows/auto.md +0 -18
  47. package/skills/review/references/workflows/complete.md +0 -7
  48. package/skills/review/references/workflows/edit.md +0 -7
  49. package/skills/review/references/workflows/help.md +0 -30
  50. package/skills/review/references/workflows/merge.md +0 -6
  51. package/skills/review/references/workflows/new.md +0 -19
  52. package/skills/review/references/workflows/open.md +0 -3
  53. package/skills/review/references/workflows/publish.md +0 -8
@@ -16642,8 +16642,14 @@ async function rpc(events, method, payload, timeout = 5000) {
16642
16642
  // src/workflow.ts
16643
16643
  import { readFile as readFile3 } from "node:fs/promises";
16644
16644
  import { join as join6 } from "node:path";
16645
- async function loadPlanWorkflow(name, args) {
16646
- const workflowDir = join6(resolveBundledWorkflowsDir(), "plan");
16645
+ function loadPlanWorkflow(name, args) {
16646
+ return loadWorkflow("plan", name, args);
16647
+ }
16648
+ function loadReviewWorkflow(name, args) {
16649
+ return loadWorkflow("review", name, args);
16650
+ }
16651
+ async function loadWorkflow(scope, name, args) {
16652
+ const workflowDir = join6(resolveBundledWorkflowsDir(), scope);
16647
16653
  const content = await readFile3(join6(workflowDir, `${name}.md`), "utf8");
16648
16654
  return [`Workflow directory: ${workflowDir}`, content.trim(), `Arguments:
16649
16655
  ${JSON.stringify(args, null, 2)}`].join(`
@@ -16854,46 +16860,172 @@ function registerReloadCommand(pi) {
16854
16860
  }
16855
16861
 
16856
16862
  // src/commands/review.ts
16857
- var REVIEWER_VERBS = new Set(["address", "auto", "launch"]);
16858
16863
  function registerReviewCommand(pi, modes) {
16859
16864
  pi.registerCommand("review", {
16860
- description: "Code review: auto, new, edit, address, publish, complete, merge (add --local or --bg)",
16861
- handler: async (args, ctx) => handleReviewCommand(args, ctx, pi, modes)
16865
+ description: "Code review: auto, new, edit, address, publish, complete, merge, help",
16866
+ handler: async (args, ctx) => runReviewCommand(args, { pi, ctx, modes })
16862
16867
  });
16863
16868
  }
16864
- async function handleReviewCommand(args, ctx, pi, modes) {
16865
- const background = hasFlag2(args, "--bg");
16866
- const invocation = withoutFlag(args, "--bg") || "help";
16867
- const verb = reviewVerb(invocation);
16868
- if (background) {
16869
- await launchBackgroundAgent(pi.events, {
16870
- name: `Review ${verb}`,
16871
- agent: "orchestrator",
16872
- cwd: ctx.cwd,
16873
- prompt: reviewPrompt(invocation, "orchestrator")
16874
- });
16875
- return;
16869
+ async function runReviewCommand(raw, runtime) {
16870
+ try {
16871
+ const reviewCommand = createReviewCommand(runtime);
16872
+ const parsed = await dryRun(reviewCommand, tokenizeCommandArgs(raw));
16873
+ if (parsed._tag === "error")
16874
+ throw new Error(parsed.error);
16875
+ } catch (error) {
16876
+ const help = await generateCommandHelp(createReviewCommand(runtime));
16877
+ runtime.ctx.ui.notify(`${error instanceof Error ? error.message : String(error)}
16878
+ ${help}`, "error");
16876
16879
  }
16877
- const agent = REVIEWER_VERBS.has(verb) ? "reviewer" : "orchestrator";
16878
- const activation = await modes.set(agent, ctx);
16879
- pi.sendMessage({
16880
- customType: "diffpi-review-command",
16881
- display: false,
16882
- content: `${reviewPrompt(invocation, agent)} ${activation.message}`
16883
- }, { triggerTurn: true });
16884
- }
16885
- function reviewVerb(invocation) {
16886
- return invocation.split(/\s+/).find((token) => token && !token.startsWith("-")) ?? "help";
16887
16880
  }
16888
- function reviewPrompt(invocation, agent) {
16889
- 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.";
16890
- 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.`;
16881
+ function createReviewCommand(runtime) {
16882
+ const invokeWorkflow = createReviewWorkflowInvoker(runtime);
16883
+ const auto = command({
16884
+ name: "auto",
16885
+ args: { target: targetArgument(), local: flag({ long: "local" }), background: flag({ long: "bg" }) },
16886
+ handler: async ({ target, local, background }) => invokeWorkflow({
16887
+ workflow: "auto",
16888
+ arguments: { target, local, background },
16889
+ agent: "reviewer",
16890
+ isBackground: background
16891
+ })
16892
+ });
16893
+ const create = command({
16894
+ name: "new",
16895
+ args: {
16896
+ intent: option({ long: "intent", type: optional(string5), defaultValue: () => {
16897
+ return;
16898
+ } }),
16899
+ base: option({ long: "base", type: optional(string5), defaultValue: () => {
16900
+ return;
16901
+ } }),
16902
+ local: flag({ long: "local" }),
16903
+ background: flag({ long: "bg" }),
16904
+ title: restPositionals({ type: string5 })
16905
+ },
16906
+ handler: async ({ title, intent, base, local, background }) => invokeWorkflow({
16907
+ workflow: "new",
16908
+ arguments: { title: title.length ? title.join(" ") : undefined, intent, base, local, background },
16909
+ agent: "orchestrator",
16910
+ isBackground: background
16911
+ })
16912
+ });
16913
+ const edit = command({
16914
+ name: "edit",
16915
+ args: { target: targetArgument(), local: flag({ long: "local" }), background: flag({ long: "bg" }) },
16916
+ handler: async ({ target, local, background }) => invokeWorkflow({
16917
+ workflow: "edit",
16918
+ arguments: { target, local, background },
16919
+ agent: "orchestrator",
16920
+ isBackground: background
16921
+ })
16922
+ });
16923
+ const address = command({
16924
+ name: "address",
16925
+ args: { target: targetArgument(), local: flag({ long: "local" }), background: flag({ long: "bg" }) },
16926
+ handler: async ({ target, local, background }) => invokeWorkflow({
16927
+ workflow: "address",
16928
+ arguments: { target, local, background },
16929
+ agent: "reviewer",
16930
+ isBackground: background
16931
+ })
16932
+ });
16933
+ const publish = command({
16934
+ name: "publish",
16935
+ args: {
16936
+ target: targetArgument(),
16937
+ comment: flag({ long: "comment" }),
16938
+ approve: flag({ long: "approve" }),
16939
+ requestChanges: flag({ long: "request-changes" }),
16940
+ close: flag({ long: "close" }),
16941
+ background: flag({ long: "bg" })
16942
+ },
16943
+ handler: async ({ target, comment, approve, requestChanges, close, background }) => invokeWorkflow({
16944
+ workflow: "publish",
16945
+ arguments: { target, comment, approve, requestChanges, close, background },
16946
+ agent: "orchestrator",
16947
+ isBackground: background
16948
+ })
16949
+ });
16950
+ const complete = command({
16951
+ name: "complete",
16952
+ args: {
16953
+ target: targetArgument(),
16954
+ local: flag({ long: "local" }),
16955
+ approve: flag({ long: "approve" }),
16956
+ reject: flag({ long: "reject" }),
16957
+ abandon: flag({ long: "abandon" }),
16958
+ background: flag({ long: "bg" })
16959
+ },
16960
+ handler: async ({ target, local, approve, reject, abandon, background }) => invokeWorkflow({
16961
+ workflow: "complete",
16962
+ arguments: { target, local, approve, reject, abandon, background },
16963
+ agent: "orchestrator",
16964
+ isBackground: background
16965
+ })
16966
+ });
16967
+ const merge = command({
16968
+ name: "merge",
16969
+ args: { target: targetArgument(), background: flag({ long: "bg" }) },
16970
+ handler: async ({ target, background }) => invokeWorkflow({
16971
+ workflow: "merge",
16972
+ arguments: { target, background },
16973
+ agent: "orchestrator",
16974
+ isBackground: background
16975
+ })
16976
+ });
16977
+ const help = command({
16978
+ name: "help",
16979
+ args: {},
16980
+ handler: async () => invokeWorkflow({ workflow: "help", arguments: {}, agent: "orchestrator" })
16981
+ });
16982
+ return subcommands({
16983
+ name: "review",
16984
+ cmds: {
16985
+ auto,
16986
+ new: create,
16987
+ edit,
16988
+ address,
16989
+ publish,
16990
+ complete,
16991
+ merge,
16992
+ help,
16993
+ open: create,
16994
+ create,
16995
+ draft: create,
16996
+ launch: auto,
16997
+ ready: publish,
16998
+ close: complete,
16999
+ land: merge
17000
+ }
17001
+ });
16891
17002
  }
16892
- function hasFlag2(input, flag) {
16893
- return input.split(/\s+/).includes(flag);
17003
+ function createReviewWorkflowInvoker(runtime) {
17004
+ return async function invokeWorkflow(invocation) {
17005
+ const { workflow, arguments: args, agent, isBackground = false } = invocation;
17006
+ const prompt = await loadReviewWorkflow(workflow, args);
17007
+ if (isBackground) {
17008
+ await launchBackgroundAgent(runtime.pi.events, {
17009
+ name: `Review ${workflow}`,
17010
+ agent: "orchestrator",
17011
+ cwd: runtime.ctx.cwd,
17012
+ inheritContext: true,
17013
+ prompt
17014
+ });
17015
+ return;
17016
+ }
17017
+ const activation = await runtime.modes.set(agent, runtime.ctx);
17018
+ runtime.pi.sendMessage({
17019
+ customType: `diffpi-review-${workflow}-command`,
17020
+ display: false,
17021
+ content: `${prompt}
17022
+
17023
+ ${activation.message}`
17024
+ }, { triggerTurn: true });
17025
+ };
16894
17026
  }
16895
- function withoutFlag(input, flag) {
16896
- return input.split(/\s+/).filter((token) => token && token !== flag).join(" ").trim();
17027
+ function targetArgument() {
17028
+ return positional({ type: optional(string5), displayName: "target" });
16897
17029
  }
16898
17030
 
16899
17031
  // src/commands/index.ts
@@ -17774,18 +17906,6 @@ async function reviewsDir(cwd, homeDir = homedir5()) {
17774
17906
  await mkdir2(dir, { recursive: true });
17775
17907
  return dir;
17776
17908
  }
17777
- async function completedReviewsDir(cwd, homeDir = homedir5()) {
17778
- const store = await ensureStore(cwd, homeDir);
17779
- const dir = join11(store.link, "reviews");
17780
- await mkdir2(dir, { recursive: true });
17781
- return dir;
17782
- }
17783
- async function sessionsDir(cwd, homeDir = homedir5()) {
17784
- const store = await ensureStore(cwd, homeDir);
17785
- const dir = join11(store.link, "sessions");
17786
- await mkdir2(dir, { recursive: true });
17787
- return dir;
17788
- }
17789
17909
  async function assertStoreLink(path, dest) {
17790
17910
  const entry = await lstat(path);
17791
17911
  if (!entry.isSymbolicLink())
@@ -24608,10 +24728,10 @@ function result2(text, details = {}) {
24608
24728
 
24609
24729
  // src/tools/review.ts
24610
24730
  import { existsSync as existsSync3 } from "node:fs";
24611
- import { mkdir as mkdir7, readdir as readdir3, readFile as readFile14, rename as rename3, stat, unlink as unlink2, writeFile as writeFile7 } from "node:fs/promises";
24731
+ import { mkdir as mkdir8, writeFile as writeFile6 } from "node:fs/promises";
24612
24732
  import { join as join21 } from "node:path";
24613
24733
  import { defineTool as defineTool6 } from "@earendil-works/pi-coding-agent";
24614
- import { z as z13 } from "zod";
24734
+ import { z as z11 } from "zod";
24615
24735
 
24616
24736
  // src/review/types.ts
24617
24737
  import { z as z10 } from "zod";
@@ -24636,21 +24756,6 @@ function yymmdd(date = new Date) {
24636
24756
  function reviewRecordName(target, date = new Date) {
24637
24757
  return `${yymmdd(date)}-${reviewSlug(target) || "uncommitted"}`;
24638
24758
  }
24639
- function localResponseMarker(threadId) {
24640
- return `<!-- diffpi -->`;
24641
- }
24642
- function isLocalResponse(body) {
24643
- return /^<!-- diffpi(?:-local-response:[A-Za-z0-9_-]+)? -->\n/m.test(body);
24644
- }
24645
- function parseReviewThreadAction(body) {
24646
- const match = body.match(/^\[(REOPEN|RESOLVE|DELETE)\]\s*/i);
24647
- if (!match)
24648
- return { body };
24649
- return {
24650
- action: match[1].toLowerCase(),
24651
- body: body.slice(match[0].length)
24652
- };
24653
- }
24654
24759
  function dedupeFindings(findings) {
24655
24760
  const rank = { BLOCKING: 3, CONSIDER: 2, NOTE: 1 };
24656
24761
  const byKey = new Map;
@@ -24696,18 +24801,6 @@ function renderCommentBody(finding) {
24696
24801
  return `${prefix}${finding.body}${reference}`;
24697
24802
  }
24698
24803
  // src/review/review-markdown.ts
24699
- import { z as z11 } from "zod";
24700
- var reviewThreadRecordSchema = z11.object({
24701
- id: z11.string().min(1),
24702
- file: z11.string().nullable().optional().transform((value) => value ?? undefined),
24703
- line: z11.number().int().positive().nullable().optional().transform((value) => value ?? undefined),
24704
- body: z11.string(),
24705
- author: z11.string().optional(),
24706
- resolved: z11.boolean(),
24707
- addressed: z11.boolean().optional(),
24708
- question: z11.boolean(),
24709
- replies: z11.array(z11.string()).optional()
24710
- });
24711
24804
  function renderReviewDoc(input) {
24712
24805
  const anchored = dedupeFindings(input.findings).filter((finding) => finding.line > 0);
24713
24806
  const lines = [`# Review: ${input.title}`, "", "## Metadata"];
@@ -24749,88 +24842,6 @@ function renderReviewDoc(input) {
24749
24842
  `).trimEnd()}
24750
24843
  `;
24751
24844
  }
24752
- function renderThreadArtifact(title, target, threads, options = {}) {
24753
- const records = threads.map((thread) => ({
24754
- id: thread.id,
24755
- file: thread.file,
24756
- line: thread.line,
24757
- body: thread.body,
24758
- author: thread.author,
24759
- resolved: thread.resolved,
24760
- addressed: thread.addressed,
24761
- question: thread.question,
24762
- replies: thread.replies
24763
- }));
24764
- const payload = Buffer.from(JSON.stringify(records), "utf8").toString("base64url");
24765
- const lines = [
24766
- `<!-- diffpi-threads:${payload} -->`,
24767
- `# Review threads: ${title}`,
24768
- "",
24769
- "## Metadata",
24770
- "",
24771
- `- Target: ${target}`,
24772
- `- Pulled: ${options.timestamp ?? new Date().toISOString()}`
24773
- ];
24774
- if (options.number !== undefined)
24775
- lines.push(`- PR/MR: #${options.number}${options.url ? ` — ${options.url}` : ""}`);
24776
- lines.push("", "## Replies", "");
24777
- for (const thread of threads) {
24778
- const id = Buffer.from(thread.id, "utf8").toString("base64url");
24779
- lines.push(`### ${thread.file ?? "review"}:${thread.line ?? "n/a"} (${thread.id})`, "", `<!-- diffpi-reply-start:${id} -->`, thread.reply ?? "", `<!-- diffpi-reply-end:${id} -->`, "");
24780
- }
24781
- lines.push("## Source comments", "");
24782
- for (const thread of threads) {
24783
- 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, "", "---", "");
24784
- }
24785
- return `${lines.join(`
24786
- `).trimEnd()}
24787
- `;
24788
- }
24789
- function parseThreadArtifact(content) {
24790
- const payload = content.match(/^<!-- diffpi-threads:([A-Za-z0-9_-]+) -->$/m)?.[1];
24791
- if (!payload)
24792
- throw new Error("This file is not a Diffpi thread artifact.");
24793
- let threads;
24794
- try {
24795
- const decoded = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
24796
- threads = z11.array(reviewThreadRecordSchema).parse(decoded);
24797
- } catch {
24798
- throw new Error("Cannot parse the Diffpi thread artifact payload.");
24799
- }
24800
- const replies = content.split(/^## Source comments$/m, 1)[0] ?? "";
24801
- return threads.map((thread) => {
24802
- const id = Buffer.from(thread.id, "utf8").toString("base64url");
24803
- const startMarker = `<!-- diffpi-reply-start:${id} -->
24804
- `;
24805
- const endMarker = `
24806
- <!-- diffpi-reply-end:${id} -->`;
24807
- const start = replies.indexOf(startMarker);
24808
- const end = start < 0 ? -1 : replies.indexOf(endMarker, start + startMarker.length);
24809
- const reply = start >= 0 && end >= 0 ? replies.slice(start + startMarker.length, end).trim() : "";
24810
- return reply ? { ...thread, reply } : thread;
24811
- });
24812
- }
24813
- function upsertThreadReply(content, threadId, body, question, resolved) {
24814
- const threads = parseThreadArtifact(content);
24815
- const thread = threads.find((candidate) => candidate.id === threadId);
24816
- if (!thread)
24817
- throw new Error(`Review thread ${threadId} was not found in the local artifact.`);
24818
- thread.reply = body;
24819
- thread.addressed = true;
24820
- if (question !== undefined)
24821
- thread.question = question;
24822
- if (resolved !== undefined)
24823
- thread.resolved = resolved;
24824
- const title = content.match(/^# Review threads: (.+)$/m)?.[1] ?? "review";
24825
- const target = content.match(/^- Target: (.+)$/m)?.[1] ?? "local";
24826
- const timestamp = content.match(/^- Pulled: (.+)$/m)?.[1];
24827
- const pr = content.match(/^- PR\/MR: #(\d+)(?: — (.+))?$/m);
24828
- return renderThreadArtifact(title, target, threads, {
24829
- timestamp,
24830
- number: pr ? Number.parseInt(pr[1], 10) : undefined,
24831
- url: pr?.[2]
24832
- });
24833
- }
24834
24845
  // src/review/github-review-backend.ts
24835
24846
  function GitHubReviewBackend(vcs, number) {
24836
24847
  const pendingReview = async () => {
@@ -24892,9 +24903,6 @@ function GitHubReviewBackend(vcs, number) {
24892
24903
  id: thread.id,
24893
24904
  file: thread.path ?? undefined,
24894
24905
  line: thread.line ?? undefined,
24895
- rootCommentId: comment?.id,
24896
- commentIds: nodes.flatMap((node) => [node.id, node.databaseId?.toString()].filter((id) => Boolean(id))),
24897
- commentNodeIds: nodes.flatMap((node) => node.id ? [node.id] : []),
24898
24906
  body,
24899
24907
  author: comment?.author?.login,
24900
24908
  resolved: thread.isResolved,
@@ -24904,7 +24912,6 @@ function GitHubReviewBackend(vcs, number) {
24904
24912
  });
24905
24913
  };
24906
24914
  return {
24907
- kind: "remote",
24908
24915
  async stage(draft) {
24909
24916
  const pending = await pendingReview();
24910
24917
  if (!pending) {
@@ -24962,33 +24969,6 @@ function GitHubReviewBackend(vcs, number) {
24962
24969
  await ghChecked(args);
24963
24970
  }
24964
24971
  },
24965
- async readDraft() {
24966
- const pending = await pendingReview();
24967
- if (!pending)
24968
- return { comments: [], body: "" };
24969
- const review = await ghChecked(["api", `/repos/${vcs.owner}/${vcs.repo}/pulls/${number}/reviews/${pending.id}`]);
24970
- const comments = await ghChecked([
24971
- "api",
24972
- `/repos/${vcs.owner}/${vcs.repo}/pulls/${number}/reviews/${pending.id}/comments`
24973
- ]);
24974
- let reviewData;
24975
- let commentData;
24976
- try {
24977
- reviewData = JSON.parse(review.stdout);
24978
- commentData = JSON.parse(comments.stdout);
24979
- } catch {
24980
- throw new Error("Cannot parse the pending GitHub review as JSON.");
24981
- }
24982
- return {
24983
- body: reviewData.body ?? "",
24984
- comments: commentData.map((comment) => ({
24985
- file: comment.path,
24986
- line: comment.line ?? comment.original_line ?? undefined,
24987
- side: comment.side ?? undefined,
24988
- body: comment.body
24989
- }))
24990
- };
24991
- },
24992
24972
  listThreads,
24993
24973
  async reply(input) {
24994
24974
  const threads = await listThreads();
@@ -25005,32 +24985,14 @@ function GitHubReviewBackend(vcs, number) {
25005
24985
  `body=${input.body}`
25006
24986
  ]);
25007
24987
  }
25008
- if (input.resolve)
25009
- await this.setResolved?.(input.threadId, true);
25010
- },
25011
- async setResolved(threadId, resolved) {
25012
- await ghChecked([
25013
- "api",
25014
- "graphql",
25015
- "-f",
25016
- `query=${resolved ? GITHUB_RESOLVE_MUTATION : GITHUB_UNRESOLVE_MUTATION}`,
25017
- "-f",
25018
- `threadId=${threadId}`
25019
- ]);
25020
- },
25021
- async deleteThread(threadId) {
25022
- const thread = (await listThreads()).find((candidate) => candidate.id === threadId);
25023
- const commentNodeIds = thread?.commentNodeIds ?? (thread?.rootCommentId ? [thread.rootCommentId] : []);
25024
- if (!commentNodeIds.length)
25025
- throw new Error(`Cannot delete GitHub thread ${threadId}: comment node IDs are missing.`);
25026
- for (const commentId of commentNodeIds) {
24988
+ if (input.resolve) {
25027
24989
  await ghChecked([
25028
24990
  "api",
25029
24991
  "graphql",
25030
24992
  "-f",
25031
- `query=${GITHUB_DELETE_COMMENT_MUTATION}`,
24993
+ `query=${GITHUB_RESOLVE_MUTATION}`,
25032
24994
  "-f",
25033
- `commentId=${commentId}`
24995
+ `threadId=${input.threadId}`
25034
24996
  ]);
25035
24997
  }
25036
24998
  },
@@ -25049,13 +25011,11 @@ function GitHubReviewBackend(vcs, number) {
25049
25011
  function githubReviewSubmissionEndpoint(owner, repo, id, pendingReviewId) {
25050
25012
  return pendingReviewId ? `/repos/${owner}/${repo}/pulls/${id}/reviews/${pendingReviewId}/events` : `/repos/${owner}/${repo}/pulls/${id}/reviews`;
25051
25013
  }
25052
- 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{id,databaseId,body,author{login}}}}pageInfo{hasNextPage,endCursor}}}}}`;
25014
+ 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}}}}}`;
25053
25015
  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}}}`;
25054
25016
  var GITHUB_ADD_FILE_THREAD_MUTATION = `mutation($reviewId:ID!,$body:String!,$path:String!){addPullRequestReviewThread(input:{pullRequestReviewId:$reviewId,body:$body,path:$path,subjectType:FILE}){thread{id}}}`;
25055
25017
  var GITHUB_REPLY_MUTATION = `mutation($threadId:ID!,$body:String!){addPullRequestReviewThreadReply(input:{pullRequestReviewThreadId:$threadId,body:$body}){comment{id}}}`;
25056
25018
  var GITHUB_RESOLVE_MUTATION = `mutation($threadId:ID!){resolveReviewThread(input:{threadId:$threadId}){thread{isResolved}}}`;
25057
- var GITHUB_UNRESOLVE_MUTATION = `mutation($threadId:ID!){unresolveReviewThread(input:{threadId:$threadId}){thread{isResolved}}}`;
25058
- var GITHUB_DELETE_COMMENT_MUTATION = `mutation($commentId:ID!){deletePullRequestReviewComment(input:{id:$commentId}){clientMutationId}}`;
25059
25019
 
25060
25020
  // src/review/gitlab-review-backend.ts
25061
25021
  function GitLabReviewBackend(vcs, number) {
@@ -25092,7 +25052,6 @@ function GitLabReviewBackend(vcs, number) {
25092
25052
  });
25093
25053
  };
25094
25054
  return {
25095
- kind: "remote",
25096
25055
  async stage(draft) {
25097
25056
  const endpoint = `${mergeRequestEndpoint()}/draft_notes`;
25098
25057
  if (draft.body.trim()) {
@@ -25121,36 +25080,6 @@ function GitLabReviewBackend(vcs, number) {
25121
25080
  });
25122
25081
  }
25123
25082
  },
25124
- async readDraft() {
25125
- const result = await glabChecked(["api", `${mergeRequestEndpoint()}/draft_notes`]);
25126
- let notes;
25127
- try {
25128
- notes = JSON.parse(result.stdout);
25129
- } catch {
25130
- throw new Error("Cannot parse GitLab draft notes as JSON.");
25131
- }
25132
- const comments = [];
25133
- const body = [];
25134
- for (const note of notes) {
25135
- if (!note.note)
25136
- continue;
25137
- const file = note.position?.new_path ?? note.position?.old_path;
25138
- const line = note.position?.new_line ?? note.position?.old_line;
25139
- if (file && line) {
25140
- comments.push({
25141
- file,
25142
- line,
25143
- side: note.position?.new_line ? "RIGHT" : "LEFT",
25144
- body: note.note
25145
- });
25146
- } else {
25147
- body.push(note.note);
25148
- }
25149
- }
25150
- return { comments, body: body.join(`
25151
-
25152
- `) };
25153
- },
25154
25083
  listThreads,
25155
25084
  async reply(input) {
25156
25085
  const endpoint = `${mergeRequestEndpoint()}/discussions/${encodeURIComponent(input.threadId)}`;
@@ -25162,15 +25091,7 @@ function GitLabReviewBackend(vcs, number) {
25162
25091
  });
25163
25092
  }
25164
25093
  if (input.resolve)
25165
- await this.setResolved?.(input.threadId, true);
25166
- },
25167
- async setResolved(threadId, resolved) {
25168
- const endpoint = `${mergeRequestEndpoint()}/discussions/${encodeURIComponent(threadId)}`;
25169
- await glabChecked(["api", "--method", "PUT", `${endpoint}?resolved=${resolved}`]);
25170
- },
25171
- async deleteThread(threadId) {
25172
- const endpoint = `${mergeRequestEndpoint()}/discussions/${encodeURIComponent(threadId)}`;
25173
- await glabChecked(["api", "--method", "DELETE", endpoint]);
25094
+ await glabChecked(["api", "--method", "PUT", `${endpoint}?resolved=true`]);
25174
25095
  },
25175
25096
  async publish(event) {
25176
25097
  assertReviewEventSupported(vcs.provider, event);
@@ -25210,8 +25131,17 @@ function hasGitlabDraftNotes(input) {
25210
25131
  }
25211
25132
  }
25212
25133
 
25213
- // src/review/local-review-backend.ts
25214
- import { readFile as readFile12, writeFile as writeFile5 } from "node:fs/promises";
25134
+ // src/review/review-backend.ts
25135
+ function createRemoteReviewBackend(vcs, number) {
25136
+ if (vcs.provider === "github")
25137
+ return GitHubReviewBackend(vcs, number);
25138
+ if (vcs.provider === "gitlab")
25139
+ return GitLabReviewBackend(vcs, number);
25140
+ throw new Error("A remote review backend requires a GitHub or GitLab repository.");
25141
+ }
25142
+ // src/review/local-reviews.ts
25143
+ import { mkdir as mkdir7, readdir as readdir3, readFile as readFile12, unlink as unlink2, writeFile as writeFile5 } from "node:fs/promises";
25144
+ import { join as join20 } from "node:path";
25215
25145
 
25216
25146
  // src/extensions/tuicrx.ts
25217
25147
  import { readFile as readFile11, realpath as realpath4 } from "node:fs/promises";
@@ -25282,11 +25212,6 @@ async function resolveReviewSession(cwd, target) {
25282
25212
  }
25283
25213
  return resolveSession(cwd, target.branch);
25284
25214
  }
25285
- async function resolvePublishSession(cwd, target) {
25286
- if (target.owner && target.repo && target.number !== undefined)
25287
- return resolvePrSession(cwd, target.owner, target.repo, target.number);
25288
- return resolveSession(cwd, target.branch);
25289
- }
25290
25215
  async function resolvePrSession(cwd, owner, repo, number) {
25291
25216
  const coordinate = `${owner}/${repo}`.toLowerCase();
25292
25217
  const sessions = await listSessions(cwd);
@@ -25321,8 +25246,6 @@ async function readSession(path) {
25321
25246
  function toLocalReviewThreads(session) {
25322
25247
  const threads = [];
25323
25248
  const add = (comment, location = {}) => {
25324
- if (isLocalResponse(comment.content))
25325
- return;
25326
25249
  const author = commentAuthor(comment);
25327
25250
  threads.push({
25328
25251
  id: comment.id ?? `local-${threads.length + 1}`,
@@ -25348,54 +25271,6 @@ function toLocalReviewThreads(session) {
25348
25271
  }
25349
25272
  return threads;
25350
25273
  }
25351
- function toFindings(session, options = {}) {
25352
- const comments = [];
25353
- const include = (comment) => (!options.agentOnly || commentAuthor(comment)?.startsWith("Agent: ")) && (!options.excludeLocalResponses || !isLocalResponse(comment.content));
25354
- const publishableBody = (comment) => {
25355
- const author = commentAuthor(comment);
25356
- const route = author?.match(/^Agent:\s*(.+)$/)?.[1];
25357
- return route ? withRemoteProvenance(comment.content, route) : comment.content;
25358
- };
25359
- const bodyParts = (session.review_comments ?? []).flatMap((comment) => include(comment) ? [publishableBody(comment)] : []);
25360
- for (const [file, entry] of Object.entries(session.files ?? {})) {
25361
- for (const fileComment of entry.file_comments ?? []) {
25362
- if (!include(fileComment))
25363
- continue;
25364
- const comment = {
25365
- file,
25366
- body: fileComment.content,
25367
- ...fileComment.id ? { sourceCommentId: String(fileComment.id) } : {}
25368
- };
25369
- const author = commentAuthor(fileComment);
25370
- if (author)
25371
- comment.author = author;
25372
- comments.push(comment);
25373
- }
25374
- for (const [lineKey, lineComments] of Object.entries(entry.line_comments ?? {})) {
25375
- const line = Number.parseInt(lineKey, 10);
25376
- if (!Number.isFinite(line))
25377
- continue;
25378
- for (const lineComment of lineComments) {
25379
- if (!include(lineComment))
25380
- continue;
25381
- const comment = {
25382
- file,
25383
- line,
25384
- side: lineComment.side === "old" ? "LEFT" : "RIGHT",
25385
- body: lineComment.content,
25386
- ...lineComment.id ? { sourceCommentId: String(lineComment.id) } : {}
25387
- };
25388
- const author = commentAuthor(lineComment);
25389
- if (author)
25390
- comment.author = author;
25391
- comments.push(comment);
25392
- }
25393
- }
25394
- }
25395
- return { comments, body: bodyParts.join(`
25396
-
25397
- `) };
25398
- }
25399
25274
  async function requireTuicr() {
25400
25275
  if (!await tuicrAvailable())
25401
25276
  throw new Error("tuicr is not installed or is not available on PATH.");
@@ -25411,167 +25286,83 @@ async function canonicalPath3(path) {
25411
25286
  }
25412
25287
  }
25413
25288
 
25414
- // src/review/local-review-backend.ts
25415
- function LocalReviewBackend(options) {
25416
- return {
25417
- kind: "local",
25418
- async stage(draft) {
25419
- for (const comment of draft.comments) {
25420
- await addComment(options.session, comment.body, {
25421
- targetFile: comment.file,
25422
- line: comment.line,
25423
- side: comment.side === "LEFT" ? "old" : "new",
25424
- username: options.author
25425
- });
25426
- }
25427
- if (draft.body.trim())
25428
- await addComment(options.session, draft.body, { username: options.author });
25429
- },
25430
- async readDraft() {
25431
- return toFindings(await readSession(options.session), { excludeLocalResponses: true });
25432
- },
25433
- async listThreads() {
25434
- try {
25435
- return parseThreadArtifact(await readFile12(options.artifactPath, "utf8"));
25436
- } catch (error) {
25437
- if (error.code === "ENOENT")
25438
- return [];
25439
- throw error;
25440
- }
25441
- },
25442
- async reply(input) {
25443
- const content = await readFile12(options.artifactPath, "utf8");
25444
- await writeFile5(options.artifactPath, upsertThreadReply(content, input.threadId, input.body, input.question, input.resolve), "utf8");
25445
- },
25446
- async publish() {
25447
- throw new Error("Promote a local draft through a remote review backend before publishing it.");
25448
- }
25289
+ // src/review/local-reviews.ts
25290
+ async function captureLocalReview(input) {
25291
+ const id = reviewSlug(input.branch) || "local-review";
25292
+ const dir = join20(await reviewsDir(input.cwd, input.homeDir), id);
25293
+ await mkdir7(dir, { recursive: true });
25294
+ const revision = await nextRevision(dir);
25295
+ const output = await readFile12(input.session.path, "utf8");
25296
+ const comments = toLocalReviewThreads(await readSession(input.session.path));
25297
+ const dump = {
25298
+ schemaVersion: 1,
25299
+ id,
25300
+ revision,
25301
+ branch: input.branch,
25302
+ base: input.base,
25303
+ createdAt: (input.now ?? (() => new Date))().toISOString(),
25304
+ diff: input.diff,
25305
+ comments,
25306
+ tuicr: { slug: input.session.slug, output }
25449
25307
  };
25308
+ const path = join20(dir, `${revision}.json`);
25309
+ await writeFile5(path, `${JSON.stringify(dump, null, 2)}
25310
+ `, { encoding: "utf8", flag: "wx" });
25311
+ await unlink2(input.session.path);
25312
+ return { path, dump };
25450
25313
  }
25451
-
25452
- // src/review/review-backend.ts
25453
- function createRemoteReviewBackend(vcs, number) {
25454
- if (vcs.provider === "github")
25455
- return GitHubReviewBackend(vcs, number);
25456
- if (vcs.provider === "gitlab")
25457
- return GitLabReviewBackend(vcs, number);
25458
- throw new Error("A remote review backend requires a GitHub or GitLab repository.");
25459
- }
25460
- function createLocalReviewBackend(options) {
25461
- return LocalReviewBackend(options);
25462
- }
25463
- // src/review/review-state.ts
25464
- import { createHash as createHash2 } from "node:crypto";
25465
- import { readFile as readFile13, rename as rename2, writeFile as writeFile6 } from "node:fs/promises";
25466
- import { join as join20 } from "node:path";
25467
- import { z as z12 } from "zod";
25468
- var reviewPublicationStateSchema = z12.object({
25469
- target: z12.string().optional(),
25470
- bodies: z12.array(z12.string()).default([]),
25471
- comments: z12.array(z12.string()).default([]),
25472
- stagedComments: z12.array(z12.string()).default([]),
25473
- replies: z12.array(z12.string()).default([]),
25474
- overlayPath: z12.string().optional()
25475
- });
25476
- function reviewBodyFingerprint(body) {
25477
- return digest(body);
25478
- }
25479
- function reviewCommentFingerprint(comment) {
25480
- return digest([comment.file, String(comment.line), comment.side ?? "RIGHT", comment.body].join("\x00"));
25481
- }
25482
- function reviewReplyFingerprint(threadId, body) {
25483
- return digest(`${threadId}\x00${body}`);
25484
- }
25485
- function unpublishedReviewComments(comments, knownFingerprints) {
25486
- return comments.filter((comment) => !knownFingerprints.has(reviewCommentFingerprint(comment)));
25314
+ async function nextRevision(dir) {
25315
+ return await latestRevision(dir) + 1;
25487
25316
  }
25488
- async function loadReviewPublicationState(cwd, vcs, number, homeDir) {
25489
- const target = `${vcs.provider}:${vcs.owner}/${vcs.repo}#${number}`;
25490
- const path = join20(await sessionsDir(cwd, homeDir), `review-publish-${digest(target).slice(0, 16)}.json`);
25317
+ async function latestRevision(dir) {
25491
25318
  try {
25492
- const parsed = reviewPublicationStateSchema.parse(JSON.parse(await readFile13(path, "utf8")));
25493
- return { path, state: { ...parsed, target } };
25319
+ return (await readdir3(dir)).reduce((latest, name) => {
25320
+ const match = name.match(/^(\d+)\.json$/);
25321
+ return match ? Math.max(latest, Number(match[1])) : latest;
25322
+ }, 0);
25494
25323
  } catch (error) {
25495
- if (error.code === "ENOENT") {
25496
- return { path, state: { target, bodies: [], comments: [], stagedComments: [], replies: [] } };
25497
- }
25498
- if (error instanceof SyntaxError || error instanceof z12.ZodError) {
25499
- throw new Error(`Cannot parse review publication state: ${path}`);
25500
- }
25324
+ if (error.code === "ENOENT")
25325
+ return 0;
25501
25326
  throw error;
25502
25327
  }
25503
25328
  }
25504
- async function saveReviewPublicationState(path, state) {
25505
- const temp = `${path}.${process.pid}.tmp`;
25506
- await writeFile6(temp, `${JSON.stringify(state, null, 2)}
25507
- `, "utf8");
25508
- await rename2(temp, path);
25509
- }
25510
- function digest(value) {
25511
- return createHash2("sha256").update(value).digest("hex");
25512
- }
25513
25329
  // src/tools/review.ts
25514
- var contextSchema = z13.object({
25515
- cwd: z13.string().optional(),
25516
- target: z13.string().optional(),
25517
- workingTree: z13.boolean().optional()
25330
+ var contextSchema = z11.object({
25331
+ cwd: z11.string().optional(),
25332
+ target: z11.string().optional(),
25333
+ workingTree: z11.boolean().optional()
25518
25334
  });
25519
- var localSchema = contextSchema.extend({ local: z13.boolean().optional() });
25335
+ var localSchema = contextSchema.extend({ local: z11.boolean().optional() });
25520
25336
  var openSchema = localSchema.extend({
25521
- title: z13.string().optional(),
25522
- intent: z13.string().optional(),
25523
- issueUrl: z13.string().url().optional(),
25524
- base: z13.string().optional()
25337
+ title: z11.string().optional(),
25338
+ intent: z11.string().optional(),
25339
+ issueUrl: z11.string().url().optional(),
25340
+ base: z11.string().optional()
25525
25341
  });
25526
25342
  var submitSchema = localSchema.extend({
25527
25343
  findings: findingsSchema,
25528
- overallIssues: z13.array(z13.string()).optional(),
25529
- notVerified: z13.array(z13.string()).optional(),
25530
- title: z13.string().optional()
25344
+ overallIssues: z11.array(z11.string()).optional(),
25345
+ notVerified: z11.array(z11.string()).optional(),
25346
+ title: z11.string().optional()
25531
25347
  });
25532
25348
  var addCommentSchema = localSchema.extend({
25533
- body: z13.string().min(1),
25534
- file: z13.string().min(1),
25535
- line: z13.number().int().positive(),
25536
- side: z13.enum(["LEFT", "RIGHT"]).optional()
25349
+ body: z11.string().min(1),
25350
+ file: z11.string().min(1),
25351
+ line: z11.number().int().positive(),
25352
+ side: z11.enum(["LEFT", "RIGHT"]).optional()
25537
25353
  });
25538
25354
  var respondSchema = localSchema.extend({
25539
- threadId: z13.string().min(1),
25540
- body: z13.string().min(1),
25541
- question: z13.boolean().optional(),
25542
- resolve: z13.boolean().optional()
25355
+ threadId: z11.string().min(1),
25356
+ body: z11.string().min(1),
25357
+ question: z11.boolean().optional(),
25358
+ resolve: z11.boolean().optional()
25543
25359
  });
25544
25360
  var publishSchema = localSchema.extend({
25545
- status: z13.enum(["COMMENT", "APPROVE", "REQUEST_CHANGES", "CLOSE"]).optional()
25361
+ status: z11.enum(["COMMENT", "APPROVE", "REQUEST_CHANGES", "CLOSE"]).optional()
25546
25362
  });
25547
25363
  var completeSchema = localSchema.extend({
25548
- action: z13.enum(["approve", "reject", "abandon"]).optional()
25364
+ action: z11.enum(["approve", "reject", "abandon"]).optional()
25549
25365
  });
25550
- function hasReviewDraft(comments, body) {
25551
- return comments.length > 0 || body.trim().length > 0;
25552
- }
25553
- function partitionReviewComments(comments, knownFingerprints, model) {
25554
- const normalized = comments.map((comment) => {
25555
- const published = { ...comment, body: withCommentProvenance(comment, model) };
25556
- return { source: comment, published, fingerprint: reviewCommentFingerprint(published) };
25557
- });
25558
- return {
25559
- checkpointed: normalized.filter((comment) => knownFingerprints.has(comment.fingerprint)),
25560
- candidates: normalized.filter((comment) => !knownFingerprints.has(comment.fingerprint))
25561
- };
25562
- }
25563
- function resolveReviewThread(comment, threads) {
25564
- const sourceCommentId = comment.sourceCommentId;
25565
- if (sourceCommentId) {
25566
- const identified = threads.find((thread) => thread.id === sourceCommentId || thread.rootCommentId === sourceCommentId || thread.commentIds?.includes(sourceCommentId));
25567
- if (identified)
25568
- return identified;
25569
- }
25570
- const matches = threads.filter((thread) => thread.file === comment.file && (thread.line ?? undefined) === (comment.line ?? undefined));
25571
- if (matches.length > 1)
25572
- throw new Error(`Ambiguous remote review threads at ${comment.file}:${comment.line ?? "file"}: ${matches.map((thread) => thread.id).join(", ")}.`);
25573
- return matches[0];
25574
- }
25575
25366
  async function workingTreeDiff(cwd) {
25576
25367
  const tracked = await run2("git", ["-C", cwd, "diff", "HEAD"], { capture: "unbounded" });
25577
25368
  if (tracked.code !== 0)
@@ -25767,7 +25558,7 @@ ${openMessage}`, {
25767
25558
  const gates = await runMiseGates(review.cwd);
25768
25559
  const baseRef = review.pr?.baseRef ?? (review.forge ? await review.forge.defaultBranch() : "local");
25769
25560
  const artifact = await newReviewArtifactPath(review.cwd, params.local || params.workingTree || !review.pr ? "uncommitted" : await reviewTargetId(review));
25770
- await writeFile7(artifact, renderReviewDoc({
25561
+ await writeFile6(artifact, renderReviewDoc({
25771
25562
  title: params.title ?? review.pr?.title ?? review.vcs.branch,
25772
25563
  number: useLocalBackend ? undefined : review.pr?.number,
25773
25564
  url: useLocalBackend ? undefined : review.pr?.url,
@@ -25790,17 +25581,21 @@ ${openMessage}`, {
25790
25581
  count: findings.length
25791
25582
  });
25792
25583
  }
25793
- const backend = createLocalReviewBackend({
25794
- session: session.path,
25795
- artifactPath: artifact,
25796
- author: localReviewAuthor(model)
25797
- });
25798
- await backend.stage({ comments, body });
25584
+ for (const comment of comments) {
25585
+ await addComment(session.path, comment.body, {
25586
+ targetFile: comment.file,
25587
+ line: comment.line,
25588
+ side: comment.side === "LEFT" ? "old" : "new",
25589
+ username: localReviewAuthor(model)
25590
+ });
25591
+ }
25592
+ if (body.trim())
25593
+ await addComment(session.path, body, { username: localReviewAuthor(model) });
25799
25594
  return result3(`Local review staged in tuicr: ${artifact}`, { artifact, count: findings.length, session });
25800
25595
  }
25801
25596
  if (!review.pr)
25802
25597
  return result3(`Review written: ${artifact}. No PR/MR matches this remote target.`, { artifact });
25803
- const hasDraft = hasReviewDraft(comments, body);
25598
+ const hasDraft = comments.length > 0 || body.trim().length > 0;
25804
25599
  if (hasDraft) {
25805
25600
  await createRemoteReviewBackend(review.vcs, review.pr.number).stage({ comments, body });
25806
25601
  }
@@ -25836,12 +25631,12 @@ ${openMessage}`, {
25836
25631
  const session = await resolveTuicrSession(review, Boolean(params.local || params.workingTree));
25837
25632
  if (!session)
25838
25633
  return result3("No matching tuicr session. Open review_new or review_launch_ui first.");
25839
- const backend = createLocalReviewBackend({
25840
- session: session.path,
25841
- artifactPath: "",
25842
- author: localReviewAuthor(model)
25634
+ await addComment(session.path, comment.body, {
25635
+ targetFile: comment.file,
25636
+ line: comment.line,
25637
+ side: comment.side === "LEFT" ? "old" : "new",
25638
+ username: localReviewAuthor(model)
25843
25639
  });
25844
- await backend.stage({ comments: [comment], body: "" });
25845
25640
  return result3(`Comment added to tuicr session ${session.slug}.`, { session, comment });
25846
25641
  }
25847
25642
  if (!review.pr)
@@ -25853,88 +25648,70 @@ ${openMessage}`, {
25853
25648
  defineTool6({
25854
25649
  name: "review_comments",
25855
25650
  label: "review comments",
25856
- description: "Pull review threads or local tuicr comments and write a target-named artifact.",
25857
- promptSnippet: "Call review_comments before addressing findings",
25858
- promptGuidelines: ["Pass local=true to prepare the local reply overlay."],
25651
+ description: "Read remote GitHub or GitLab review threads.",
25652
+ promptSnippet: "Call review_comments before addressing remote findings",
25653
+ promptGuidelines: ["Use review_dump for a local tuicr review."],
25859
25654
  parameters: parameters6(localSchema),
25860
- executionMode: "sequential",
25655
+ executionMode: "parallel",
25861
25656
  async execute(_id, input) {
25862
25657
  const params = localSchema.parse(input);
25658
+ if (params.local || params.workingTree)
25659
+ return result3("Use review_dump for a local tuicr review.");
25863
25660
  const review = await resolveReviewContext(resolveWorkingDirectory(params), params.target);
25864
- let threads;
25865
- let artifact;
25866
- if (params.local || params.workingTree) {
25867
- const session = await resolveTuicrSession(review, true);
25868
- if (!session)
25869
- return result3("No matching tuicr session found.");
25870
- artifact = await localThreadArtifactPath(review.cwd, session.slug);
25871
- threads = await syncLocalThreadArtifact(artifact, review.pr?.title ?? review.vcs.branch, session.slug, toLocalReviewThreads(await readSession(session.path)));
25872
- } else if (review.pr && review.forge) {
25873
- threads = await createRemoteReviewBackend(review.vcs, review.pr.number).listThreads();
25874
- const target = await reviewTargetId(review);
25875
- artifact = await newReviewArtifactPath(review.cwd, target);
25876
- await writeFile7(artifact, renderThreadArtifact(review.pr.title, target, threads, { number: review.pr.number, url: review.pr.url }), "utf8");
25877
- } else {
25661
+ if (!review.pr || !review.forge)
25878
25662
  return result3(review.forge ? "No PR/MR matches this remote review target." : unsupportedForgeMessage());
25879
- }
25663
+ const threads = await createRemoteReviewBackend(review.vcs, review.pr.number).listThreads();
25880
25664
  return result3(threads.map((thread) => `${thread.id} ${thread.file ?? "review"}:${thread.line ?? "-"} — ${thread.body}`).join(`
25881
- `) || "No comments.", { artifact, threads });
25665
+ `) || "No comments.", { pr: review.pr, threads });
25666
+ }
25667
+ }),
25668
+ defineTool6({
25669
+ name: "review_dump",
25670
+ label: "review dump",
25671
+ description: "Save one immutable local tuicr review revision and remove its completed session.",
25672
+ promptSnippet: "Call review_dump before applying local review feedback",
25673
+ promptGuidelines: ["Each dump advances the local review revision."],
25674
+ parameters: parameters6(localSchema),
25675
+ executionMode: "sequential",
25676
+ async execute(_id, input) {
25677
+ const params = localSchema.parse(input);
25678
+ const review = await resolveReviewContext(resolveWorkingDirectory(params), params.target);
25679
+ const session = await resolveTuicrSession(review, true);
25680
+ if (!session)
25681
+ return result3("No matching local tuicr session found.");
25682
+ const base = review.pr?.baseRef ?? (review.forge ? await review.forge.defaultBranch() : "HEAD");
25683
+ const captured = await captureLocalReview({
25684
+ cwd: review.cwd,
25685
+ branch: review.vcs.branch,
25686
+ base,
25687
+ diff: await workingTreeDiff(review.cwd),
25688
+ session
25689
+ });
25690
+ return result3(captured.dump.comments.map((thread) => `${thread.id} ${thread.file ?? "review"}:${thread.line ?? "-"} — ${thread.body}`).join(`
25691
+ `) || "No comments.", { artifact: captured.path, review: captured.dump, threads: captured.dump.comments });
25882
25692
  }
25883
25693
  }),
25884
25694
  defineTool6({
25885
25695
  name: "review_respond",
25886
25696
  label: "review respond",
25887
- description: "Post local question replies to tuicr and record their publish overlay, or reply to remote threads.",
25888
- promptSnippet: "Call review_respond after addressing a comment",
25889
- promptGuidelines: ["Question replies remain unresolved."],
25697
+ description: "Reply directly to a remote GitHub or GitLab review thread.",
25698
+ promptSnippet: "Call review_respond after addressing a remote comment",
25699
+ promptGuidelines: ["Leave threads open unless the user explicitly requests resolution."],
25890
25700
  parameters: parameters6(respondSchema),
25891
25701
  executionMode: "sequential",
25892
25702
  async execute(_id, input, _signal, _onUpdate, ctx) {
25893
25703
  const params = respondSchema.parse(input);
25704
+ if (params.local)
25705
+ return result3("Local reviews use immutable revision dumps, not thread replies.");
25894
25706
  const review = await resolveReviewContext(resolveWorkingDirectory(params), params.target);
25895
- const model = modelRoute(ctx);
25896
- if (params.local) {
25897
- const tuicrSession = await resolveTuicrSession(review, true);
25898
- if (!tuicrSession)
25899
- return result3("No matching tuicr session. Open review_new or review_launch_ui first.");
25900
- const artifact = await localThreadArtifactPath(review.cwd, tuicrSession.slug);
25901
- if (!existsSync3(artifact))
25902
- return result3("No local review artifact. Run review_comments with local=true first.");
25903
- const threads = await readThreadArtifact(artifact);
25904
- const known = threads.find((thread) => thread.id === params.threadId);
25905
- const question = known?.question === true || params.question === true || !known && params.question === undefined;
25906
- const awaitUserDeletion = !question && params.resolve === true;
25907
- await addComment(tuicrSession.path, `${localResponseMarker(params.threadId)}
25908
- ${params.body}`, {
25909
- targetFile: known?.file,
25910
- line: known?.line,
25911
- side: "new",
25912
- username: localReviewAuthor(model)
25913
- });
25914
- await createLocalReviewBackend({
25915
- session: tuicrSession.path,
25916
- artifactPath: artifact,
25917
- author: localReviewAuthor(model)
25918
- }).reply({
25919
- threadId: params.threadId,
25920
- body: params.body,
25921
- resolve: false,
25922
- question
25923
- });
25924
- return result3(`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 });
25925
- }
25926
25707
  if (!review.pr || !review.forge)
25927
25708
  return result3("No remote PR/MR for this reply.");
25928
- const remote = createRemoteReviewBackend(review.vcs, review.pr.number);
25929
- const remoteThreads = await remote.listThreads();
25930
- const known = remoteThreads.find((thread) => thread.id === params.threadId);
25931
- const question = known?.question === true || params.question === true || !known && params.question === undefined;
25932
- const resolve = question ? false : params.resolve ?? true;
25933
- await remote.reply({
25709
+ const resolve = params.question ? false : params.resolve ?? false;
25710
+ await createRemoteReviewBackend(review.vcs, review.pr.number).reply({
25934
25711
  threadId: params.threadId,
25935
- body: withRemoteProvenance(params.body, model),
25712
+ body: withRemoteProvenance(params.body, modelRoute(ctx)),
25936
25713
  resolve,
25937
- question
25714
+ question: params.question
25938
25715
  });
25939
25716
  return result3(`Replied to ${params.threadId}${resolve ? " and resolved it" : " and left it open"}.`, {
25940
25717
  pr: review.pr,
@@ -25945,51 +25722,40 @@ ${params.body}`, {
25945
25722
  defineTool6({
25946
25723
  name: "review_publish",
25947
25724
  label: "review publish",
25948
- description: "Publish pending remote draft PR/MR review comments and status, or promote a local tuicr draft before publishing.",
25725
+ description: "Publish pending remote review comments and status.",
25949
25726
  promptSnippet: "Call review_publish to make remote review work public",
25950
- promptGuidelines: ["Statuses are COMMENT, APPROVE, REQUEST_CHANGES, or CLOSE."],
25727
+ promptGuidelines: ["Local reviews use revision dumps and are never published remotely."],
25951
25728
  parameters: parameters6(publishSchema),
25952
25729
  executionMode: "sequential",
25953
- async execute(_id, input, _signal, _onUpdate, ctx) {
25730
+ async execute(_id, input) {
25954
25731
  const params = publishSchema.parse(input);
25732
+ if (params.local)
25733
+ return result3("Local reviews use immutable revision dumps and have no publish step.");
25955
25734
  const review = await resolveReviewContext(resolveWorkingDirectory(params), params.target);
25956
25735
  if (!review.pr || !review.forge)
25957
25736
  return result3("No remote PR/MR to publish.");
25958
- return publishResolvedReview(review, params, modelRoute(ctx));
25737
+ return publishResolvedReview(review, params);
25959
25738
  }
25960
25739
  }),
25961
25740
  defineTool6({
25962
25741
  name: "review_complete",
25963
25742
  label: "review complete",
25964
- description: "Approve, reject, or abandon a remote review, or archive a local review artifact.",
25965
- promptSnippet: "Call review_complete to finish a review without merging it.",
25966
- promptGuidelines: ["A local completion archives the Diffpi artifact and deletes the matching tuicr session."],
25743
+ description: "Approve, reject, or abandon a remote review.",
25744
+ promptSnippet: "Call review_complete to finish a remote review without merging it.",
25745
+ promptGuidelines: ["Use review_dump to finish a local review revision."],
25967
25746
  parameters: parameters6(completeSchema),
25968
25747
  executionMode: "sequential",
25969
- async execute(_id, input, _signal, _onUpdate, ctx) {
25748
+ async execute(_id, input) {
25970
25749
  const params = completeSchema.parse(input);
25750
+ if (params.local)
25751
+ return result3("Use review_dump to finish a local review revision.");
25971
25752
  const review = await resolveReviewContext(resolveWorkingDirectory(params), params.target);
25972
- if (params.local) {
25973
- const session = await resolveTuicrSession(review, true);
25974
- if (!session)
25975
- return result3("No matching local tuicr session to close.");
25976
- const artifact = await localThreadArtifactPath(review.cwd, session.slug);
25977
- if (!existsSync3(artifact))
25978
- return result3("No local review artifact. Run review_comments with local=true first.");
25979
- const archived = await uniqueRecordPath(await completedReviewsDir(review.cwd), reviewSlug(session.slug));
25980
- await rename3(artifact, archived);
25981
- await unlink2(session.path);
25982
- return result3(`Archived ${archived} and deleted the tuicr session ${session.slug}.`, {
25983
- artifact: archived,
25984
- session
25985
- });
25986
- }
25987
25753
  if (!review.pr || !review.forge)
25988
25754
  return result3("No remote PR/MR to complete.");
25989
25755
  if (!params.action)
25990
25756
  return result3("Choose a complete action: approve, reject, or abandon.");
25991
25757
  const status = { approve: "APPROVE", reject: "REQUEST_CHANGES", abandon: "CLOSE" }[params.action];
25992
- return publishResolvedReview(review, { ...params, status }, modelRoute(ctx));
25758
+ return publishResolvedReview(review, { ...params, status });
25993
25759
  }
25994
25760
  }),
25995
25761
  defineTool6({
@@ -25998,10 +25764,10 @@ ${params.body}`, {
25998
25764
  description: "Squash-merge an open, ready GitHub PR after checking its conventional subject.",
25999
25765
  promptSnippet: "Call review_merge after confirming the PR is open, ready, and CI is settled; approval is optional",
26000
25766
  promptGuidelines: ["This is intentionally GitHub-only until GitLab merge support is added."],
26001
- parameters: parameters6(contextSchema.extend({ subject: z13.string().optional() })),
25767
+ parameters: parameters6(contextSchema.extend({ subject: z11.string().optional() })),
26002
25768
  executionMode: "sequential",
26003
25769
  async execute(_id, input) {
26004
- const params = contextSchema.extend({ subject: z13.string().optional() }).parse(input);
25770
+ const params = contextSchema.extend({ subject: z11.string().optional() }).parse(input);
26005
25771
  const review = await resolveReviewContext(resolveWorkingDirectory(params), params.target);
26006
25772
  if (review.vcs.provider !== "github" || !review.forge)
26007
25773
  return result3("review_merge currently supports GitHub only.");
@@ -26034,32 +25800,8 @@ ${params.body}`, {
26034
25800
  })
26035
25801
  ];
26036
25802
  }
26037
- async function syncLocalThreadArtifact(path, title, sessionSlug, threads) {
26038
- let previous = [];
26039
- try {
26040
- previous = parseThreadArtifact(await readFile14(path, "utf8"));
26041
- } catch (error) {
26042
- if (error.code !== "ENOENT")
26043
- throw error;
26044
- }
26045
- const previousById = new Map(previous.map((thread) => [thread.id, thread]));
26046
- const currentIds = new Set(threads.map((thread) => thread.id));
26047
- const current = threads.map((thread) => {
26048
- const existing = previousById.get(thread.id);
26049
- return {
26050
- ...thread,
26051
- resolved: false,
26052
- addressed: existing?.addressed ?? Boolean(existing?.reply),
26053
- reply: existing?.reply
26054
- };
26055
- });
26056
- const removed = previous.filter((thread) => !currentIds.has(thread.id)).map((thread) => ({ ...thread, resolved: true }));
26057
- const merged = [...current, ...removed];
26058
- await writeFile7(path, renderThreadArtifact(title, sessionSlug, merged), "utf8");
26059
- return merged;
26060
- }
26061
25803
  function parameters6(schema) {
26062
- return z13.toJSONSchema(schema, { io: "input" });
25804
+ return z11.toJSONSchema(schema, { io: "input" });
26063
25805
  }
26064
25806
  function resolveWorkingDirectory(params) {
26065
25807
  return params.cwd ?? process.cwd();
@@ -26075,159 +25817,17 @@ function modelRoute(ctx) {
26075
25817
  throw new Error("Cannot record review provenance because Pi did not provide an active model route.");
26076
25818
  return `${ctx.model.provider}/${ctx.model.id}`;
26077
25819
  }
26078
- async function publishResolvedReview(review, params, model) {
25820
+ async function publishResolvedReview(review, params) {
26079
25821
  const status = params.status ?? "COMMENT";
26080
25822
  const event = status === "CLOSE" ? "COMMENT" : status;
26081
25823
  assertReviewEventSupported(review.vcs.provider, event);
26082
- const remote = createRemoteReviewBackend(review.vcs, review.pr.number);
26083
- const promotion = params.local ? await promoteLocalReview(review, remote, model) : undefined;
26084
25824
  if (status !== "CLOSE" && review.pr.isDraft)
26085
25825
  await review.forge.markReady(review.pr.number);
26086
- await remote.publish(event);
26087
- if (promotion) {
26088
- promotion.publication.state.bodies = [
26089
- ...new Set([...promotion.publication.state.bodies, ...promotion.bodyFingerprints])
26090
- ];
26091
- promotion.publication.state.comments = [
26092
- ...new Set([...promotion.publication.state.comments, ...promotion.commentFingerprints])
26093
- ];
26094
- const published = new Set(promotion.commentFingerprints);
26095
- promotion.publication.state.stagedComments = promotion.publication.state.stagedComments.filter((fingerprint) => !published.has(fingerprint));
26096
- await saveReviewPublicationState(promotion.publication.path, promotion.publication.state);
26097
- }
25826
+ await createRemoteReviewBackend(review.vcs, review.pr.number).publish(event);
26098
25827
  if (status === "CLOSE")
26099
25828
  await review.forge.closePr(review.pr.number);
26100
25829
  const finalPr = await review.forge.viewPr(String(review.pr.number));
26101
- const promotedBodies = promotion?.promotedBodies ?? 0;
26102
- const promotedComments = promotion?.promotedComments ?? 0;
26103
- const promotedReplies = promotion?.promotedReplies ?? 0;
26104
- return result3(`Published #${review.pr.number} (${status}); promoted ${promotedBodies} review bodies, ${promotedComments} comments, and ${promotedReplies} replies.`, { pr: finalPr ?? review.pr, status, promotedBodies, promotedComments, promotedReplies });
26105
- }
26106
- async function promoteLocalReview(review, remote, model) {
26107
- const publication = await loadReviewPublicationState(review.cwd, review.vcs, review.pr.number);
26108
- const session = await resolvePublishSession(review.cwd, {
26109
- branch: review.vcs.branch,
26110
- owner: review.vcs.provider === "none" ? undefined : review.vcs.owner,
26111
- repo: review.vcs.provider === "none" ? undefined : review.vcs.repo,
26112
- number: review.pr?.number
26113
- });
26114
- if (!session)
26115
- throw new Error("No matching tuicr session to publish.");
26116
- const sessionIsWorkingTree = session.kind === "local";
26117
- const local = createLocalReviewBackend({
26118
- session: session.path,
26119
- artifactPath: "",
26120
- author: localReviewAuthor(model)
26121
- });
26122
- const draft = await local.readDraft();
26123
- const remoteDraft = await remote.readDraft();
26124
- const knownRootComments = new Set([
26125
- ...publication.state.comments,
26126
- ...publication.state.stagedComments,
26127
- ...remoteDraft.comments.map(reviewCommentFingerprint)
26128
- ]);
26129
- const { checkpointed, candidates } = partitionReviewComments(draft.comments, knownRootComments, model);
26130
- const remoteThreads = await remote.listThreads();
26131
- const threadReplies = sessionIsWorkingTree ? [] : candidates.flatMap(({ source: comment }) => {
26132
- const thread = resolveReviewThread(comment, remoteThreads);
26133
- return thread ? [{ comment, thread }] : [];
26134
- });
26135
- const unmatchedActions = sessionIsWorkingTree ? [] : candidates.map(({ source }) => source).filter((comment) => parseReviewThreadAction(comment.body).action && !threadReplies.some((reply) => reply.comment === comment));
26136
- if (unmatchedActions.length > 0) {
26137
- const locations = unmatchedActions.map(({ file, line }) => `${file}:${line ?? "?"}`).join(", ");
26138
- throw new Error(`Cannot apply review thread action: no matching remote thread at ${locations}.`);
26139
- }
26140
- const comments = candidates.filter(({ source }) => !threadReplies.some((reply) => reply.comment === source)).map(({ published }) => published);
26141
- const body = draft.body.trim();
26142
- const bodyFingerprints = body ? [reviewBodyFingerprint(body)] : [];
26143
- const commentFingerprints = [
26144
- ...checkpointed.map(({ fingerprint }) => fingerprint),
26145
- ...comments.map(reviewCommentFingerprint)
26146
- ];
26147
- const knownBodies = new Set(publication.state.bodies);
26148
- if (remoteDraft.body.trim())
26149
- knownBodies.add(reviewBodyFingerprint(remoteDraft.body));
26150
- const unpublishedBody = body && !knownBodies.has(reviewBodyFingerprint(body)) ? body : "";
26151
- const unpublished = unpublishedReviewComments(comments, knownRootComments);
26152
- if (unpublishedBody)
26153
- await remote.stage({ comments: [], body: unpublishedBody });
26154
- let promotedComments = 0;
26155
- for (const comment of unpublished) {
26156
- await remote.stage({ comments: [comment], body: "" });
26157
- publication.state.stagedComments.push(reviewCommentFingerprint(comment));
26158
- await saveReviewPublicationState(publication.path, publication.state);
26159
- promotedComments += 1;
26160
- }
26161
- const promotedReplies = await promoteLocalReplies(review, remote, publication, model, sessionIsWorkingTree) + await promoteLocationReplies(remote, publication, threadReplies, model);
26162
- return {
26163
- publication,
26164
- bodyFingerprints,
26165
- commentFingerprints,
26166
- promotedBodies: unpublishedBody ? 1 : 0,
26167
- promotedComments,
26168
- promotedReplies
26169
- };
26170
- }
26171
- async function promoteLocationReplies(remote, publication, replies, model) {
26172
- let count = 0;
26173
- for (const { comment, thread } of replies) {
26174
- const parsed = parseReviewThreadAction(comment.body);
26175
- const body = parsed.body.trim();
26176
- const action = parsed.action;
26177
- const fingerprint = reviewReplyFingerprint(thread.id, `${action ?? "reply"}\x00${body}`);
26178
- if (publication.state.replies.includes(fingerprint))
26179
- continue;
26180
- if (action === "delete") {
26181
- if (!remote.deleteThread)
26182
- throw new Error(`The review backend cannot delete thread ${thread.id}.`);
26183
- await remote.deleteThread(thread.id);
26184
- publication.state.replies.push(fingerprint);
26185
- await saveReviewPublicationState(publication.path, publication.state);
26186
- count += 1;
26187
- continue;
26188
- }
26189
- const remoteBody = withCommentProvenance({ ...comment, body }, model);
26190
- if (body && !thread.replies?.some((reply) => reply === body || reply === remoteBody)) {
26191
- await remote.reply({
26192
- threadId: thread.id,
26193
- body: remoteBody,
26194
- resolve: false
26195
- });
26196
- }
26197
- if (action && remote.setResolved)
26198
- await remote.setResolved(thread.id, action === "resolve");
26199
- publication.state.replies.push(fingerprint);
26200
- await saveReviewPublicationState(publication.path, publication.state);
26201
- count += 1;
26202
- }
26203
- return count;
26204
- }
26205
- async function promoteLocalReplies(review, remote, publication, model, workingTree) {
26206
- const target = workingTree ? "uncommitted" : await reviewTargetId(review);
26207
- const artifact = workingTree ? await latestThreadArtifact(review.cwd, review, target) : publication.state.overlayPath ?? await latestThreadArtifact(review.cwd, review, target);
26208
- if (!artifact)
26209
- return 0;
26210
- if (!workingTree)
26211
- publication.state.overlayPath = artifact;
26212
- let count = 0;
26213
- for (const thread of await readThreadArtifact(artifact)) {
26214
- if (!thread.reply)
26215
- continue;
26216
- const body = withRemoteProvenance(thread.reply, model);
26217
- const fingerprint = reviewReplyFingerprint(thread.id, body);
26218
- if (publication.state.replies.includes(fingerprint))
26219
- continue;
26220
- await remote.reply({
26221
- threadId: thread.id,
26222
- body,
26223
- resolve: !thread.question,
26224
- question: thread.question
26225
- });
26226
- publication.state.replies.push(fingerprint);
26227
- await saveReviewPublicationState(publication.path, publication.state);
26228
- count += 1;
26229
- }
26230
- return count;
25830
+ return result3(`Published #${review.pr.number} (${status}).`, { pr: finalPr ?? review.pr, status });
26231
25831
  }
26232
25832
  async function resolveReviewContext(cwd, target) {
26233
25833
  const vcs = await detectVcs(cwd);
@@ -26268,10 +25868,6 @@ async function assertRemoteBranchReady(cwd) {
26268
25868
  if (head.stdout.trim() !== pushed.stdout.trim())
26269
25869
  throw new Error("Push the current branch before opening a remote review.");
26270
25870
  }
26271
- function withCommentProvenance(comment, fallbackModel) {
26272
- const route = comment.author?.match(/^Agent:\s*(.+)$/)?.[1];
26273
- return route ? withRemoteProvenance(comment.body, route || fallbackModel) : comment.body;
26274
- }
26275
25871
  async function reviewTargetId(review) {
26276
25872
  if (!review.pr)
26277
25873
  return "uncommitted";
@@ -26288,60 +25884,9 @@ async function headSha(cwd) {
26288
25884
  }
26289
25885
  async function newReviewArtifactPath(cwd, target) {
26290
25886
  const dir = await reviewsDir(cwd);
26291
- await mkdir7(dir, { recursive: true });
25887
+ await mkdir8(dir, { recursive: true });
26292
25888
  return uniqueRecordPath(dir, reviewRecordName(target));
26293
25889
  }
26294
- async function localThreadArtifactPath(cwd, sessionSlug) {
26295
- const dir = await reviewsDir(cwd);
26296
- return join21(dir, `${reviewSlug(sessionSlug) || "local-review"}.md`);
26297
- }
26298
- async function readThreadArtifact(path) {
26299
- try {
26300
- return parseThreadArtifact(await readFile14(path, "utf8"));
26301
- } catch (error) {
26302
- if (error.code === "ENOENT") {
26303
- throw new Error(`Expected local reply overlay is missing: ${path}`);
26304
- }
26305
- throw error;
26306
- }
26307
- }
26308
- async function latestThreadArtifact(cwd, review, target) {
26309
- try {
26310
- return await findLatestThreadArtifact(cwd, review, target);
26311
- } catch (error) {
26312
- throw new Error(`Cannot locate the latest review thread artifact for ${target}.`, { cause: error });
26313
- }
26314
- }
26315
- async function findLatestThreadArtifact(cwd, review, target) {
26316
- const dir = await reviewsDir(cwd);
26317
- const suffix = reviewSlug(target) || "uncommitted";
26318
- const names = await listReviewArtifactNames(dir);
26319
- const candidates = await Promise.all(names.flatMap((name) => {
26320
- if (!name.endsWith(".md"))
26321
- return [];
26322
- return [
26323
- (async () => {
26324
- const path = join21(dir, name);
26325
- const info = await stat(path);
26326
- return { path, name, content: await readFile14(path, "utf8"), modified: info.mtimeMs };
26327
- })()
26328
- ];
26329
- }));
26330
- return candidates.filter(({ name, content }) => {
26331
- if (!content.startsWith("<!-- diffpi-threads:"))
26332
- return false;
26333
- if (review.pr && target !== "local" && target !== "uncommitted")
26334
- return content.includes(`- PR/MR: #${review.pr.number}`);
26335
- return artifactNameMatches(name, suffix);
26336
- }).sort((a, b) => b.modified - a.modified)[0]?.path;
26337
- }
26338
- async function listReviewArtifactNames(dir) {
26339
- try {
26340
- return await readdir3(dir);
26341
- } catch (error) {
26342
- throw new Error(`Cannot read review artifacts in ${dir}.`, { cause: error });
26343
- }
26344
- }
26345
25890
  function deriveTitle(branch) {
26346
25891
  return branch.replace(/^(feature|feat|fix|bug|chore)\//, "").replace(/^eng-\d+-/i, "").replace(/[-_]+/g, " ").replace(/^\w/, (char) => char.toUpperCase());
26347
25892
  }
@@ -26352,30 +25897,19 @@ function uniqueRecordPath(dir, base) {
26352
25897
  path = join21(dir, `${base}-${count++}.md`);
26353
25898
  return path;
26354
25899
  }
26355
- function artifactNameMatches(name, suffix) {
26356
- if (!name.endsWith(".md"))
26357
- return false;
26358
- const stem = name.slice(0, -3);
26359
- const marker = `-${suffix}`;
26360
- const markerIndex = stem.lastIndexOf(marker);
26361
- if (markerIndex < 0)
26362
- return false;
26363
- const tail = stem.slice(markerIndex + marker.length);
26364
- return tail === "" || /^-\d+$/.test(tail);
26365
- }
26366
25900
 
26367
25901
  // src/tools/setup.ts
26368
25902
  import { defineTool as defineTool7 } from "@earendil-works/pi-coding-agent";
26369
- import { z as z14 } from "zod";
25903
+ import { z as z12 } from "zod";
26370
25904
 
26371
25905
  // src/setup.ts
26372
25906
  import { parseFrontmatter as parseFrontmatter3 } from "@earendil-works/pi-coding-agent";
26373
- import { readdir as readdir4, readFile as readFile16 } from "node:fs/promises";
25907
+ import { readdir as readdir4, readFile as readFile14 } from "node:fs/promises";
26374
25908
  import { homedir as homedir10 } from "node:os";
26375
25909
  import { basename as basename7, join as join24 } from "node:path";
26376
25910
 
26377
25911
  // src/mcp.ts
26378
- import { mkdir as mkdir8, readFile as readFile15, writeFile as writeFile8 } from "node:fs/promises";
25912
+ import { mkdir as mkdir9, readFile as readFile13, writeFile as writeFile7 } from "node:fs/promises";
26379
25913
  import { homedir as homedir8 } from "node:os";
26380
25914
  import { dirname as dirname9, join as join22 } from "node:path";
26381
25915
  var mcp = {
@@ -26393,8 +25927,8 @@ var mcp = {
26393
25927
  const next = { ...current, mcpServers: nextServers };
26394
25928
  const changed = JSON.stringify(current) !== JSON.stringify(next);
26395
25929
  if (changed && !options.dryRun) {
26396
- await mkdir8(dirname9(path), { recursive: true });
26397
- await writeFile8(path, `${JSON.stringify(next, null, 2)}
25930
+ await mkdir9(dirname9(path), { recursive: true });
25931
+ await writeFile7(path, `${JSON.stringify(next, null, 2)}
26398
25932
  `, "utf8");
26399
25933
  }
26400
25934
  return { path, changed, existed: currentText !== undefined, planned: changed && options.dryRun === true };
@@ -26423,7 +25957,7 @@ function getParsedConfig(content, path) {
26423
25957
  }
26424
25958
  async function getOptionalFile2(path) {
26425
25959
  try {
26426
- return await readFile15(path, "utf8");
25960
+ return await readFile13(path, "utf8");
26427
25961
  } catch (error) {
26428
25962
  if (error instanceof Error && "code" in error && error.code === "ENOENT")
26429
25963
  return;
@@ -26435,7 +25969,7 @@ function isRecord(value) {
26435
25969
  }
26436
25970
 
26437
25971
  // src/pi.ts
26438
- import { mkdir as mkdir9, writeFile as writeFile9 } from "node:fs/promises";
25972
+ import { mkdir as mkdir10, writeFile as writeFile8 } from "node:fs/promises";
26439
25973
  import { homedir as homedir9 } from "node:os";
26440
25974
  import { dirname as dirname10, join as join23 } from "node:path";
26441
25975
  var pi = {
@@ -26454,8 +25988,8 @@ async function ensurePiAgent(filename, content, agentDir = resolvePiAgentDir(),
26454
25988
  const currentText = await readTextIfExists(path);
26455
25989
  const changed = currentText !== content;
26456
25990
  if (changed && !dryRun) {
26457
- await mkdir9(dirname10(path), { recursive: true });
26458
- await writeFile9(path, content, "utf8");
25991
+ await mkdir10(dirname10(path), { recursive: true });
25992
+ await writeFile8(path, content, "utf8");
26459
25993
  }
26460
25994
  return { path, changed, existed: currentText !== undefined, planned: changed && dryRun };
26461
25995
  }
@@ -26491,8 +26025,8 @@ async function ensurePiConfig(path, update, dryRun = false) {
26491
26025
  const next = update(current);
26492
26026
  const changed = JSON.stringify(current) !== JSON.stringify(next);
26493
26027
  if (changed && !dryRun) {
26494
- await mkdir9(dirname10(path), { recursive: true });
26495
- await writeFile9(path, `${JSON.stringify(next, null, 2)}
26028
+ await mkdir10(dirname10(path), { recursive: true });
26029
+ await writeFile8(path, `${JSON.stringify(next, null, 2)}
26496
26030
  `, "utf8");
26497
26031
  }
26498
26032
  return { path, changed, existed: currentText !== undefined, planned: changed && dryRun };
@@ -26630,7 +26164,7 @@ async function ensurePiAgents(options = {}) {
26630
26164
  const actions = [];
26631
26165
  for (const entry of entries) {
26632
26166
  const id = basename7(entry.name, ".md").replace(/^diffpi-/, "");
26633
- const source = await readFile16(join24(bundledAgentsDir, entry.name), "utf8");
26167
+ const source = await readFile14(join24(bundledAgentsDir, entry.name), "utf8");
26634
26168
  const content = materializeAgentModels(source, id, userConfig.config, options.availableModels);
26635
26169
  const result = await pi.agentEnsure(entry.name, content, agentDir, options.dryRun);
26636
26170
  actions.push(getConfigSetupAction(`pi agent ${id}`, result));
@@ -26835,7 +26369,7 @@ function reportProgress(options, message) {
26835
26369
  options.onProgress?.(message);
26836
26370
  }
26837
26371
  async function packageVersion2() {
26838
- const source = await readFile16(join24(resolveBundledAgentsDir(), "..", "package.json"), "utf8");
26372
+ const source = await readFile14(join24(resolveBundledAgentsDir(), "..", "package.json"), "utf8");
26839
26373
  let value;
26840
26374
  try {
26841
26375
  value = JSON.parse(source);
@@ -26851,13 +26385,13 @@ function getRecord(value) {
26851
26385
  }
26852
26386
 
26853
26387
  // src/tools/setup.ts
26854
- var setupParametersSchema = z14.object({
26855
- issueTracker: z14.enum(["none", "linear", "jira"]).default("none").describe("Issue tracker MCP server to configure. Use none unless the user explicitly selects Linear or Jira."),
26856
- forges: z14.array(z14.enum(["github", "gitlab"])).default([]).describe("Hosted VCS MCP servers to configure. Installs matching CLIs and registers each MCP server."),
26857
- forge: z14.enum(["none", "github", "gitlab"]).optional().describe("Deprecated single-host alias. Prefer forges for one or more VCS MCP servers."),
26858
- bindZedKey: z14.boolean().default(false).describe("Opt in to a Zed keybinding for the tuicr review task.")
26388
+ var setupParametersSchema = z12.object({
26389
+ issueTracker: z12.enum(["none", "linear", "jira"]).default("none").describe("Issue tracker MCP server to configure. Use none unless the user explicitly selects Linear or Jira."),
26390
+ forges: z12.array(z12.enum(["github", "gitlab"])).default([]).describe("Hosted VCS MCP servers to configure. Installs matching CLIs and registers each MCP server."),
26391
+ forge: z12.enum(["none", "github", "gitlab"]).optional().describe("Deprecated single-host alias. Prefer forges for one or more VCS MCP servers."),
26392
+ bindZedKey: z12.boolean().default(false).describe("Opt in to a Zed keybinding for the tuicr review task.")
26859
26393
  });
26860
- var setupParameters = z14.toJSONSchema(setupParametersSchema, { io: "input" });
26394
+ var setupParameters = z12.toJSONSchema(setupParametersSchema, { io: "input" });
26861
26395
  var diffpiSetupTool = defineTool7({
26862
26396
  name: "diffpi_setup",
26863
26397
  label: "diffpi setup",
@@ -26930,11 +26464,11 @@ ${lines.join(`
26930
26464
 
26931
26465
  // src/tools/templates.ts
26932
26466
  import { defineTool as defineTool8 } from "@earendil-works/pi-coding-agent";
26933
- import { z as z15 } from "zod";
26934
- var templateSchema = z15.object({
26935
- name: z15.string().min(1),
26936
- variables: z15.record(z15.string(), z15.string()).optional(),
26937
- homeDir: z15.string().optional()
26467
+ import { z as z13 } from "zod";
26468
+ var templateSchema = z13.object({
26469
+ name: z13.string().min(1),
26470
+ variables: z13.record(z13.string(), z13.string()).optional(),
26471
+ homeDir: z13.string().optional()
26938
26472
  });
26939
26473
  var diffpiTemplateTool = defineTool8({
26940
26474
  name: "diffpi_template",
@@ -26942,7 +26476,7 @@ var diffpiTemplateTool = defineTool8({
26942
26476
  description: "Load a bundled Diffpi template or a user override and render named variables.",
26943
26477
  promptSnippet: "Use diffpi_template for package workflow templates",
26944
26478
  promptGuidelines: ["User overrides live under ~/.difflab/diffpi/templates."],
26945
- parameters: z15.toJSONSchema(templateSchema, { io: "input" }),
26479
+ parameters: z13.toJSONSchema(templateSchema, { io: "input" }),
26946
26480
  executionMode: "parallel",
26947
26481
  async execute(_id, input) {
26948
26482
  const params = templateSchema.parse(input);