@difflab/pi 0.6.0 → 0.7.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 (94) hide show
  1. package/README.md +11 -9
  2. package/agents/diffpi-orchestrator.md +6 -4
  3. package/agents/diffpi-planner.md +3 -3
  4. package/agents/diffpi-reviewer.md +4 -4
  5. package/agents/diffpi-worker.md +1 -1
  6. package/dist/assets.d.ts +0 -1
  7. package/dist/assets.d.ts.map +1 -1
  8. package/dist/cli.js +417 -84
  9. package/dist/commands/plan.d.ts +2 -2
  10. package/dist/commands/plan.d.ts.map +1 -1
  11. package/dist/commands/review.d.ts +2 -2
  12. package/dist/commands/review.d.ts.map +1 -1
  13. package/dist/environment.d.ts +2 -0
  14. package/dist/environment.d.ts.map +1 -1
  15. package/dist/extensions/index.js +1778 -3799
  16. package/dist/extensions/tuicrx.d.ts +8 -1
  17. package/dist/extensions/tuicrx.d.ts.map +1 -1
  18. package/dist/index.d.ts +8 -7
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +891 -292
  21. package/dist/plan/markdown.d.ts +4 -1
  22. package/dist/plan/markdown.d.ts.map +1 -1
  23. package/dist/{tools/plan-schema.d.ts → plan/schema.d.ts} +113 -75
  24. package/dist/plan/schema.d.ts.map +1 -0
  25. package/dist/plan/store.d.ts +39 -1
  26. package/dist/plan/store.d.ts.map +1 -1
  27. package/dist/plan/types.d.ts +27 -3
  28. package/dist/plan/types.d.ts.map +1 -1
  29. package/dist/review/github-review-backend.d.ts.map +1 -1
  30. package/dist/review/gitlab-review-backend.d.ts.map +1 -1
  31. package/dist/review/index.d.ts +6 -6
  32. package/dist/review/index.d.ts.map +1 -1
  33. package/dist/review/local-review-backend.d.ts +3 -0
  34. package/dist/review/local-review-backend.d.ts.map +1 -0
  35. package/dist/review/review-backend.d.ts +3 -2
  36. package/dist/review/review-backend.d.ts.map +1 -1
  37. package/dist/review/review-markdown.d.ts +4 -1
  38. package/dist/review/review-markdown.d.ts.map +1 -1
  39. package/dist/review/review-state.d.ts +20 -0
  40. package/dist/review/review-state.d.ts.map +1 -0
  41. package/dist/review/types.d.ts +28 -0
  42. package/dist/review/types.d.ts.map +1 -1
  43. package/dist/store.d.ts +2 -0
  44. package/dist/store.d.ts.map +1 -1
  45. package/dist/tools/index.js +1632 -575
  46. package/dist/tools/plan.d.ts.map +1 -1
  47. package/dist/tools/review.d.ts +38 -0
  48. package/dist/tools/review.d.ts.map +1 -1
  49. package/package.json +2 -4
  50. package/skills/plan/SKILL.md +8 -14
  51. package/skills/plan/references/design-quality.md +41 -0
  52. package/skills/plan/references/workflows/annotate.md +11 -0
  53. package/skills/plan/references/workflows/finalize.md +7 -0
  54. package/skills/plan/references/workflows/go.md +8 -0
  55. package/skills/plan/references/workflows/help.md +10 -0
  56. package/skills/plan/references/workflows/init.md +5 -0
  57. package/skills/plan/references/workflows/new.md +7 -0
  58. package/skills/plan/references/workflows/update.md +7 -0
  59. package/skills/review/SKILL.md +7 -7
  60. package/skills/review/references/review-standards.md +8 -1
  61. package/skills/review/references/workflows/address.md +11 -0
  62. package/skills/review/references/workflows/auto.md +18 -0
  63. package/skills/review/references/workflows/complete.md +7 -0
  64. package/skills/review/references/workflows/edit.md +7 -0
  65. package/skills/review/references/workflows/help.md +36 -0
  66. package/skills/review/references/workflows/merge.md +6 -0
  67. package/skills/review/references/workflows/new.md +19 -0
  68. package/skills/review/references/workflows/open.md +3 -0
  69. package/skills/review/references/workflows/publish.md +8 -0
  70. package/skills/review/references/workflows/status.md +3 -0
  71. package/templates/plan/PLAN.md +2 -2
  72. package/templates/plan/implementation.md +16 -10
  73. package/dist/commands.d.ts +0 -22
  74. package/dist/commands.d.ts.map +0 -1
  75. package/dist/extensions/cmdtsx.d.ts +0 -2
  76. package/dist/extensions/cmdtsx.d.ts.map +0 -1
  77. package/dist/tools/plan-schema.d.ts.map +0 -1
  78. package/dist/workflow.d.ts +0 -80
  79. package/dist/workflow.d.ts.map +0 -1
  80. package/workflows/plan/annotate.md +0 -13
  81. package/workflows/plan/finalize.md +0 -15
  82. package/workflows/plan/go.md +0 -20
  83. package/workflows/plan/help.md +0 -20
  84. package/workflows/plan/init.md +0 -13
  85. package/workflows/plan/new.md +0 -20
  86. package/workflows/plan/update.md +0 -18
  87. package/workflows/review/address.md +0 -8
  88. package/workflows/review/auto.md +0 -8
  89. package/workflows/review/complete.md +0 -7
  90. package/workflows/review/edit.md +0 -7
  91. package/workflows/review/help.md +0 -14
  92. package/workflows/review/merge.md +0 -6
  93. package/workflows/review/new.md +0 -7
  94. 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";
