@difflab/pi 0.6.0 → 0.7.0-rc.202609271854.6af8600

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 (95) hide show
  1. package/README.md +11 -9
  2. package/agents/diffpi-orchestrator.md +6 -4
  3. package/agents/diffpi-plan-reviewer.md +27 -0
  4. package/agents/diffpi-planner.md +5 -5
  5. package/agents/diffpi-reviewer.md +4 -4
  6. package/agents/diffpi-worker.md +1 -1
  7. package/dist/assets.d.ts +0 -1
  8. package/dist/assets.d.ts.map +1 -1
  9. package/dist/cli.js +446 -160
  10. package/dist/commands/plan.d.ts +2 -2
  11. package/dist/commands/plan.d.ts.map +1 -1
  12. package/dist/commands/review.d.ts +2 -2
  13. package/dist/commands/review.d.ts.map +1 -1
  14. package/dist/environment.d.ts +2 -0
  15. package/dist/environment.d.ts.map +1 -1
  16. package/dist/extensions/index.js +1841 -3858
  17. package/dist/extensions/tuicrx.d.ts +8 -1
  18. package/dist/extensions/tuicrx.d.ts.map +1 -1
  19. package/dist/index.d.ts +8 -7
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +951 -349
  22. package/dist/plan/markdown.d.ts +4 -1
  23. package/dist/plan/markdown.d.ts.map +1 -1
  24. package/dist/{tools/plan-schema.d.ts → plan/schema.d.ts} +113 -75
  25. package/dist/plan/schema.d.ts.map +1 -0
  26. package/dist/plan/store.d.ts +39 -1
  27. package/dist/plan/store.d.ts.map +1 -1
  28. package/dist/plan/types.d.ts +27 -3
  29. package/dist/plan/types.d.ts.map +1 -1
  30. package/dist/review/github-review-backend.d.ts.map +1 -1
  31. package/dist/review/gitlab-review-backend.d.ts.map +1 -1
  32. package/dist/review/index.d.ts +6 -6
  33. package/dist/review/index.d.ts.map +1 -1
  34. package/dist/review/local-review-backend.d.ts +3 -0
  35. package/dist/review/local-review-backend.d.ts.map +1 -0
  36. package/dist/review/review-backend.d.ts +3 -2
  37. package/dist/review/review-backend.d.ts.map +1 -1
  38. package/dist/review/review-markdown.d.ts +4 -1
  39. package/dist/review/review-markdown.d.ts.map +1 -1
  40. package/dist/review/review-state.d.ts +20 -0
  41. package/dist/review/review-state.d.ts.map +1 -0
  42. package/dist/review/types.d.ts +28 -0
  43. package/dist/review/types.d.ts.map +1 -1
  44. package/dist/store.d.ts +2 -0
  45. package/dist/store.d.ts.map +1 -1
  46. package/dist/tools/index.js +1668 -607
  47. package/dist/tools/plan.d.ts.map +1 -1
  48. package/dist/tools/review.d.ts +38 -0
  49. package/dist/tools/review.d.ts.map +1 -1
  50. package/package.json +2 -4
  51. package/skills/plan/SKILL.md +8 -14
  52. package/skills/plan/references/design-quality.md +30 -0
  53. package/skills/plan/references/workflows/annotate.md +11 -0
  54. package/skills/plan/references/workflows/finalize.md +7 -0
  55. package/skills/plan/references/workflows/go.md +8 -0
  56. package/skills/plan/references/workflows/help.md +10 -0
  57. package/skills/plan/references/workflows/init.md +5 -0
  58. package/skills/plan/references/workflows/new.md +7 -0
  59. package/skills/plan/references/workflows/update.md +7 -0
  60. package/skills/review/SKILL.md +7 -7
  61. package/skills/review/references/review-standards.md +8 -1
  62. package/skills/review/references/workflows/address.md +11 -0
  63. package/skills/review/references/workflows/auto.md +18 -0
  64. package/skills/review/references/workflows/complete.md +7 -0
  65. package/skills/review/references/workflows/edit.md +7 -0
  66. package/skills/review/references/workflows/help.md +36 -0
  67. package/skills/review/references/workflows/merge.md +6 -0
  68. package/skills/review/references/workflows/new.md +19 -0
  69. package/skills/review/references/workflows/open.md +3 -0
  70. package/skills/review/references/workflows/publish.md +8 -0
  71. package/skills/review/references/workflows/status.md +3 -0
  72. package/templates/plan/PLAN.md +17 -11
  73. package/templates/plan/implementation.md +34 -12
  74. package/dist/commands.d.ts +0 -22
  75. package/dist/commands.d.ts.map +0 -1
  76. package/dist/extensions/cmdtsx.d.ts +0 -2
  77. package/dist/extensions/cmdtsx.d.ts.map +0 -1
  78. package/dist/tools/plan-schema.d.ts.map +0 -1
  79. package/dist/workflow.d.ts +0 -80
  80. package/dist/workflow.d.ts.map +0 -1
  81. package/workflows/plan/annotate.md +0 -13
  82. package/workflows/plan/finalize.md +0 -15
  83. package/workflows/plan/go.md +0 -20
  84. package/workflows/plan/help.md +0 -20
  85. package/workflows/plan/init.md +0 -13
  86. package/workflows/plan/new.md +0 -20
  87. package/workflows/plan/update.md +0 -18
  88. package/workflows/review/address.md +0 -8
  89. package/workflows/review/auto.md +0 -8
  90. package/workflows/review/complete.md +0 -7
  91. package/workflows/review/edit.md +0 -7
  92. package/workflows/review/help.md +0 -14
  93. package/workflows/review/merge.md +0 -6
  94. package/workflows/review/new.md +0 -7
  95. package/workflows/review/publish.md +0 -6
package/dist/index.js CHANGED
@@ -605,6 +605,16 @@ function diffpiLaunchName(cwd, workflow) {
605
605
  }
606
606
  async function openFileAdjacent(path, opts) {
607
607
  const env = opts.env ?? process.env;
608
+ const mux = detectMux(env);
609
+ if (mux !== "none") {
610
+ const editor = await resolveEditor(env);
611
+ if (editor) {
612
+ const command = persistentEditorArgs(editor, path, opts.cwd, env.SHELL);
613
+ const opened = await openMuxTab(mux, command, opts.cwd, opts.name ?? "plan", [editor, path].join(" "), command);
614
+ if (opened)
615
+ return opened;
616
+ }
617
+ }
608
618
  const ide = detectIde(env);
609
619
  if (ide !== "unknown") {
610
620
  const ideCommand = { zed: "zed", vscode: "code", cursor: "cursor", windsurf: "windsurf", jetbrains: "idea" }[ide];
@@ -614,16 +624,6 @@ async function openFileAdjacent(path, opts) {
614
624
  return { launched: true, via: ide, command: `${ideCommand} ${path}` };
615
625
  }
616
626
  }
617
- const mux = detectMux(env);
618
- if (mux !== "none") {
619
- const editor = await resolveEditor(env);
620
- if (editor) {
621
- const command = [editor, path];
622
- const opened = await openMuxTab(mux, command, opts.cwd, opts.name ?? "plan", command.join(" "));
623
- if (opened)
624
- return opened;
625
- }
626
- }
627
627
  return { launched: false, via: "print", command: path, reason: "No supported IDE or multiplexer was available." };
628
628
  }
