@difflab/pi 0.5.0-rc.202609231305.97666c2 → 0.5.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 (52) hide show
  1. package/README.md +5 -5
  2. package/agents/diffpi-reviewer.md +3 -3
  3. package/dist/commands/review.d.ts +1 -0
  4. package/dist/commands/review.d.ts.map +1 -1
  5. package/dist/extensions/index.js +804 -296
  6. package/dist/extensions/tuicrx.d.ts +8 -1
  7. package/dist/extensions/tuicrx.d.ts.map +1 -1
  8. package/dist/index.d.ts +8 -7
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.js +458 -197
  11. package/dist/review/github-review-backend.d.ts.map +1 -1
  12. package/dist/review/gitlab-review-backend.d.ts.map +1 -1
  13. package/dist/review/index.d.ts +6 -6
  14. package/dist/review/index.d.ts.map +1 -1
  15. package/dist/review/local-review-backend.d.ts +3 -0
  16. package/dist/review/local-review-backend.d.ts.map +1 -0
  17. package/dist/review/review-backend.d.ts +3 -2
  18. package/dist/review/review-backend.d.ts.map +1 -1
  19. package/dist/review/review-markdown.d.ts +4 -1
  20. package/dist/review/review-markdown.d.ts.map +1 -1
  21. package/dist/review/review-state.d.ts +20 -0
  22. package/dist/review/review-state.d.ts.map +1 -0
  23. package/dist/review/types.d.ts +28 -0
  24. package/dist/review/types.d.ts.map +1 -1
  25. package/dist/store.d.ts +2 -0
  26. package/dist/store.d.ts.map +1 -1
  27. package/dist/tools/index.js +783 -185
  28. package/dist/tools/review.d.ts +30 -0
  29. package/dist/tools/review.d.ts.map +1 -1
  30. package/dist/workflow.d.ts +0 -6
  31. package/dist/workflow.d.ts.map +1 -1
  32. package/package.json +1 -1
  33. package/skills/review/SKILL.md +8 -6
  34. package/skills/review/references/workflows/address.md +11 -0
  35. package/skills/review/references/workflows/auto.md +18 -0
  36. package/skills/review/references/workflows/complete.md +7 -0
  37. package/skills/review/references/workflows/edit.md +7 -0
  38. package/skills/review/references/workflows/help.md +30 -0
  39. package/skills/review/references/workflows/merge.md +6 -0
  40. package/skills/review/references/workflows/new.md +19 -0
  41. package/skills/review/references/workflows/open.md +3 -0
  42. package/skills/review/references/workflows/publish.md +8 -0
  43. package/dist/review/local-reviews.d.ts +0 -31
  44. package/dist/review/local-reviews.d.ts.map +0 -1
  45. package/workflows/review/address.md +0 -8
  46. package/workflows/review/auto.md +0 -8
  47. package/workflows/review/complete.md +0 -7
  48. package/workflows/review/edit.md +0 -7
  49. package/workflows/review/help.md +0 -14
  50. package/workflows/review/merge.md +0 -6
  51. package/workflows/review/new.md +0 -7
  52. package/workflows/review/publish.md +0 -6