2637
+ import { createHash as createHash3, randomUUID as randomUUID2 } from "node:crypto";
2638
+ import { mkdir as mkdir7, readdir as readdir2, readFile as readFile12, realpath as realpath4, rename as rename3, rm as rm2 } from "node:fs/promises";
2382
2639
  import { basename as basename6, dirname as dirname8, join as join16, 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,
@@ -2431,7 +2688,7 @@ async function appendLogEntry(logPath, event) {
2431
2688
  }
2432
2689
 
2433
2690
  // src/templates.ts
2434
- import { readFile as readFile10 } from "node:fs/promises";
2691
+ import { readFile as readFile11 } from "node:fs/promises";
2435
2692
  import { homedir as homedir8 } from "node:os";
2436
2693
  import { join as join14, normalize } from "node:path";
2437
2694
  async function loadTemplate(name, options = {}) {
@@ -2458,7 +2715,7 @@ function templateRelativePath(name) {
2458
2715
  }
2459
2716
  async function readOptionalFile(path) {
2460
2717
  try {
2461
- return await readFile10(path, "utf8");
2718
+ return await readFile11(path, "utf8");
2462
2719
  } catch (error) {
2463
2720
  if (error.code === "ENOENT")
2464
2721
  return;
@@ -7135,11 +7392,13 @@ function validatePlanDocument(plan, options = {}) {
7135
7392
  if (!taskIds.has(dependency))
7136
7393
  add("dangling-dependency", "error", `Task ${task.id} depends on missing ${dependency}.`);
7137
7394
  }
7138
- if (options.strict && task.acceptanceCriteria.length === 0)
7139
- add("acceptance", "error", `Task ${task.id} has no acceptance criteria.`, task.id);
7395
+ if (options.strict && isPlaceholder(task.title))
7396
+ add("placeholder", "error", `Task ${task.id} has placeholder content.`, task.id);
7140
7397
  }
7141
7398
  if (options.strict && phase.tasks.length === 0)
7142
7399
  add("empty-phase", "error", `Phase ${phase.id} has no tasks.`, phase.id);
7400
+ if (options.strict && (isPlaceholder(phase.title) || isPlaceholder(phase.objective)))
7401
+ add("placeholder", "error", `Phase ${phase.id} has placeholder content.`, phase.id);
7143
7402
  }
7144
7403
  for (const cycle of dependencyCycles(plan))
7145
7404
  add("dependency-cycle", "error", `Dependency cycle: ${cycle.join(" -> ")}.`);
@@ -7148,10 +7407,131 @@ function validatePlanDocument(plan, options = {}) {
7148
7407
  add("design-length", "warning", `Design is ${designWords} words; prefer 300 or fewer.`);
7149
7408
  if (options.strict && designWords > 800)
7150
7409
  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.");
7410
+ if (options.strict) {
7411
+ if (plan.phases.length === 0)
7412
+ add("no-phases", "error", "Finalization requires at least one phase.");
7413
+ if (isPlaceholder(plan.intent))
7414
+ add("placeholder", "error", "Intent must contain concrete content.", "intent");
7415
+ if (!plan.requirements.length || plan.requirements.some(isPlaceholder))
7416
+ add("requirements", "error", "Requirements must contain concrete content.", "requirements");
7417
+ for (const [key, value] of Object.entries(plan.design)) {
7418
+ if (isPlaceholder(value))
7419
+ add("design", "error", `Design/${key} must contain concrete content.`, `design.${key}`);
7420
+ }
7421
+ }
7422
+ return issues;
7423
+ }
7424
+ function validatePlanBriefs(plan, briefs) {
7425
+ const issues = [];
7426
+ const phaseIds = plan.phases.map((phase) => phase.id);
7427
+ const briefIds = briefs.map((brief) => brief.phaseId);
7428
+ if (!sameIds(phaseIds, briefIds))
7429
+ issues.push({
7430
+ code: "brief-phase-ids",
7431
+ severity: "error",
7432
+ message: `Brief phase IDs must exactly match plan phases in order: ${phaseIds.join(", ")}.`
7433
+ });
7434
+ for (const [index, phase] of plan.phases.entries()) {
7435
+ const brief = briefs[index];
7436
+ if (!brief || brief.phaseId !== phase.id)
7437
+ continue;
7438
+ if (isPlaceholder(brief.summary))
7439
+ issues.push({ code: "brief-placeholder", severity: "error", message: `Brief ${phase.id} needs a summary.` });
7440
+ const taskIds = phase.tasks.map((task) => task.id);
7441
+ const briefTaskIds = brief.tasks.map((task) => task.taskId);
7442
+ if (!sameIds(taskIds, briefTaskIds))
7443
+ issues.push({
7444
+ code: "brief-task-ids",
7445
+ severity: "error",
7446
+ message: `Brief ${phase.id} task IDs must exactly match phase tasks in order: ${taskIds.join(", ")}.`
7447
+ });
7448
+ for (const task of brief.tasks) {
7449
+ for (const [field, values] of [
7450
+ ["ordered steps", task.steps],
7451
+ ["file scopes", task.fileScopes],
7452
+ ["acceptance criteria", task.acceptanceCriteria]
7453
+ ]) {
7454
+ if (!values.length || values.some(isPlaceholder))
7455
+ issues.push({
7456
+ code: "brief-incomplete",
7457
+ severity: "error",
7458
+ message: `Brief task ${task.taskId} needs concrete ${field}.`,
7459
+ path: task.taskId
7460
+ });
7461
+ }
7462
+ }
7463
+ for (const [field, values] of [
7464
+ ["API changes", brief.apiChanges],
7465
+ ["libraries", brief.libraries],
7466
+ ["constraints", brief.constraints]
7467
+ ]) {
7468
+ if (values.some(isPlaceholder))
7469
+ issues.push({
7470
+ code: "brief-placeholder",
7471
+ severity: "error",
7472
+ message: `Brief ${phase.id} has placeholder ${field}.`,
7473
+ path: phase.id
7474
+ });
7475
+ }
7476
+ }
7153
7477
  return issues;
7154
7478
  }
7479
+ function renderImplementationBrief(planRevision, ordinal, phase, brief) {
7480
+ const marker = { schemaVersion: 1, planRevision, ordinal, phaseId: phase.id };
7481
+ const tasks = phase.tasks.map((task, index) => {
7482
+ const details = brief.tasks[index];
7483
+ if (!details || details.taskId !== task.id)
7484
+ throw new Error(`Missing implementation details for ${task.id}.`);
7485
+ return `<!-- diffpi-brief-task: ${json({ id: task.id })} -->
7486
+ ### Task: ${task.title}
7487
+
7488
+ #### Ordered Steps
7489
+
7490
+ ${bulletList(details.steps)}
7491
+
7492
+ #### File Scopes
7493
+
7494
+ ${bulletList(details.fileScopes)}
7495
+
7496
+ #### Acceptance Criteria
7497
+
7498
+ ${bulletList(details.acceptanceCriteria)}
7499
+
7500
+ <!-- /diffpi-brief-task -->`;
7501
+ }).join(`
7502
+
7503
+ `);
7504
+ return `<!-- diffpi-implementation: ${json(marker)} -->
7505
+ # Phase ${ordinal}: ${phase.title}
7506
+
7507
+ - **Phase ID:** ${phase.id}
7508
+ - **Plan Revision:** ${planRevision}
7509
+
7510
+ ## Summary
7511
+
7512
+ ${brief.summary}
7513
+
7514
+ ## Objective
7515
+
7516
+ ${phase.objective}
7517
+
7518
+ ## Tasks
7519
+
7520
+ ${tasks}
7521
+
7522
+ ## API Changes
7523
+
7524
+ ${bulletList(brief.apiChanges, "None.")}
7525
+
7526
+ ## Libraries and Algorithms
7527
+
7528
+ ${bulletList(brief.libraries, "None.")}
7529
+
7530
+ ## Implementation Constraints
7531
+
7532
+ ${bulletList(brief.constraints, "None.")}
7533
+ `;
7534
+ }
7155
7535
  function renderPlanDocument(plan, previousSource) {
7156
7536
  if (previousSource && canPatch(previousSource, plan))
7157
7537
  return patchMarkers(previousSource, plan);
@@ -7160,6 +7540,8 @@ function renderPlanDocument(plan, previousSource) {
7160
7540
  id: plan.id,
7161
7541
  revision: plan.revision,
7162
7542
  branch: plan.branch,
7543
+ issueId: plan.issueId,
7544
+ issueUrl: plan.issueUrl,
7163
7545
  status: plan.status,
7164
7546
  execution: plan.execution,
7165
7547
  createdAt: plan.createdAt,
@@ -7168,6 +7550,8 @@ function renderPlanDocument(plan, previousSource) {
7168
7550
  const metadata = import_mdast_builder.list("unordered", [
7169
7551
  ["Plan ID", plan.id],
7170
7552
  ["Branch", plan.branch],
7553
+ ...plan.issueId ? [["Issue", plan.issueId]] : [],
7554
+ ...plan.issueUrl ? [["Issue URL", plan.issueUrl]] : [],
7171
7555
  ["Status", plan.status],
7172
7556
  ["Revision", String(plan.revision)]
7173
7557
  ].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 +7573,7 @@ function renderPlanDocument(plan, previousSource) {
7189
7573
  import_mdast_builder.heading(3, import_mdast_builder.text("Consequences")),
7190
7574
  import_mdast_builder.html(plan.design.consequences || "<!-- Describe trade-offs and limitations. -->"),
7191
7575
  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. -->")],
7576
+ ...plan.phases.length ? plan.phases.flatMap((phase) => [import_mdast_builder.html(renderPhase(phase))]) : [import_mdast_builder.html("<!-- Add phases with plan_apply_revision. -->")],
7193
7577
  import_mdast_builder.heading(2, import_mdast_builder.text("References")),
7194
7578
  references
7195
7579
  ]);
@@ -7263,10 +7647,7 @@ function renderTask(task) {
7263
7647
  const checked = task.status === "completed" || task.status === "skipped" ? "x" : " ";
7264
7648
  return `<!-- diffpi-task: ${json(marker)} -->
7265
7649
  - [${checked}] **${task.title}**
7266
- ${taskList("Steps", task.steps ?? [])}
7267
7650
  - Dependencies: ${list3(task.dependencies)}
7268
- ${taskList("File scopes", task.fileScopes)}
7269
- ${taskList("Acceptance criteria", task.acceptanceCriteria)}
7270
7651
  <!-- /diffpi-task -->`;
7271
7652
  }
7272
7653
  function parsePhases(input, ref) {
@@ -7316,10 +7697,7 @@ function parseTasks(input, ref) {
7316
7697
  return {
7317
7698
  ...marker,
7318
7699
  title,
7319
- steps: parseListValue(body, "Steps"),
7320
- dependencies: parseListValue(body, "Dependencies"),
7321
- fileScopes: parseListValue(body, "File scopes"),
7322
- acceptanceCriteria: parseListValue(body, "Acceptance criteria")
7700
+ dependencies: parseListValue(body, "Dependencies")
7323
7701
  };
7324
7702
  });
7325
7703
  assertUniqueIds(tasks.map((task) => task.id), "task id");
@@ -7346,6 +7724,8 @@ function patchMarkers(source, plan) {
7346
7724
  id: plan.id,
7347
7725
  revision: plan.revision,
7348
7726
  branch: plan.branch,
7727
+ issueId: plan.issueId,
7728
+ issueUrl: plan.issueUrl,
7349
7729
  status: plan.status,
7350
7730
  execution: plan.execution,
7351
7731
  createdAt: plan.createdAt,
@@ -7395,19 +7775,12 @@ function contentShape(plan) {
7395
7775
  requirements: plan.requirements,
7396
7776
  design: plan.design,
7397
7777
  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
- }))
7778
+ phases: plan.phases.map((phase) => ({
7779
+ id: phase.id,
7780
+ title: phase.title,
7781
+ objective: phase.objective,
7782
+ dependencies: phase.dependencies,
7783
+ tasks: phase.tasks.map(({ id, title, dependencies }) => ({ id, title, dependencies }))
7411
7784
  }))
7412
7785
  };
7413
7786
  }
@@ -7507,6 +7880,13 @@ function assertUniqueIds(ids, label) {
7507
7880
  function cleanPlaceholder(value) {
7508
7881
  return value.replace(/<!--[^]*?-->/g, "").trim();
7509
7882
  }
7883
+ function isPlaceholder(value) {
7884
+ const normalized = cleanPlaceholder(value).trim();
7885
+ return normalized.length === 0 || /^(?:todo|tbd|none|n\/a)$/i.test(normalized) || /define during implementation|describe the|add requirements|add implementation/i.test(normalized);
7886
+ }
7887
+ function sameIds(expected, actual) {
7888
+ return expected.length === actual.length && expected.every((value, index) => actual[index] === value);
7889
+ }
7510
7890
  function parseMarker(value, label) {
7511
7891
  try {
7512
7892
  return JSON.parse(value);
@@ -7514,15 +7894,14 @@ function parseMarker(value, label) {
7514
7894
  throw new Error(`Malformed ${label} marker JSON.`);
7515
7895
  }
7516
7896
  }
7517
- function taskList(label, values) {
7897
+ function bulletList(values, empty = "") {
7518
7898
  if (!values.length)
7519
- return ` - ${label}: none`;
7520
- const items = values.flatMap((value) => {
7899
+ return empty;
7900
+ return values.flatMap((value) => {
7521
7901
  const [first = "", ...continuations] = value.split(`
7522
7902
  `);
7523
- return [` - ${first}`, ...continuations.map((line) => ` ${line}`)];
7524
- });
7525
- return [` - ${label}:`, ...items].join(`
7903
+ return [`- ${first}`, ...continuations.map((line) => ` ${line}`)];
7904
+ }).join(`
7526
7905
  `);
7527
7906
  }
7528
7907
  function list3(values) {
@@ -7542,7 +7921,7 @@ function planRecordName(shortSlug, date = new Date) {
7542
7921
  }
7543
7922
  async function resolvePlan(cwd, query, filters = {}, options = {}) {
7544
7923
  const root = await plansDir(cwd, options.homeDir);
7545
- const entries = await readdir3(root, { withFileTypes: true });
7924
+ const entries = await readdir2(root, { withFileTypes: true });
7546
7925
  const normalizedQuery = query ? normalizeQuery(query) : undefined;
7547
7926
  const records = [];
7548
7927
  for (const entry of entries) {
@@ -7569,6 +7948,15 @@ async function resolvePlan(cwd, query, filters = {}, options = {}) {
7569
7948
  }
7570
7949
  function createPlanStore(options = {}) {
7571
7950
  const now = options.now ?? (() => new Date);
7951
+ const read = async (cwd, query, filters) => {
7952
+ const result = await resolvePlan(cwd, query, filters, options);
7953
+ if (!result.record) {
7954
+ if (result.ambiguous)
7955
+ throw new Error(`Plan query "${query}" is ambiguous: ${result.candidates.map((item) => item.id).join(", ")}.`);
7956
+ throw new Error(`Plan "${query}" was not found.`);
7957
+ }
7958
+ return result.record;
7959
+ };
7572
7960
  return {
7573
7961
  context(cwd, query, filters) {
7574
7962
  return resolvePlan(cwd, query, filters, options);
@@ -7577,57 +7965,77 @@ function createPlanStore(options = {}) {
7577
7965
  const root = await plansDir(input.cwd, options.homeDir);
7578
7966
  const id = planRecordName(input.shortSlug, now());
7579
7967
  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
- }
7968
+ await createPlanDirectory(root, dir, id);
7588
7969
  try {
7589
7970
  const timestamp = now().toISOString();
7590
7971
  const template = await loadTemplate("plan/PLAN", {
7591
7972
  homeDir: options.homeDir,
7592
7973
  bundledDir: options.bundledTemplatesDir
7593
7974
  });
7975
+ const title = input.title?.trim() || titleFromSlug(input.shortSlug);
7594
7976
  const source = renderTemplate(template.content, {
7595
7977
  id,
7596
7978
  branch: input.branch,
7597
- title: input.title?.trim() || titleFromSlug(input.shortSlug),
7979
+ title,
7598
7980
  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. -->",
7981
+ issue_id: input.issueId?.trim() || "",
7982
+ issue_url: input.issueUrl?.trim() || "",
7601
7983
  created_at: timestamp,
7602
7984
  updated_at: timestamp
7603
7985
  });
7604
7986
  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
7614
- };
7987
+ const authoringRequest = input.request ?? { kind: "user", text: input.intent?.trim() || `Initialize phase-less plan ${title}.` };
7988
+ await writeRevisionSnapshot(dir, document, source, authoringRequest, []);
7989
+ await writeLatestView(dir, document, source, []);
7990
+ return await readRecord(dir);
7615
7991
  } catch (error) {
7616
7992
  await rm2(dir, { recursive: true, force: true });
7617
7993
  throw error;
7618
7994
  }
7619
7995
  },
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.`);
7996
+ read,
7997
+ async applyRevision(cwd, request) {
7998
+ if (request.mode === "create") {
7999
+ const root = await plansDir(cwd, options.homeDir);
8000
+ const id = planRecordName(request.shortSlug, now());
8001
+ const dir = join16(root, id);
8002
+ await createPlanDirectory(root, dir, id);
8003
+ try {
8004
+ const timestamp = now().toISOString();
8005
+ const document = normalizeRevision(undefined, request, id, 0, timestamp);
8006
+ assertCompleteRevision(document, request.briefs);
8007
+ const source = renderPlanDocument(document);
8008
+ parsePlanDocument(source, join16(dir, "PLAN.md"));
8009
+ await writeRevisionSnapshot(dir, document, source, request.request, request.briefs);
8010
+ await writeLatestView(dir, document, source, request.briefs);
8011
+ return await readRecord(dir);
8012
+ } catch (error) {
8013
+ await rm2(dir, { recursive: true, force: true });
8014
+ throw error;
8015
+ }
7626
8016
  }
7627
- return result.record;
8017
+ const initial = await read(cwd, request.plan);
8018
+ return withDirectoryLock(join16(initial.dir, ".lock"), async () => {
8019
+ const current = await readRecord(initial.dir);
8020
+ if (current.document.revision !== request.expectedPlanRevision)
8021
+ throw new Error(`Expected plan revision ${request.expectedPlanRevision}, found ${current.document.revision}.`);
8022
+ const revision = current.document.revision + 1;
8023
+ const document = normalizeRevision(current.document, request, current.id, revision, now().toISOString());
8024
+ assertCompleteRevision(document, request.briefs);
8025
+ const source = renderPlanDocument(document);
8026
+ parsePlanDocument(source, current.planPath);
8027
+ await writeRevisionSnapshot(current.dir, document, source, request.request, request.briefs);
8028
+ try {
8029
+ await writeLatestView(current.dir, document, source, request.briefs);
8030
+ } catch (error) {
8031
+ await rm2(join16(current.dir, "revisions", String(revision)), { recursive: true, force: true });
8032
+ throw error;
8033
+ }
8034
+ return await readRecord(current.dir);
8035
+ }, { operation: "apply revision" });
7628
8036
  },
7629
8037
  async mutate(cwd, query, operation, update) {
7630
- const initial = await this.read(cwd, query);
8038
+ const initial = await read(cwd, query);
7631
8039
  return withDirectoryLock(join16(initial.dir, ".lock"), async () => {
7632
8040
  const current = await readRecord(initial.dir);
7633
8041
  const next = await update(structuredClone(current.document));
@@ -7635,18 +8043,17 @@ function createPlanStore(options = {}) {
7635
8043
  throw new Error("A plan mutation cannot change the plan ID.");
7636
8044
  const document = {
7637
8045
  ...next,
7638
- revision: current.document.revision + 1,
8046
+ revision: current.document.revision,
7639
8047
  updatedAt: now().toISOString()
7640
8048
  };
7641
8049
  const source = renderPlanDocument(document, current.source);
7642
8050
  parsePlanDocument(source, current.planPath);
7643
8051
  await atomicWrite(current.planPath, source);
7644
- await ensureImplementationFiles(current.dir, document, options);
7645
- return { ...current, document, source, implementationDir: join16(current.dir, "implementation") };
8052
+ return { ...current, document, source };
7646
8053
  }, { operation });
7647
8054
  },
7648
8055
  async log(cwd, query, event) {
7649
- const record = await this.read(cwd, query);
8056
+ const record = await read(cwd, query);
7650
8057
  return appendLogEntry(record.logPath, event);
7651
8058
  }
7652
8059
  };
@@ -7654,7 +8061,7 @@ function createPlanStore(options = {}) {
7654
8061
  async function readRecord(dir) {
7655
8062
  const planPath = join16(dir, "PLAN.md");
7656
8063
  const logPath = join16(dir, "logs.txt");
7657
- const source = await readFile11(planPath, "utf8");
8064
+ const source = await readFile12(planPath, "utf8");
7658
8065
  const document = parsePlanDocument(source, planPath);
7659
8066
  return {
7660
8067
  id: basename6(dir),
@@ -7666,29 +8073,208 @@ async function readRecord(dir) {
7666
8073
  source
7667
8074
  };
7668
8075
  }
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`);
8076
+ async function createPlanDirectory(root, dir, id) {
8077
+ await assertContained(root, dirname8(dir));
8078
+ try {
8079
+ await mkdir7(dir);
8080
+ } catch (error) {
8081
+ if (error.code === "EEXIST")
8082
+ throw new Error(`Plan ${id} already exists.`);
8083
+ throw error;
8084
+ }
8085
+ }
8086
+ async function writeRevisionSnapshot(planDir, document, source, request, briefs) {
8087
+ if (!request.text.trim())
8088
+ throw new Error("An exact non-empty authoring request is required.");
8089
+ if (request.kind === "annotation" && !request.response?.trim())
8090
+ throw new Error("Annotation requests require a non-empty response.");
8091
+ const revisionsDir = join16(planDir, "revisions");
8092
+ await mkdir7(revisionsDir, { recursive: true });
8093
+ const target = join16(revisionsDir, String(document.revision));
8094
+ try {
8095
+ await readFile12(join16(target, "metadata.json"), "utf8");
8096
+ throw new Error(`Revision ${document.revision} already exists and is immutable.`);
8097
+ } catch (error) {
8098
+ if (error.code !== "ENOENT")
8099
+ throw error;
8100
+ }
8101
+ const staging = join16(revisionsDir, `.tmp-${document.revision}-${randomUUID2()}`);
8102
+ await mkdir7(staging);
8103
+ try {
8104
+ const requestSource = request.kind === "annotation" ? `## Original annotation
8105
+ ${request.text}
8106
+
8107
+ ## LLM response
8108
+ ${request.response}` : request.text;
8109
+ await atomicWrite(join16(staging, "request.md"), requestSource);
8110
+ await atomicWrite(join16(staging, "metadata.json"), `${JSON.stringify({
8111
+ schemaVersion: 1,
8112
+ id: document.id,
8113
+ revision: document.revision,
8114
+ requestKind: request.kind,
8115
+ requestSha256: sha256(request.text),
8116
+ ...request.kind === "annotation" ? { originalAnnotationSha256: sha256(request.text), responseSha256: sha256(request.response) } : {},
8117
+ createdAt: document.updatedAt
8118
+ }, null, 2)}
8119
+ `);
8120
+ await atomicWrite(join16(staging, "PLAN.md"), source);
8121
+ await writeImplementationDirectory(staging, document, briefs);
8122
+ await rename3(staging, target);
8123
+ } catch (error) {
8124
+ await rm2(staging, { recursive: true, force: true });
8125
+ throw error;
8126
+ }
8127
+ }
8128
+ async function writeLatestView(planDir, document, source, briefs) {
8129
+ const current = join16(planDir, "implementation");
8130
+ const stagingRoot = join16(planDir, `.latest-${randomUUID2()}`);
8131
+ const stagedImplementation = join16(stagingRoot, "implementation");
8132
+ const backup = join16(planDir, `.implementation-backup-${randomUUID2()}`);
8133
+ await mkdir7(stagingRoot);
8134
+ await writeImplementationDirectory(stagingRoot, document, briefs);
8135
+ let backedUp = false;
8136
+ try {
7678
8137
  try {
7679
- await readFile11(path, "utf8");
8138
+ await rename3(current, backup);
8139
+ backedUp = true;
7680
8140
  } catch (error) {
7681
8141
  if (error.code !== "ENOENT")
7682
8142
  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
8143
  }
7691
- }
8144
+ await rename3(stagedImplementation, current);
8145
+ await atomicWrite(join16(planDir, "PLAN.md"), source);
8146
+ if (backedUp)
8147
+ await rm2(backup, { recursive: true, force: true });
8148
+ } catch (error) {
8149
+ await rm2(current, { recursive: true, force: true });
8150
+ if (backedUp)
8151
+ await rename3(backup, current);
8152
+ throw error;
8153
+ } finally {
8154
+ await rm2(stagingRoot, { recursive: true, force: true });
8155
+ }
8156
+ }
8157
+ async function writeImplementationDirectory(baseDir, document, briefs) {
8158
+ const implementationDir = join16(baseDir, "implementation");
8159
+ await mkdir7(implementationDir, { recursive: true });
8160
+ for (const [index, phase] of document.phases.entries()) {
8161
+ const brief = briefs[index];
8162
+ if (!brief)
8163
+ throw new Error(`Missing implementation brief for phase ${phase.id}.`);
8164
+ await atomicWrite(join16(implementationDir, `phase-${index + 1}.md`), renderImplementationBrief(document.revision, index + 1, phase, brief));
8165
+ }
8166
+ }
8167
+ function normalizeRevision(current, request, id, revision, updatedAt) {
8168
+ if (current?.status === "completed")
8169
+ throw new Error("Completed plans cannot be amended.");
8170
+ if (current?.execution?.active && request.request.kind !== "blocker")
8171
+ throw new Error("An active execution accepts only a blocker amendment.");
8172
+ const currentPhases = new Map(current?.phases.map((phase) => [phase.id, phase]) ?? []);
8173
+ for (const phase of current?.phases ?? []) {
8174
+ if (!request.phases.some((candidate) => candidate.id === phase.id) && (phase.status === "completed" || phase.status === "in_progress"))
8175
+ throw new Error(`${phase.status === "completed" ? "Completed" : "Active"} phase ${phase.id} cannot be removed.`);
8176
+ }
8177
+ const phases = request.phases.map((draft) => reconcilePhase(currentPhases.get(draft.id), draft, current));
8178
+ const createdAt = current?.createdAt ?? updatedAt;
8179
+ return {
8180
+ schemaVersion: 1,
8181
+ id,
8182
+ revision,
8183
+ title: request.title,
8184
+ branch: current?.branch ?? (request.mode === "create" ? request.branch : ""),
8185
+ issueId: current?.issueId ?? (request.mode === "create" ? request.issueId : undefined),
8186
+ issueUrl: current?.issueUrl ?? (request.mode === "create" ? request.issueUrl : undefined),
8187
+ intent: request.intent,
8188
+ requirements: request.requirements,
8189
+ design: request.design,
8190
+ references: request.references,
8191
+ phases,
8192
+ status: nextPlanStatus(current),
8193
+ execution: current?.execution,
8194
+ createdAt,
8195
+ updatedAt
8196
+ };
8197
+ }
8198
+ function reconcilePhase(current, draft, plan) {
8199
+ if (!current)
8200
+ return {
8201
+ ...draft,
8202
+ revision: 0,
8203
+ status: "pending",
8204
+ gate: { phaseRevision: 0, status: "pending", results: [] },
8205
+ tasks: draft.tasks.map(newTask)
8206
+ };
8207
+ if (current.status === "completed") {
8208
+ if (JSON.stringify(phaseContent(current)) !== JSON.stringify(draft))
8209
+ throw new Error(`Completed phase ${current.id} cannot be changed.`);
8210
+ return current;
8211
+ }
8212
+ const byId = new Map(current.tasks.map((task) => [task.id, task]));
8213
+ for (const task of current.tasks) {
8214
+ if (!draft.tasks.some((candidate) => candidate.id === task.id) && (task.status === "completed" || task.status === "in_progress"))
8215
+ throw new Error(`${task.status === "completed" ? "Completed" : "Active"} task ${task.id} cannot be removed.`);
8216
+ }
8217
+ const tasks = draft.tasks.map((task) => reconcileTask(byId.get(task.id), task));
8218
+ const changed = JSON.stringify(phaseContent(current)) !== JSON.stringify(draft);
8219
+ const revision = changed ? current.revision + 1 : current.revision;
8220
+ const wasBlocked = current.status === "blocked";
8221
+ return {
8222
+ ...current,
8223
+ ...draft,
8224
+ revision,
8225
+ tasks,
8226
+ status: wasBlocked ? plan?.execution?.active ? "in_progress" : "pending" : current.status,
8227
+ gate: changed || wasBlocked ? { phaseRevision: revision, status: "stale", results: [] } : current.gate,
8228
+ blocker: wasBlocked ? undefined : current.blocker
8229
+ };
8230
+ }
8231
+ function reconcileTask(current, draft) {
8232
+ if (!current)
8233
+ return newTask(draft);
8234
+ const changed = JSON.stringify(taskContent(current)) !== JSON.stringify(draft);
8235
+ if ((current.status === "completed" || current.status === "in_progress") && changed)
8236
+ throw new Error(`${current.status === "completed" ? "Completed" : "Active"} task ${current.id} cannot be changed.`);
8237
+ if (current.status === "completed" || current.status === "in_progress")
8238
+ return current;
8239
+ return {
8240
+ ...current,
8241
+ ...draft,
8242
+ revision: changed ? current.revision + 1 : current.revision,
8243
+ status: current.status === "blocked" ? "pending" : current.status,
8244
+ owner: current.status === "blocked" ? undefined : current.owner,
8245
+ executionId: current.status === "blocked" ? undefined : current.executionId,
8246
+ blocker: current.status === "blocked" ? undefined : current.blocker
8247
+ };
8248
+ }
8249
+ function newTask(draft) {
8250
+ return { ...draft, revision: 0, status: "pending" };
8251
+ }
8252
+ function phaseContent(phase) {
8253
+ return {
8254
+ id: phase.id,
8255
+ title: phase.title,
8256
+ objective: phase.objective,
8257
+ dependencies: phase.dependencies,
8258
+ tasks: phase.tasks.map(taskContent)
8259
+ };
8260
+ }
8261
+ function taskContent(task) {
8262
+ return { id: task.id, title: task.title, dependencies: task.dependencies };
8263
+ }
8264
+ function nextPlanStatus(current) {
8265
+ if (!current)
8266
+ return "draft";
8267
+ if (current.execution?.active)
8268
+ return current.status === "blocked" ? "in_progress" : current.status;
8269
+ return current.status === "ready" || current.status === "blocked" ? "draft" : current.status;
8270
+ }
8271
+ function assertCompleteRevision(document, briefs) {
8272
+ const errors = [...validatePlanDocument(document, { strict: true }), ...validatePlanBriefs(document, briefs)].filter((issue) => issue.severity === "error");
8273
+ if (errors.length)
8274
+ throw new Error(`Invalid plan revision: ${errors.map((issue) => issue.message).join(" ")}`);
8275
+ }
8276
+ function sha256(value) {
8277
+ return createHash3("sha256").update(value).digest("hex");
7692
8278
  }
7693
8279
  async function assertContained(root, candidate) {
7694
8280
  const canonicalRoot = await realpath4(root);
@@ -7721,11 +8307,14 @@ function stripDate(value) {
7721
8307
  return value.replace(/^\d{6}-/, "");
7722
8308
  }
7723
8309
  function titleFromSlug(value) {
7724
- return normalizeSlug(value).replace(/^(?:[a-z]+-\d+-)/, "").split("-").map((part) => part[0].toUpperCase() + part.slice(1)).join(" ");
8310
+ const slug = normalizeSlug(value).replace(/^(?:[a-z]+-\d+-)/, "");
8311
+ return slug.replace(/(^|-)([a-z])/g, (_match, separator, letter) => {
8312
+ return `${separator ? " " : ""}${letter.toUpperCase()}`;
8313
+ });
7725
8314
  }
7726
8315
  // src/setup.ts
7727
8316
  import { parseFrontmatter as parseFrontmatter3 } from "@earendil-works/pi-coding-agent";
7728
- import { readdir as readdir4, readFile as readFile12 } from "node:fs/promises";
8317
+ import { readdir as readdir3, readFile as readFile13 } from "node:fs/promises";
7729
8318
  import { homedir as homedir9 } from "node:os";
7730
8319
  import { basename as basename7, join as join17 } from "node:path";
7731
8320
  var MISE_DEPENDENCIES = [
@@ -7828,11 +8417,11 @@ async function ensurePiAgents(options = {}) {
7828
8417
  const agentDir = options.agentDir ?? pi.agentDir(options.homeDir);
7829
8418
  const bundledAgentsDir = options.bundledAgentsDir ?? BUNDLED_AGENTS_DIR2;
7830
8419
  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));
8420
+ 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
8421
  const actions = [];
7833
8422
  for (const entry of entries) {
7834
8423
  const id = basename7(entry.name, ".md").replace(/^diffpi-/, "");
7835
- const source = await readFile12(join17(bundledAgentsDir, entry.name), "utf8");
8424
+ const source = await readFile13(join17(bundledAgentsDir, entry.name), "utf8");
7836
8425
  const content = materializeAgentModels(source, id, userConfig.config, options.availableModels);
7837
8426
  const result = await pi.agentEnsure(entry.name, content, agentDir, options.dryRun);
7838
8427
  actions.push(getConfigSetupAction(`pi agent ${id}`, result));
@@ -8037,7 +8626,7 @@ function reportProgress(options, message) {
8037
8626
  options.onProgress?.(message);
8038
8627
  }
8039
8628
  async function packageVersion2() {
8040
- const source = await readFile12(join17(resolveBundledAgentsDir(), "..", "package.json"), "utf8");
8629
+ const source = await readFile13(join17(resolveBundledAgentsDir(), "..", "package.json"), "utf8");
8041
8630
  let value;
8042
8631
  try {
8043
8632
  value = JSON.parse(source);
@@ -8062,11 +8651,11 @@ export {
8062
8651
  addComment,
8063
8652
  assertGitHubMergeReady,
8064
8653
  assertReviewEventSupported,
8065
- captureLocalReview,
8066
8654
  checkConventionalSubject,
8067
8655
  computeProjectSlug,
8068
8656
  createForge,
8069
8657
  createForgeBackend,
8658
+ createLocalReviewBackend,
8070
8659
  createModeController,
8071
8660
  createPlanStore,
8072
8661
  createRemoteReviewBackend,
@@ -8099,6 +8688,7 @@ export {
8099
8688
  launch,
8100
8689
  listSessions,
8101
8690
  loadDiffpiConfig,
8691
+ loadReviewPublicationState,
8102
8692
  loadTemplate,
8103
8693
  localReviewAuthor,
8104
8694
  mcp,
@@ -8107,28 +8697,37 @@ export {
8107
8697
  openInNewTab,
8108
8698
  parseGitlabDiffRefs,
8109
8699
  parseRemote,
8700
+ parseThreadArtifact,
8110
8701
  pi,
8111
8702
  plansDir,
8112
- readLocalReview,
8113
8703
  readSession,
8114
8704
  renderReviewDoc,
8115
8705
  renderTemplate,
8706
+ renderThreadArtifact,
8116
8707
  resolveAgentMode,
8117
8708
  resolveAgentModelPreferences,
8118
8709
  resolvePrSession,
8119
8710
  resolveReviewSession,
8120
8711
  resolveSession,
8712
+ reviewBodyFingerprint,
8713
+ reviewCommentFingerprint,
8121
8714
  reviewRecordName,
8715
+ reviewReplyFingerprint,
8122
8716
  reviewSlug,
8123
8717
  reviewsDir,
8124
8718
  runMiseGates,
8719
+ saveReviewPublicationState,
8720
+ sessionsDir,
8125
8721
  setupPi,
8126
8722
  severitySchema,
8127
8723
  storeDir,
8128
8724
  storeGlobalRoot,
8129
8725
  templateRelativePath,
8726
+ toFindings,
8130
8727
  toReviewComments,
8131
8728
  tuicrAvailable,
8729
+ unpublishedReviewComments,
8730
+ upsertThreadReply,
8132
8731
  withRemoteProvenance,
8133
8732
  yymmdd,
8134
8733
  zedKeymapPath,