@difflab/pi 0.5.0-rc.202609231313.eaf7fa0 → 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 (53) hide show
  1. package/README.md +5 -5
  2. package/agents/diffpi-copilot.md +3 -6
  3. package/agents/diffpi-reviewer.md +3 -3
  4. package/dist/commands/review.d.ts +1 -0
  5. package/dist/commands/review.d.ts.map +1 -1
  6. package/dist/extensions/index.js +810 -344
  7. package/dist/extensions/tuicrx.d.ts +8 -1
  8. package/dist/extensions/tuicrx.d.ts.map +1 -1
  9. package/dist/index.d.ts +8 -7
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +458 -197
  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-review-backend.d.ts +3 -0
  17. package/dist/review/local-review-backend.d.ts.map +1 -0
  18. package/dist/review/review-backend.d.ts +3 -2
  19. package/dist/review/review-backend.d.ts.map +1 -1
  20. package/dist/review/review-markdown.d.ts +4 -1
  21. package/dist/review/review-markdown.d.ts.map +1 -1
  22. package/dist/review/review-state.d.ts +20 -0
  23. package/dist/review/review-state.d.ts.map +1 -0
  24. package/dist/review/types.d.ts +28 -0
  25. package/dist/review/types.d.ts.map +1 -1
  26. package/dist/store.d.ts +2 -0
  27. package/dist/store.d.ts.map +1 -1
  28. package/dist/tools/index.js +783 -185
  29. package/dist/tools/review.d.ts +30 -0
  30. package/dist/tools/review.d.ts.map +1 -1
  31. package/dist/workflow.d.ts +0 -47
  32. package/dist/workflow.d.ts.map +1 -1
  33. package/package.json +1 -1
  34. package/skills/review/SKILL.md +8 -6
  35. package/skills/review/references/workflows/address.md +11 -0
  36. package/skills/review/references/workflows/auto.md +18 -0
  37. package/skills/review/references/workflows/complete.md +7 -0
  38. package/skills/review/references/workflows/edit.md +7 -0
  39. package/skills/review/references/workflows/help.md +30 -0
  40. package/skills/review/references/workflows/merge.md +6 -0
  41. package/skills/review/references/workflows/new.md +19 -0
  42. package/skills/review/references/workflows/open.md +3 -0
  43. package/skills/review/references/workflows/publish.md +8 -0
  44. package/dist/review/local-reviews.d.ts +0 -31
  45. package/dist/review/local-reviews.d.ts.map +0 -1
  46. package/workflows/review/address.md +0 -8
  47. package/workflows/review/auto.md +0 -8
  48. package/workflows/review/complete.md +0 -7
  49. package/workflows/review/edit.md +0 -7
  50. package/workflows/review/help.md +0 -14
  51. package/workflows/review/merge.md +0 -6
  52. package/workflows/review/new.md +0 -7
  53. 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,172 +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
