@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
package/dist/index.js CHANGED
@@ -1301,6 +1301,9 @@ function yymmdd(date = new Date) {
1301
1301
  function reviewRecordName(target, date = new Date) {
1302
1302
  return `${yymmdd(date)}-${reviewSlug(target) || "uncommitted"}`;
1303
1303
  }
1304
+ function isLocalResponse(body) {
1305
+ return /^<!-- diffpi(?:-local-response:[A-Za-z0-9_-]+)? -->\n/m.test(body);
1306
+ }
1304
1307
  function dedupeFindings(findings) {
1305
1308
  const rank = { BLOCKING: 3, CONSIDER: 2, NOTE: 1 };
1306
1309
  const byKey = new Map;
@@ -1346,6 +1349,18 @@ function renderCommentBody(finding) {
1346
1349
  return `${prefix}${finding.body}${reference}`;
1347
1350
  }
1348
1351
  // src/review/review-markdown.ts
1352
+ import { z as z4 } from "zod";
1353
+ var reviewThreadRecordSchema = z4.object({
1354
+ id: z4.string().min(1),
1355
+ file: z4.string().nullable().optional().transform((value) => value ?? undefined),
1356
+ line: z4.number().int().positive().nullable().optional().transform((value) => value ?? undefined),
1357
+ body: z4.string(),
1358
+ author: z4.string().optional(),
1359
+ resolved: z4.boolean(),
1360
+ addressed: z4.boolean().optional(),
1361
+ question: z4.boolean(),
1362
+ replies: z4.array(z4.string()).optional()
1363
+ });
1349
1364
  function renderReviewDoc(input) {
1350
1365
  const anchored = dedupeFindings(input.findings).filter((finding) => finding.line > 0);
1351
1366
  const lines = [`# Review: ${input.title}`, "", "## Metadata"];
@@ -1387,6 +1402,88 @@ function renderReviewDoc(input) {
1387
1402
  `).trimEnd()}
1388
1403
  `;
1389
1404
  }
1405
+ function renderThreadArtifact(title, target, threads, options = {}) {
1406
+ const records = threads.map((thread) => ({
1407
+ id: thread.id,
1408
+ file: thread.file,
1409
+ line: thread.line,
1410
+ body: thread.body,
1411
+ author: thread.author,
1412
+ resolved: thread.resolved,
1413
+ addressed: thread.addressed,
1414
+ question: thread.question,
1415
+ replies: thread.replies
1416
+ }));
1417
+ const payload = Buffer.from(JSON.stringify(records), "utf8").toString("base64url");
1418
+ const lines = [
1419
+ `<!-- diffpi-threads:${payload} -->`,
1420
+ `# Review threads: ${title}`,
1421
+ "",
1422
+ "## Metadata",
1423
+ "",
1424
+ `- Target: ${target}`,
1425
+ `- Pulled: ${options.timestamp ?? new Date().toISOString()}`
1426
+ ];
1427
+ if (options.number !== undefined)
1428
+ lines.push(`- PR/MR: #${options.number}${options.url ? ` — ${options.url}` : ""}`);
1429
+ lines.push("", "## Replies", "");
1430
+ for (const thread of threads) {
1431
+ const id = Buffer.from(thread.id, "utf8").toString("base64url");
1432
+ lines.push(`### ${thread.file ?? "review"}:${thread.line ?? "n/a"} (${thread.id})`, "", `<!-- diffpi-reply-start:${id} -->`, thread.reply ?? "", `<!-- diffpi-reply-end:${id} -->`, "");
1433
+ }
1434
+ lines.push("## Source comments", "");
1435
+ for (const thread of threads) {
1436
+ 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, "", "---", "");
1437
+ }
1438
+ return `${lines.join(`
1439
+ `).trimEnd()}
1440
+ `;
1441
+ }
1442
+ function parseThreadArtifact(content) {
1443
+ const payload = content.match(/^<!-- diffpi-threads:([A-Za-z0-9_-]+) -->$/m)?.[1];
1444
+ if (!payload)
1445
+ throw new Error("This file is not a Diffpi thread artifact.");
1446
+ let threads;
1447
+ try {
1448
+ const decoded = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
1449
+ threads = z4.array(reviewThreadRecordSchema).parse(decoded);
1450
+ } catch {
1451
+ throw new Error("Cannot parse the Diffpi thread artifact payload.");
1452
+ }
1453
+ const replies = content.split(/^## Source comments$/m, 1)[0] ?? "";
1454
+ return threads.map((thread) => {
1455
+ const id = Buffer.from(thread.id, "utf8").toString("base64url");
1456
+ const startMarker = `<!-- diffpi-reply-start:${id} -->
1457
+ `;
1458
+ const endMarker = `
1459
+ <!-- diffpi-reply-end:${id} -->`;
1460
+ const start = replies.indexOf(startMarker);
1461
+ const end = start < 0 ? -1 : replies.indexOf(endMarker, start + startMarker.length);
1462
+ const reply = start >= 0 && end >= 0 ? replies.slice(start + startMarker.length, end).trim() : "";
1463
+ return reply ? { ...thread, reply } : thread;
1464
+ });
1465
+ }
1466
+ function upsertThreadReply(content, threadId, body, question, resolved) {
1467
+ const threads = parseThreadArtifact(content);
1468
+ const thread = threads.find((candidate) => candidate.id === threadId);
1469
+ if (!thread)
1470
+ throw new Error(`Review thread ${threadId} was not found in the local artifact.`);
1471
+ thread.reply = body;
1472
+ thread.addressed = true;
1473
+ if (question !== undefined)
1474
+ thread.question = question;
1475
+ if (resolved !== undefined)
1476
+ thread.resolved = resolved;
1477
+ const title = content.match(/^# Review threads: (.+)$/m)?.[1] ?? "review";
1478
+ const target = content.match(/^- Target: (.+)$/m)?.[1] ?? "local";
1479
+ const timestamp = content.match(/^- Pulled: (.+)$/m)?.[1];
1480
+ const pr = content.match(/^- PR\/MR: #(\d+)(?: — (.+))?$/m);
1481
+ return renderThreadArtifact(title, target, threads, {
1482
+ timestamp,
1483
+ number: pr ? Number.parseInt(pr[1], 10) : undefined,
1484
+ url: pr?.[2]
1485
+ });
1486
+ }
1390
1487
  // src/review/github-review-backend.ts
1391
1488
  function GitHubReviewBackend(vcs, number) {
1392
1489
  const pendingReview = async () => {
@@ -1448,6 +1545,9 @@ function GitHubReviewBackend(vcs, number) {
1448
1545
  id: thread.id,
1449
1546
  file: thread.path ?? undefined,
1450
1547
  line: thread.line ?? undefined,
1548
+ rootCommentId: comment?.id,
1549
+ commentIds: nodes.flatMap((node) => [node.id, node.databaseId?.toString()].filter((id) => Boolean(id))),
1550
+ commentNodeIds: nodes.flatMap((node) => node.id ? [node.id] : []),
1451
1551
  body,
1452
1552
  author: comment?.author?.login,
1453
1553
  resolved: thread.isResolved,
@@ -1457,6 +1557,7 @@ function GitHubReviewBackend(vcs, number) {
1457
1557
  });
1458
1558
  };
1459
1559
  return {
1560
+ kind: "remote",
1460
1561
  async stage(draft) {
1461
1562
  const pending = await pendingReview();
1462
1563
  if (!pending) {
@@ -1514,6 +1615,33 @@ function GitHubReviewBackend(vcs, number) {
1514
1615
  await ghChecked(args);
1515
1616
  }
1516
1617
  },
1618
+ async readDraft() {
1619
+ const pending = await pendingReview();
1620
+ if (!pending)
1621
+ return { comments: [], body: "" };
1622
+ const review = await ghChecked(["api", `/repos/${vcs.owner}/${vcs.repo}/pulls/${number}/reviews/${pending.id}`]);
1623
+ const comments = await ghChecked([
1624
+ "api",
1625
+ `/repos/${vcs.owner}/${vcs.repo}/pulls/${number}/reviews/${pending.id}/comments`
1626
+ ]);
1627
+ let reviewData;
1628
+ let commentData;
1629
+ try {
1630
+ reviewData = JSON.parse(review.stdout);
1631
+ commentData = JSON.parse(comments.stdout);
1632
+ } catch {
1633
+ throw new Error("Cannot parse the pending GitHub review as JSON.");
1634
+ }
1635
+ return {
1636
+ body: reviewData.body ?? "",
1637
+ comments: commentData.map((comment) => ({
1638
+ file: comment.path,
1639
+ line: comment.line ?? comment.original_line ?? undefined,
1640
+ side: comment.side ?? undefined,
1641
+ body: comment.body
1642
+ }))
1643
+ };
1644
+ },
1517
1645
  listThreads,
1518
1646
  async reply(input) {
1519
1647
  const threads = await listThreads();
@@ -1530,14 +1658,32 @@ function GitHubReviewBackend(vcs, number) {
1530
1658
  `body=${input.body}`
1531
1659
  ]);
1532
1660
  }
1533
- if (input.resolve) {
1661
+ if (input.resolve)
1662
+ await this.setResolved?.(input.threadId, true);
1663
+ },
1664
+ async setResolved(threadId, resolved) {
1665
+ await ghChecked([
1666
+ "api",
1667
+ "graphql",
1668
+ "-f",
1669
+ `query=${resolved ? GITHUB_RESOLVE_MUTATION : GITHUB_UNRESOLVE_MUTATION}`,
1670
+ "-f",
1671
+ `threadId=${threadId}`
1672
+ ]);
1673
+ },
1674
+ async deleteThread(threadId) {
1675
+ const thread = (await listThreads()).find((candidate) => candidate.id === threadId);
1676
+ const commentNodeIds = thread?.commentNodeIds ?? (thread?.rootCommentId ? [thread.rootCommentId] : []);
1677
+ if (!commentNodeIds.length)
1678
+ throw new Error(`Cannot delete GitHub thread ${threadId}: comment node IDs are missing.`);
1679
+ for (const commentId of commentNodeIds) {
1534
1680
  await ghChecked([
1535
1681
  "api",
1536
1682
  "graphql",
1537
1683
  "-f",
1538
- `query=${GITHUB_RESOLVE_MUTATION}`,
1684
+ `query=${GITHUB_DELETE_COMMENT_MUTATION}`,
1539
1685
  "-f",
1540
- `threadId=${input.threadId}`
1686
+ `commentId=${commentId}`
1541
1687
  ]);
1542
1688
  }
1543
1689
  },
@@ -1556,11 +1702,13 @@ function GitHubReviewBackend(vcs, number) {
1556
1702
  function githubReviewSubmissionEndpoint(owner, repo, id, pendingReviewId) {
1557
1703
  return pendingReviewId ? `/repos/${owner}/${repo}/pulls/${id}/reviews/${pendingReviewId}/events` : `/repos/${owner}/${repo}/pulls/${id}/reviews`;
1558
1704
  }
1559
- 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}}}}}`;
1705
+ 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}}}}}`;
1560
1706
  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}}}`;
1561
1707
  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}}}`;