629
629
  async function openInNewTab(command, opts) {
@@ -658,10 +658,15 @@ async function openInNewTab(command, opts) {
658
658
  function screenWindowArgs(command, cwd, name) {
659
659
  return ["-X", "screen", "-t", name, "sh", "-lc", 'cd -- "$1" && shift && exec "$@"', "sh", cwd, ...command];
660
660
  }
661
+ function persistentEditorArgs(editor, path, cwd, shell = "sh") {
662
+ return ["bash", "-lc", 'cd -- "$1" && shift && "$@"; exec "$0" -i', shell, cwd, editor, path];
663
+ }
661
664
  async function resolveEditor(env) {
662
- const configured = (env.VISUAL ?? env.EDITOR)?.trim().split(/\s+/)[0];
663
- if (configured && await findExecutable(configured))
664
- return configured;
665
+ for (const configured of [env.EDITOR, env.VISUAL]) {
666
+ const editor = configured?.trim();
667
+ if (editor && await findExecutable(editor))
668
+ return editor;
669
+ }
665
670
  for (const candidate of ["hx", "nvim", "vim"])
666
671
  if (await findExecutable(candidate))
667
672
  return candidate;
@@ -678,7 +683,7 @@ async function packageVersion() {
678
683
  throw new Error("Cannot resolve the installed @difflab/pi version.");
679
684
  return value.version;
680
685
  }
681
- async function openMuxTab(mux, command, cwd, name, printable) {
686
+ async function openMuxTab(mux, command, cwd, name, printable, tmuxCommand) {
682
687
  if (mux === "zellij" && await findExecutable("zellij")) {
683
688
  const result = await run("zellij", ["action", "new-tab", "--cwd", cwd, "--name", name, "--", ...command]);
684
689
  if (result.code === 0)
@@ -688,7 +693,7 @@ async function openMuxTab(mux, command, cwd, name, printable) {
688
693
  return { launched: true, via: "zellij-run", command: printable };
689
694
  }
690
695
  if (mux === "tmux" && await findExecutable("tmux")) {
691
- const result = await run("tmux", ["new-window", "-c", cwd, "-n", name, printable]);
696
+ const result = tmuxCommand ? await run("tmux", ["new-window", "-c", cwd, "-n", name, ...tmuxCommand]) : await run("tmux", ["new-window", "-c", cwd, "-n", name, printable]);
692
697
  if (result.code === 0)
693
698
  return { launched: true, via: "tmux", command: printable };
694
699
  }
@@ -1301,6 +1306,9 @@ function yymmdd(date = new Date) {
1301
1306
  function reviewRecordName(target, date = new Date) {
1302
1307
  return `${yymmdd(date)}-${reviewSlug(target) || "uncommitted"}`;
1303
1308
  }
1309
+ function isLocalResponse(body) {
1310
+ return /^<!-- diffpi(?:-local-response:[A-Za-z0-9_-]+)? -->\n/m.test(body);
1311
+ }
1304
1312
  function dedupeFindings(findings) {
1305
1313
  const rank = { BLOCKING: 3, CONSIDER: 2, NOTE: 1 };
1306
1314
  const byKey = new Map;
@@ -1346,6 +1354,18 @@ function renderCommentBody(finding) {
1346
1354
  return `${prefix}${finding.body}${reference}`;
1347
1355
  }
1348
1356
  // src/review/review-markdown.ts
1357
+ import { z as z4 } from "zod";
1358
+ var reviewThreadRecordSchema = z4.object({
1359
+ id: z4.string().min(1),
1360
+ file: z4.string().nullable().optional().transform((value) => value ?? undefined),
1361
+ line: z4.number().int().positive().nullable().optional().transform((value) => value ?? undefined),
1362
+ body: z4.string(),
1363
+ author: z4.string().optional(),
1364
+ resolved: z4.boolean(),
1365
+ addressed: z4.boolean().optional(),
1366
+ question: z4.boolean(),
1367
+ replies: z4.array(z4.string()).optional()
1368
+ });
1349
1369
  function renderReviewDoc(input) {
1350
1370
  const anchored = dedupeFindings(input.findings).filter((finding) => finding.line > 0);
1351
1371
  const lines = [`# Review: ${input.title}`, "", "## Metadata"];
@@ -1387,6 +1407,88 @@ function renderReviewDoc(input) {
1387
1407
  `).trimEnd()}
1388
1408
  `;
1389
1409
  }
1410
+ function renderThreadArtifact(title, target, threads, options = {}) {
1411
+ const records = threads.map((thread) => ({
1412
+ id: thread.id,
1413
+ file: thread.file,
1414
+ line: thread.line,
1415
+ body: thread.body,
1416
+ author: thread.author,
1417
+ resolved: thread.resolved,
1418
+ addressed: thread.addressed,
1419
+ question: thread.question,
1420
+ replies: thread.replies
1421
+ }));
1422
+ const payload = Buffer.from(JSON.stringify(records), "utf8").toString("base64url");
1423
+ const lines = [
1424
+ `<!-- diffpi-threads:${payload} -->`,
1425
+ `# Review threads: ${title}`,
1426
+ "",
1427
+ "## Metadata",
1428
+ "",
1429
+ `- Target: ${target}`,
1430
+ `- Pulled: ${options.timestamp ?? new Date().toISOString()}`
1431
+ ];
1432
+ if (options.number !== undefined)
1433
+ lines.push(`- PR/MR: #${options.number}${options.url ? ` — ${options.url}` : ""}`);
1434
+ lines.push("", "## Replies", "");
1435
+ for (const thread of threads) {
1436
+ const id = Buffer.from(thread.id, "utf8").toString("base64url");
1437
+ lines.push(`### ${thread.file ?? "review"}:${thread.line ?? "n/a"} (${thread.id})`, "", `<!-- diffpi-reply-start:${id} -->`, thread.reply ?? "", `<!-- diffpi-reply-end:${id} -->`, "");
1438
+ }
1439
+ lines.push("## Source comments", "");
1440
+ for (const thread of threads) {
1441
+ lines.push(`### ${thread.file ?? "review"}:${thread.line ?? "n/a"} — ${thread.author ?? "unknown"}`, "", `Thread: ${thread.id}`, `Status: ${thread.resolved ? "resolved" : thread.addressed ? "addressed" : "open"}`, "", "**Source comment**", "", thread.body, "", "**Recorded response**", "", thread.reply?.trim() || "_No response recorded._", "", "---", "");
1442
+ }
1443
+ return `${lines.join(`
1444
+ `).trimEnd()}
1445
+ `;
1446
+ }
1447
+ function parseThreadArtifact(content) {
1448
+ const payload = content.match(/^<!-- diffpi-threads:([A-Za-z0-9_-]+) -->$/m)?.[1];
1449
+ if (!payload)
1450
+ throw new Error("This file is not a Diffpi thread artifact.");
1451
+ let threads;
1452
+ try {
1453
+ const decoded = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
1454
+ threads = z4.array(reviewThreadRecordSchema).parse(decoded);
1455
+ } catch {
1456
+ throw new Error("Cannot parse the Diffpi thread artifact payload.");
1457
+ }
1458
+ const replies = content.split(/^## Source comments$/m, 1)[0] ?? "";
1459
+ return threads.map((thread) => {
1460
+ const id = Buffer.from(thread.id, "utf8").toString("base64url");
1461
+ const startMarker = `<!-- diffpi-reply-start:${id} -->
1462
+ `;
1463
+ const endMarker = `
1464
+ <!-- diffpi-reply-end:${id} -->`;
1465
+ const start = replies.indexOf(startMarker);
1466
+ const end = start < 0 ? -1 : replies.indexOf(endMarker, start + startMarker.length);
1467
+ const reply = start >= 0 && end >= 0 ? replies.slice(start + startMarker.length, end).trim() : "";
1468
+ return reply ? { ...thread, reply } : thread;
1469
+ });
1470
+ }
1471
+ function upsertThreadReply(content, threadId, body, question, resolved) {
1472
+ const threads = parseThreadArtifact(content);
1473
+ const thread = threads.find((candidate) => candidate.id === threadId);
1474
+ if (!thread)
1475
+ throw new Error(`Review thread ${threadId} was not found in the local artifact.`);
1476
+ thread.reply = body;
1477
+ thread.addressed = true;
1478
+ if (question !== undefined)
1479
+ thread.question = question;
1480
+ if (resolved !== undefined)
1481
+ thread.resolved = resolved;
1482
+ const title = content.match(/^# Review threads: (.+)$/m)?.[1] ?? "review";
1483
+ const target = content.match(/^- Target: (.+)$/m)?.[1] ?? "local";
1484
+ const timestamp = content.match(/^- Pulled: (.+)$/m)?.[1];
1485
+ const pr = content.match(/^- PR\/MR: #(\d+)(?: — (.+))?$/m);
1486
+ return renderThreadArtifact(title, target, threads, {
1487
+ timestamp,
1488
+ number: pr ? Number.parseInt(pr[1], 10) : undefined,
1489
+ url: pr?.[2]
1490
+ });
1491
+ }
1390
1492
  // src/review/github-review-backend.ts
1391
1493
  function GitHubReviewBackend(vcs, number) {
1392
1494
  const pendingReview = async () => {
@@ -1448,6 +1550,9 @@ function GitHubReviewBackend(vcs, number) {
1448
1550
  id: thread.id,
1449
1551
  file: thread.path ?? undefined,
1450
1552
  line: thread.line ?? undefined,
1553
+ rootCommentId: comment?.id,
1554
+ commentIds: nodes.flatMap((node) => [node.id, node.databaseId?.toString()].filter((id) => Boolean(id))),
1555
+ commentNodeIds: nodes.flatMap((node) => node.id ? [node.id] : []),
1451
1556
  body,
1452
1557
  author: comment?.author?.login,
1453
1558
  resolved: thread.isResolved,
@@ -1457,6 +1562,7 @@ function GitHubReviewBackend(vcs, number) {
1457
1562
  });
1458
1563
  };
1459
1564
  return {
1565
+ kind: "remote",
1460
1566
  async stage(draft) {
1461
1567
  const pending = await pendingReview();
1462
1568
  if (!pending) {
@@ -1514,6 +1620,33 @@ function GitHubReviewBackend(vcs, number) {
1514
1620
  await ghChecked(args);
1515
1621
  }
1516
1622
  },
1623
+ async readDraft() {
1624
+ const pending = await pendingReview();
1625
+ if (!pending)
1626
+ return { comments: [], body: "" };
1627
+ const review = await ghChecked(["api", `/repos/${vcs.owner}/${vcs.repo}/pulls/${number}/reviews/${pending.id}`]);
1628
+ const comments = await ghChecked([
1629
+ "api",
1630
+ `/repos/${vcs.owner}/${vcs.repo}/pulls/${number}/reviews/${pending.id}/comments`
1631
+ ]);
1632
+ let reviewData;
1633
+ let commentData;
1634
+ try {
1635
+ reviewData = JSON.parse(review.stdout);
1636
+ commentData = JSON.parse(comments.stdout);
1637
+ } catch {
1638
+ throw new Error("Cannot parse the pending GitHub review as JSON.");
1639
+ }
1640
+ return {
1641
+ body: reviewData.body ?? "",
1642
+ comments: commentData.map((comment) => ({
1643
+ file: comment.path,
1644
+ line: comment.line ?? comment.original_line ?? undefined,
1645
+ side: comment.side ?? undefined,
1646
+ body: comment.body
1647
+ }))
1648
+ };
1649
+ },
1517
1650
  listThreads,
1518
1651
  async reply(input) {
1519
1652
  const threads = await listThreads();
@@ -1530,14 +1663,32 @@ function GitHubReviewBackend(vcs, number) {
1530
1663
  `body=${input.body}`
1531
1664
  ]);
1532
1665
  }
1533
- if (input.resolve) {
1666
+ if (input.resolve)
1667
+ await this.setResolved?.(input.threadId, true);
1668
+ },
1669
+ async setResolved(threadId, resolved) {
1670
+ await ghChecked([
1671
+ "api",
1672
+ "graphql",
1673
+ "-f",
1674
+ `query=${resolved ? GITHUB_RESOLVE_MUTATION : GITHUB_UNRESOLVE_MUTATION}`,
1675
+ "-f",
1676
+ `threadId=${threadId}`
1677
+ ]);
1678
+ },
1679
+ async deleteThread(threadId) {
1680
+ const thread = (await listThreads()).find((candidate) => candidate.id === threadId);
1681
+ const commentNodeIds = thread?.commentNodeIds ?? (thread?.rootCommentId ? [thread.rootCommentId] : []);
1682
+ if (!commentNodeIds.length)
1683
+ throw new Error(`Cannot delete GitHub thread ${threadId}: comment node IDs are missing.`);
1684
+ for (const commentId of commentNodeIds) {
1534
1685
  await ghChecked([
1535
1686
  "api",
1536
1687
  "graphql",
1537
1688
  "-f",
1538
- `query=${GITHUB_RESOLVE_MUTATION}`,
1689
+ `query=${GITHUB_DELETE_COMMENT_MUTATION}`,
1539
1690
  "-f",
1540
- `threadId=${input.threadId}`
1691
+ `commentId=${commentId}`
1541
1692
  ]);
1542
1693
  }
1543
1694
  },
@@ -1556,11 +1707,13 @@ function GitHubReviewBackend(vcs, number) {
1556
1707
  function githubReviewSubmissionEndpoint(owner, repo, id, pendingReviewId) {
1557
1708
  return pendingReviewId ? `/repos/${owner}/${repo}/pulls/${id}/reviews/${pendingReviewId}/events` : `/repos/${owner}/${repo}/pulls/${id}/reviews`;
1558
1709
  }
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}}}}}`;
1710
+ 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
1711
  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
1712
  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
1713
  var GITHUB_REPLY_MUTATION = `mutation($threadId:ID!,$body:String!){addPullRequestReviewThreadReply(input:{pullRequestReviewThreadId:$threadId,body:$body}){comment{id}}}`;
1563
1714
  var GITHUB_RESOLVE_MUTATION = `mutation($threadId:ID!){resolveReviewThread(input:{threadId:$threadId}){thread{isResolved}}}`;
1715
+ var GITHUB_UNRESOLVE_MUTATION = `mutation($threadId:ID!){unresolveReviewThread(input:{threadId:$threadId}){thread{isResolved}}}`;
1716
+ var GITHUB_DELETE_COMMENT_MUTATION = `mutation($commentId:ID!){deletePullRequestReviewComment(input:{id:$commentId}){clientMutationId}}`;
1564
1717
 
1565
1718
  // src/review/gitlab-review-backend.ts
1566
1719
  function GitLabReviewBackend(vcs, number) {
@@ -1597,6 +1750,7 @@ function GitLabReviewBackend(vcs, number) {
1597
1750
  });
1598
1751
  };
1599
1752
  return {
1753
+ kind: "remote",
1600
1754
  async stage(draft) {
1601
1755
  const endpoint = `${mergeRequestEndpoint()}/draft_notes`;
1602
1756
  if (draft.body.trim()) {
@@ -1625,6 +1779,36 @@ function GitLabReviewBackend(vcs, number) {
1625
1779
  });
1626
1780
  }
1627
1781
  },
1782
+ async readDraft() {
1783
+ const result = await glabChecked(["api", `${mergeRequestEndpoint()}/draft_notes`]);
1784
+ let notes;
1785
+ try {
1786
+ notes = JSON.parse(result.stdout);
1787
+ } catch {
1788
+ throw new Error("Cannot parse GitLab draft notes as JSON.");
1789
+ }
1790
+ const comments = [];
1791
+ const body = [];
1792
+ for (const note of notes) {
1793
+ if (!note.note)
1794
+ continue;
1795
+ const file = note.position?.new_path ?? note.position?.old_path;
1796
+ const line = note.position?.new_line ?? note.position?.old_line;
1797
+ if (file && line) {
1798
+ comments.push({
1799
+ file,
1800
+ line,
1801
+ side: note.position?.new_line ? "RIGHT" : "LEFT",
1802
+ body: note.note
1803
+ });
1804
+ } else {
1805
+ body.push(note.note);
1806
+ }
1807
+ }
1808
+ return { comments, body: body.join(`
1809
+
1810
+ `) };
1811
+ },
1628
1812
  listThreads,
1629
1813
  async reply(input) {
1630
1814
  const endpoint = `${mergeRequestEndpoint()}/discussions/${encodeURIComponent(input.threadId)}`;
@@ -1636,7 +1820,15 @@ function GitLabReviewBackend(vcs, number) {
1636
1820
  });
1637
1821
  }
1638
1822
  if (input.resolve)
1639
- await glabChecked(["api", "--method", "PUT", `${endpoint}?resolved=true`]);
1823
+ await this.setResolved?.(input.threadId, true);
1824
+ },
1825
+ async setResolved(threadId, resolved) {
1826
+ const endpoint = `${mergeRequestEndpoint()}/discussions/${encodeURIComponent(threadId)}`;
1827
+ await glabChecked(["api", "--method", "PUT", `${endpoint}?resolved=${resolved}`]);
1828
+ },
1829
+ async deleteThread(threadId) {
1830
+ const endpoint = `${mergeRequestEndpoint()}/discussions/${encodeURIComponent(threadId)}`;
1831
+ await glabChecked(["api", "--method", "DELETE", endpoint]);
1640
1832
  },
1641
1833
  async publish(event) {
1642
1834
  assertReviewEventSupported(vcs.provider, event);
@@ -1676,105 +1868,12 @@ function hasGitlabDraftNotes(input) {
1676
1868
  }
1677
1869
  }
1678
1870
 
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
- }
1871
+ // src/review/local-review-backend.ts
1872
+ import { readFile as readFile6, writeFile as writeFile3 } from "node:fs/promises";
1774
1873
 
1775
1874
  // src/extensions/tuicrx.ts
1776
- import { readFile as readFile5, realpath as realpath3 } from "node:fs/promises";
1777
- import { resolve as resolve3 } from "node:path";
1875
+ import { readFile as readFile5, realpath as realpath2 } from "node:fs/promises";
1876
+ import { resolve as resolve2 } from "node:path";
1778
1877
  async function tuicrAvailable() {
1779
1878
  return Boolean(await findExecutable("tuicr"));
1780
1879
  }
@@ -1850,7 +1949,7 @@ async function resolvePrSession(cwd, owner, repo, number) {
1850
1949
  });
1851
1950
  }
1852
1951
  async function findMatchingSession(sessions, cwd, branch) {
1853
- const repository = await canonicalPath3(await gitToplevel(cwd));
1952
+ const repository = await canonicalPath2(await gitToplevel(cwd));
1854
1953
  for (const session of sessions) {
1855
1954
  if (session.kind !== "local")
1856
1955
  continue;
@@ -1858,7 +1957,7 @@ async function findMatchingSession(sessions, cwd, branch) {
1858
1957
  const data = await readSession(session.path);
1859
1958
  if (data.branch_name !== branch || !data.repo_path)
1860
1959
  continue;
1861
- if (await canonicalPath3(data.repo_path) === repository)
1960
+ if (await canonicalPath2(data.repo_path) === repository)
1862
1961
  return session;
1863
1962
  } catch {}
1864
1963
  }
@@ -1872,33 +1971,53 @@ async function readSession(path) {
1872
1971
  throw new Error(`Cannot parse tuicr session JSON: ${path}`);
1873
1972
  }
1874
1973
  }
1875
- function toLocalReviewThreads(session) {
1876
- const threads = [];
1877
- const add = (comment, location = {}) => {
1974
+ function toFindings(session, options = {}) {
1975
+ const comments = [];
1976
+ const include = (comment) => (!options.agentOnly || commentAuthor(comment)?.startsWith("Agent: ")) && (!options.excludeLocalResponses || !isLocalResponse(comment.content));
1977
+ const publishableBody = (comment) => {
1878
1978
  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
- });
1979
+ const route = author?.match(/^Agent:\s*(.+)$/)?.[1];
1980
+ return route ? withRemoteProvenance(comment.content, route) : comment.content;
1887
1981
  };
1888
- for (const comment of session.review_comments ?? [])
1889
- add(comment);
1982
+ const bodyParts = (session.review_comments ?? []).flatMap((comment) => include(comment) ? [publishableBody(comment)] : []);
1890
1983
  for (const [file, entry] of Object.entries(session.files ?? {})) {
1891
- for (const comment of entry.file_comments ?? [])
1892
- add(comment, { file });
1984
+ for (const fileComment of entry.file_comments ?? []) {
1985
+ if (!include(fileComment))
1986
+ continue;
1987
+ const comment = {
1988
+ file,
1989
+ body: fileComment.content,
1990
+ ...fileComment.id ? { sourceCommentId: String(fileComment.id) } : {}
1991
+ };
1992
+ const author = commentAuthor(fileComment);
1993
+ if (author)
1994
+ comment.author = author;
1995
+ comments.push(comment);
1996
+ }
1893
1997
  for (const [lineKey, lineComments] of Object.entries(entry.line_comments ?? {})) {
1894
1998
  const line = Number.parseInt(lineKey, 10);
1895
1999
  if (!Number.isFinite(line))
1896
2000
  continue;
1897
- for (const comment of lineComments)
1898
- add(comment, { file, line });
2001
+ for (const lineComment of lineComments) {
2002
+ if (!include(lineComment))
2003
+ continue;
2004
+ const comment = {
2005
+ file,
2006
+ line,
2007
+ side: lineComment.side === "old" ? "LEFT" : "RIGHT",
2008
+ body: lineComment.content,
2009
+ ...lineComment.id ? { sourceCommentId: String(lineComment.id) } : {}
2010
+ };
2011
+ const author = commentAuthor(lineComment);
2012
+ if (author)
2013
+ comment.author = author;
2014
+ comments.push(comment);
2015
+ }
1899
2016
  }
1900
2017
  }
1901
- return threads;
2018
+ return { comments, body: bodyParts.join(`
2019
+
2020
+ `) };
1902
2021
  }
1903
2022
  async function requireTuicr() {
1904
2023
  if (!await tuicrAvailable())
@@ -1907,70 +2026,207 @@ async function requireTuicr() {
1907
2026
  function commentAuthor(comment) {
1908
2027
  return comment.username ?? comment.author;
1909
2028
  }
1910
- async function canonicalPath3(path) {
2029
+ async function canonicalPath2(path) {
1911
2030
  try {
1912
- return await realpath3(path);
2031
+ return await realpath2(path);
1913
2032
  } catch {
1914
- return resolve3(path);
2033
+ return resolve2(path);
1915
2034
  }
1916
2035
  }
1917
2036
 
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 }
2037
+ // src/review/local-review-backend.ts
2038
+ function LocalReviewBackend(options) {
2039
+ return {
2040
+ kind: "local",
2041
+ async stage(draft) {
2042
+ for (const comment of draft.comments) {
2043
+ await addComment(options.session, comment.body, {
2044
+ targetFile: comment.file,
2045
+ line: comment.line,
2046
+ side: comment.side === "LEFT" ? "old" : "new",
2047
+ username: options.author
2048
+ });
2049
+ }
2050
+ if (draft.body.trim())
2051
+ await addComment(options.session, draft.body, { username: options.author });
2052
+ },
2053
+ async readDraft() {
2054
+ return toFindings(await readSession(options.session), { excludeLocalResponses: true });
2055
+ },
2056
+ async listThreads() {
2057
+ try {
2058
+ return parseThreadArtifact(await readFile6(options.artifactPath, "utf8"));
2059
+ } catch (error) {
2060
+ if (error.code === "ENOENT")
2061
+ return [];
2062
+ throw error;
2063
+ }
2064
+ },
2065
+ async reply(input) {
2066
+ const content = await readFile6(options.artifactPath, "utf8");
2067
+ await writeFile3(options.artifactPath, upsertThreadReply(content, input.threadId, input.body, input.question, input.resolve), "utf8");
2068
+ },
2069
+ async publish() {
2070
+ throw new Error("Promote a local draft through a remote review backend before publishing it.");
2071
+ }
1936
2072
  };
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;
2073
+ }
2074
+
2075
+ // src/review/review-backend.ts
2076
+ function createRemoteReviewBackend(vcs, number) {
2077
+ if (vcs.provider === "github")
2078
+ return GitHubReviewBackend(vcs, number);
2079
+ if (vcs.provider === "gitlab")
2080
+ return GitLabReviewBackend(vcs, number);
2081
+ throw new Error("A remote review backend requires a GitHub or GitLab repository.");
2082
+ }
2083
+ function createLocalReviewBackend(options) {
2084
+ return LocalReviewBackend(options);
2085
+ }
2086
+ // src/review/review-state.ts
2087
+ import { createHash as createHash2 } from "node:crypto";
2088
+ import { readFile as readFile7, rename, writeFile as writeFile4 } from "node:fs/promises";
2089
+ import { join as join9 } from "node:path";
2090
+ import { z as z5 } from "zod";
2091
+
2092
+ // src/store.ts
2093
+ import { createHash } from "node:crypto";
2094
+ import { lstat, mkdir as mkdir3, readlink, realpath as realpath3, symlink, unlink } from "node:fs/promises";
2095
+ import { homedir as homedir4 } from "node:os";
2096
+ import { dirname as dirname4, isAbsolute as isAbsolute2, join as join8, resolve as resolve3 } from "node:path";
2097
+ var STORE_LINK = ".diffpi";
2098
+ var LEGACY_STORE_LINK = join8(".pi", "diffpi");
2099
+ async function computeProjectSlug(cwd) {
2100
+ return projectSlug(await inspectGitRepository(cwd));
2101
+ }
2102
+ function storeGlobalRoot(homeDir = homedir4()) {
2103
+ return join8(homeDir, ".difflab", "diffpi", "projects");
2104
+ }
2105
+ async function ensureStore(cwd, homeDir = homedir4()) {
2106
+ const repository = await inspectGitRepository(cwd);
2107
+ const root = repository.root;
2108
+ const slug = projectSlug(repository);
2109
+ const dest = join8(storeGlobalRoot(homeDir), slug);
2110
+ const link = join8(root, STORE_LINK);
2111
+ await mkdir3(dest, { recursive: true });
1949
2112
  try {
1950
- return JSON.parse(await readFile6(join9(dir, `${selected}.json`), "utf8"));
2113
+ await assertStoreLink(link, dest);
1951
2114
  } catch (error) {
1952
- if (error.code === "ENOENT")
2115
+ if (error.code !== "ENOENT")
2116
+ throw error;
2117
+ await symlink(dest, link);
2118
+ }
2119
+ await removeLegacyStoreLink(join8(root, LEGACY_STORE_LINK), dest);
2120
+ return { slug, root, dest, link, linked: true };
2121
+ }
2122
+ async function storeDir(cwd, homeDir = homedir4()) {
2123
+ const store = await ensureStore(cwd, homeDir);
2124
+ return store.dest;
2125
+ }
2126
+ async function plansDir(cwd, homeDir = homedir4()) {
2127
+ const store = await ensureStore(cwd, homeDir);
2128
+ const dir = join8(store.link, "plan");
2129
+ await mkdir3(dir, { recursive: true });
2130
+ return dir;
2131
+ }
2132
+ async function reviewsDir(cwd, homeDir = homedir4()) {
2133
+ const store = await ensureStore(cwd, homeDir);
2134
+ const dir = join8(store.link, "review");
2135
+ await mkdir3(dir, { recursive: true });
2136
+ return dir;
2137
+ }
2138
+ async function sessionsDir(cwd, homeDir = homedir4()) {
2139
+ const store = await ensureStore(cwd, homeDir);
2140
+ const dir = join8(store.link, "sessions");
2141
+ await mkdir3(dir, { recursive: true });
2142
+ return dir;
2143
+ }
2144
+ async function assertStoreLink(path, dest) {
2145
+ const entry = await lstat(path);
2146
+ if (!entry.isSymbolicLink())
2147
+ throw new Error(`${path} exists and is not a symlink.`);
2148
+ const target = await symlinkTarget(path);
2149
+ if (target !== await canonicalPath3(dest))
2150
+ throw new Error(`${path} points to ${target}, not ${dest}.`);
2151
+ }
2152
+ async function removeLegacyStoreLink(path, dest) {
2153
+ try {
2154
+ const entry = await lstat(path);
2155
+ if (!entry.isSymbolicLink())
1953
2156
  return;
1954
- throw error;
2157
+ const target = await symlinkTarget(path);
2158
+ if (target === await canonicalPath3(dest))
2159
+ await unlink(path);
2160
+ } catch (error) {
2161
+ if (error.code !== "ENOENT")
2162
+ throw error;
1955
2163
  }
1956
2164
  }
1957
- async function nextRevision(dir) {
1958
- return await latestRevision(dir) + 1;
2165
+ async function symlinkTarget(path) {
2166
+ const target = await readlink(path);
2167
+ return canonicalPath3(isAbsolute2(target) ? target : resolve3(dirname4(path), target));
1959
2168
  }
1960
- async function latestRevision(dir) {
2169
+ async function canonicalPath3(path) {
1961
2170
  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);
2171
+ return await realpath3(path);
2172
+ } catch {
2173
+ return resolve3(path);
2174
+ }
2175
+ }
2176
+ function projectSlug(repository) {
2177
+ const readable = repository.name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "unnamed";
2178
+ const digest = createHash("sha256").update(repository.identity).digest("hex").slice(0, 12);
2179
+ return `${readable}-${digest}`;
2180
+ }
2181
+
2182
+ // src/review/review-state.ts
2183
+ var reviewPublicationStateSchema = z5.object({
2184
+ target: z5.string().optional(),
2185
+ bodies: z5.array(z5.string()).default([]),
2186
+ comments: z5.array(z5.string()).default([]),
2187
+ stagedComments: z5.array(z5.string()).default([]),
2188
+ replies: z5.array(z5.string()).default([]),
2189
+ overlayPath: z5.string().optional()
2190
+ });
2191
+ function reviewBodyFingerprint(body) {
2192
+ return digest(body);
2193
+ }
2194
+ function reviewCommentFingerprint(comment) {
2195
+ return digest([comment.file, String(comment.line), comment.side ?? "RIGHT", comment.body].join("\x00"));
2196
+ }
2197
+ function reviewReplyFingerprint(threadId, body) {
2198
+ return digest(`${threadId}\x00${body}`);
2199
+ }
2200
+ function unpublishedReviewComments(comments, knownFingerprints) {
2201
+ return comments.filter((comment) => !knownFingerprints.has(reviewCommentFingerprint(comment)));
2202
+ }
2203
+ async function loadReviewPublicationState(cwd, vcs, number, homeDir) {
2204
+ const target = `${vcs.provider}:${vcs.owner}/${vcs.repo}#${number}`;
2205
+ const path = join9(await sessionsDir(cwd, homeDir), `review-publish-${digest(target).slice(0, 16)}.json`);
2206
+ try {
2207
+ const parsed = reviewPublicationStateSchema.parse(JSON.parse(await readFile7(path, "utf8")));
2208
+ return { path, state: { ...parsed, target } };
1966
2209
  } catch (error) {
1967
- if (error.code === "ENOENT")
1968
- return 0;
2210
+ if (error.code === "ENOENT") {
2211
+ return { path, state: { target, bodies: [], comments: [], stagedComments: [], replies: [] } };
2212
+ }
2213
+ if (error instanceof SyntaxError || error instanceof z5.ZodError) {
2214
+ throw new Error(`Cannot parse review publication state: ${path}`);
2215
+ }
1969
2216
  throw error;
1970
2217
  }
1971
2218
  }
2219
+ async function saveReviewPublicationState(path, state) {
2220
+ const temp = `${path}.${process.pid}.tmp`;
2221
+ await writeFile4(temp, `${JSON.stringify(state, null, 2)}
2222
+ `, "utf8");
2223
+ await rename(temp, path);
2224
+ }
2225
+ function digest(value) {
2226
+ return createHash2("sha256").update(value).digest("hex");
2227
+ }
1972
2228
  // src/mcp.ts
1973
- import { mkdir as mkdir5, readFile as readFile7, writeFile as writeFile4 } from "node:fs/promises";
2229
+ import { mkdir as mkdir4, readFile as readFile8, writeFile as writeFile5 } from "node:fs/promises";
1974
2230
  import { homedir as homedir5 } from "node:os";
1975
2231
  import { dirname as dirname5, join as join10 } from "node:path";
1976
2232
  var mcp = {
@@ -1988,8 +2244,8 @@ var mcp = {
1988
2244
  const next = { ...current, mcpServers: nextServers };
1989
2245
  const changed = JSON.stringify(current) !== JSON.stringify(next);
1990
2246
  if (changed && !options.dryRun) {
1991
- await mkdir5(dirname5(path), { recursive: true });
1992
- await writeFile4(path, `${JSON.stringify(next, null, 2)}
2247
+ await mkdir4(dirname5(path), { recursive: true });
2248
+ await writeFile5(path, `${JSON.stringify(next, null, 2)}
1993
2249
  `, "utf8");
1994
2250
  }
1995
2251
  return { path, changed, existed: currentText !== undefined, planned: changed && options.dryRun === true };
@@ -2018,7 +2274,7 @@ function getParsedConfig(content, path) {
2018
2274
  }
2019
2275
  async function getOptionalFile2(path) {
2020
2276
  try {
2021
- return await readFile7(path, "utf8");
2277
+ return await readFile8(path, "utf8");
2022
2278
  } catch (error) {
2023
2279
  if (error instanceof Error && "code" in error && error.code === "ENOENT")
2024
2280
  return;
@@ -2033,7 +2289,7 @@ import {
2033
2289
  getAgentDir,
2034
2290
  parseFrontmatter as parseFrontmatter2
2035
2291
  } from "@earendil-works/pi-coding-agent";
2036
- import { readFile as readFile8 } from "node:fs/promises";
2292
+ import { readFile as readFile9 } from "node:fs/promises";
2037
2293
  import { homedir as homedir6 } from "node:os";
2038
2294
  import { basename as basename4, extname, join as join11 } from "node:path";
2039
2295
  async function discoverAgentModes(options) {
@@ -2232,7 +2488,7 @@ async function loadAgentModes(directory, source, modes, diagnostics, skillName)
2232
2488
  for (const entry of entries.filter((item) => item.isFile() && item.name.endsWith(".md")).sort((a, b) => a.name.localeCompare(b.name))) {
2233
2489
  const path = join11(directory, entry.name);
2234
2490
  try {
2235
- const content = await readFile8(path, "utf8");
2491
+ const content = await readFile9(path, "utf8");
2236
2492
  const { frontmatter, body } = parseFrontmatter2(content.startsWith("\uFEFF") ? content.slice(1) : content);
2237
2493
  if (frontmatter.enabled === false || frontmatter.inline === false)
2238
2494
  continue;
@@ -2288,7 +2544,7 @@ function isModeBaseline(value) {
2288
2544
  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
2545
  }
2290
2546
  // src/pi.ts
2291
- import { mkdir as mkdir6, writeFile as writeFile5 } from "node:fs/promises";
2547
+ import { mkdir as mkdir5, writeFile as writeFile6 } from "node:fs/promises";
2292
2548
  import { homedir as homedir7 } from "node:os";
2293
2549
  import { dirname as dirname6, join as join12 } from "node:path";
2294
2550
  var pi = {
@@ -2307,8 +2563,8 @@ async function ensurePiAgent(filename, content, agentDir = resolvePiAgentDir(),
2307
2563
  const currentText = await readTextIfExists(path);
2308
2564
  const changed = currentText !== content;
2309
2565
  if (changed && !dryRun) {
2310
- await mkdir6(dirname6(path), { recursive: true });
2311
- await writeFile5(path, content, "utf8");
2566
+ await mkdir5(dirname6(path), { recursive: true });
2567
+ await writeFile6(path, content, "utf8");
2312
2568
  }
2313
2569
  return { path, changed, existed: currentText !== undefined, planned: changed && dryRun };
2314
2570
  }
@@ -2344,8 +2600,8 @@ async function ensurePiConfig(path, update, dryRun = false) {
2344
2600
  const next = update(current);
2345
2601
  const changed = JSON.stringify(current) !== JSON.stringify(next);
2346
2602
  if (changed && !dryRun) {
2347
- await mkdir6(dirname6(path), { recursive: true });
2348
- await writeFile5(path, `${JSON.stringify(next, null, 2)}
2603
+ await mkdir5(dirname6(path), { recursive: true });
2604
+ await writeFile6(path, `${JSON.stringify(next, null, 2)}
2349
2605
  `, "utf8");
2350
2606
  }
2351
2607
  return { path, changed, existed: currentText !== undefined, planned: changed && dryRun };
@@ -2378,16 +2634,17 @@ function parseJsonObject(content, path) {
2378
2634
  throw new Error(`Expected valid JSON object in ${path}.`);
2379
2635
  }
2380
2636
  // 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";
2382
- import { basename as basename6, dirname as dirname8, join as join16, resolve as resolve4, sep } from "node:path";
2637
+ import { createHash as createHash3, randomUUID as randomUUID2 } from "node:crypto";
2638
+ import { mkdir as mkdir7, readdir as readdir2, readFile as readFile11, realpath as realpath4, rename as rename3, rm as rm2 } from "node:fs/promises";
2639
+ import { basename as basename6, dirname as dirname8, join as join15, resolve as resolve4, sep } from "node:path";
2383
2640
 
2384
2641
  // src/extensions/fsx.ts
2385
- import { mkdir as mkdir7, rename, rm, writeFile as writeFile6 } from "node:fs/promises";
2642
+ import { mkdir as mkdir6, rename as rename2, rm, writeFile as writeFile7 } from "node:fs/promises";
2386
2643
  import { basename as basename5, dirname as dirname7, join as join13 } from "node:path";
2387
2644
  async function atomicWrite(path, content) {
2388
2645
  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);
2646
+ await writeFile7(temp, content, { encoding: "utf8", mode: 384, flag: "wx" });
2647
+ await rename2(temp, path);
2391
2648
  }
2392
2649
  async function withDirectoryLock(lockDir, operation, options = {}) {
2393
2650
  const waitMs = options.waitMs ?? 5000;
@@ -2395,7 +2652,7 @@ async function withDirectoryLock(lockDir, operation, options = {}) {
2395
2652
  const started = Date.now();
2396
2653
  while (true) {
2397
2654
  try {
2398
- await mkdir7(lockDir);
2655
+ await mkdir6(lockDir);
2399
2656
  break;
2400
2657
  } catch (error) {
2401
2658
  if (error.code !== "EEXIST")
@@ -2417,7 +2674,7 @@ function sleep(ms) {
2417
2674
 
2418
2675
  // src/log.ts
2419
2676
  import { randomUUID } from "node:crypto";
2420
- import { appendFile, readFile as readFile9 } from "node:fs/promises";
2677
+ import { appendFile, readFile as readFile10 } from "node:fs/promises";
2421
2678
  async function appendLogEntry(logPath, event) {
2422
2679
  const entry = {
2423
2680
  ...event,
@@ -2430,42 +2687,6 @@ async function appendLogEntry(logPath, event) {
2430
2687
  return entry;
2431
2688
  }
2432
2689
 
2433
- // src/templates.ts
2434
- import { readFile as readFile10 } from "node:fs/promises";
2435
- import { homedir as homedir8 } from "node:os";
2436
- import { join as join14, normalize } from "node:path";
2437
- async function loadTemplate(name, options = {}) {
2438
- const relative = templateRelativePath(name);
2439
- const userPath = join14(options.homeDir ?? homedir8(), ".difflab", "diffpi", "templates", relative);
2440
- const bundledPath = join14(options.bundledDir ?? resolveBundledTemplatesDir(), relative);
2441
- const user = await readOptionalFile(userPath);
2442
- if (user !== undefined)
2443
- return { name, path: userPath, source: "user", content: user };
2444
- const bundled = await readOptionalFile(bundledPath);
2445
- if (bundled !== undefined)
2446
- return { name, path: bundledPath, source: "bundled", content: bundled };
2447
- throw new Error(`Template "${name}" was not found at ${userPath} or ${bundledPath}.`);
2448
- }
2449
- function renderTemplate(content, variables) {
2450
- return content.replace(/\{\{\s*([a-zA-Z0-9_.-]+)\s*\}\}/g, (match, key) => variables[key] ?? match);
2451
- }
2452
- function templateRelativePath(name) {
2453
- const normalized = normalize(name.replaceAll("\\", "/")).replace(/^\.\//, "");
2454
- if (!normalized || normalized.startsWith("../") || normalized.includes("/../") || normalized.startsWith("/")) {
2455
- throw new Error(`Invalid template name: ${name}`);
2456
- }
2457
- return normalized.endsWith(".md") ? normalized : `${normalized}.md`;
2458
- }
2459
- async function readOptionalFile(path) {
2460
- try {
2461
- return await readFile10(path, "utf8");
2462
- } catch (error) {
2463
- if (error.code === "ENOENT")
2464
- return;
2465
- throw error;
2466
- }
2467
- }
2468
-
2469
2690
  // src/plan/markdown.ts
2470
2691
  var import_mdast_builder = __toESM(require_src(), 1);
2471
2692
 
@@ -3812,7 +4033,7 @@ var handle = {
3812
4033
  };
3813
4034
 
3814
4035
  // ../../node_modules/mdast-util-to-markdown/lib/join.js
3815
- var join15 = [joinDefaults];
4036
+ var join14 = [joinDefaults];
3816
4037
  function joinDefaults(left, right, parent, state) {
3817
4038
  if (right.type === "code" && formatCodeAsIndented(right, state) && (left.type === "list" || left.type === right.type && formatCodeAsIndented(left, state))) {
3818
4039
  return false;
@@ -6358,7 +6579,7 @@ function toMarkdown(tree, options) {
6358
6579
  handle: undefined,
6359
6580
  indentLines,
6360
6581
  indexStack: [],
6361
- join: [...join15],
6582
+ join: [...join14],
6362
6583
  options: {},
6363
6584
  safe: safeBound,
6364
6585
  stack: [],
@@ -7135,11 +7356,13 @@ function validatePlanDocument(plan, options = {}) {
7135
7356
  if (!taskIds.has(dependency))
7136
7357
  add("dangling-dependency", "error", `Task ${task.id} depends on missing ${dependency}.`);
7137
7358
  }
7138
- if (options.strict && task.acceptanceCriteria.length === 0)
7139
- add("acceptance", "error", `Task ${task.id} has no acceptance criteria.`, task.id);
7359
+ if (options.strict && isPlaceholder(task.title))
7360
+ add("placeholder", "error", `Task ${task.id} has placeholder content.`, task.id);
7140
7361
  }
7141
7362
  if (options.strict && phase.tasks.length === 0)
7142
7363
  add("empty-phase", "error", `Phase ${phase.id} has no tasks.`, phase.id);
7364
+ if (options.strict && (isPlaceholder(phase.title) || isPlaceholder(phase.objective)))
7365
+ add("placeholder", "error", `Phase ${phase.id} has placeholder content.`, phase.id);
7143
7366
  }
7144
7367
  for (const cycle of dependencyCycles(plan))
7145
7368
  add("dependency-cycle", "error", `Dependency cycle: ${cycle.join(" -> ")}.`);
@@ -7148,10 +7371,131 @@ function validatePlanDocument(plan, options = {}) {
7148
7371
  add("design-length", "warning", `Design is ${designWords} words; prefer 300 or fewer.`);
7149
7372
  if (options.strict && designWords > 800)
7150
7373
  add("design-too-long", "error", `Design is ${designWords} words; finalization allows at most 800.`);
7151
- if (options.strict && plan.phases.length === 0)
7152
- add("no-phases", "error", "Finalization requires at least one phase.");
7374
+ if (options.strict) {
7375
+ if (plan.phases.length === 0)
7376
+ add("no-phases", "error", "Finalization requires at least one phase.");
7377
+ if (isPlaceholder(plan.intent))
7378
+ add("placeholder", "error", "Intent must contain concrete content.", "intent");
7379
+ if (!plan.requirements.length || plan.requirements.some(isPlaceholder))
7380
+ add("requirements", "error", "Requirements must contain concrete content.", "requirements");
7381
+ for (const [key, value] of Object.entries(plan.design)) {
7382
+ if (isPlaceholder(value))
7383
+ add("design", "error", `Design/${key} must contain concrete content.`, `design.${key}`);
7384
+ }
7385
+ }
7386
+ return issues;
7387
+ }
7388
+ function validatePlanBriefs(plan, briefs) {
7389
+ const issues = [];
7390
+ const phaseIds = plan.phases.map((phase) => phase.id);
7391
+ const briefIds = briefs.map((brief) => brief.phaseId);
7392
+ if (!sameIds(phaseIds, briefIds))
7393
+ issues.push({
7394
+ code: "brief-phase-ids",
7395
+ severity: "error",
7396
+ message: `Brief phase IDs must exactly match plan phases in order: ${phaseIds.join(", ")}.`
7397
+ });
7398
+ for (const [index, phase] of plan.phases.entries()) {
7399
+ const brief = briefs[index];
7400
+ if (!brief || brief.phaseId !== phase.id)
7401
+ continue;
7402
+ if (isPlaceholder(brief.summary))
7403
+ issues.push({ code: "brief-placeholder", severity: "error", message: `Brief ${phase.id} needs a summary.` });
7404
+ const taskIds = phase.tasks.map((task) => task.id);
7405
+ const briefTaskIds = brief.tasks.map((task) => task.taskId);
7406
+ if (!sameIds(taskIds, briefTaskIds))
7407
+ issues.push({
7408
+ code: "brief-task-ids",
7409
+ severity: "error",
7410
+ message: `Brief ${phase.id} task IDs must exactly match phase tasks in order: ${taskIds.join(", ")}.`
7411
+ });
7412
+ for (const task of brief.tasks) {
7413
+ for (const [field, values] of [
7414
+ ["ordered steps", task.steps],
7415
+ ["file scopes", task.fileScopes],
7416
+ ["acceptance criteria", task.acceptanceCriteria]
7417
+ ]) {
7418
+ if (!values.length || values.some(isPlaceholder))
7419
+ issues.push({
7420
+ code: "brief-incomplete",
7421
+ severity: "error",
7422
+ message: `Brief task ${task.taskId} needs concrete ${field}.`,
7423
+ path: task.taskId
7424
+ });
7425
+ }
7426
+ }
7427
+ for (const [field, values] of [
7428
+ ["API changes", brief.apiChanges],
7429
+ ["libraries", brief.libraries],
7430
+ ["constraints", brief.constraints]
7431
+ ]) {
7432
+ if (values.some(isPlaceholder))
7433
+ issues.push({
7434
+ code: "brief-placeholder",
7435
+ severity: "error",
7436
+ message: `Brief ${phase.id} has placeholder ${field}.`,
7437
+ path: phase.id
7438
+ });
7439
+ }
7440
+ }
7153
7441
  return issues;
7154
7442
  }
7443
+ function renderImplementationBrief(planRevision, ordinal, phase, brief) {
7444
+ const marker = { schemaVersion: 1, planRevision, ordinal, phaseId: phase.id };
7445
+ const tasks = phase.tasks.map((task, index) => {
7446
+ const details = brief.tasks[index];
7447
+ if (!details || details.taskId !== task.id)
7448
+ throw new Error(`Missing implementation details for ${task.id}.`);
7449
+ return `<!-- diffpi-brief-task: ${json({ id: task.id })} -->
7450
+ ### Task: ${task.title}
7451
+
7452
+ #### Ordered Steps
7453
+
7454
+ ${bulletList(details.steps)}
7455
+
7456
+ #### File Scopes
7457
+
7458
+ ${bulletList(details.fileScopes)}
7459
+
7460
+ #### Acceptance Criteria
7461
+
7462
+ ${bulletList(details.acceptanceCriteria)}
7463
+
7464
+ <!-- /diffpi-brief-task -->`;
7465
+ }).join(`
7466
+
7467
+ `);
7468
+ return `<!-- diffpi-implementation: ${json(marker)} -->
7469
+ # Phase ${ordinal}: ${phase.title}
7470
+
7471
+ - **Phase ID:** ${phase.id}
7472
+ - **Plan Revision:** ${planRevision}
7473
+
7474
+ ## Summary
7475
+
7476
+ ${brief.summary}
7477
+
7478
+ ## Objective
7479
+
7480
+ ${phase.objective}
7481
+
7482
+ ## Tasks
7483
+
7484
+ ${tasks}
7485
+
7486
+ ## API Changes
7487
+
7488
+ ${bulletList(brief.apiChanges, "None.")}
7489
+
7490
+ ## Libraries and Algorithms
7491
+
7492
+ ${bulletList(brief.libraries, "None.")}
7493
+
7494
+ ## Implementation Constraints
7495
+
7496
+ ${bulletList(brief.constraints, "None.")}
7497
+ `;
7498
+ }
7155
7499
  function renderPlanDocument(plan, previousSource) {
7156
7500
  if (previousSource && canPatch(previousSource, plan))
7157
7501
  return patchMarkers(previousSource, plan);
@@ -7160,6 +7504,8 @@ function renderPlanDocument(plan, previousSource) {
7160
7504
  id: plan.id,
7161
7505
  revision: plan.revision,
7162
7506
  branch: plan.branch,
7507
+ issueId: plan.issueId,
7508
+ issueUrl: plan.issueUrl,
7163
7509
  status: plan.status,
7164
7510
  execution: plan.execution,
7165
7511
  createdAt: plan.createdAt,
@@ -7168,6 +7514,8 @@ function renderPlanDocument(plan, previousSource) {
7168
7514
  const metadata = import_mdast_builder.list("unordered", [
7169
7515
  ["Plan ID", plan.id],
7170
7516
  ["Branch", plan.branch],
7517
+ ...plan.issueId ? [["Issue", plan.issueId]] : [],
7518
+ ...plan.issueUrl ? [["Issue URL", plan.issueUrl]] : [],
7171
7519
  ["Status", plan.status],
7172
7520
  ["Revision", String(plan.revision)]
7173
7521
  ].map(([label, value]) => import_mdast_builder.listItem(import_mdast_builder.paragraph([import_mdast_builder.strong(import_mdast_builder.text(`${label}:`)), import_mdast_builder.text(` ${value}`)]))));
@@ -7189,7 +7537,7 @@ function renderPlanDocument(plan, previousSource) {
7189
7537
  import_mdast_builder.heading(3, import_mdast_builder.text("Consequences")),
7190
7538
  import_mdast_builder.html(plan.design.consequences || "<!-- Describe trade-offs and limitations. -->"),
7191
7539
  import_mdast_builder.heading(2, import_mdast_builder.text("Implementation")),
7192
- ...plan.phases.length ? plan.phases.flatMap((phase) => [import_mdast_builder.html(renderPhase(phase))]) : [import_mdast_builder.html("<!-- Add phases with plan_add_phase. -->")],
7540
+ ...plan.phases.length ? plan.phases.flatMap((phase) => [import_mdast_builder.html(renderPhase(phase))]) : [import_mdast_builder.html("<!-- Add phases with plan_apply_revision. -->")],
7193
7541
  import_mdast_builder.heading(2, import_mdast_builder.text("References")),
7194
7542
  references
7195
7543
  ]);
@@ -7263,10 +7611,7 @@ function renderTask(task) {
7263
7611
  const checked = task.status === "completed" || task.status === "skipped" ? "x" : " ";
7264
7612
  return `<!-- diffpi-task: ${json(marker)} -->
7265
7613
  - [${checked}] **${task.title}**
7266
- ${taskList("Steps", task.steps ?? [])}
7267
7614
  - Dependencies: ${list3(task.dependencies)}
7268
- ${taskList("File scopes", task.fileScopes)}
7269
- ${taskList("Acceptance criteria", task.acceptanceCriteria)}
7270
7615
  <!-- /diffpi-task -->`;
7271
7616
  }
7272
7617
  function parsePhases(input, ref) {
@@ -7316,10 +7661,7 @@ function parseTasks(input, ref) {
7316
7661
  return {
7317
7662
  ...marker,
7318
7663
  title,
7319
- steps: parseListValue(body, "Steps"),
7320
- dependencies: parseListValue(body, "Dependencies"),
7321
- fileScopes: parseListValue(body, "File scopes"),
7322
- acceptanceCriteria: parseListValue(body, "Acceptance criteria")
7664
+ dependencies: parseListValue(body, "Dependencies")
7323
7665
  };
7324
7666
  });
7325
7667
  assertUniqueIds(tasks.map((task) => task.id), "task id");
@@ -7346,6 +7688,8 @@ function patchMarkers(source, plan) {
7346
7688
  id: plan.id,
7347
7689
  revision: plan.revision,
7348
7690
  branch: plan.branch,
7691
+ issueId: plan.issueId,
7692
+ issueUrl: plan.issueUrl,
7349
7693
  status: plan.status,
7350
7694
  execution: plan.execution,
7351
7695
  createdAt: plan.createdAt,
@@ -7395,19 +7739,12 @@ function contentShape(plan) {
7395
7739
  requirements: plan.requirements,
7396
7740
  design: plan.design,
7397
7741
  references: plan.references,
7398
- phases: plan.phases.map(({ id, title, objective, dependencies, tasks }) => ({
7399
- id,
7400
- title,
7401
- objective,
7402
- dependencies,
7403
- tasks: tasks.map(({ id: taskId, title: taskTitle, steps, dependencies: taskDependencies, fileScopes, acceptanceCriteria }) => ({
7404
- id: taskId,
7405
- title: taskTitle,
7406
- steps,
7407
- dependencies: taskDependencies,
7408
- fileScopes,
7409
- acceptanceCriteria
7410
- }))
7742
+ phases: plan.phases.map((phase) => ({
7743
+ id: phase.id,
7744
+ title: phase.title,
7745
+ objective: phase.objective,
7746
+ dependencies: phase.dependencies,
7747
+ tasks: phase.tasks.map(({ id, title, dependencies }) => ({ id, title, dependencies }))
7411
7748
  }))
7412
7749
  };
7413
7750
  }
@@ -7507,6 +7844,13 @@ function assertUniqueIds(ids, label) {
7507
7844
  function cleanPlaceholder(value) {
7508
7845
  return value.replace(/<!--[^]*?-->/g, "").trim();
7509
7846
  }
7847
+ function isPlaceholder(value) {
7848
+ const normalized = cleanPlaceholder(value).trim();
7849
+ return normalized.length === 0 || /^(?:todo|tbd|none|n\/a)$/i.test(normalized) || /define during implementation|describe the|add requirements|add implementation/i.test(normalized);
7850
+ }
7851
+ function sameIds(expected, actual) {
7852
+ return expected.length === actual.length && expected.every((value, index) => actual[index] === value);
7853
+ }
7510
7854
  function parseMarker(value, label) {
7511
7855
  try {
7512
7856
  return JSON.parse(value);
@@ -7514,15 +7858,14 @@ function parseMarker(value, label) {
7514
7858
  throw new Error(`Malformed ${label} marker JSON.`);
7515
7859
  }
7516
7860
  }
7517
- function taskList(label, values) {
7861
+ function bulletList(values, empty = "") {
7518
7862
  if (!values.length)
7519
- return ` - ${label}: none`;
7520
- const items = values.flatMap((value) => {
7863
+ return empty;
7864
+ return values.flatMap((value) => {
7521
7865
  const [first = "", ...continuations] = value.split(`
7522
7866
  `);
7523
- return [` - ${first}`, ...continuations.map((line) => ` ${line}`)];
7524
- });
7525
- return [` - ${label}:`, ...items].join(`
7867
+ return [`- ${first}`, ...continuations.map((line) => ` ${line}`)];
7868
+ }).join(`
7526
7869
  `);
7527
7870
  }
7528
7871
  function list3(values) {
@@ -7542,7 +7885,7 @@ function planRecordName(shortSlug, date = new Date) {
7542
7885
  }
7543
7886
  async function resolvePlan(cwd, query, filters = {}, options = {}) {
7544
7887
  const root = await plansDir(cwd, options.homeDir);
7545
- const entries = await readdir3(root, { withFileTypes: true });
7888
+ const entries = await readdir2(root, { withFileTypes: true });
7546
7889
  const normalizedQuery = query ? normalizeQuery(query) : undefined;
7547
7890
  const records = [];
7548
7891
  for (const entry of entries) {
@@ -7550,7 +7893,7 @@ async function resolvePlan(cwd, query, filters = {}, options = {}) {
7550
7893
  continue;
7551
7894
  if (normalizedQuery && entry.name !== normalizedQuery && stripDate(entry.name) !== normalizedQuery)
7552
7895
  continue;
7553
- const dir = join16(root, entry.name);
7896
+ const dir = join15(root, entry.name);
7554
7897
  await assertContained(root, dir);
7555
7898
  try {
7556
7899
  const record = await readRecord(dir);
@@ -7569,6 +7912,15 @@ async function resolvePlan(cwd, query, filters = {}, options = {}) {
7569
7912
  }
7570
7913
  function createPlanStore(options = {}) {
7571
7914
  const now = options.now ?? (() => new Date);
7915
+ const read = async (cwd, query, filters) => {
7916
+ const result = await resolvePlan(cwd, query, filters, options);
7917
+ if (!result.record) {
7918
+ if (result.ambiguous)
7919
+ throw new Error(`Plan query "${query}" is ambiguous: ${result.candidates.map((item) => item.id).join(", ")}.`);
7920
+ throw new Error(`Plan "${query}" was not found.`);
7921
+ }
7922
+ return result.record;
7923
+ };
7572
7924
  return {
7573
7925
  context(cwd, query, filters) {
7574
7926
  return resolvePlan(cwd, query, filters, options);
@@ -7576,84 +7928,107 @@ function createPlanStore(options = {}) {
7576
7928
  async init(input) {
7577
7929
  const root = await plansDir(input.cwd, options.homeDir);
7578
7930
  const id = planRecordName(input.shortSlug, now());
7579
- const dir = join16(root, id);
7580
- await assertContained(root, dirname8(dir));
7581
- try {
7582
- await mkdir8(dir);
7583
- } catch (error) {
7584
- if (error.code === "EEXIST")
7585
- throw new Error(`Plan ${id} already exists.`);
7586
- throw error;
7587
- }
7931
+ const dir = join15(root, id);
7932
+ await createPlanDirectory(root, dir, id);
7588
7933
  try {
7589
7934
  const timestamp = now().toISOString();
7590
- const template = await loadTemplate("plan/PLAN", {
7591
- homeDir: options.homeDir,
7592
- bundledDir: options.bundledTemplatesDir
7593
- });
7594
- const source = renderTemplate(template.content, {
7935
+ const title = input.title?.trim() || titleFromSlug(input.shortSlug);
7936
+ const document = {
7937
+ schemaVersion: 1,
7595
7938
  id,
7939
+ revision: 0,
7940
+ title,
7596
7941
  branch: input.branch,
7597
- title: input.title?.trim() || titleFromSlug(input.shortSlug),
7942
+ ...input.issueId?.trim() ? { issueId: input.issueId.trim() } : {},
7943
+ ...input.issueUrl?.trim() ? { issueUrl: input.issueUrl.trim() } : {},
7598
7944
  intent: input.intent?.trim() || "<!-- Describe the intended outcome. -->",
7599
- issue_id: input.issueId?.trim() || "<!-- Add issue tracker ID. -->",
7600
- issue_url: input.issueUrl?.trim() || "<!-- Add issue tracker URL. -->",
7601
- created_at: timestamp,
7602
- updated_at: timestamp
7603
- });
7604
- const document = parsePlanDocument(source, join16(dir, "PLAN.md"));
7605
- await atomicWrite(join16(dir, "PLAN.md"), source);
7606
- return {
7607
- id,
7608
- dir,
7609
- planPath: join16(dir, "PLAN.md"),
7610
- logPath: join16(dir, "logs.txt"),
7611
- implementationDir: join16(dir, "implementation"),
7612
- document,
7613
- source
7945
+ requirements: [],
7946
+ design: { bigIdeas: "", keyApiUpdates: "", consequences: "" },
7947
+ phases: [],
7948
+ references: [],
7949
+ status: "draft",
7950
+ createdAt: timestamp,
7951
+ updatedAt: timestamp
7614
7952
  };
7953
+ const source = renderPlanDocument(document);
7954
+ parsePlanDocument(source, join15(dir, "PLAN.md"));
7955
+ const authoringRequest = input.request ?? { kind: "user", text: input.intent?.trim() || `Initialize phase-less plan ${title}.` };
7956
+ await writeRevisionSnapshot(dir, document, source, authoringRequest, []);
7957
+ await writeLatestView(dir, document, source, []);
7958
+ return await readRecord(dir);
7615
7959
  } catch (error) {
7616
7960
  await rm2(dir, { recursive: true, force: true });
7617
7961
  throw error;
7618
7962
  }
7619
7963
  },
7620
- async read(cwd, query, filters) {
7621
- const result = await resolvePlan(cwd, query, filters, options);
7622
- if (!result.record) {
7623
- if (result.ambiguous)
7624
- throw new Error(`Plan query "${query}" is ambiguous: ${result.candidates.map((item) => item.id).join(", ")}.`);
7625
- throw new Error(`Plan "${query}" was not found.`);
7964
+ read,
7965
+ async applyRevision(cwd, request) {
7966
+ if (request.mode === "create") {
7967
+ const root = await plansDir(cwd, options.homeDir);
7968
+ const id = planRecordName(request.shortSlug, now());
7969
+ const dir = join15(root, id);
7970
+ await createPlanDirectory(root, dir, id);
7971
+ try {
7972
+ const timestamp = now().toISOString();
7973
+ const document = normalizeRevision(undefined, request, id, 0, timestamp);
7974
+ assertCompleteRevision(document, request.briefs);
7975
+ const source = renderPlanDocument(document);
7976
+ parsePlanDocument(source, join15(dir, "PLAN.md"));
7977
+ await writeRevisionSnapshot(dir, document, source, request.request, request.briefs);
7978
+ await writeLatestView(dir, document, source, request.briefs);
7979
+ return await readRecord(dir);
7980
+ } catch (error) {
7981
+ await rm2(dir, { recursive: true, force: true });
7982
+ throw error;
7983
+ }
7626
7984
  }
7627
- return result.record;
7985
+ const initial = await read(cwd, request.plan);
7986
+ return withDirectoryLock(join15(initial.dir, ".lock"), async () => {
7987
+ const current = await readRecord(initial.dir);
7988
+ if (current.document.revision !== request.expectedPlanRevision)
7989
+ throw new Error(`Expected plan revision ${request.expectedPlanRevision}, found ${current.document.revision}.`);
7990
+ const revision = current.document.revision + 1;
7991
+ const document = normalizeRevision(current.document, request, current.id, revision, now().toISOString());
7992
+ assertCompleteRevision(document, request.briefs);
7993
+ const source = renderPlanDocument(document);
7994
+ parsePlanDocument(source, current.planPath);
7995
+ await writeRevisionSnapshot(current.dir, document, source, request.request, request.briefs);
7996
+ try {
7997
+ await writeLatestView(current.dir, document, source, request.briefs);
7998
+ } catch (error) {
7999
+ await rm2(join15(current.dir, "revisions", String(revision)), { recursive: true, force: true });
8000
+ throw error;
8001
+ }
8002
+ return await readRecord(current.dir);
8003
+ }, { operation: "apply revision" });
7628
8004
  },
7629
8005
  async mutate(cwd, query, operation, update) {
7630
- const initial = await this.read(cwd, query);
7631
- return withDirectoryLock(join16(initial.dir, ".lock"), async () => {
8006
+ const initial = await read(cwd, query);
8007
+ return withDirectoryLock(join15(initial.dir, ".lock"), async () => {
7632
8008
  const current = await readRecord(initial.dir);
7633
8009
  const next = await update(structuredClone(current.document));
7634
8010
  if (next.id !== current.id)
7635
8011
  throw new Error("A plan mutation cannot change the plan ID.");
7636
8012
  const document = {
7637
8013
  ...next,
7638
- revision: current.document.revision + 1,
8014
+ revision: current.document.revision,
7639
8015
  updatedAt: now().toISOString()
7640
8016
  };
7641
8017
  const source = renderPlanDocument(document, current.source);
7642
8018
  parsePlanDocument(source, current.planPath);
7643
8019
  await atomicWrite(current.planPath, source);
7644
- await ensureImplementationFiles(current.dir, document, options);
7645
- return { ...current, document, source, implementationDir: join16(current.dir, "implementation") };
8020
+ return { ...current, document, source };
7646
8021
  }, { operation });
7647
8022
  },
7648
8023
  async log(cwd, query, event) {
7649
- const record = await this.read(cwd, query);
8024
+ const record = await read(cwd, query);
7650
8025
  return appendLogEntry(record.logPath, event);
7651
8026
  }
7652
8027
  };
7653
8028
  }
7654
8029
  async function readRecord(dir) {
7655
- const planPath = join16(dir, "PLAN.md");
7656
- const logPath = join16(dir, "logs.txt");
8030
+ const planPath = join15(dir, "PLAN.md");
8031
+ const logPath = join15(dir, "logs.txt");
7657
8032
  const source = await readFile11(planPath, "utf8");
7658
8033
  const document = parsePlanDocument(source, planPath);
7659
8034
  return {
@@ -7661,34 +8036,213 @@ async function readRecord(dir) {
7661
8036
  dir,
7662
8037
  planPath,
7663
8038
  logPath,
7664
- implementationDir: join16(dir, "implementation"),
8039
+ implementationDir: join15(dir, "implementation"),
7665
8040
  document,
7666
8041
  source
7667
8042
  };
7668
8043
  }
7669
- async function ensureImplementationFiles(dir, document, options) {
7670
- const implementationDir = join16(dir, "implementation");
7671
- await mkdir8(implementationDir, { recursive: true });
7672
- const template = await loadTemplate("plan/implementation", {
7673
- homeDir: options.homeDir,
7674
- bundledDir: options.bundledTemplatesDir
7675
- });
7676
- for (const phase of document.phases) {
7677
- const path = join16(implementationDir, `phase-${phase.id}.md`);
8044
+ async function createPlanDirectory(root, dir, id) {
8045
+ await assertContained(root, dirname8(dir));
8046
+ try {
8047
+ await mkdir7(dir);
8048
+ } catch (error) {
8049
+ if (error.code === "EEXIST")
8050
+ throw new Error(`Plan ${id} already exists.`);
8051
+ throw error;
8052
+ }
8053
+ }
8054
+ async function writeRevisionSnapshot(planDir, document, source, request, briefs) {
8055
+ if (!request.text.trim())
8056
+ throw new Error("An exact non-empty authoring request is required.");
8057
+ if (request.kind === "annotation" && !request.response?.trim())
8058
+ throw new Error("Annotation requests require a non-empty response.");
8059
+ const revisionsDir = join15(planDir, "revisions");
8060
+ await mkdir7(revisionsDir, { recursive: true });
8061
+ const target = join15(revisionsDir, String(document.revision));
8062
+ try {
8063
+ await readFile11(join15(target, "metadata.json"), "utf8");
8064
+ throw new Error(`Revision ${document.revision} already exists and is immutable.`);
8065
+ } catch (error) {
8066
+ if (error.code !== "ENOENT")
8067
+ throw error;
8068
+ }
8069
+ const staging = join15(revisionsDir, `.tmp-${document.revision}-${randomUUID2()}`);
8070
+ await mkdir7(staging);
8071
+ try {
8072
+ const requestSource = request.kind === "annotation" ? `## Original annotation
8073
+ ${request.text}
8074
+
8075
+ ## LLM response
8076
+ ${request.response}` : request.text;
8077
+ await atomicWrite(join15(staging, "request.md"), requestSource);
8078
+ await atomicWrite(join15(staging, "metadata.json"), `${JSON.stringify({
8079
+ schemaVersion: 1,
8080
+ id: document.id,
8081
+ revision: document.revision,
8082
+ requestKind: request.kind,
8083
+ requestSha256: sha256(request.text),
8084
+ ...request.kind === "annotation" ? { originalAnnotationSha256: sha256(request.text), responseSha256: sha256(request.response) } : {},
8085
+ createdAt: document.updatedAt
8086
+ }, null, 2)}
8087
+ `);
8088
+ await atomicWrite(join15(staging, "PLAN.md"), source);
8089
+ await writeImplementationDirectory(staging, document, briefs);
8090
+ await rename3(staging, target);
8091
+ } catch (error) {
8092
+ await rm2(staging, { recursive: true, force: true });
8093
+ throw error;
8094
+ }
8095
+ }
8096
+ async function writeLatestView(planDir, document, source, briefs) {
8097
+ const current = join15(planDir, "implementation");
8098
+ const stagingRoot = join15(planDir, `.latest-${randomUUID2()}`);
8099
+ const stagedImplementation = join15(stagingRoot, "implementation");
8100
+ const backup = join15(planDir, `.implementation-backup-${randomUUID2()}`);
8101
+ await mkdir7(stagingRoot);
8102
+ await writeImplementationDirectory(stagingRoot, document, briefs);
8103
+ let backedUp = false;
8104
+ try {
7678
8105
  try {
7679
- await readFile11(path, "utf8");
8106
+ await rename3(current, backup);
8107
+ backedUp = true;
7680
8108
  } catch (error) {
7681
8109
  if (error.code !== "ENOENT")
7682
8110
  throw error;
7683
- const tasks = phase.tasks.map((task) => `- ${task.id}: ${task.title}`).join("\\n") || "- Add implementation tasks.";
7684
- await atomicWrite(path, renderTemplate(template.content, {
7685
- phase_id: phase.id,
7686
- phase_title: phase.title,
7687
- phase_objective: phase.objective,
7688
- phase_tasks: tasks
7689
- }));
7690
8111
  }
7691
- }
8112
+ await rename3(stagedImplementation, current);
8113
+ await atomicWrite(join15(planDir, "PLAN.md"), source);
8114
+ if (backedUp)
8115
+ await rm2(backup, { recursive: true, force: true });
8116
+ } catch (error) {
8117
+ await rm2(current, { recursive: true, force: true });
8118
+ if (backedUp)
8119
+ await rename3(backup, current);
8120
+ throw error;
8121
+ } finally {
8122
+ await rm2(stagingRoot, { recursive: true, force: true });
8123
+ }
8124
+ }
8125
+ async function writeImplementationDirectory(baseDir, document, briefs) {
8126
+ const implementationDir = join15(baseDir, "implementation");
8127
+ await mkdir7(implementationDir, { recursive: true });
8128
+ for (const [index, phase] of document.phases.entries()) {
8129
+ const brief = briefs[index];
8130
+ if (!brief)
8131
+ throw new Error(`Missing implementation brief for phase ${phase.id}.`);
8132
+ await atomicWrite(join15(implementationDir, `phase-${index + 1}.md`), renderImplementationBrief(document.revision, index + 1, phase, brief));
8133
+ }
8134
+ }
8135
+ function normalizeRevision(current, request, id, revision, updatedAt) {
8136
+ if (current?.status === "completed")
8137
+ throw new Error("Completed plans cannot be amended.");
8138
+ if (current?.execution?.active && request.request.kind !== "blocker")
8139
+ throw new Error("An active execution accepts only a blocker amendment.");
8140
+ const currentPhases = new Map(current?.phases.map((phase) => [phase.id, phase]) ?? []);
8141
+ for (const phase of current?.phases ?? []) {
8142
+ if (!request.phases.some((candidate) => candidate.id === phase.id) && (phase.status === "completed" || phase.status === "in_progress"))
8143
+ throw new Error(`${phase.status === "completed" ? "Completed" : "Active"} phase ${phase.id} cannot be removed.`);
8144
+ }
8145
+ const phases = request.phases.map((draft) => reconcilePhase(currentPhases.get(draft.id), draft, current));
8146
+ const createdAt = current?.createdAt ?? updatedAt;
8147
+ return {
8148
+ schemaVersion: 1,
8149
+ id,
8150
+ revision,
8151
+ title: request.title,
8152
+ branch: current?.branch ?? (request.mode === "create" ? request.branch : ""),
8153
+ issueId: current?.issueId ?? (request.mode === "create" ? request.issueId : undefined),
8154
+ issueUrl: current?.issueUrl ?? (request.mode === "create" ? request.issueUrl : undefined),
8155
+ intent: request.intent,
8156
+ requirements: request.requirements,
8157
+ design: request.design,
8158
+ references: request.references,
8159
+ phases,
8160
+ status: nextPlanStatus(current),
8161
+ execution: current?.execution,
8162
+ createdAt,
8163
+ updatedAt
8164
+ };
8165
+ }
8166
+ function reconcilePhase(current, draft, plan) {
8167
+ if (!current)
8168
+ return {
8169
+ ...draft,
8170
+ revision: 0,
8171
+ status: "pending",
8172
+ gate: { phaseRevision: 0, status: "pending", results: [] },
8173
+ tasks: draft.tasks.map(newTask)
8174
+ };
8175
+ if (current.status === "completed") {
8176
+ if (JSON.stringify(phaseContent(current)) !== JSON.stringify(draft))
8177
+ throw new Error(`Completed phase ${current.id} cannot be changed.`);
8178
+ return current;
8179
+ }
8180
+ const byId = new Map(current.tasks.map((task) => [task.id, task]));
8181
+ for (const task of current.tasks) {
8182
+ if (!draft.tasks.some((candidate) => candidate.id === task.id) && (task.status === "completed" || task.status === "in_progress"))
8183
+ throw new Error(`${task.status === "completed" ? "Completed" : "Active"} task ${task.id} cannot be removed.`);
8184
+ }
8185
+ const tasks = draft.tasks.map((task) => reconcileTask(byId.get(task.id), task));
8186
+ const changed = JSON.stringify(phaseContent(current)) !== JSON.stringify(draft);
8187
+ const revision = changed ? current.revision + 1 : current.revision;
8188
+ const wasBlocked = current.status === "blocked";
8189
+ return {
8190
+ ...current,
8191
+ ...draft,
8192
+ revision,
8193
+ tasks,
8194
+ status: wasBlocked ? plan?.execution?.active ? "in_progress" : "pending" : current.status,
8195
+ gate: changed || wasBlocked ? { phaseRevision: revision, status: "stale", results: [] } : current.gate,
8196
+ blocker: wasBlocked ? undefined : current.blocker
8197
+ };
8198
+ }
8199
+ function reconcileTask(current, draft) {
8200
+ if (!current)
8201
+ return newTask(draft);
8202
+ const changed = JSON.stringify(taskContent(current)) !== JSON.stringify(draft);
8203
+ if ((current.status === "completed" || current.status === "in_progress") && changed)
8204
+ throw new Error(`${current.status === "completed" ? "Completed" : "Active"} task ${current.id} cannot be changed.`);
8205
+ if (current.status === "completed" || current.status === "in_progress")
8206
+ return current;
8207
+ return {
8208
+ ...current,
8209
+ ...draft,
8210
+ revision: changed ? current.revision + 1 : current.revision,
8211
+ status: current.status === "blocked" ? "pending" : current.status,
8212
+ owner: current.status === "blocked" ? undefined : current.owner,
8213
+ executionId: current.status === "blocked" ? undefined : current.executionId,
8214
+ blocker: current.status === "blocked" ? undefined : current.blocker
8215
+ };
8216
+ }
8217
+ function newTask(draft) {
8218
+ return { ...draft, revision: 0, status: "pending" };
8219
+ }
8220
+ function phaseContent(phase) {
8221
+ return {
8222
+ id: phase.id,
8223
+ title: phase.title,
8224
+ objective: phase.objective,
8225
+ dependencies: phase.dependencies,
8226
+ tasks: phase.tasks.map(taskContent)
8227
+ };
8228
+ }
8229
+ function taskContent(task) {
8230
+ return { id: task.id, title: task.title, dependencies: task.dependencies };
8231
+ }
8232
+ function nextPlanStatus(current) {
8233
+ if (!current)
8234
+ return "draft";
8235
+ if (current.execution?.active)
8236
+ return current.status === "blocked" ? "in_progress" : current.status;
8237
+ return current.status === "ready" || current.status === "blocked" ? "draft" : current.status;
8238
+ }
8239
+ function assertCompleteRevision(document, briefs) {
8240
+ const errors = [...validatePlanDocument(document, { strict: true }), ...validatePlanBriefs(document, briefs)].filter((issue) => issue.severity === "error");
8241
+ if (errors.length)
8242
+ throw new Error(`Invalid plan revision: ${errors.map((issue) => issue.message).join(" ")}`);
8243
+ }
8244
+ function sha256(value) {
8245
+ return createHash3("sha256").update(value).digest("hex");
7692
8246
  }
7693
8247
  async function assertContained(root, candidate) {
7694
8248
  const canonicalRoot = await realpath4(root);
@@ -7721,13 +8275,16 @@ function stripDate(value) {
7721
8275
  return value.replace(/^\d{6}-/, "");
7722
8276
  }
7723
8277
  function titleFromSlug(value) {
7724
- return normalizeSlug(value).replace(/^(?:[a-z]+-\d+-)/, "").split("-").map((part) => part[0].toUpperCase() + part.slice(1)).join(" ");
8278
+ const slug = normalizeSlug(value).replace(/^(?:[a-z]+-\d+-)/, "");
8279
+ return slug.replace(/(^|-)([a-z])/g, (_match, separator, letter) => {
8280
+ return `${separator ? " " : ""}${letter.toUpperCase()}`;
8281
+ });
7725
8282
  }
7726
8283
  // src/setup.ts
7727
8284
  import { parseFrontmatter as parseFrontmatter3 } from "@earendil-works/pi-coding-agent";
7728
- import { readdir as readdir4, readFile as readFile12 } from "node:fs/promises";
7729
- import { homedir as homedir9 } from "node:os";
7730
- import { basename as basename7, join as join17 } from "node:path";
8285
+ import { readdir as readdir3, readFile as readFile12 } from "node:fs/promises";
8286
+ import { homedir as homedir8 } from "node:os";
8287
+ import { basename as basename7, join as join16 } from "node:path";
7731
8288
  var MISE_DEPENDENCIES = [
7732
8289
  { name: "node", tool: "node", spec: "node@22", minimumVersion: "22.19.0" },
7733
8290
  { name: "zellij", tool: "zellij", spec: "zellij@latest", minimumVersion: undefined },
@@ -7765,8 +8322,8 @@ var FORGE_DEPENDENCIES = {
7765
8322
  gitlab: { name: "glab", tool: "glab", spec: "glab@latest", minimumVersion: undefined }
7766
8323
  };
7767
8324
  async function ensureMise(options = {}) {
7768
- const homeDir = options.homeDir ?? homedir9();
7769
- const current = await mise.executableCheck() ?? await mise.executableCheck(join17(homeDir, ".local", "bin", "mise"));
8325
+ const homeDir = options.homeDir ?? homedir8();
8326
+ const current = await mise.executableCheck() ?? await mise.executableCheck(join16(homeDir, ".local", "bin", "mise"));
7770
8327
  if (current)
7771
8328
  return { executable: current, action: createSetupAction("mise", "ready", current) };
7772
8329
  reportProgress(options, "Installing mise");
@@ -7815,9 +8372,9 @@ async function ensureMiseDeps(miseExecutable, options = {}) {
7815
8372
  async function ensurePiPlugins(options = {}) {
7816
8373
  const actions = await ensurePiPackages(PI_PACKAGES, options);
7817
8374
  const agentDir = options.agentDir ?? pi.agentDir(options.homeDir);
7818
- const webSearch = await pi.configEnsure(join17(agentDir, "web-search.json"), (config) => ({ ...config, workflow: "auto-summary" }), options.dryRun);
8375
+ const webSearch = await pi.configEnsure(join16(agentDir, "web-search.json"), (config) => ({ ...config, workflow: "auto-summary" }), options.dryRun);
7819
8376
  actions.push(getConfigSetupAction("web search settings", webSearch));
7820
- const lsp = await pi.configEnsure(join17(agentDir, "pi-lsp.json"), (config) => ({
8377
+ const lsp = await pi.configEnsure(join16(agentDir, "pi-lsp.json"), (config) => ({
7821
8378
  ...config,
7822
8379
  progressive: { ...getRecord(config.progressive), enabled: true, inject: "none" }
7823
8380
  }), options.dryRun);
@@ -7828,11 +8385,11 @@ async function ensurePiAgents(options = {}) {
7828
8385
  const agentDir = options.agentDir ?? pi.agentDir(options.homeDir);
7829
8386
  const bundledAgentsDir = options.bundledAgentsDir ?? BUNDLED_AGENTS_DIR2;
7830
8387
  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));
8388
+ 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
8389
  const actions = [];
7833
8390
  for (const entry of entries) {
7834
8391
  const id = basename7(entry.name, ".md").replace(/^diffpi-/, "");
7835
- const source = await readFile12(join17(bundledAgentsDir, entry.name), "utf8");
8392
+ const source = await readFile12(join16(bundledAgentsDir, entry.name), "utf8");
7836
8393
  const content = materializeAgentModels(source, id, userConfig.config, options.availableModels);
7837
8394
  const result = await pi.agentEnsure(entry.name, content, agentDir, options.dryRun);
7838
8395
  actions.push(getConfigSetupAction(`pi agent ${id}`, result));
@@ -7841,7 +8398,7 @@ async function ensurePiAgents(options = {}) {
7841
8398
  }
7842
8399
  async function ensurePiSkills(miseExecutable, options = {}) {
7843
8400
  const agentDir = options.agentDir ?? pi.agentDir(options.homeDir);
7844
- const sharedSkillsDir = join17(options.homeDir ?? homedir9(), ".agents", "skills");
8401
+ const sharedSkillsDir = join16(options.homeDir ?? homedir8(), ".agents", "skills");
7845
8402
  const actions = [];
7846
8403
  for (const source of PI_SKILL_SOURCES) {
7847
8404
  const missing = [];
@@ -8037,7 +8594,7 @@ function reportProgress(options, message) {
8037
8594
  options.onProgress?.(message);
8038
8595
  }
8039
8596
  async function packageVersion2() {
8040
- const source = await readFile12(join17(resolveBundledAgentsDir(), "..", "package.json"), "utf8");
8597
+ const source = await readFile12(join16(resolveBundledAgentsDir(), "..", "package.json"), "utf8");
8041
8598
  let value;
8042
8599
  try {
8043
8600
  value = JSON.parse(source);
@@ -8051,6 +8608,41 @@ async function packageVersion2() {
8051
8608
  function getRecord(value) {
8052
8609
  return value !== null && typeof value === "object" && !Array.isArray(value) ? value : {};
8053
8610
  }
8611
+ // src/templates.ts
8612
+ import { readFile as readFile13 } from "node:fs/promises";
8613
+ import { homedir as homedir9 } from "node:os";
8614
+ import { join as join17, normalize } from "node:path";
8615
+ async function loadTemplate(name, options = {}) {
8616
+ const relative = templateRelativePath(name);
8617
+ const userPath = join17(options.homeDir ?? homedir9(), ".difflab", "diffpi", "templates", relative);
8618
+ const bundledPath = join17(options.bundledDir ?? resolveBundledTemplatesDir(), relative);
8619
+ const user = await readOptionalFile(userPath);
8620
+ if (user !== undefined)
8621
+ return { name, path: userPath, source: "user", content: user };
8622
+ const bundled = await readOptionalFile(bundledPath);
8623
+ if (bundled !== undefined)
8624
+ return { name, path: bundledPath, source: "bundled", content: bundled };
8625
+ throw new Error(`Template "${name}" was not found at ${userPath} or ${bundledPath}.`);
8626
+ }
8627
+ function renderTemplate(content, variables) {
8628
+ return content.replace(/\{\{\s*([a-zA-Z0-9_.-]+)\s*\}\}/g, (match, key) => variables[key] ?? match);
8629
+ }
8630
+ function templateRelativePath(name) {
8631
+ const normalized = normalize(name.replaceAll("\\", "/")).replace(/^\.\//, "");
8632
+ if (!normalized || normalized.startsWith("../") || normalized.includes("/../") || normalized.startsWith("/")) {
8633
+ throw new Error(`Invalid template name: ${name}`);
8634
+ }
8635
+ return normalized.endsWith(".md") ? normalized : `${normalized}.md`;
8636
+ }
8637
+ async function readOptionalFile(path) {
8638
+ try {
8639
+ return await readFile13(path, "utf8");
8640
+ } catch (error) {
8641
+ if (error.code === "ENOENT")
8642
+ return;
8643
+ throw error;
8644
+ }
8645
+ }
8054
8646
  export {
8055
8647
  CONVENTIONAL_COMMIT,
8056
8648
  GitHubVcsBackend,
@@ -8062,11 +8654,11 @@ export {
8062
8654
  addComment,
8063
8655
  assertGitHubMergeReady,
8064
8656
  assertReviewEventSupported,
8065
- captureLocalReview,
8066
8657
  checkConventionalSubject,
8067
8658
  computeProjectSlug,
8068
8659
  createForge,
8069
8660
  createForgeBackend,
8661
+ createLocalReviewBackend,
8070
8662
  createModeController,
8071
8663
  createPlanStore,
8072
8664
  createRemoteReviewBackend,
@@ -8099,6 +8691,7 @@ export {
8099
8691
  launch,
8100
8692
  listSessions,
8101
8693
  loadDiffpiConfig,
8694
+ loadReviewPublicationState,
8102
8695
  loadTemplate,
8103
8696
  localReviewAuthor,
8104
8697
  mcp,
@@ -8107,28 +8700,37 @@ export {
8107
8700
  openInNewTab,
8108
8701
  parseGitlabDiffRefs,
8109
8702
  parseRemote,
8703
+ parseThreadArtifact,
8110
8704
  pi,
8111
8705
  plansDir,
8112
- readLocalReview,
8113
8706
  readSession,
8114
8707
  renderReviewDoc,
8115
8708
  renderTemplate,
8709
+ renderThreadArtifact,
8116
8710
  resolveAgentMode,
8117
8711
  resolveAgentModelPreferences,
8118
8712
  resolvePrSession,
8119
8713
  resolveReviewSession,
8120
8714
  resolveSession,
8715
+ reviewBodyFingerprint,
8716
+ reviewCommentFingerprint,
8121
8717
  reviewRecordName,
8718
+ reviewReplyFingerprint,
8122
8719
  reviewSlug,
8123
8720
  reviewsDir,
8124
8721
  runMiseGates,
8722
+ saveReviewPublicationState,
8723
+ sessionsDir,
8125
8724
  setupPi,
8126
8725
  severitySchema,
8127
8726
  storeDir,
8128
8727
  storeGlobalRoot,
8129
8728
  templateRelativePath,
8729
+ toFindings,
8130
8730
  toReviewComments,
8131
8731
  tuicrAvailable,
8732
+ unpublishedReviewComments,
8733
+ upsertThreadReply,
8132
8734
  withRemoteProvenance,
8133
8735
  yymmdd,
8134
8736
  zedKeymapPath,