- description: "Code review: auto, new, edit, address, publish, complete, merge, help",
16866
- handler: async (args, ctx) => runReviewCommand(args, { pi, ctx, modes })
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)
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");
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;
16879
16876
  }
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 });
16880
16884
  }
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
- });
16885
+ function reviewVerb(invocation) {
16886
+ return invocation.split(/\s+/).find((token) => token && !token.startsWith("-")) ?? "help";
17002
16887
  }
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
- };
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.`;
17026
16891
  }
17027
- function targetArgument() {
17028
- return positional({ type: optional(string5), displayName: "target" });
16892
+ function hasFlag2(input, flag) {
16893
+ return input.split(/\s+/).includes(flag);
16894
+ }
16895
+ function withoutFlag(input, flag) {
16896
+ return input.split(/\s+/).filter((token) => token && token !== flag).join(" ").trim();
17029
16897
  }
17030
16898
 
17031
16899
  // src/commands/index.ts
@@ -17906,6 +17774,18 @@ async function reviewsDir(cwd, homeDir = homedir5()) {
17906
17774
  await mkdir2(dir, { recursive: true });
17907
17775
  return dir;
17908
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
+ }
17909
17789
  async function assertStoreLink(path, dest) {
17910
17790
  const entry = await lstat(path);
17911
17791
  if (!entry.isSymbolicLink())
@@ -24728,10 +24608,10 @@ function result2(text, details = {}) {
24728
24608
 
24729
24609
  // src/tools/review.ts
24730
24610
  import { existsSync as existsSync3 } from "node:fs";
24731
- 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";
24732
24612
  import { join as join21 } from "node:path";
24733
24613
  import { defineTool as defineTool6 } from "@earendil-works/pi-coding-agent";
24734
- import { z as z11 } from "zod";
24614
+ import { z as z13 } from "zod";
24735
24615
 
24736
24616
  // src/review/types.ts
24737
24617
  import { z as z10 } from "zod";
@@ -24756,6 +24636,21 @@ function yymmdd(date = new Date) {
24756
24636
  function reviewRecordName(target, date = new Date) {
24757
24637
  return `${yymmdd(date)}-${reviewSlug(target) || "uncommitted"}`;
24758
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
+ }
24759
24654
  function dedupeFindings(findings) {
24760
24655
  const rank = { BLOCKING: 3, CONSIDER: 2, NOTE: 1 };
24761
24656
  const byKey = new Map;
@@ -24801,6 +24696,18 @@ function renderCommentBody(finding) {
24801
24696
  return `${prefix}${finding.body}${reference}`;
24802
24697
  }
24803
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
+ });
24804
24711
  function renderReviewDoc(input) {
24805
24712
  const anchored = dedupeFindings(input.findings).filter((finding) => finding.line > 0);
24806
24713
  const lines = [`# Review: ${input.title}`, "", "## Metadata"];
@@ -24842,6 +24749,88 @@ function renderReviewDoc(input) {
24842
24749
  `).trimEnd()}
24843
24750
  `;
24844
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
+ }
24845
24834
  // src/review/github-review-backend.ts
24846
24835
  function GitHubReviewBackend(vcs, number) {
24847
24836
  const pendingReview = async () => {
@@ -24903,6 +24892,9 @@ function GitHubReviewBackend(vcs, number) {
24903
24892
  id: thread.id,
24904
24893
  file: thread.path ?? undefined,
24905
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] : []),
24906
24898
  body,
24907
24899
  author: comment?.author?.login,
24908
24900
  resolved: thread.isResolved,
@@ -24912,6 +24904,7 @@ function GitHubReviewBackend(vcs, number) {
24912
24904
  });
24913
24905
  };
24914
24906
  return {
24907
+ kind: "remote",
24915
24908
  async stage(draft) {
24916
24909
  const pending = await pendingReview();
24917
24910
  if (!pending) {
@@ -24969,6 +24962,33 @@ function GitHubReviewBackend(vcs, number) {
24969
24962
  await ghChecked(args);
24970
24963
  }
24971
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
+ },
24972
24992
  listThreads,
24973
24993
  async reply(input) {
24974
24994
  const threads = await listThreads();
@@ -24985,14 +25005,32 @@ function GitHubReviewBackend(vcs, number) {
24985
25005
  `body=${input.body}`
24986
25006
  ]);
24987
25007
  }
24988
- 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) {
24989
25027
  await ghChecked([
24990
25028
  "api",
24991
25029
  "graphql",
24992
25030
  "-f",
24993
- `query=${GITHUB_RESOLVE_MUTATION}`,
25031
+ `query=${GITHUB_DELETE_COMMENT_MUTATION}`,
24994
25032
  "-f",
24995
- `threadId=${input.threadId}`
25033
+ `commentId=${commentId}`
24996
25034
  ]);
24997
25035
  }
24998
25036
  },
@@ -25011,11 +25049,13 @@ function GitHubReviewBackend(vcs, number) {
25011
25049
  function githubReviewSubmissionEndpoint(owner, repo, id, pendingReviewId) {
25012
25050
  return pendingReviewId ? `/repos/${owner}/${repo}/pulls/${id}/reviews/${pendingReviewId}/events` : `/repos/${owner}/${repo}/pulls/${id}/reviews`;
25013
25051
  }
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}}}}}`;
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}}}}}`;
25015
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}}}`;
25016
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}}}`;
25017
25055
  var GITHUB_REPLY_MUTATION = `mutation($threadId:ID!,$body:String!){addPullRequestReviewThreadReply(input:{pullRequestReviewThreadId:$threadId,body:$body}){comment{id}}}`;