1562
1708
  var GITHUB_REPLY_MUTATION = `mutation($threadId:ID!,$body:String!){addPullRequestReviewThreadReply(input:{pullRequestReviewThreadId:$threadId,body:$body}){comment{id}}}`;
1563
1709
  var GITHUB_RESOLVE_MUTATION = `mutation($threadId:ID!){resolveReviewThread(input:{threadId:$threadId}){thread{isResolved}}}`;
1710
+ var GITHUB_UNRESOLVE_MUTATION = `mutation($threadId:ID!){unresolveReviewThread(input:{threadId:$threadId}){thread{isResolved}}}`;
1711
+ var GITHUB_DELETE_COMMENT_MUTATION = `mutation($commentId:ID!){deletePullRequestReviewComment(input:{id:$commentId}){clientMutationId}}`;
1564
1712
 
1565
1713
  // src/review/gitlab-review-backend.ts
1566
1714
  function GitLabReviewBackend(vcs, number) {
@@ -1597,6 +1745,7 @@ function GitLabReviewBackend(vcs, number) {
1597
1745
  });
1598
1746
  };
1599
1747
  return {
1748
+ kind: "remote",
1600
1749
  async stage(draft) {
1601
1750
  const endpoint = `${mergeRequestEndpoint()}/draft_notes`;
1602
1751
  if (draft.body.trim()) {
@@ -1625,6 +1774,36 @@ function GitLabReviewBackend(vcs, number) {
1625
1774
  });
1626
1775
  }
1627
1776
  },