@@ -16642,14 +16642,8 @@ 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
- 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);
16645
+ async function loadPlanWorkflow(name, args) {
16646
+ const workflowDir = join6(resolveBundledWorkflowsDir(), "plan");
16653
16647
  const content = await readFile3(join6(workflowDir, `${name}.md`), "utf8");
16654
16648
  return [`Workflow directory: ${workflowDir}`, content.trim(), `Arguments:
16655
16649
  ${JSON.stringify(args, null, 2)}`].join(`
@@ -16860,130 +16854,46 @@ function registerReloadCommand(pi) {
16860
16854
  }
16861
16855
 
16862
16856
  // src/commands/review.ts
16857
+ var REVIEWER_VERBS = new Set(["address", "auto", "launch"]);
16863
16858
  function registerReviewCommand(pi, modes) {
16864
16859
  pi.registerCommand("review", {
16865
16860
  description: "Code review: auto, new, edit, address, publish, complete, merge (add --local or --bg)",
16866
- handler: async (args, ctx) => runReviewCommand(args, { pi, ctx, modes })
16861
+ handler: async (args, ctx) => handleReviewCommand(args, ctx, pi, modes)
16867
16862
  });
16868
16863
  }
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");
16879
- }
16880
- }
16881
- function createReviewCommand(runtime) {
16882
- const invoke = (workflow) => async (args) => {
16883
- await invokeReviewWorkflow(runtime, {
16884
- workflow,
16885
- invocation: serializeInvocation(args),
16886
- background: args.background
16887
- });
16888
- };
16889
- const commands = {
16890
- auto: reviewSubcommand("auto", invoke("auto")),
16891
- new: reviewSubcommand("new", invoke("new")),
16892
- edit: reviewSubcommand("edit", invoke("edit")),
16893
- address: reviewSubcommand("address", invoke("address")),
16894
- publish: reviewSubcommand("publish", invoke("publish")),
16895
- complete: reviewSubcommand("complete", invoke("complete")),
16896
- merge: reviewSubcommand("merge", invoke("merge")),
16897
- help: command({
16898
- name: "help",
16899
- args: {},
16900
- handler: async () => invokeReviewWorkflow(runtime, { workflow: "help", invocation: "", background: false })
16901
- })
16902
- };
16903
- return subcommands({
16904
- name: "review",
16905
- cmds: {
16906
- ...commands,
16907
- open: commands.new,
16908
- create: commands.new,
16909
- draft: commands.new,
16910
- launch: commands.auto,
16911
- ready: commands.publish,
16912
- close: commands.complete,
16913
- land: commands.merge
16914
- }
16915
- });
16916
- }
16917
- async function invokeReviewWorkflow(runtime, invocation) {
16918
- const { workflow, background } = invocation;
16919
- const prompt = await loadReviewWorkflow(workflow, { invocation: invocation.invocation, background });
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);
16920
16868
  if (background) {
16921
- await launchBackgroundAgent(runtime.pi.events, {
16922
- name: `Review ${workflow}`,
16869
+ await launchBackgroundAgent(pi.events, {
16870
+ name: `Review ${verb}`,
16923
16871
  agent: "orchestrator",
16924
- cwd: runtime.ctx.cwd,
16925
- inheritContext: true,
16926
- prompt
16872
+ cwd: ctx.cwd,
16873
+ prompt: reviewPrompt(invocation, "orchestrator")
16927
16874
  });
16928
16875
  return;
16929
16876
  }
16930
- const agent = workflow === "auto" || workflow === "address" ? "reviewer" : "orchestrator";
16931
- const activation = await runtime.modes.set(agent, runtime.ctx);
16932
- runtime.pi.sendMessage({
16933
- customType: `diffpi-review-${workflow}-command`,
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",
16934
16881
  display: false,
16935
- content: `${prompt}
16936
-
16937
- ${activation.message}`
16882
+ content: `${reviewPrompt(invocation, agent)} ${activation.message}`
16938
16883
  }, { triggerTurn: true });
16939
16884
  }
16940
- function reviewSubcommand(name, handler) {
16941
- return command({
16942
- name,
16943
- args: {
16944
- background: flag({ long: "bg" }),
16945
- local: flag({ long: "local" }),
16946
- intent: option({ long: "intent", type: optional(string5), defaultValue: () => {
16947
- return;
16948
- } }),
16949
- base: option({ long: "base", type: optional(string5), defaultValue: () => {
16950
- return;
16951
- } }),
16952
- comment: flag({ long: "comment" }),
16953
- approve: flag({ long: "approve" }),
16954
- requestChanges: flag({ long: "request-changes" }),
16955
- close: flag({ long: "close" }),
16956
- reject: flag({ long: "reject" }),
16957
- abandon: flag({ long: "abandon" }),
16958
- values: restPositionals({ type: string5 })
16959
- },
16960
- handler
16961
- });
16885
+ function reviewVerb(invocation) {
16886
+ return invocation.split(/\s+/).find((token) => token && !token.startsWith("-")) ?? "help";
16887
+ }
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.`;
16891
+ }
16892
+ function hasFlag2(input, flag) {
16893
+ return input.split(/\s+/).includes(flag);
16962
16894
  }
16963
- function serializeInvocation(args) {
16964
- const values = [...args.values];
16965
- if (args.intent)
16966
- values.push("--intent", quoteArg(args.intent));
16967
- if (args.base)
16968
- values.push("--base", quoteArg(args.base));
16969
- if (args.local)
16970
- values.push("--local");
16971
- if (args.comment)
16972
- values.push("--comment");
16973
- if (args.approve)
16974
- values.push("--approve");
16975
- if (args.requestChanges)
16976
- values.push("--request-changes");
16977
- if (args.close)
16978
- values.push("--close");
16979
- if (args.reject)
16980
- values.push("--reject");
16981
- if (args.abandon)
16982
- values.push("--abandon");
16983
- return values.join(" ");
16984
- }
16985
- function quoteArg(value) {
16986
- return /\s/.test(value) ? JSON.stringify(value) : value;
16895
+ function withoutFlag(input, flag) {
16896
+ return input.split(/\s+/).filter((token) => token && token !== flag).join(" ").trim();
16987
16897
  }
16988
16898
 
16989
16899
  // src/commands/index.ts
@@ -17864,6 +17774,18 @@ async function reviewsDir(cwd, homeDir = homedir5()) {
17864
17774
  await mkdir2(dir, { recursive: true });
17865
17775
  return dir;
17866
17776
  }
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
+ }
17867
17789
  async function assertStoreLink(path, dest) {
17868
17790
  const entry = await lstat(path);
17869
17791
  if (!entry.isSymbolicLink())
@@ -24686,10 +24608,10 @@ function result2(text, details = {}) {
24686
24608
 
24687
24609
  // src/tools/review.ts
24688
24610
  import { existsSync as existsSync3 } from "node:fs";
24689
- import { mkdir as mkdir8, writeFile as writeFile6 } from "node:fs/promises";
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";
24690
24612
  import { join as join21 } from "node:path";
24691
24613
  import { defineTool as defineTool6 } from "@earendil-works/pi-coding-agent";
24692
- import { z as z11 } from "zod";
24614
+ import { z as z13 } from "zod";
24693
24615
 
24694
24616
  // src/review/types.ts
24695
24617
  import { z as z10 } from "zod";
@@ -24714,6 +24636,21 @@ function yymmdd(date = new Date) {
24714
24636
  function reviewRecordName(target, date = new Date) {
24715
24637
  return `${yymmdd(date)}-${reviewSlug(target) || "uncommitted"}`;
24716
24638
  }
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
+ }
24717
24654
  function dedupeFindings(findings) {
24718
24655
  const rank = { BLOCKING: 3, CONSIDER: 2, NOTE: 1 };
24719
24656
  const byKey = new Map;
@@ -24759,6 +24696,18 @@ function renderCommentBody(finding) {
24759
24696
  return `${prefix}${finding.body}${reference}`;
24760
24697
  }
24761
24698
  // 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
+ });
24762
24711
  function renderReviewDoc(input) {
24763
24712
  const anchored = dedupeFindings(input.findings).filter((finding) => finding.line > 0);
24764
24713
  const lines = [`# Review: ${input.title}`, "", "## Metadata"];
@@ -24800,6 +24749,88 @@ function renderReviewDoc(input) {
24800
24749
  `).trimEnd()}
24801
24750
  `;
24802
24751
  }
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
+ }
24803
24834
  // src/review/github-review-backend.ts
24804
24835
  function GitHubReviewBackend(vcs, number) {
24805
24836
  const pendingReview = async () => {
@@ -24861,6 +24892,9 @@ function GitHubReviewBackend(vcs, number) {
24861
24892
  id: thread.id,
24862
24893
  file: thread.path ?? undefined,
24863
24894
  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] : []),
24864
24898
  body,
24865
24899
  author: comment?.author?.login,
24866
24900
  resolved: thread.isResolved,
@@ -24870,6 +24904,7 @@ function GitHubReviewBackend(vcs, number) {
24870
24904
  });
24871
24905
  };
24872
24906
  return {
24907
+ kind: "remote",
24873
24908
  async stage(draft) {
24874
24909
  const pending = await pendingReview();
24875
24910
  if (!pending) {
@@ -24927,6 +24962,33 @@ function GitHubReviewBackend(vcs, number) {
24927
24962
  await ghChecked(args);
24928
24963
  }
24929
24964
  },
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
+ },
24930
24992
  listThreads,
24931
24993
  async reply(input) {
24932
24994
  const threads = await listThreads();
@@ -24943,14 +25005,32 @@ function GitHubReviewBackend(vcs, number) {
24943
25005
  `body=${input.body}`
24944
25006
  ]);
24945
25007
  }
24946
- if (input.resolve) {
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) {
24947
25027
  await ghChecked([
24948
25028
  "api",
24949
25029
  "graphql",
24950
25030
  "-f",
24951
- `query=${GITHUB_RESOLVE_MUTATION}`,
25031
+ `query=${GITHUB_DELETE_COMMENT_MUTATION}`,
24952
25032
  "-f",
24953
- `threadId=${input.threadId}`
25033
+ `commentId=${commentId}`
24954
25034
  ]);
24955
25035
  }
24956
25036
  },