25018
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}}`;
25019
25059
 
25020
25060
  // src/review/gitlab-review-backend.ts
25021
25061
  function GitLabReviewBackend(vcs, number) {
@@ -25052,6 +25092,7 @@ function GitLabReviewBackend(vcs, number) {
25052
25092
  });
25053
25093
  };
25054
25094
  return {
25095
+ kind: "remote",
25055
25096
  async stage(draft) {
25056
25097
  const endpoint = `${mergeRequestEndpoint()}/draft_notes`;
25057
25098
  if (draft.body.trim()) {
@@ -25080,6 +25121,36 @@ function GitLabReviewBackend(vcs, number) {
25080
25121
  });
25081
25122
  }
25082
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
+ },
25083
25154
  listThreads,
25084
25155
  async reply(input) {
25085
25156
  const endpoint = `${mergeRequestEndpoint()}/discussions/${encodeURIComponent(input.threadId)}`;
@@ -25091,7 +25162,15 @@ function GitLabReviewBackend(vcs, number) {
25091
25162
  });
25092
25163
  }
25093
25164
  if (input.resolve)
25094
- 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]);
25095
25174
  },
25096
25175
  async publish(event) {
25097
25176
  assertReviewEventSupported(vcs.provider, event);
@@ -25131,17 +25210,8 @@ function hasGitlabDraftNotes(input) {
25131
25210
  }
25132
25211
  }
25133
25212
 
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";
25213
+ // src/review/local-review-backend.ts
25214
+ import { readFile as readFile12, writeFile as writeFile5 } from "node:fs/promises";
25145
25215
 
25146
25216
  // src/extensions/tuicrx.ts
25147
25217
  import { readFile as readFile11, realpath as realpath4 } from "node:fs/promises";
@@ -25212,6 +25282,11 @@ async function resolveReviewSession(cwd, target) {
25212
25282
  }
25213
25283
  return resolveSession(cwd, target.branch);
25214
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
+ }
25215
25290
  async function resolvePrSession(cwd, owner, repo, number) {
25216
25291
  const coordinate = `${owner}/${repo}`.toLowerCase();
25217
25292
  const sessions = await listSessions(cwd);
@@ -25246,6 +25321,8 @@ async function readSession(path) {
25246
25321
  function toLocalReviewThreads(session) {
25247
25322
  const threads = [];
25248
25323
  const add = (comment, location = {}) => {
25324
+ if (isLocalResponse(comment.content))
25325
+ return;
25249
25326
  const author = commentAuthor(comment);
25250
25327
  threads.push({
25251
25328
  id: comment.id ?? `local-${threads.length + 1}`,
@@ -25271,6 +25348,54 @@ function toLocalReviewThreads(session) {
25271
25348
  }
25272
25349
  return threads;
25273
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
+ }
25274
25399
  async function requireTuicr() {
25275
25400
  if (!await tuicrAvailable())
25276
25401
  throw new Error("tuicr is not installed or is not available on PATH.");
@@ -25286,83 +25411,167 @@ async function canonicalPath3(path) {
25286
25411
  }
25287
25412
  }
25288
25413
 
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 }
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
+ }
25307
25449
  };
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 };
25313
25450
  }
25314
- async function nextRevision(dir) {
25315
- 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);
25316
25462
  }
25317
- async function latestRevision(dir) {
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)));
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`);
25318
25491
  try {
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);
25492
+ const parsed = reviewPublicationStateSchema.parse(JSON.parse(await readFile13(path, "utf8")));
25493
+ return { path, state: { ...parsed, target } };
25323
25494
  } catch (error) {
25324
- if (error.code === "ENOENT")
25325
- 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
+ }
25326
25501
  throw error;
25327
25502
  }
25328
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
+ }
25329
25513
  // src/tools/review.ts