1777
+ async readDraft() {
1778
+ const result = await glabChecked(["api", `${mergeRequestEndpoint()}/draft_notes`]);
1779
+ let notes;
1780
+ try {
1781
+ notes = JSON.parse(result.stdout);
1782
+ } catch {
1783
+ throw new Error("Cannot parse GitLab draft notes as JSON.");
1784
+ }
1785
+ const comments = [];
1786
+ const body = [];
1787
+ for (const note of notes) {
1788
+ if (!note.note)
1789
+ continue;
1790
+ const file = note.position?.new_path ?? note.position?.old_path;
1791
+ const line = note.position?.new_line ?? note.position?.old_line;
1792
+ if (file && line) {
1793
+ comments.push({
1794
+ file,
1795
+ line,
1796
+ side: note.position?.new_line ? "RIGHT" : "LEFT",
1797
+ body: note.note
1798
+ });
1799
+ } else {
1800
+ body.push(note.note);
1801
+ }
1802
+ }
1803
+ return { comments, body: body.join(`
1804
+
1805
+ `) };
1806
+ },
1628
1807
  listThreads,
1629
1808
  async reply(input) {
1630
1809
  const endpoint = `${mergeRequestEndpoint()}/discussions/${encodeURIComponent(input.threadId)}`;
@@ -1636,7 +1815,15 @@ function GitLabReviewBackend(vcs, number) {
1636
1815
  });
1637
1816
  }
1638
1817
  if (input.resolve)
1639
- await glabChecked(["api", "--method", "PUT", `${endpoint}?resolved=true`]);
1818
+ await this.setResolved?.(input.threadId, true);
1819
+ },
1820
+ async setResolved(threadId, resolved) {
1821
+ const endpoint = `${mergeRequestEndpoint()}/discussions/${encodeURIComponent(threadId)}`;
1822
+ await glabChecked(["api", "--method", "PUT", `${endpoint}?resolved=${resolved}`]);
1823
+ },
1824
+ async deleteThread(threadId) {
1825
+ const endpoint = `${mergeRequestEndpoint()}/discussions/${encodeURIComponent(threadId)}`;
1826
+ await glabChecked(["api", "--method", "DELETE", endpoint]);
1640
1827
  },
1641
1828
  async publish(event) {
1642
1829
  assertReviewEventSupported(vcs.provider, event);
@@ -1676,105 +1863,12 @@ function hasGitlabDraftNotes(input) {
1676
1863
  }
1677
1864
  }
1678
1865
 
1679
- // src/review/review-backend.ts
1680
- function createRemoteReviewBackend(vcs, number) {
1681
- if (vcs.provider === "github")
1682
- return GitHubReviewBackend(vcs, number);
1683
- if (vcs.provider === "gitlab")
1684
- return GitLabReviewBackend(vcs, number);
1685
- throw new Error("A remote review backend requires a GitHub or GitLab repository.");
1686
- }
1687
- // src/review/local-reviews.ts
1688
- import { mkdir as mkdir4, readdir as readdir2, readFile as readFile6, unlink as unlink2, writeFile as writeFile3 } from "node:fs/promises";
1689
- import { join as join9 } from "node:path";
1690
-
1691
- // src/store.ts
1692
- import { createHash } from "node:crypto";
1693
- import { lstat, mkdir as mkdir3, readlink, realpath as realpath2, symlink, unlink } from "node:fs/promises";
1694
- import { homedir as homedir4 } from "node:os";
1695
- import { dirname as dirname4, isAbsolute as isAbsolute2, join as join8, resolve as resolve2 } from "node:path";
1696
- var STORE_LINK = ".diffpi";
1697
- var LEGACY_STORE_LINK = join8(".pi", "diffpi");
1698
- async function computeProjectSlug(cwd) {
1699
- return projectSlug(await inspectGitRepository(cwd));
1700
- }
1701
- function storeGlobalRoot(homeDir = homedir4()) {
1702
- return join8(homeDir, ".difflab", "diffpi", "projects");
1703
- }
1704
- async function ensureStore(cwd, homeDir = homedir4()) {
1705
- const repository = await inspectGitRepository(cwd);
1706
- const root = repository.root;
1707
- const slug = projectSlug(repository);
1708
- const dest = join8(storeGlobalRoot(homeDir), slug);
1709
- const link = join8(root, STORE_LINK);
1710
- await mkdir3(dest, { recursive: true });
1711
- try {
1712
- await assertStoreLink(link, dest);
1713
- } catch (error) {
1714
- if (error.code !== "ENOENT")
1715
- throw error;
1716
- await symlink(dest, link);
1717
- }
1718
- await removeLegacyStoreLink(join8(root, LEGACY_STORE_LINK), dest);
1719
- return { slug, root, dest, link, linked: true };
1720
- }
1721
- async function storeDir(cwd, homeDir = homedir4()) {
1722
- const store = await ensureStore(cwd, homeDir);
1723
- return store.dest;
1724
- }
1725
- async function plansDir(cwd, homeDir = homedir4()) {
1726
- const store = await ensureStore(cwd, homeDir);
1727
- const dir = join8(store.link, "plan");
1728
- await mkdir3(dir, { recursive: true });
1729
- return dir;
1730
- }
1731
- async function reviewsDir(cwd, homeDir = homedir4()) {
1732
- const store = await ensureStore(cwd, homeDir);
1733
- const dir = join8(store.link, "review");
1734
- await mkdir3(dir, { recursive: true });
1735
- return dir;
1736
- }
1737
- async function assertStoreLink(path, dest) {
1738
- const entry = await lstat(path);
1739
- if (!entry.isSymbolicLink())
1740
- throw new Error(`${path} exists and is not a symlink.`);
1741
- const target = await symlinkTarget(path);
1742
- if (target !== await canonicalPath2(dest))
1743
- throw new Error(`${path} points to ${target}, not ${dest}.`);
1744
- }
1745
- async function removeLegacyStoreLink(path, dest) {
1746
- try {
1747
- const entry = await lstat(path);
1748
- if (!entry.isSymbolicLink())
1749
- return;
1750
- const target = await symlinkTarget(path);
1751
- if (target === await canonicalPath2(dest))
1752
- await unlink(path);
1753
- } catch (error) {
1754
- if (error.code !== "ENOENT")
1755
- throw error;
1756
- }
1757
- }
1758
- async function symlinkTarget(path) {
1759
- const target = await readlink(path);
1760
- return canonicalPath2(isAbsolute2(target) ? target : resolve2(dirname4(path), target));
1761
- }
1762
- async function canonicalPath2(path) {
1763
- try {
1764
- return await realpath2(path);
1765
- } catch {
1766
- return resolve2(path);
1767
- }
1768
- }
1769
- function projectSlug(repository) {
1770
- const readable = repository.name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "unnamed";
1771
- const digest = createHash("sha256").update(repository.identity).digest("hex").slice(0, 12);
1772
- return `${readable}-${digest}`;
1773
- }
1866
+ // src/review/local-review-backend.ts
1867
+ import { readFile as readFile6, writeFile as writeFile3 } from "node:fs/promises";
1774
1868
 