@@ -24969,11 +25049,13 @@ function GitHubReviewBackend(vcs, number) {
24969
25049
  function githubReviewSubmissionEndpoint(owner, repo, id, pendingReviewId) {
24970
25050
  return pendingReviewId ? `/repos/${owner}/${repo}/pulls/${id}/reviews/${pendingReviewId}/events` : `/repos/${owner}/${repo}/pulls/${id}/reviews`;
24971
25051
  }
24972
- 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}}}}}`;
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}}}}}`;
24973
25053
  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}}}`;
24974
25054
  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}}}`;
24975
25055
  var GITHUB_REPLY_MUTATION = `mutation($threadId:ID!,$body:String!){addPullRequestReviewThreadReply(input:{pullRequestReviewThreadId:$threadId,body:$body}){comment{id}}}`;
24976
25056
  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}}`;
24977
25059
 
24978
25060
  // src/review/gitlab-review-backend.ts
24979
25061
  function GitLabReviewBackend(vcs, number) {
@@ -25010,6 +25092,7 @@ function GitLabReviewBackend(vcs, number) {
25010
25092
  });
25011
25093
  };
25012
25094
  return {
25095
+ kind: "remote",
25013
25096
  async stage(draft) {
25014
25097
  const endpoint = `${mergeRequestEndpoint()}/draft_notes`;
25015
25098
  if (draft.body.trim()) {
@@ -25038,6 +25121,36 @@ function GitLabReviewBackend(vcs, number) {
25038
25121
  });
25039
25122
  }
25040
25123
  },
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
+ },
25041
25154
  listThreads,
25042
25155
  async reply(input) {
25043
25156
  const endpoint = `${mergeRequestEndpoint()}/discussions/${encodeURIComponent(input.threadId)}`;
@@ -25049,7 +25162,15 @@ function GitLabReviewBackend(vcs, number) {
25049
25162
  });
25050
25163
  }
25051
25164
  if (input.resolve)
25052
- await glabChecked(["api", "--method", "PUT", `${endpoint}?resolved=true`]);
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]);
25053
25174
  },
25054
25175
  async publish(event) {
25055
25176
  assertReviewEventSupported(vcs.provider, event);
@@ -25089,17 +25210,8 @@ function hasGitlabDraftNotes(input) {
25089
25210
  }
25090
25211
  }
25091
25212
 
25092
- // src/review/review-backend.ts
25093
- function createRemoteReviewBackend(vcs, number) {
25094
- if (vcs.provider === "github")
25095
- return GitHubReviewBackend(vcs, number);
25096
- if (vcs.provider === "gitlab")
25097
- return GitLabReviewBackend(vcs, number);
25098
- throw new Error("A remote review backend requires a GitHub or GitLab repository.");
25099
- }
25100
- // src/review/local-reviews.ts
25101
- import { mkdir as mkdir7, readdir as readdir3, readFile as readFile12, unlink as unlink2, writeFile as writeFile5 } from "node:fs/promises";
25102
- import { join as join20 } from "node:path";
25213
+ // src/review/local-review-backend.ts
25214
+ import { readFile as readFile12, writeFile as writeFile5 } from "node:fs/promises";
25103
25215
 
25104
25216
  // src/extensions/tuicrx.ts
25105
25217
  import { readFile as readFile11, realpath as realpath4 } from "node:fs/promises";
@@ -25170,6 +25282,11 @@ async function resolveReviewSession(cwd, target) {
25170
25282
  }
25171
25283
  return resolveSession(cwd, target.branch);
25172
25284
  }
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
+ }
25173
25290
  async function resolvePrSession(cwd, owner, repo, number) {
25174
25291
  const coordinate = `${owner}/${repo}`.toLowerCase();
25175
25292
  const sessions = await listSessions(cwd);
@@ -25204,6 +25321,8 @@ async function readSession(path) {
25204
25321
  function toLocalReviewThreads(session) {
25205
25322
  const threads = [];
25206
25323
  const add = (comment, location = {}) => {
25324
+ if (isLocalResponse(comment.content))
25325
+ return;
25207
25326
  const author = commentAuthor(comment);
25208
25327
  threads.push({
25209
25328
  id: comment.id ?? `local-${threads.length + 1}`,
@@ -25229,6 +25348,54 @@ function toLocalReviewThreads(session) {
25229
25348
  }
25230
25349
  return threads;
25231
25350
  }
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
+ }
25232
25399
  async function requireTuicr() {
25233
25400
  if (!await tuicrAvailable())
25234
25401
  throw new Error("tuicr is not installed or is not available on PATH.");
@@ -25244,83 +25411,167 @@ async function canonicalPath3(path) {
25244
25411
  }
25245
25412
  }
25246
25413
 
25247
- // src/review/local-reviews.ts
25248
- async function captureLocalReview(input) {
25249
- const id = reviewSlug(input.branch) || "local-review";
25250
- const dir = join20(await reviewsDir(input.cwd, input.homeDir), id);
25251
- await mkdir7(dir, { recursive: true });
25252
- const revision = await nextRevision(dir);
25253
- const output = await readFile12(input.session.path, "utf8");
25254
- const comments = toLocalReviewThreads(await readSession(input.session.path));
25255
- const dump = {
25256
- schemaVersion: 1,
25257
- id,
25258
- revision,
25259
- branch: input.branch,
25260
- base: input.base,
25261
- createdAt: (input.now ?? (() => new Date))().toISOString(),
25262
- diff: input.diff,
25263
- comments,
25264
- tuicr: { slug: input.session.slug, output }
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
+ }
25265
25449
  };
25266
- const path = join20(dir, `${revision}.json`);
25267
- await writeFile5(path, `${JSON.stringify(dump, null, 2)}
25268
- `, { encoding: "utf8", flag: "wx" });
25269
- await unlink2(input.session.path);
25270
- return { path, dump };
25271
25450
  }
25272
- async function nextRevision(dir) {
25273
- return await latestRevision(dir) + 1;
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}`);
25274
25484
  }