25330
- var contextSchema = z11.object({
25331
- cwd: z11.string().optional(),
25332
- target: z11.string().optional(),
25333
- workingTree: z11.boolean().optional()
25514
+ var contextSchema = z13.object({
25515
+ cwd: z13.string().optional(),
25516
+ target: z13.string().optional(),
25517
+ workingTree: z13.boolean().optional()
25334
25518
  });
25335
- var localSchema = contextSchema.extend({ local: z11.boolean().optional() });
25519
+ var localSchema = contextSchema.extend({ local: z13.boolean().optional() });
25336
25520
  var openSchema = localSchema.extend({
25337
- title: z11.string().optional(),
25338
- intent: z11.string().optional(),
25339
- issueUrl: z11.string().url().optional(),
25340
- 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()
25341
25525
  });
25342
25526
  var submitSchema = localSchema.extend({
25343
25527
  findings: findingsSchema,
25344
- overallIssues: z11.array(z11.string()).optional(),
25345
- notVerified: z11.array(z11.string()).optional(),
25346
- title: z11.string().optional()
25528
+ overallIssues: z13.array(z13.string()).optional(),
25529
+ notVerified: z13.array(z13.string()).optional(),
25530
+ title: z13.string().optional()
25347
25531
  });
25348
25532
  var addCommentSchema = localSchema.extend({
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()
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()
25353
25537
  });
25354
25538
  var respondSchema = localSchema.extend({
25355
- threadId: z11.string().min(1),
25356
- body: z11.string().min(1),
25357
- question: z11.boolean().optional(),
25358
- 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()
25359
25543
  });
25360
25544
  var publishSchema = localSchema.extend({
25361
- status: z11.enum(["COMMENT", "APPROVE", "REQUEST_CHANGES", "CLOSE"]).optional()
25545
+ status: z13.enum(["COMMENT", "APPROVE", "REQUEST_CHANGES", "CLOSE"]).optional()
25362
25546
  });
25363
25547
  var completeSchema = localSchema.extend({
25364
- action: z11.enum(["approve", "reject", "abandon"]).optional()
25548
+ action: z13.enum(["approve", "reject", "abandon"]).optional()
25365
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
+ }
25366
25575
  async function workingTreeDiff(cwd) {
25367
25576
  const tracked = await run2("git", ["-C", cwd, "diff", "HEAD"], { capture: "unbounded" });
25368
25577
  if (tracked.code !== 0)
@@ -25558,7 +25767,7 @@ ${openMessage}`, {
25558
25767
  const gates = await runMiseGates(review.cwd);
25559
25768
  const baseRef = review.pr?.baseRef ?? (review.forge ? await review.forge.defaultBranch() : "local");
25560
25769
  const artifact = await newReviewArtifactPath(review.cwd, params.local || params.workingTree || !review.pr ? "uncommitted" : await reviewTargetId(review));
25561
- await writeFile6(artifact, renderReviewDoc({
25770
+ await writeFile7(artifact, renderReviewDoc({
25562
25771
  title: params.title ?? review.pr?.title ?? review.vcs.branch,
25563
25772
  number: useLocalBackend ? undefined : review.pr?.number,
25564
25773
  url: useLocalBackend ? undefined : review.pr?.url,
@@ -25581,21 +25790,17 @@ ${openMessage}`, {
25581
25790
  count: findings.length
25582
25791
  });
25583
25792
  }
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) });
25793
+ const backend = createLocalReviewBackend({
25794
+ session: session.path,
25795
+ artifactPath: artifact,
25796
+ author: localReviewAuthor(model)
25797
+ });
25798
+ await backend.stage({ comments, body });
25594
25799
  return result3(`Local review staged in tuicr: ${artifact}`, { artifact, count: findings.length, session });
25595
25800
  }
25596
25801
  if (!review.pr)
25597
25802
  return result3(`Review written: ${artifact}. No PR/MR matches this remote target.`, { artifact });
25598
- const hasDraft = comments.length > 0 || body.trim().length > 0;
25803
+ const hasDraft = hasReviewDraft(comments, body);
25599
25804
  if (hasDraft) {
25600
25805
  await createRemoteReviewBackend(review.vcs, review.pr.number).stage({ comments, body });
25601
25806
  }