1775
1869
  // src/extensions/tuicrx.ts
1776
- import { readFile as readFile5, realpath as realpath3 } from "node:fs/promises";
1777
- import { resolve as resolve3 } from "node:path";
1870
+ import { readFile as readFile5, realpath as realpath2 } from "node:fs/promises";
1871
+ import { resolve as resolve2 } from "node:path";
1778
1872
  async function tuicrAvailable() {
1779
1873
  return Boolean(await findExecutable("tuicr"));
1780
1874
  }
@@ -1850,7 +1944,7 @@ async function resolvePrSession(cwd, owner, repo, number) {
1850
1944
  });
1851
1945
  }
1852
1946
  async function findMatchingSession(sessions, cwd, branch) {
1853
- const repository = await canonicalPath3(await gitToplevel(cwd));
1947
+ const repository = await canonicalPath2(await gitToplevel(cwd));
1854
1948
  for (const session of sessions) {
1855
1949
  if (session.kind !== "local")
1856
1950
  continue;
@@ -1858,7 +1952,7 @@ async function findMatchingSession(sessions, cwd, branch) {
1858
1952
  const data = await readSession(session.path);
1859
1953
  if (data.branch_name !== branch || !data.repo_path)
1860
1954
  continue;
1861
- if (await canonicalPath3(data.repo_path) === repository)
1955
+ if (await canonicalPath2(data.repo_path) === repository)
1862
1956
  return session;
1863
1957
  } catch {}
1864
1958
  }
@@ -1872,33 +1966,53 @@ async function readSession(path) {
1872
1966
  throw new Error(`Cannot parse tuicr session JSON: ${path}`);
1873
1967
  }
1874
1968
  }