25275
- async function latestRevision(dir) {
25485
+ function unpublishedReviewComments(comments, knownFingerprints) {
25486
+ return comments.filter((comment) => !knownFingerprints.has(reviewCommentFingerprint(comment)));
25487
+ }
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`);
25276
25491
  try {
25277
- return (await readdir3(dir)).reduce((latest, name) => {
25278
- const match = name.match(/^(\d+)\.json$/);
25279
- return match ? Math.max(latest, Number(match[1])) : latest;
25280
- }, 0);
25492
+ const parsed = reviewPublicationStateSchema.parse(JSON.parse(await readFile13(path, "utf8")));
25493
+ return { path, state: { ...parsed, target } };
25281
25494
  } catch (error) {
25282
- if (error.code === "ENOENT")
25283
- return 0;
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
+ }
25284
25501
  throw error;
25285
25502
  }
25286
25503
  }
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
+ }
25287
25513
  // src/tools/review.ts
25288
- var contextSchema = z11.object({
25289
- cwd: z11.string().optional(),
25290
- target: z11.string().optional(),
25291
- workingTree: z11.boolean().optional()
25514
+ var contextSchema = z13.object({
25515
+ cwd: z13.string().optional(),
25516
+ target: z13.string().optional(),
25517
+ workingTree: z13.boolean().optional()
25292
25518
  });
25293
- var localSchema = contextSchema.extend({ local: z11.boolean().optional() });
25519
+ var localSchema = contextSchema.extend({ local: z13.boolean().optional() });
25294
25520
  var openSchema = localSchema.extend({
25295
- title: z11.string().optional(),
25296
- intent: z11.string().optional(),
25297
- issueUrl: z11.string().url().optional(),
25298
- base: z11.string().optional()
25521
+ title: z13.string().optional(),
25522
+ intent: z13.string().optional(),
25523
+ issueUrl: z13.string().url().optional(),
25524
+ base: z13.string().optional()
25299
25525
  });
25300
25526
  var submitSchema = localSchema.extend({
25301
25527
  findings: findingsSchema,
25302
- overallIssues: z11.array(z11.string()).optional(),
25303
- notVerified: z11.array(z11.string()).optional(),
25304
- title: z11.string().optional()
25528
+ overallIssues: z13.array(z13.string()).optional(),
25529
+ notVerified: z13.array(z13.string()).optional(),
25530
+ title: z13.string().optional()
25305
25531
  });
25306
25532
  var addCommentSchema = localSchema.extend({
25307
- body: z11.string().min(1),
25308
- file: z11.string().min(1),
25309
- line: z11.number().int().positive(),
25310
- side: z11.enum(["LEFT", "RIGHT"]).optional()
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()
25311
25537
  });
25312
25538
  var respondSchema = localSchema.extend({
25313
- threadId: z11.string().min(1),
25314
- body: z11.string().min(1),
25315
- question: z11.boolean().optional(),
25316
- resolve: z11.boolean().optional()
25539
+ threadId: z13.string().min(1),
25540
+ body: z13.string().min(1),
25541
+ question: z13.boolean().optional(),
25542
+ resolve: z13.boolean().optional()
25317
25543
  });
25318
25544
  var publishSchema = localSchema.extend({
25319
- status: z11.enum(["COMMENT", "APPROVE", "REQUEST_CHANGES", "CLOSE"]).optional()
25545
+ status: z13.enum(["COMMENT", "APPROVE", "REQUEST_CHANGES", "CLOSE"]).optional()
25320
25546
  });
25321
25547
  var completeSchema = localSchema.extend({
25322
- action: z11.enum(["approve", "reject", "abandon"]).optional()
25548
+ action: z13.enum(["approve", "reject", "abandon"]).optional()
25323
25549
  });
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
+ }
25324
25575
  async function workingTreeDiff(cwd) {
25325
25576
  const tracked = await run2("git", ["-C", cwd, "diff", "HEAD"], { capture: "unbounded" });
25326
25577
  if (tracked.code !== 0)
@@ -25516,7 +25767,7 @@ ${openMessage}`, {
25516
25767
  const gates = await runMiseGates(review.cwd);
25517
25768
  const baseRef = review.pr?.baseRef ?? (review.forge ? await review.forge.defaultBranch() : "local");
25518
25769
  const artifact = await newReviewArtifactPath(review.cwd, params.local || params.workingTree || !review.pr ? "uncommitted" : await reviewTargetId(review));
25519
- await writeFile6(artifact, renderReviewDoc({
25770
+ await writeFile7(artifact, renderReviewDoc({
25520
25771
  title: params.title ?? review.pr?.title ?? review.vcs.branch,
25521
25772
  number: useLocalBackend ? undefined : review.pr?.number,
25522
25773
  url: useLocalBackend ? undefined : review.pr?.url,
@@ -25539,21 +25790,17 @@ ${openMessage}`, {
25539
25790
  count: findings.length
25540
25791
  });
25541
25792
  }
25542
- for (const comment of comments) {
25543
- await addComment(session.path, comment.body, {
25544
- targetFile: comment.file,
25545
- line: comment.line,
25546
- side: comment.side === "LEFT" ? "old" : "new",
25547
- username: localReviewAuthor(model)
25548
- });
25549
- }
25550
- if (body.trim())
25551
- await addComment(session.path, body, { username: localReviewAuthor(model) });
25793
+ const backend = createLocalReviewBackend({
25794
+ session: session.path,
25795
+ artifactPath: artifact,
25796
+ author: localReviewAuthor(model)
25797
+ });
25798
+ await backend.stage({ comments, body });
25552
25799
  return result3(`Local review staged in tuicr: ${artifact}`, { artifact, count: findings.length, session });
25553
25800
  }
25554
25801
  if (!review.pr)
25555
25802
  return result3(`Review written: ${artifact}. No PR/MR matches this remote target.`, { artifact });
25556
- const hasDraft = comments.length > 0 || body.trim().length > 0;
25803
+ const hasDraft = hasReviewDraft(comments, body);
25557
25804
  if (hasDraft) {
25558
25805
  await createRemoteReviewBackend(review.vcs, review.pr.number).stage({ comments, body });
25559
25806
  }
@@ -25589,12 +25836,12 @@ ${openMessage}`, {
25589
25836
  const session = await resolveTuicrSession(review, Boolean(params.local || params.workingTree));
25590
25837
  if (!session)
25591
25838
  return result3("No matching tuicr session. Open review_new or review_launch_ui first.");
25592
- await addComment(session.path, comment.body, {
25593
- targetFile: comment.file,
25594
- line: comment.line,
25595
- side: comment.side === "LEFT" ? "old" : "new",
25596
- username: localReviewAuthor(model)
25839
+ const backend = createLocalReviewBackend({
25840
+ session: session.path,
25841
+ artifactPath: "",
25842
+ author: localReviewAuthor(model)
25597
25843
  });
25844
+ await backend.stage({ comments: [comment], body: "" });
25598
25845
  return result3(`Comment added to tuicr session ${session.slug}.`, { session, comment });
25599
25846
  }
25600
25847
  if (!review.pr)
@@ -25606,70 +25853,88 @@ ${openMessage}`, {
25606
25853
  defineTool6({
25607
25854
  name: "review_comments",
25608
25855
  label: "review comments",
25609
- description: "Read remote GitHub or GitLab review threads.",
25610
- promptSnippet: "Call review_comments before addressing remote findings",
25611
- promptGuidelines: ["Use review_dump for a local tuicr review."],
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."],
25612
25859
  parameters: parameters6(localSchema),
25613
- executionMode: "parallel",
25860
+ executionMode: "sequential",
25614
25861
  async execute(_id, input) {
25615
25862
  const params = localSchema.parse(input);
25616
- if (params.local || params.workingTree)
25617
- return result3("Use review_dump for a local tuicr review.");
25618
25863
  const review = await resolveReviewContext(resolveWorkingDirectory(params), params.target);
25619
- if (!review.pr || !review.forge)
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 {
25620
25878
  return result3(review.forge ? "No PR/MR matches this remote review target." : unsupportedForgeMessage());
25621
- const threads = await createRemoteReviewBackend(review.vcs, review.pr.number).listThreads();
25879
+ }
25622
25880
  return result3(threads.map((thread) => `${thread.id} ${thread.file ?? "review"}:${thread.line ?? "-"} — ${thread.body}`).join(`
25623
- `) || "No comments.", { pr: review.pr, threads });
25624
- }
25625
- }),
25626
- defineTool6({
25627
- name: "review_dump",
25628
- label: "review dump",
25629
- description: "Save one immutable local tuicr review revision and remove its completed session.",
25630
- promptSnippet: "Call review_dump before applying local review feedback",
25631
- promptGuidelines: ["Each dump advances the local review revision."],
25632
- parameters: parameters6(localSchema),
25633
- executionMode: "sequential",
25634
- async execute(_id, input) {
25635
- const params = localSchema.parse(input);
25636
- const review = await resolveReviewContext(resolveWorkingDirectory(params), params.target);
25637
- const session = await resolveTuicrSession(review, true);
25638
- if (!session)
25639
- return result3("No matching local tuicr session found.");
25640
- const base = review.pr?.baseRef ?? (review.forge ? await review.forge.defaultBranch() : "HEAD");
25641
- const captured = await captureLocalReview({
25642
- cwd: review.cwd,
25643
- branch: review.vcs.branch,
25644
- base,
25645
- diff: await workingTreeDiff(review.cwd),
25646
- session
25647
- });
25648
- return result3(captured.dump.comments.map((thread) => `${thread.id} ${thread.file ?? "review"}:${thread.line ?? "-"} — ${thread.body}`).join(`
25649
- `) || "No comments.", { artifact: captured.path, review: captured.dump, threads: captured.dump.comments });
25881
+ `) || "No comments.", { artifact, threads });
25650
25882
  }
25651
25883
  }),
25652
25884
  defineTool6({
25653
25885
  name: "review_respond",
25654
25886
  label: "review respond",
25655
- description: "Reply directly to a remote GitHub or GitLab review thread.",
25656
- promptSnippet: "Call review_respond after addressing a remote comment",
25657
- promptGuidelines: ["Leave threads open unless the user explicitly requests resolution."],
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."],
25658
25890
  parameters: parameters6(respondSchema),
25659
25891
  executionMode: "sequential",
25660
25892
  async execute(_id, input, _signal, _onUpdate, ctx) {
25661
25893
  const params = respondSchema.parse(input);
25662
- if (params.local)
25663
- return result3("Local reviews use immutable revision dumps, not thread replies.");
25664
25894
  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
+ }
25665
25926
  if (!review.pr || !review.forge)
25666
25927
  return result3("No remote PR/MR for this reply.");
25667
- const resolve = params.question ? false : params.resolve ?? false;
25668
- await createRemoteReviewBackend(review.vcs, review.pr.number).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({
25669
25934
  threadId: params.threadId,
25670
- body: withRemoteProvenance(params.body, modelRoute(ctx)),
25935
+ body: withRemoteProvenance(params.body, model),
25671
25936
  resolve,
25672
- question: params.question
25937
+ question
25673
25938
  });
25674
25939
  return result3(`Replied to ${params.threadId}${resolve ? " and resolved it" : " and left it open"}.`, {
25675
25940
  pr: review.pr,
@@ -25680,40 +25945,51 @@ ${openMessage}`, {
25680
25945
  defineTool6({
25681
25946
  name: "review_publish",
25682
25947
  label: "review publish",
25683
- description: "Publish pending remote review comments and status.",
25948
+ description: "Publish pending remote draft PR/MR review comments and status, or promote a local tuicr draft before publishing.",
25684
25949
  promptSnippet: "Call review_publish to make remote review work public",
25685
- promptGuidelines: ["Local reviews use revision dumps and are never published remotely."],
25950
+ promptGuidelines: ["Statuses are COMMENT, APPROVE, REQUEST_CHANGES, or CLOSE."],
25686
25951
  parameters: parameters6(publishSchema),
25687
25952
  executionMode: "sequential",
25688
- async execute(_id, input) {
25953
+ async execute(_id, input, _signal, _onUpdate, ctx) {
25689
25954
  const params = publishSchema.parse(input);
25690
- if (params.local)
25691
- return result3("Local reviews use immutable revision dumps and have no publish step.");
25692
25955
  const review = await resolveReviewContext(resolveWorkingDirectory(params), params.target);
25693
25956
  if (!review.pr || !review.forge)
25694
25957
  return result3("No remote PR/MR to publish.");
25695
- return publishResolvedReview(review, params);
25958
+ return publishResolvedReview(review, params, modelRoute(ctx));
25696
25959
  }
25697
25960
  }),
25698
25961
  defineTool6({
25699
25962
  name: "review_complete",
25700
25963
  label: "review complete",
25701
- description: "Approve, reject, or abandon a remote review.",
25702
- promptSnippet: "Call review_complete to finish a remote review without merging it.",
25703
- promptGuidelines: ["Use review_dump to finish a local review revision."],
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."],
25704
25967
  parameters: parameters6(completeSchema),
25705
25968
  executionMode: "sequential",
25706
- async execute(_id, input) {
25969
+ async execute(_id, input, _signal, _onUpdate, ctx) {
25707
25970
  const params = completeSchema.parse(input);
25708
- if (params.local)
25709
- return result3("Use review_dump to finish a local review revision.");
25710
25971
  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
+ }
25711
25987
  if (!review.pr || !review.forge)
25712
25988
  return result3("No remote PR/MR to complete.");
25713
25989
  if (!params.action)
25714
25990
  return result3("Choose a complete action: approve, reject, or abandon.");
25715
25991
  const status = { approve: "APPROVE", reject: "REQUEST_CHANGES", abandon: "CLOSE" }[params.action];
25716
- return publishResolvedReview(review, { ...params, status });
25992
+ return publishResolvedReview(review, { ...params, status }, modelRoute(ctx));
25717
25993
  }
25718
25994
  }),
25719
25995
  defineTool6({
@@ -25722,10 +25998,10 @@ ${openMessage}`, {
25722
25998
  description: "Squash-merge an open, ready GitHub PR after checking its conventional subject.",
25723
25999
  promptSnippet: "Call review_merge after confirming the PR is open, ready, and CI is settled; approval is optional",
25724
26000
  promptGuidelines: ["This is intentionally GitHub-only until GitLab merge support is added."],
25725
- parameters: parameters6(contextSchema.extend({ subject: z11.string().optional() })),
26001
+ parameters: parameters6(contextSchema.extend({ subject: z13.string().optional() })),
25726
26002
  executionMode: "sequential",
25727
26003
  async execute(_id, input) {
25728
- const params = contextSchema.extend({ subject: z11.string().optional() }).parse(input);
26004
+ const params = contextSchema.extend({ subject: z13.string().optional() }).parse(input);
25729
26005
  const review = await resolveReviewContext(resolveWorkingDirectory(params), params.target);
25730
26006
  if (review.vcs.provider !== "github" || !review.forge)
25731
26007
  return result3("review_merge currently supports GitHub only.");
@@ -25758,8 +26034,32 @@ ${openMessage}`, {
25758
26034
  })
25759
26035
  ];
25760
26036
  }
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
+ }
25761
26061
  function parameters6(schema) {
25762
- return z11.toJSONSchema(schema, { io: "input" });
26062
+ return z13.toJSONSchema(schema, { io: "input" });
25763
26063
  }
25764
26064
  function resolveWorkingDirectory(params) {
25765
26065
  return params.cwd ?? process.cwd();
@@ -25775,17 +26075,159 @@ function modelRoute(ctx) {
25775
26075
  throw new Error("Cannot record review provenance because Pi did not provide an active model route.");
25776
26076
  return `${ctx.model.provider}/${ctx.model.id}`;
25777
26077
  }
25778
- async function publishResolvedReview(review, params) {
26078
+ async function publishResolvedReview(review, params, model) {
25779
26079
  const status = params.status ?? "COMMENT";
25780
26080
  const event = status === "CLOSE" ? "COMMENT" : status;
25781
26081
  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;
25782
26084
  if (status !== "CLOSE" && review.pr.isDraft)
25783
26085
  await review.forge.markReady(review.pr.number);
25784
- await createRemoteReviewBackend(review.vcs, review.pr.number).publish(event);
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
+ }
25785
26098
  if (status === "CLOSE")
25786
26099
  await review.forge.closePr(review.pr.number);
25787
26100
  const finalPr = await review.forge.viewPr(String(review.pr.number));
25788
- return result3(`Published #${review.pr.number} (${status}).`, { pr: finalPr ?? review.pr, status });
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;
25789
26231
  }
25790
26232
  async function resolveReviewContext(cwd, target) {
25791
26233
  const vcs = await detectVcs(cwd);
@@ -25826,6 +26268,10 @@ async function assertRemoteBranchReady(cwd) {
25826
26268
  if (head.stdout.trim() !== pushed.stdout.trim())
25827
26269
  throw new Error("Push the current branch before opening a remote review.");
25828
26270
  }
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
+ }
25829
26275
  async function reviewTargetId(review) {
25830
26276
  if (!review.pr)
25831
26277
  return "uncommitted";
@@ -25842,9 +26288,60 @@ async function headSha(cwd) {
25842
26288
  }
25843
26289
  async function newReviewArtifactPath(cwd, target) {
25844
26290
  const dir = await reviewsDir(cwd);
25845
- await mkdir8(dir, { recursive: true });
26291
+ await mkdir7(dir, { recursive: true });
25846
26292
  return uniqueRecordPath(dir, reviewRecordName(target));
25847
26293
  }
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
+ }
25848
26345
  function deriveTitle(branch) {
25849
26346
  return branch.replace(/^(feature|feat|fix|bug|chore)\//, "").replace(/^eng-\d+-/i, "").replace(/[-_]+/g, " ").replace(/^\w/, (char) => char.toUpperCase());
25850
26347
  }
@@ -25855,19 +26352,30 @@ function uniqueRecordPath(dir, base) {
25855
26352
  path = join21(dir, `${base}-${count++}.md`);
25856
26353
  return path;
25857
26354
  }
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
+ }
25858
26366
 
25859
26367
  // src/tools/setup.ts
25860
26368
  import { defineTool as defineTool7 } from "@earendil-works/pi-coding-agent";
25861
- import { z as z12 } from "zod";
26369
+ import { z as z14 } from "zod";
25862
26370
 
25863
26371
  // src/setup.ts
25864
26372
  import { parseFrontmatter as parseFrontmatter3 } from "@earendil-works/pi-coding-agent";
25865
- import { readdir as readdir4, readFile as readFile14 } from "node:fs/promises";
26373
+ import { readdir as readdir4, readFile as readFile16 } from "node:fs/promises";
25866
26374
  import { homedir as homedir10 } from "node:os";
25867
26375
  import { basename as basename7, join as join24 } from "node:path";
25868
26376
 
25869
26377
  // src/mcp.ts
25870
- import { mkdir as mkdir9, readFile as readFile13, writeFile as writeFile7 } from "node:fs/promises";
26378
+ import { mkdir as mkdir8, readFile as readFile15, writeFile as writeFile8 } from "node:fs/promises";
25871
26379
  import { homedir as homedir8 } from "node:os";
25872
26380
  import { dirname as dirname9, join as join22 } from "node:path";
25873
26381
  var mcp = {
@@ -25885,8 +26393,8 @@ var mcp = {
25885
26393
  const next = { ...current, mcpServers: nextServers };
25886
26394
  const changed = JSON.stringify(current) !== JSON.stringify(next);
25887
26395
  if (changed && !options.dryRun) {
25888
- await mkdir9(dirname9(path), { recursive: true });
25889
- await writeFile7(path, `${JSON.stringify(next, null, 2)}
26396
+ await mkdir8(dirname9(path), { recursive: true });
26397
+ await writeFile8(path, `${JSON.stringify(next, null, 2)}
25890
26398
  `, "utf8");
25891
26399
  }
25892
26400
  return { path, changed, existed: currentText !== undefined, planned: changed && options.dryRun === true };
@@ -25915,7 +26423,7 @@ function getParsedConfig(content, path) {
25915
26423
  }
25916
26424
  async function getOptionalFile2(path) {
25917
26425
  try {
25918
- return await readFile13(path, "utf8");
26426
+ return await readFile15(path, "utf8");
25919
26427
  } catch (error) {
25920
26428
  if (error instanceof Error && "code" in error && error.code === "ENOENT")
25921
26429
  return;
@@ -25927,7 +26435,7 @@ function isRecord(value) {
25927
26435
  }
25928
26436
 
25929
26437
  // src/pi.ts
25930
- import { mkdir as mkdir10, writeFile as writeFile8 } from "node:fs/promises";
26438
+ import { mkdir as mkdir9, writeFile as writeFile9 } from "node:fs/promises";
25931
26439
  import { homedir as homedir9 } from "node:os";
25932
26440
  import { dirname as dirname10, join as join23 } from "node:path";
25933
26441
  var pi = {
@@ -25946,8 +26454,8 @@ async function ensurePiAgent(filename, content, agentDir = resolvePiAgentDir(),
25946
26454
  const currentText = await readTextIfExists(path);
25947
26455
  const changed = currentText !== content;
25948
26456
  if (changed && !dryRun) {
25949
- await mkdir10(dirname10(path), { recursive: true });
25950
- await writeFile8(path, content, "utf8");
26457
+ await mkdir9(dirname10(path), { recursive: true });
26458
+ await writeFile9(path, content, "utf8");
25951
26459
  }
25952
26460
  return { path, changed, existed: currentText !== undefined, planned: changed && dryRun };
25953
26461
  }
@@ -25983,8 +26491,8 @@ async function ensurePiConfig(path, update, dryRun = false) {
25983
26491
  const next = update(current);
25984
26492
  const changed = JSON.stringify(current) !== JSON.stringify(next);
25985
26493
  if (changed && !dryRun) {
25986
- await mkdir10(dirname10(path), { recursive: true });
25987
- await writeFile8(path, `${JSON.stringify(next, null, 2)}
26494
+ await mkdir9(dirname10(path), { recursive: true });
26495
+ await writeFile9(path, `${JSON.stringify(next, null, 2)}
25988
26496
  `, "utf8");
25989
26497
  }
25990
26498
  return { path, changed, existed: currentText !== undefined, planned: changed && dryRun };
@@ -26122,7 +26630,7 @@ async function ensurePiAgents(options = {}) {
26122
26630
  const actions = [];
26123
26631
  for (const entry of entries) {
26124
26632
  const id = basename7(entry.name, ".md").replace(/^diffpi-/, "");
26125
- const source = await readFile14(join24(bundledAgentsDir, entry.name), "utf8");
26633
+ const source = await readFile16(join24(bundledAgentsDir, entry.name), "utf8");
26126
26634
  const content = materializeAgentModels(source, id, userConfig.config, options.availableModels);
26127
26635
  const result = await pi.agentEnsure(entry.name, content, agentDir, options.dryRun);
26128
26636
  actions.push(getConfigSetupAction(`pi agent ${id}`, result));
@@ -26327,7 +26835,7 @@ function reportProgress(options, message) {
26327
26835
  options.onProgress?.(message);
26328
26836
  }
26329
26837
  async function packageVersion2() {
26330
- const source = await readFile14(join24(resolveBundledAgentsDir(), "..", "package.json"), "utf8");
26838
+ const source = await readFile16(join24(resolveBundledAgentsDir(), "..", "package.json"), "utf8");
26331
26839
  let value;
26332
26840
  try {
26333
26841
  value = JSON.parse(source);
@@ -26343,13 +26851,13 @@ function getRecord(value) {
26343
26851
  }
26344
26852
 
26345
26853
  // src/tools/setup.ts
26346
- var setupParametersSchema = z12.object({
26347
- 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."),
26348
- forges: z12.array(z12.enum(["github", "gitlab"])).default([]).describe("Hosted VCS MCP servers to configure. Installs matching CLIs and registers each MCP server."),
26349
- forge: z12.enum(["none", "github", "gitlab"]).optional().describe("Deprecated single-host alias. Prefer forges for one or more VCS MCP servers."),
26350
- bindZedKey: z12.boolean().default(false).describe("Opt in to a Zed keybinding for the tuicr review task.")
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.")
26351
26859
  });
26352
- var setupParameters = z12.toJSONSchema(setupParametersSchema, { io: "input" });
26860
+ var setupParameters = z14.toJSONSchema(setupParametersSchema, { io: "input" });
26353
26861
  var diffpiSetupTool = defineTool7({
26354
26862
  name: "diffpi_setup",
26355
26863
  label: "diffpi setup",
@@ -26422,11 +26930,11 @@ ${lines.join(`
26422
26930
 
26423
26931
  // src/tools/templates.ts
26424
26932
  import { defineTool as defineTool8 } from "@earendil-works/pi-coding-agent";
26425
- import { z as z13 } from "zod";
26426
- var templateSchema = z13.object({
26427
- name: z13.string().min(1),
26428
- variables: z13.record(z13.string(), z13.string()).optional(),
26429
- homeDir: z13.string().optional()
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()
26430
26938
  });
26431
26939
  var diffpiTemplateTool = defineTool8({
26432
26940
  name: "diffpi_template",
@@ -26434,7 +26942,7 @@ var diffpiTemplateTool = defineTool8({
26434
26942
  description: "Load a bundled Diffpi template or a user override and render named variables.",
26435
26943
  promptSnippet: "Use diffpi_template for package workflow templates",
26436
26944
  promptGuidelines: ["User overrides live under ~/.difflab/diffpi/templates."],
26437
- parameters: z13.toJSONSchema(templateSchema, { io: "input" }),
26945
+ parameters: z15.toJSONSchema(templateSchema, { io: "input" }),
26438
26946
  executionMode: "parallel",
26439
26947
  async execute(_id, input) {
26440
26948
  const params = templateSchema.parse(input);