@@ -25631,12 +25836,12 @@ ${openMessage}`, {
25631
25836
  const session = await resolveTuicrSession(review, Boolean(params.local || params.workingTree));
25632
25837
  if (!session)
25633
25838
  return result3("No matching tuicr session. Open review_new or review_launch_ui first.");
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)
25839
+ const backend = createLocalReviewBackend({
25840
+ session: session.path,
25841
+ artifactPath: "",
25842
+ author: localReviewAuthor(model)
25639
25843
  });
25844
+ await backend.stage({ comments: [comment], body: "" });
25640
25845
  return result3(`Comment added to tuicr session ${session.slug}.`, { session, comment });
25641
25846
  }
25642
25847
  if (!review.pr)
@@ -25648,70 +25853,88 @@ ${openMessage}`, {
25648
25853
  defineTool6({
25649
25854
  name: "review_comments",
25650
25855
  label: "review comments",
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."],
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."],
25654
25859
  parameters: parameters6(localSchema),
25655
- executionMode: "parallel",
25860
+ executionMode: "sequential",
25656
25861
  async execute(_id, input) {
25657
25862
  const params = localSchema.parse(input);
25658
- if (params.local || params.workingTree)
25659
- return result3("Use review_dump for a local tuicr review.");
25660
25863
  const review = await resolveReviewContext(resolveWorkingDirectory(params), params.target);
25661
- 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 {
25662
25878
  return result3(review.forge ? "No PR/MR matches this remote review target." : unsupportedForgeMessage());
25663
- const threads = await createRemoteReviewBackend(review.vcs, review.pr.number).listThreads();
25879
+ }
25664
25880
  return result3(threads.map((thread) => `${thread.id} ${thread.file ?? "review"}:${thread.line ?? "-"} — ${thread.body}`).join(`
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 });
25881
+ `) || "No comments.", { artifact, threads });
25692
25882
  }
25693
25883
  }),
25694
25884
  defineTool6({
25695
25885
  name: "review_respond",
25696
25886
  label: "review respond",
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."],
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."],
25700
25890
  parameters: parameters6(respondSchema),
25701
25891
  executionMode: "sequential",
25702
25892
  async execute(_id, input, _signal, _onUpdate, ctx) {
25703
25893
  const params = respondSchema.parse(input);
25704
- if (params.local)
25705
- return result3("Local reviews use immutable revision dumps, not thread replies.");
25706
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
+ }
25707
25926
  if (!review.pr || !review.forge)
25708
25927
  return result3("No remote PR/MR for this reply.");
25709
- const resolve = params.question ? false : params.resolve ?? false;
25710
- 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({
25711
25934
  threadId: params.threadId,
25712
- body: withRemoteProvenance(params.body, modelRoute(ctx)),
25935
+ body: withRemoteProvenance(params.body, model),
25713
25936
  resolve,
25714
- question: params.question
25937
+ question
25715
25938
  });
25716
25939
  return result3(`Replied to ${params.threadId}${resolve ? " and resolved it" : " and left it open"}.`, {
25717
25940
  pr: review.pr,
@@ -25722,40 +25945,51 @@ ${openMessage}`, {
25722
25945
  defineTool6({
25723
25946
  name: "review_publish",
25724
25947
  label: "review publish",
25725
- 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.",
25726
25949
  promptSnippet: "Call review_publish to make remote review work public",
25727
- promptGuidelines: ["Local reviews use revision dumps and are never published remotely."],
25950
+ promptGuidelines: ["Statuses are COMMENT, APPROVE, REQUEST_CHANGES, or CLOSE."],
25728
25951
  parameters: parameters6(publishSchema),
25729
25952
  executionMode: "sequential",
25730
- async execute(_id, input) {
25953
+ async execute(_id, input, _signal, _onUpdate, ctx) {
25731
25954
  const params = publishSchema.parse(input);
25732
- if (params.local)
25733
- return result3("Local reviews use immutable revision dumps and have no publish step.");
25734
25955
  const review = await resolveReviewContext(resolveWorkingDirectory(params), params.target);
25735
25956
  if (!review.pr || !review.forge)
25736
25957
  return result3("No remote PR/MR to publish.");
25737
- return publishResolvedReview(review, params);
25958
+ return publishResolvedReview(review, params, modelRoute(ctx));
25738
25959
  }
25739
25960
  }),
25740
25961
  defineTool6({
25741
25962
  name: "review_complete",
25742
25963
  label: "review complete",
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."],
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."],
25746
25967
  parameters: parameters6(completeSchema),
25747
25968
  executionMode: "sequential",
25748
- async execute(_id, input) {
25969
+ async execute(_id, input, _signal, _onUpdate, ctx) {
25749
25970
  const params = completeSchema.parse(input);
25750
- if (params.local)
25751
- return result3("Use review_dump to finish a local review revision.");
25752
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
+ }
25753
25987
  if (!review.pr || !review.forge)
25754
25988
  return result3("No remote PR/MR to complete.");
25755
25989
  if (!params.action)
25756
25990
  return result3("Choose a complete action: approve, reject, or abandon.");
25757
25991
  const status = { approve: "APPROVE", reject: "REQUEST_CHANGES", abandon: "CLOSE" }[params.action];
25758
- return publishResolvedReview(review, { ...params, status });
25992
+ return publishResolvedReview(review, { ...params, status }, modelRoute(ctx));
25759
25993
  }
25760
25994
  }),
25761
25995
  defineTool6({
@@ -25764,10 +25998,10 @@ ${openMessage}`, {
25764
25998
  description: "Squash-merge an open, ready GitHub PR after checking its conventional subject.",
25765
25999
  promptSnippet: "Call review_merge after confirming the PR is open, ready, and CI is settled; approval is optional",
25766
26000
  promptGuidelines: ["This is intentionally GitHub-only until GitLab merge support is added."],
25767
- parameters: parameters6(contextSchema.extend({ subject: z11.string().optional() })),
26001
+ parameters: parameters6(contextSchema.extend({ subject: z13.string().optional() })),
25768
26002
  executionMode: "sequential",
25769
26003
  async execute(_id, input) {
25770
- const params = contextSchema.extend({ subject: z11.string().optional() }).parse(input);
26004
+ const params = contextSchema.extend({ subject: z13.string().optional() }).parse(input);
25771
26005
  const review = await resolveReviewContext(resolveWorkingDirectory(params), params.target);
25772
26006
  if (review.vcs.provider !== "github" || !review.forge)
25773
26007
  return result3("review_merge currently supports GitHub only.");
@@ -25800,8 +26034,32 @@ ${openMessage}`, {
25800
26034
  })
25801
26035
  ];
25802
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
+ }
25803
26061
  function parameters6(schema) {
25804
- return z11.toJSONSchema(schema, { io: "input" });
26062
+ return z13.toJSONSchema(schema, { io: "input" });
25805
26063
  }
25806
26064
  function resolveWorkingDirectory(params) {
25807
26065
  return params.cwd ?? process.cwd();
@@ -25817,17 +26075,159 @@ function modelRoute(ctx) {
25817
26075
  throw new Error("Cannot record review provenance because Pi did not provide an active model route.");
25818
26076
  return `${ctx.model.provider}/${ctx.model.id}`;
25819
26077
  }
25820
- async function publishResolvedReview(review, params) {
26078
+ async function publishResolvedReview(review, params, model) {
25821
26079
  const status = params.status ?? "COMMENT";
25822
26080
  const event = status === "CLOSE" ? "COMMENT" : status;
25823
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;
25824
26084
  if (status !== "CLOSE" && review.pr.isDraft)
25825
26085
  await review.forge.markReady(review.pr.number);
25826
- 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
+ }
25827
26098
  if (status === "CLOSE")
25828
26099
  await review.forge.closePr(review.pr.number);
25829
26100
  const finalPr = await review.forge.viewPr(String(review.pr.number));
25830
- 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;
25831
26231
  }
25832
26232
  async function resolveReviewContext(cwd, target) {
25833
26233
  const vcs = await detectVcs(cwd);
@@ -25868,6 +26268,10 @@ async function assertRemoteBranchReady(cwd) {
25868
26268
  if (head.stdout.trim() !== pushed.stdout.trim())
25869
26269
  throw new Error("Push the current branch before opening a remote review.");
25870
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
+ }
25871
26275
  async function reviewTargetId(review) {
25872
26276
  if (!review.pr)
25873
26277
  return "uncommitted";
@@ -25884,9 +26288,60 @@ async function headSha(cwd) {
25884
26288
  }
25885
26289
  async function newReviewArtifactPath(cwd, target) {
25886
26290
  const dir = await reviewsDir(cwd);
25887
- await mkdir8(dir, { recursive: true });
26291
+ await mkdir7(dir, { recursive: true });
25888
26292
  return uniqueRecordPath(dir, reviewRecordName(target));
25889
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
+ }
25890
26345
  function deriveTitle(branch) {
25891
26346
  return branch.replace(/^(feature|feat|fix|bug|chore)\//, "").replace(/^eng-\d+-/i, "").replace(/[-_]+/g, " ").replace(/^\w/, (char) => char.toUpperCase());
25892
26347
  }
@@ -25897,19 +26352,30 @@ function uniqueRecordPath(dir, base) {
25897
26352
  path = join21(dir, `${base}-${count++}.md`);
25898
26353
  return path;
25899
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
+ }
25900
26366
 
25901
26367
  // src/tools/setup.ts
25902
26368
  import { defineTool as defineTool7 } from "@earendil-works/pi-coding-agent";
25903
- import { z as z12 } from "zod";
26369
+ import { z as z14 } from "zod";
25904
26370
 
25905
26371
  // src/setup.ts
25906
26372
  import { parseFrontmatter as parseFrontmatter3 } from "@earendil-works/pi-coding-agent";
25907
- import { readdir as readdir4, readFile as readFile14 } from "node:fs/promises";
26373
+ import { readdir as readdir4, readFile as readFile16 } from "node:fs/promises";
25908
26374
  import { homedir as homedir10 } from "node:os";
25909
26375
  import { basename as basename7, join as join24 } from "node:path";
25910
26376
 
25911
26377
  // src/mcp.ts
25912
- 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";
25913
26379
  import { homedir as homedir8 } from "node:os";
25914
26380
  import { dirname as dirname9, join as join22 } from "node:path";
25915
26381
  var mcp = {
@@ -25927,8 +26393,8 @@ var mcp = {
25927
26393
  const next = { ...current, mcpServers: nextServers };
25928
26394
  const changed = JSON.stringify(current) !== JSON.stringify(next);
25929
26395
  if (changed && !options.dryRun) {
25930
- await mkdir9(dirname9(path), { recursive: true });
25931
- await writeFile7(path, `${JSON.stringify(next, null, 2)}
26396
+ await mkdir8(dirname9(path), { recursive: true });
26397
+ await writeFile8(path, `${JSON.stringify(next, null, 2)}
25932
26398
  `, "utf8");
25933
26399
  }
25934
26400
  return { path, changed, existed: currentText !== undefined, planned: changed && options.dryRun === true };
@@ -25957,7 +26423,7 @@ function getParsedConfig(content, path) {
25957
26423
  }
25958
26424
  async function getOptionalFile2(path) {
25959
26425
  try {
25960
- return await readFile13(path, "utf8");
26426
+ return await readFile15(path, "utf8");
25961
26427
  } catch (error) {
25962
26428
  if (error instanceof Error && "code" in error && error.code === "ENOENT")
25963
26429
  return;
@@ -25969,7 +26435,7 @@ function isRecord(value) {
25969
26435
  }
25970
26436
 
25971
26437
  // src/pi.ts
25972
- import { mkdir as mkdir10, writeFile as writeFile8 } from "node:fs/promises";
26438
+ import { mkdir as mkdir9, writeFile as writeFile9 } from "node:fs/promises";
25973
26439
  import { homedir as homedir9 } from "node:os";
25974
26440
  import { dirname as dirname10, join as join23 } from "node:path";
25975
26441
  var pi = {
@@ -25988,8 +26454,8 @@ async function ensurePiAgent(filename, content, agentDir = resolvePiAgentDir(),
25988
26454
  const currentText = await readTextIfExists(path);
25989
26455
  const changed = currentText !== content;
25990
26456
  if (changed && !dryRun) {
25991
- await mkdir10(dirname10(path), { recursive: true });
25992
- await writeFile8(path, content, "utf8");
26457
+ await mkdir9(dirname10(path), { recursive: true });
26458
+ await writeFile9(path, content, "utf8");
25993
26459
  }
25994
26460
  return { path, changed, existed: currentText !== undefined, planned: changed && dryRun };
25995
26461
  }
@@ -26025,8 +26491,8 @@ async function ensurePiConfig(path, update, dryRun = false) {
26025
26491
  const next = update(current);
26026
26492
  const changed = JSON.stringify(current) !== JSON.stringify(next);
26027
26493
  if (changed && !dryRun) {
26028
- await mkdir10(dirname10(path), { recursive: true });
26029
- await writeFile8(path, `${JSON.stringify(next, null, 2)}
26494
+ await mkdir9(dirname10(path), { recursive: true });
26495
+ await writeFile9(path, `${JSON.stringify(next, null, 2)}
26030
26496
  `, "utf8");
26031
26497
  }
26032
26498
  return { path, changed, existed: currentText !== undefined, planned: changed && dryRun };
@@ -26164,7 +26630,7 @@ async function ensurePiAgents(options = {}) {
26164
26630
  const actions = [];
26165
26631
  for (const entry of entries) {
26166
26632
  const id = basename7(entry.name, ".md").replace(/^diffpi-/, "");
26167
- const source = await readFile14(join24(bundledAgentsDir, entry.name), "utf8");
26633
+ const source = await readFile16(join24(bundledAgentsDir, entry.name), "utf8");
26168
26634
  const content = materializeAgentModels(source, id, userConfig.config, options.availableModels);
26169
26635
  const result = await pi.agentEnsure(entry.name, content, agentDir, options.dryRun);
26170
26636
  actions.push(getConfigSetupAction(`pi agent ${id}`, result));
@@ -26369,7 +26835,7 @@ function reportProgress(options, message) {
26369
26835
  options.onProgress?.(message);
26370
26836
  }
26371
26837
  async function packageVersion2() {
26372
- const source = await readFile14(join24(resolveBundledAgentsDir(), "..", "package.json"), "utf8");
26838
+ const source = await readFile16(join24(resolveBundledAgentsDir(), "..", "package.json"), "utf8");
26373
26839
  let value;
26374
26840
  try {
26375
26841
  value = JSON.parse(source);
@@ -26385,13 +26851,13 @@ function getRecord(value) {
26385
26851
  }
26386
26852
 
26387
26853
  // src/tools/setup.ts
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.")
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.")
26393
26859
  });
26394
- var setupParameters = z12.toJSONSchema(setupParametersSchema, { io: "input" });
26860
+ var setupParameters = z14.toJSONSchema(setupParametersSchema, { io: "input" });
26395
26861
  var diffpiSetupTool = defineTool7({
26396
26862
  name: "diffpi_setup",
26397
26863
  label: "diffpi setup",
@@ -26464,11 +26930,11 @@ ${lines.join(`
26464
26930
 
26465
26931
  // src/tools/templates.ts
26466
26932
  import { defineTool as defineTool8 } from "@earendil-works/pi-coding-agent";
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()
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()
26472
26938
  });
26473
26939
  var diffpiTemplateTool = defineTool8({
26474
26940
  name: "diffpi_template",
@@ -26476,7 +26942,7 @@ var diffpiTemplateTool = defineTool8({
26476
26942
  description: "Load a bundled Diffpi template or a user override and render named variables.",
26477
26943
  promptSnippet: "Use diffpi_template for package workflow templates",
26478
26944
  promptGuidelines: ["User overrides live under ~/.difflab/diffpi/templates."],
26479
- parameters: z13.toJSONSchema(templateSchema, { io: "input" }),
26945
+ parameters: z15.toJSONSchema(templateSchema, { io: "input" }),
26480
26946
  executionMode: "parallel",
26481
26947
  async execute(_id, input) {
26482
26948
  const params = templateSchema.parse(input);