1875
- function toLocalReviewThreads(session) {
1876
- const threads = [];
1877
- const add = (comment, location = {}) => {
1969
+ function toFindings(session, options = {}) {
1970
+ const comments = [];
1971
+ const include = (comment) => (!options.agentOnly || commentAuthor(comment)?.startsWith("Agent: ")) && (!options.excludeLocalResponses || !isLocalResponse(comment.content));
1972
+ const publishableBody = (comment) => {
1878
1973
  const author = commentAuthor(comment);
1879
- threads.push({
1880
- id: comment.id ?? `local-${threads.length + 1}`,
1881
- ...location,
1882
- body: comment.content,
1883
- ...author ? { author } : {},
1884
- resolved: false,
1885
- question: /\?\s*$/.test(comment.content.trim())
1886
- });
1974
+ const route = author?.match(/^Agent:\s*(.+)$/)?.[1];
1975
+ return route ? withRemoteProvenance(comment.content, route) : comment.content;
1887
1976
  };
1888
- for (const comment of session.review_comments ?? [])
1889
- add(comment);
1977
+ const bodyParts = (session.review_comments ?? []).flatMap((comment) => include(comment) ? [publishableBody(comment)] : []);
1890
1978
  for (const [file, entry] of Object.entries(session.files ?? {})) {
1891
- for (const comment of entry.file_comments ?? [])
1892
- add(comment, { file });
1979
+ for (const fileComment of entry.file_comments ?? []) {
1980
+ if (!include(fileComment))
1981
+ continue;
1982
+ const comment = {
1983
+ file,
1984
+ body: fileComment.content,
1985
+ ...fileComment.id ? { sourceCommentId: String(fileComment.id) } : {}
1986
+ };
1987
+ const author = commentAuthor(fileComment);
1988
+ if (author)
1989
+ comment.author = author;
1990
+ comments.push(comment);
1991
+ }
1893
1992
  for (const [lineKey, lineComments] of Object.entries(entry.line_comments ?? {})) {
1894
1993
  const line = Number.parseInt(lineKey, 10);
1895
1994
  if (!Number.isFinite(line))
1896
1995
  continue;
1897
- for (const comment of lineComments)
1898
- add(comment, { file, line });
1996
+ for (const lineComment of lineComments) {
1997
+ if (!include(lineComment))
1998
+ continue;
1999
+ const comment = {
2000
+ file,
2001
+ line,
2002
+ side: lineComment.side === "old" ? "LEFT" : "RIGHT",
2003
+ body: lineComment.content,
2004
+ ...lineComment.id ? { sourceCommentId: String(lineComment.id) } : {}
2005
+ };
2006
+ const author = commentAuthor(lineComment);
2007
+ if (author)
2008
+ comment.author = author;
2009
+ comments.push(comment);
2010
+ }
1899
2011
  }
1900
2012
  }
1901
- return threads;
2013
+ return { comments, body: bodyParts.join(`
2014
+
2015
+ `) };
1902
2016
  }
1903
2017
  async function requireTuicr() {
1904
2018
  if (!await tuicrAvailable())
@@ -1907,70 +2021,207 @@ async function requireTuicr() {
1907
2021
  function commentAuthor(comment) {
1908
2022
  return comment.username ?? comment.author;
1909
2023
  }
1910
- async function canonicalPath3(path) {
2024
+ async function canonicalPath2(path) {
1911
2025
  try {
1912
- return await realpath3(path);
2026
+ return await realpath2(path);
1913
2027
  } catch {
1914
- return resolve3(path);
2028
+ return resolve2(path);
1915
2029
  }
1916
2030
  }
1917
2031
 
1918
- // src/review/local-reviews.ts
1919
- async function captureLocalReview(input) {
1920
- const id = reviewSlug(input.branch) || "local-review";
1921
- const dir = join9(await reviewsDir(input.cwd, input.homeDir), id);
1922
- await mkdir4(dir, { recursive: true });
1923
- const revision = await nextRevision(dir);
1924
- const output = await readFile6(input.session.path, "utf8");
1925
- const comments = toLocalReviewThreads(await readSession(input.session.path));
1926
- const dump = {
1927
- schemaVersion: 1,
1928
- id,
1929
- revision,
1930
- branch: input.branch,
1931
- base: input.base,
1932
- createdAt: (input.now ?? (() => new Date))().toISOString(),
1933
- diff: input.diff,
1934
- comments,
1935
- tuicr: { slug: input.session.slug, output }
2032
+ // src/review/local-review-backend.ts
2033
+ function LocalReviewBackend(options) {
2034
+ return {
2035
+ kind: "local",
2036
+ async stage(draft) {
2037
+ for (const comment of draft.comments) {
2038
+ await addComment(options.session, comment.body, {
2039
+ targetFile: comment.file,
2040
+ line: comment.line,
2041
+ side: comment.side === "LEFT" ? "old" : "new",
2042
+ username: options.author
2043
+ });
2044
+ }
2045
+ if (draft.body.trim())
2046
+ await addComment(options.session, draft.body, { username: options.author });
2047
+ },
2048
+ async readDraft() {
2049
+ return toFindings(await readSession(options.session), { excludeLocalResponses: true });
2050
+ },
2051
+ async listThreads() {
2052
+ try {
2053
+ return parseThreadArtifact(await readFile6(options.artifactPath, "utf8"));
2054
+ } catch (error) {
2055
+ if (error.code === "ENOENT")
2056
+ return [];
2057
+ throw error;
2058
+ }
2059
+ },
2060
+ async reply(input) {
2061
+ const content = await readFile6(options.artifactPath, "utf8");
2062
+ await writeFile3(options.artifactPath, upsertThreadReply(content, input.threadId, input.body, input.question, input.resolve), "utf8");
2063
+ },
2064
+ async publish() {
2065
+ throw new Error("Promote a local draft through a remote review backend before publishing it.");
2066
+ }
1936
2067
  };
1937
- const path = join9(dir, `${revision}.json`);
1938
- await writeFile3(path, `${JSON.stringify(dump, null, 2)}
1939
- `, { encoding: "utf8", flag: "wx" });
1940
- await unlink2(input.session.path);
1941
- return { path, dump };
1942
- }
1943
- async function readLocalReview(cwd, branch, revision, homeDir) {
1944
- const id = reviewSlug(branch) || "local-review";
1945
- const dir = join9(await reviewsDir(cwd, homeDir), id);
1946
- const selected = revision ?? await latestRevision(dir);
1947
- if (!selected)
1948
- return;
2068
+ }
2069
+
2070
+ // src/review/review-backend.ts
2071
+ function createRemoteReviewBackend(vcs, number) {
2072
+ if (vcs.provider === "github")
2073
+ return GitHubReviewBackend(vcs, number);
2074
+ if (vcs.provider === "gitlab")
2075
+ return GitLabReviewBackend(vcs, number);
2076
+ throw new Error("A remote review backend requires a GitHub or GitLab repository.");
2077
+ }
2078
+ function createLocalReviewBackend(options) {
2079
+ return LocalReviewBackend(options);
2080
+ }
2081
+ // src/review/review-state.ts
2082
+ import { createHash as createHash2 } from "node:crypto";
2083
+ import { readFile as readFile7, rename, writeFile as writeFile4 } from "node:fs/promises";
2084
+ import { join as join9 } from "node:path";
2085
+ import { z as z5 } from "zod";
2086
+
2087
+ // src/store.ts
2088
+ import { createHash } from "node:crypto";
2089
+ import { lstat, mkdir as mkdir3, readlink, realpath as realpath3, symlink, unlink } from "node:fs/promises";
2090
+ import { homedir as homedir4 } from "node:os";
2091
+ import { dirname as dirname4, isAbsolute as isAbsolute2, join as join8, resolve as resolve3 } from "node:path";
2092
+ var STORE_LINK = ".diffpi";
2093
+ var LEGACY_STORE_LINK = join8(".pi", "diffpi");
2094
+ async function computeProjectSlug(cwd) {
2095
+ return projectSlug(await inspectGitRepository(cwd));
2096
+ }
2097
+ function storeGlobalRoot(homeDir = homedir4()) {
2098
+ return join8(homeDir, ".difflab", "diffpi", "projects");
2099
+ }
2100
+ async function ensureStore(cwd, homeDir = homedir4()) {
2101
+ const repository = await inspectGitRepository(cwd);
2102
+ const root = repository.root;
2103
+ const slug = projectSlug(repository);
2104
+ const dest = join8(storeGlobalRoot(homeDir), slug);
2105
+ const link = join8(root, STORE_LINK);
2106
+ await mkdir3(dest, { recursive: true });
1949
2107
  try {
1950
- return JSON.parse(await readFile6(join9(dir, `${selected}.json`), "utf8"));
2108
+ await assertStoreLink(link, dest);
1951
2109
  } catch (error) {
1952
- if (error.code === "ENOENT")
2110
+ if (error.code !== "ENOENT")
2111
+ throw error;
2112
+ await symlink(dest, link);
2113
+ }
2114
+ await removeLegacyStoreLink(join8(root, LEGACY_STORE_LINK), dest);
2115
+ return { slug, root, dest, link, linked: true };
2116
+ }
2117
+ async function storeDir(cwd, homeDir = homedir4()) {
2118
+ const store = await ensureStore(cwd, homeDir);
2119
+ return store.dest;
2120
+ }
2121
+ async function plansDir(cwd, homeDir = homedir4()) {
2122
+ const store = await ensureStore(cwd, homeDir);
2123
+ const dir = join8(store.link, "plan");
2124
+ await mkdir3(dir, { recursive: true });
2125
+ return dir;
2126
+ }
2127
+ async function reviewsDir(cwd, homeDir = homedir4()) {
2128
+ const store = await ensureStore(cwd, homeDir);
2129
+ const dir = join8(store.link, "review");
2130
+ await mkdir3(dir, { recursive: true });
2131
+ return dir;
2132
+ }
2133
+ async function sessionsDir(cwd, homeDir = homedir4()) {
2134
+ const store = await ensureStore(cwd, homeDir);
2135
+ const dir = join8(store.link, "sessions");
2136
+ await mkdir3(dir, { recursive: true });
2137
+ return dir;
2138
+ }
2139
+ async function assertStoreLink(path, dest) {
2140
+ const entry = await lstat(path);
2141
+ if (!entry.isSymbolicLink())
2142
+ throw new Error(`${path} exists and is not a symlink.`);
2143
+ const target = await symlinkTarget(path);
2144
+ if (target !== await canonicalPath3(dest))
2145
+ throw new Error(`${path} points to ${target}, not ${dest}.`);
2146
+ }
2147
+ async function removeLegacyStoreLink(path, dest) {
2148
+ try {
2149
+ const entry = await lstat(path);
2150
+ if (!entry.isSymbolicLink())
1953
2151
  return;
1954
- throw error;
2152
+ const target = await symlinkTarget(path);
2153
+ if (target === await canonicalPath3(dest))
2154
+ await unlink(path);
2155
+ } catch (error) {
2156
+ if (error.code !== "ENOENT")
2157
+ throw error;
1955
2158
  }
1956
2159
  }
1957
- async function nextRevision(dir) {
1958
- return await latestRevision(dir) + 1;
2160
+ async function symlinkTarget(path) {
2161
+ const target = await readlink(path);
2162
+ return canonicalPath3(isAbsolute2(target) ? target : resolve3(dirname4(path), target));
1959
2163
  }
1960
- async function latestRevision(dir) {
2164
+ async function canonicalPath3(path) {
1961
2165
  try {
1962
- return (await readdir2(dir)).reduce((latest, name) => {
1963
- const match = name.match(/^(\d+)\.json$/);
1964
- return match ? Math.max(latest, Number(match[1])) : latest;
1965
- }, 0);
2166
+ return await realpath3(path);
2167
+ } catch {
2168
+ return resolve3(path);
2169
+ }
2170
+ }
2171
+ function projectSlug(repository) {
2172
+ const readable = repository.name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "unnamed";
2173
+ const digest = createHash("sha256").update(repository.identity).digest("hex").slice(0, 12);
2174
+ return `${readable}-${digest}`;
2175
+ }
2176
+
2177
+ // src/review/review-state.ts
2178
+ var reviewPublicationStateSchema = z5.object({
2179
+ target: z5.string().optional(),
2180
+ bodies: z5.array(z5.string()).default([]),
2181
+ comments: z5.array(z5.string()).default([]),
2182
+ stagedComments: z5.array(z5.string()).default([]),
2183
+ replies: z5.array(z5.string()).default([]),
2184
+ overlayPath: z5.string().optional()
2185
+ });
2186
+ function reviewBodyFingerprint(body) {
2187
+ return digest(body);
2188
+ }
2189
+ function reviewCommentFingerprint(comment) {
2190
+ return digest([comment.file, String(comment.line), comment.side ?? "RIGHT", comment.body].join("\x00"));
2191
+ }
2192
+ function reviewReplyFingerprint(threadId, body) {
2193
+ return digest(`${threadId}\x00${body}`);
2194
+ }
2195
+ function unpublishedReviewComments(comments, knownFingerprints) {
2196
+ return comments.filter((comment) => !knownFingerprints.has(reviewCommentFingerprint(comment)));
2197
+ }
2198
+ async function loadReviewPublicationState(cwd, vcs, number, homeDir) {
2199
+ const target = `${vcs.provider}:${vcs.owner}/${vcs.repo}#${number}`;
2200
+ const path = join9(await sessionsDir(cwd, homeDir), `review-publish-${digest(target).slice(0, 16)}.json`);
2201
+ try {
2202
+ const parsed = reviewPublicationStateSchema.parse(JSON.parse(await readFile7(path, "utf8")));
2203
+ return { path, state: { ...parsed, target } };
1966
2204
  } catch (error) {
1967
- if (error.code === "ENOENT")
1968
- return 0;
2205
+ if (error.code === "ENOENT") {
2206
+ return { path, state: { target, bodies: [], comments: [], stagedComments: [], replies: [] } };
2207
+ }
2208
+ if (error instanceof SyntaxError || error instanceof z5.ZodError) {
2209
+ throw new Error(`Cannot parse review publication state: ${path}`);
2210
+ }
1969
2211
  throw error;
1970
2212
  }
1971
2213
  }
2214
+ async function saveReviewPublicationState(path, state) {
2215
+ const temp = `${path}.${process.pid}.tmp`;
2216
+ await writeFile4(temp, `${JSON.stringify(state, null, 2)}
2217
+ `, "utf8");
2218
+ await rename(temp, path);
2219
+ }
2220
+ function digest(value) {
2221
+ return createHash2("sha256").update(value).digest("hex");
2222
+ }
1972
2223
  // src/mcp.ts
1973
- import { mkdir as mkdir5, readFile as readFile7, writeFile as writeFile4 } from "node:fs/promises";
2224
+ import { mkdir as mkdir4, readFile as readFile8, writeFile as writeFile5 } from "node:fs/promises";
1974
2225
  import { homedir as homedir5 } from "node:os";
1975
2226
  import { dirname as dirname5, join as join10 } from "node:path";
1976
2227
  var mcp = {
@@ -1988,8 +2239,8 @@ var mcp = {
1988
2239
  const next = { ...current, mcpServers: nextServers };
1989
2240
  const changed = JSON.stringify(current) !== JSON.stringify(next);
1990
2241
  if (changed && !options.dryRun) {
1991
- await mkdir5(dirname5(path), { recursive: true });
1992
- await writeFile4(path, `${JSON.stringify(next, null, 2)}
2242
+ await mkdir4(dirname5(path), { recursive: true });
2243
+ await writeFile5(path, `${JSON.stringify(next, null, 2)}
1993
2244
  `, "utf8");
1994
2245
  }
1995
2246
  return { path, changed, existed: currentText !== undefined, planned: changed && options.dryRun === true };
@@ -2018,7 +2269,7 @@ function getParsedConfig(content, path) {
2018
2269
  }
2019
2270
  async function getOptionalFile2(path) {
2020
2271
  try {
2021
- return await readFile7(path, "utf8");
2272
+ return await readFile8(path, "utf8");
2022
2273
  } catch (error) {
2023
2274
  if (error instanceof Error && "code" in error && error.code === "ENOENT")
2024
2275
  return;
@@ -2033,7 +2284,7 @@ import {
2033
2284
  getAgentDir,
2034
2285
  parseFrontmatter as parseFrontmatter2
2035
2286
  } from "@earendil-works/pi-coding-agent";
2036
- import { readFile as readFile8 } from "node:fs/promises";
2287
+ import { readFile as readFile9 } from "node:fs/promises";
2037
2288
  import { homedir as homedir6 } from "node:os";
2038
2289
  import { basename as basename4, extname, join as join11 } from "node:path";
2039
2290
  async function discoverAgentModes(options) {
@@ -2232,7 +2483,7 @@ async function loadAgentModes(directory, source, modes, diagnostics, skillName)
2232
2483
  for (const entry of entries.filter((item) => item.isFile() && item.name.endsWith(".md")).sort((a, b) => a.name.localeCompare(b.name))) {
2233
2484
  const path = join11(directory, entry.name);
2234
2485
  try {
2235
- const content = await readFile8(path, "utf8");
2486
+ const content = await readFile9(path, "utf8");
2236
2487
  const { frontmatter, body } = parseFrontmatter2(content.startsWith("\uFEFF") ? content.slice(1) : content);
2237
2488
  if (frontmatter.enabled === false || frontmatter.inline === false)
2238
2489
  continue;
@@ -2288,7 +2539,7 @@ function isModeBaseline(value) {
2288
2539
  return (model === undefined || typeof model.provider === "string" && typeof model.id === "string") && candidate.thinkingLevel !== undefined && THINKING_LEVELS.has(candidate.thinkingLevel) && Array.isArray(candidate.tools) && candidate.tools.every((tool) => typeof tool === "string");
2289
2540
  }
2290
2541
  // src/pi.ts
2291
- import { mkdir as mkdir6, writeFile as writeFile5 } from "node:fs/promises";
2542
+ import { mkdir as mkdir5, writeFile as writeFile6 } from "node:fs/promises";
2292
2543
  import { homedir as homedir7 } from "node:os";
2293
2544
  import { dirname as dirname6, join as join12 } from "node:path";
2294
2545
  var pi = {
@@ -2307,8 +2558,8 @@ async function ensurePiAgent(filename, content, agentDir = resolvePiAgentDir(),
2307
2558
  const currentText = await readTextIfExists(path);
2308
2559
  const changed = currentText !== content;
2309
2560
  if (changed && !dryRun) {
2310
- await mkdir6(dirname6(path), { recursive: true });
2311
- await writeFile5(path, content, "utf8");
2561
+ await mkdir5(dirname6(path), { recursive: true });
2562
+ await writeFile6(path, content, "utf8");
2312
2563
  }
2313
2564
  return { path, changed, existed: currentText !== undefined, planned: changed && dryRun };
2314
2565
  }
@@ -2344,8 +2595,8 @@ async function ensurePiConfig(path, update, dryRun = false) {
2344
2595
  const next = update(current);
2345
2596
  const changed = JSON.stringify(current) !== JSON.stringify(next);
2346
2597
  if (changed && !dryRun) {
2347
- await mkdir6(dirname6(path), { recursive: true });
2348
- await writeFile5(path, `${JSON.stringify(next, null, 2)}
2598
+ await mkdir5(dirname6(path), { recursive: true });
2599
+ await writeFile6(path, `${JSON.stringify(next, null, 2)}
2349
2600
  `, "utf8");
2350
2601
  }
2351
2602
  return { path, changed, existed: currentText !== undefined, planned: changed && dryRun };
@@ -2378,16 +2629,16 @@ function parseJsonObject(content, path) {
2378
2629
  throw new Error(`Expected valid JSON object in ${path}.`);
2379
2630
  }
2380
2631
  // src/plan/store.ts
2381
- import { mkdir as mkdir8, readdir as readdir3, readFile as readFile11, realpath as realpath4, rm as rm2 } from "node:fs/promises";
2632
+ import { mkdir as mkdir7, readdir as readdir2, readFile as readFile12, realpath as realpath4, rm as rm2 } from "node:fs/promises";
2382
2633
  import { basename as basename6, dirname as dirname8, join as join16, resolve as resolve4, sep } from "node:path";
2383
2634
 
2384
2635
  // src/extensions/fsx.ts
2385
- import { mkdir as mkdir7, rename, rm, writeFile as writeFile6 } from "node:fs/promises";
2636
+ import { mkdir as mkdir6, rename as rename2, rm, writeFile as writeFile7 } from "node:fs/promises";
2386
2637
  import { basename as basename5, dirname as dirname7, join as join13 } from "node:path";
2387
2638
  async function atomicWrite(path, content) {
2388
2639
  const temp = join13(dirname7(path), `.${basename5(path)}.${crypto.randomUUID()}.tmp`);
2389
- await writeFile6(temp, content, { encoding: "utf8", mode: 384, flag: "wx" });
2390
- await rename(temp, path);
2640
+ await writeFile7(temp, content, { encoding: "utf8", mode: 384, flag: "wx" });
2641
+ await rename2(temp, path);
2391
2642
  }
2392
2643
  async function withDirectoryLock(lockDir, operation, options = {}) {
2393
2644
  const waitMs = options.waitMs ?? 5000;
@@ -2395,7 +2646,7 @@ async function withDirectoryLock(lockDir, operation, options = {}) {
2395
2646
  const started = Date.now();
2396
2647
  while (true) {
2397
2648
  try {
2398
- await mkdir7(lockDir);
2649
+ await mkdir6(lockDir);
2399
2650
  break;
2400
2651
  } catch (error) {
2401
2652
  if (error.code !== "EEXIST")
@@ -2417,7 +2668,7 @@ function sleep(ms) {
2417
2668
 
2418
2669
  // src/log.ts
2419
2670
  import { randomUUID } from "node:crypto";
2420
- import { appendFile, readFile as readFile9 } from "node:fs/promises";
2671
+ import { appendFile, readFile as readFile10 } from "node:fs/promises";
2421
2672
  async function appendLogEntry(logPath, event) {
2422
2673
  const entry = {
2423
2674
  ...event,
@@ -2431,7 +2682,7 @@ async function appendLogEntry(logPath, event) {
2431
2682
  }
2432
2683
 
2433
2684
  // src/templates.ts
2434
- import { readFile as readFile10 } from "node:fs/promises";
2685
+ import { readFile as readFile11 } from "node:fs/promises";
2435
2686
  import { homedir as homedir8 } from "node:os";
2436
2687
  import { join as join14, normalize } from "node:path";
2437
2688
  async function loadTemplate(name, options = {}) {
@@ -2458,7 +2709,7 @@ function templateRelativePath(name) {
2458
2709
  }
2459
2710
  async function readOptionalFile(path) {
2460
2711
  try {
2461
- return await readFile10(path, "utf8");
2712
+ return await readFile11(path, "utf8");
2462
2713
  } catch (error) {
2463
2714
  if (error.code === "ENOENT")
2464
2715
  return;
@@ -7542,7 +7793,7 @@ function planRecordName(shortSlug, date = new Date) {
7542
7793
  }
7543
7794
  async function resolvePlan(cwd, query, filters = {}, options = {}) {
7544
7795
  const root = await plansDir(cwd, options.homeDir);
7545
- const entries = await readdir3(root, { withFileTypes: true });
7796
+ const entries = await readdir2(root, { withFileTypes: true });
7546
7797
  const normalizedQuery = query ? normalizeQuery(query) : undefined;
7547
7798
  const records = [];
7548
7799
  for (const entry of entries) {
@@ -7579,7 +7830,7 @@ function createPlanStore(options = {}) {
7579
7830
  const dir = join16(root, id);
7580
7831
  await assertContained(root, dirname8(dir));
7581
7832
  try {
7582
- await mkdir8(dir);
7833
+ await mkdir7(dir);
7583
7834
  } catch (error) {
7584
7835
  if (error.code === "EEXIST")
7585
7836
  throw new Error(`Plan ${id} already exists.`);
@@ -7654,7 +7905,7 @@ function createPlanStore(options = {}) {
7654
7905
  async function readRecord(dir) {
7655
7906
  const planPath = join16(dir, "PLAN.md");
7656
7907
  const logPath = join16(dir, "logs.txt");
7657
- const source = await readFile11(planPath, "utf8");
7908
+ const source = await readFile12(planPath, "utf8");
7658
7909
  const document = parsePlanDocument(source, planPath);
7659
7910
  return {
7660
7911
  id: basename6(dir),
@@ -7668,7 +7919,7 @@ async function readRecord(dir) {
7668
7919
  }
7669
7920
  async function ensureImplementationFiles(dir, document, options) {
7670
7921
  const implementationDir = join16(dir, "implementation");
7671
- await mkdir8(implementationDir, { recursive: true });
7922
+ await mkdir7(implementationDir, { recursive: true });
7672
7923
  const template = await loadTemplate("plan/implementation", {
7673
7924
  homeDir: options.homeDir,
7674
7925
  bundledDir: options.bundledTemplatesDir
@@ -7676,7 +7927,7 @@ async function ensureImplementationFiles(dir, document, options) {
7676
7927
  for (const phase of document.phases) {
7677
7928
  const path = join16(implementationDir, `phase-${phase.id}.md`);
7678
7929
  try {
7679
- await readFile11(path, "utf8");
7930
+ await readFile12(path, "utf8");
7680
7931
  } catch (error) {
7681
7932
  if (error.code !== "ENOENT")
7682
7933
  throw error;
@@ -7725,7 +7976,7 @@ function titleFromSlug(value) {
7725
7976
  }
7726
7977
  // src/setup.ts
7727
7978
  import { parseFrontmatter as parseFrontmatter3 } from "@earendil-works/pi-coding-agent";
7728
- import { readdir as readdir4, readFile as readFile12 } from "node:fs/promises";
7979
+ import { readdir as readdir3, readFile as readFile13 } from "node:fs/promises";
7729
7980
  import { homedir as homedir9 } from "node:os";
7730
7981
  import { basename as basename7, join as join17 } from "node:path";
7731
7982
  var MISE_DEPENDENCIES = [
@@ -7828,11 +8079,11 @@ async function ensurePiAgents(options = {}) {
7828
8079
  const agentDir = options.agentDir ?? pi.agentDir(options.homeDir);
7829
8080
  const bundledAgentsDir = options.bundledAgentsDir ?? BUNDLED_AGENTS_DIR2;
7830
8081
  const userConfig = await loadDiffpiConfig({ homeDir: options.homeDir });
7831
- const entries = (await readdir4(bundledAgentsDir, { withFileTypes: true })).filter((entry) => entry.isFile() && entry.name.startsWith("diffpi-") && entry.name.endsWith(".md")).sort((left, right) => left.name.localeCompare(right.name));
8082
+ const entries = (await readdir3(bundledAgentsDir, { withFileTypes: true })).filter((entry) => entry.isFile() && entry.name.startsWith("diffpi-") && entry.name.endsWith(".md")).sort((left, right) => left.name.localeCompare(right.name));
7832
8083
  const actions = [];
7833
8084
  for (const entry of entries) {
7834
8085
  const id = basename7(entry.name, ".md").replace(/^diffpi-/, "");
7835
- const source = await readFile12(join17(bundledAgentsDir, entry.name), "utf8");
8086
+ const source = await readFile13(join17(bundledAgentsDir, entry.name), "utf8");
7836
8087
  const content = materializeAgentModels(source, id, userConfig.config, options.availableModels);
7837
8088
  const result = await pi.agentEnsure(entry.name, content, agentDir, options.dryRun);
7838
8089
  actions.push(getConfigSetupAction(`pi agent ${id}`, result));
@@ -8037,7 +8288,7 @@ function reportProgress(options, message) {
8037
8288
  options.onProgress?.(message);
8038
8289
  }
8039
8290
  async function packageVersion2() {
8040
- const source = await readFile12(join17(resolveBundledAgentsDir(), "..", "package.json"), "utf8");
8291
+ const source = await readFile13(join17(resolveBundledAgentsDir(), "..", "package.json"), "utf8");
8041
8292
  let value;
8042
8293
  try {
8043
8294
  value = JSON.parse(source);
@@ -8062,11 +8313,11 @@ export {
8062
8313
  addComment,
8063
8314
  assertGitHubMergeReady,
8064
8315
  assertReviewEventSupported,
8065
- captureLocalReview,
8066
8316
  checkConventionalSubject,
8067
8317
  computeProjectSlug,
8068
8318
  createForge,
8069
8319
  createForgeBackend,
8320
+ createLocalReviewBackend,
8070
8321
  createModeController,
8071
8322
  createPlanStore,
8072
8323
  createRemoteReviewBackend,
@@ -8099,6 +8350,7 @@ export {
8099
8350
  launch,
8100
8351
  listSessions,
8101
8352
  loadDiffpiConfig,
8353
+ loadReviewPublicationState,
8102
8354
  loadTemplate,
8103
8355
  localReviewAuthor,
8104
8356
  mcp,
@@ -8107,28 +8359,37 @@ export {
8107
8359
  openInNewTab,
8108
8360
  parseGitlabDiffRefs,
8109
8361
  parseRemote,
8362
+ parseThreadArtifact,
8110
8363
  pi,
8111
8364
  plansDir,
8112
- readLocalReview,
8113
8365
  readSession,
8114
8366
  renderReviewDoc,
8115
8367
  renderTemplate,
8368
+ renderThreadArtifact,
8116
8369
  resolveAgentMode,
8117
8370
  resolveAgentModelPreferences,
8118
8371
  resolvePrSession,
8119
8372
  resolveReviewSession,
8120
8373
  resolveSession,
8374
+ reviewBodyFingerprint,
8375
+ reviewCommentFingerprint,
8121
8376
  reviewRecordName,
8377
+ reviewReplyFingerprint,
8122
8378
  reviewSlug,
8123
8379
  reviewsDir,
8124
8380
  runMiseGates,
8381
+ saveReviewPublicationState,
8382
+ sessionsDir,
8125
8383
  setupPi,
8126
8384
  severitySchema,
8127
8385
  storeDir,
8128
8386
  storeGlobalRoot,
8129
8387
  templateRelativePath,
8388
+ toFindings,
8130
8389
  toReviewComments,
8131
8390
  tuicrAvailable,
8391
+ unpublishedReviewComments,
8392
+ upsertThreadReply,
8132
8393
  withRemoteProvenance,
8133
8394
  yymmdd,
8134
8395
  zedKeymapPath,