@difflab/pi 0.6.0 → 0.7.0-rc.202609271932.42cb72b

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 (98) hide show
  1. package/README.md +11 -9
  2. package/agents/diffpi-orchestrator.md +8 -4
  3. package/agents/diffpi-plan-reviewer.md +33 -0
  4. package/agents/diffpi-planner.md +9 -6
  5. package/agents/diffpi-reviewer.md +6 -4
  6. package/agents/diffpi-worker.md +4 -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 +1940 -3849
  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 +1051 -357
  22. package/dist/modes.d.ts +15 -0
  23. package/dist/modes.d.ts.map +1 -1
  24. package/dist/plan/markdown.d.ts +4 -1
  25. package/dist/plan/markdown.d.ts.map +1 -1
  26. package/dist/{tools/plan-schema.d.ts → plan/schema.d.ts} +113 -75
  27. package/dist/plan/schema.d.ts.map +1 -0
  28. package/dist/plan/store.d.ts +39 -1
  29. package/dist/plan/store.d.ts.map +1 -1
  30. package/dist/plan/types.d.ts +27 -3
  31. package/dist/plan/types.d.ts.map +1 -1
  32. package/dist/review/github-review-backend.d.ts.map +1 -1
  33. package/dist/review/gitlab-review-backend.d.ts.map +1 -1
  34. package/dist/review/index.d.ts +6 -6
  35. package/dist/review/index.d.ts.map +1 -1
  36. package/dist/review/local-review-backend.d.ts +3 -0
  37. package/dist/review/local-review-backend.d.ts.map +1 -0
  38. package/dist/review/review-backend.d.ts +3 -2
  39. package/dist/review/review-backend.d.ts.map +1 -1
  40. package/dist/review/review-markdown.d.ts +4 -1
  41. package/dist/review/review-markdown.d.ts.map +1 -1
  42. package/dist/review/review-state.d.ts +20 -0
  43. package/dist/review/review-state.d.ts.map +1 -0
  44. package/dist/review/types.d.ts +28 -0
  45. package/dist/review/types.d.ts.map +1 -1
  46. package/dist/store.d.ts +2 -0
  47. package/dist/store.d.ts.map +1 -1
  48. package/dist/tools/index.js +1674 -597
  49. package/dist/tools/modes.d.ts.map +1 -1
  50. package/dist/tools/plan.d.ts.map +1 -1
  51. package/dist/tools/review.d.ts +38 -0
  52. package/dist/tools/review.d.ts.map +1 -1
  53. package/package.json +2 -4
  54. package/skills/plan/SKILL.md +8 -14
  55. package/skills/plan/references/design-quality.md +30 -0
  56. package/skills/plan/references/workflows/annotate.md +11 -0
  57. package/skills/plan/references/workflows/finalize.md +7 -0
  58. package/skills/plan/references/workflows/go.md +8 -0
  59. package/skills/plan/references/workflows/help.md +10 -0
  60. package/skills/plan/references/workflows/init.md +5 -0
  61. package/skills/plan/references/workflows/new.md +7 -0
  62. package/skills/plan/references/workflows/update.md +7 -0
  63. package/skills/review/SKILL.md +7 -7
  64. package/skills/review/references/review-standards.md +8 -1
  65. package/skills/review/references/workflows/address.md +11 -0
  66. package/skills/review/references/workflows/auto.md +18 -0
  67. package/skills/review/references/workflows/complete.md +7 -0
  68. package/skills/review/references/workflows/edit.md +7 -0
  69. package/skills/review/references/workflows/help.md +36 -0
  70. package/skills/review/references/workflows/merge.md +6 -0
  71. package/skills/review/references/workflows/new.md +19 -0
  72. package/skills/review/references/workflows/open.md +3 -0
  73. package/skills/review/references/workflows/publish.md +8 -0
  74. package/skills/review/references/workflows/status.md +3 -0
  75. package/templates/plan/PLAN.md +17 -11
  76. package/templates/plan/implementation.md +34 -12
  77. package/dist/commands.d.ts +0 -22
  78. package/dist/commands.d.ts.map +0 -1
  79. package/dist/extensions/cmdtsx.d.ts +0 -2
  80. package/dist/extensions/cmdtsx.d.ts.map +0 -1
  81. package/dist/tools/plan-schema.d.ts.map +0 -1
  82. package/dist/workflow.d.ts +0 -80
  83. package/dist/workflow.d.ts.map +0 -1
  84. package/workflows/plan/annotate.md +0 -13
  85. package/workflows/plan/finalize.md +0 -15
  86. package/workflows/plan/go.md +0 -20
  87. package/workflows/plan/help.md +0 -20
  88. package/workflows/plan/init.md +0 -13
  89. package/workflows/plan/new.md +0 -20
  90. package/workflows/plan/update.md +0 -18
  91. package/workflows/review/address.md +0 -8
  92. package/workflows/review/auto.md +0 -8
  93. package/workflows/review/complete.md +0 -7
  94. package/workflows/review/edit.md +0 -7
  95. package/workflows/review/help.md +0 -14
  96. package/workflows/review/merge.md +0 -6
  97. package/workflows/review/new.md +0 -7
  98. 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) {
@@ -2111,14 +2367,36 @@ function createModeController(pi, options = {}) {
2111
2367
  const result = resolveAgentMode(catalog.modes, agent);
2112
2368
  if (!result.ok || !result.active)
2113
2369
  return result;
2370
+ const previousActive = active;
2371
+ const previousBaseline = baseline;
2114
2372
  baseline ??= captureRuntime(pi, ctx);
2115
2373
  if (active && baseline)
2116
2374
  await restoreRuntime(pi, baseline, ctx);
2117
2375
  active = result.active;
2118
- const runtimeMessage = await applyModeRuntime(pi, active, ctx);
2119
- pi.appendEntry(MODE_STATE_ENTRY, { active, baseline });
2120
- updateStatus(ctx);
2121
- return { ...result, message: `${result.message} ${runtimeMessage}` };
2376
+ try {
2377
+ const runtimeMessage = await applyModeRuntime(pi, active, ctx);
2378
+ pi.appendEntry(MODE_STATE_ENTRY, { active, baseline });
2379
+ updateStatus(ctx);
2380
+ return { ...result, message: `${result.message} ${runtimeMessage}` };
2381
+ } catch (error) {
2382
+ await restoreRuntime(pi, baseline, ctx);
2383
+ active = undefined;
2384
+ baseline = undefined;
2385
+ if (previousActive && previousBaseline) {
2386
+ try {
2387
+ active = previousActive;
2388
+ baseline = previousBaseline;
2389
+ await applyModeRuntime(pi, active, ctx);
2390
+ } catch {
2391
+ active = undefined;
2392
+ baseline = undefined;
2393
+ }
2394
+ }
2395
+ if (!active)
2396
+ pi.appendEntry(MODE_STATE_ENTRY, { active: null });
2397
+ updateStatus(ctx);
2398
+ return { ok: false, message: error instanceof Error ? error.message : String(error) };
2399
+ }
2122
2400
  },
2123
2401
  async unset(ctx) {
2124
2402
  if (!active)
@@ -2138,9 +2416,20 @@ function createModeController(pi, options = {}) {
2138
2416
  const restored = entry?.data?.active;
2139
2417
  const restoredBaseline = entry?.data?.baseline;
2140
2418
  if (isAgentModeSnapshot(restored)) {
2141
- active = restored;
2419
+ const catalog = await list(ctx, { includeSkills: restored.id.includes(":") });
2420
+ const current = catalog.modes.find((mode) => mode.id === restored.id);
2421
+ active = current ?? normalizeAgentModeSnapshot(restored);
2142
2422
  baseline = isModeBaseline(restoredBaseline) ? restoredBaseline : previousBaseline;
2143
- await applyModeRuntime(pi, active, ctx);
2423
+ try {
2424
+ await applyModeRuntime(pi, active, ctx);
2425
+ } catch (error) {
2426
+ const fallbackBaseline = previousBaseline ?? (isModeBaseline(restoredBaseline) ? restoredBaseline : undefined);
2427
+ if (fallbackBaseline)
2428
+ await restoreRuntime(pi, fallbackBaseline, ctx);
2429
+ active = undefined;
2430
+ baseline = undefined;
2431
+ throw error;
2432
+ }
2144
2433
  } else {
2145
2434
  if (previousActive && previousBaseline)
2146
2435
  pi.setActiveTools(previousBaseline.tools);
@@ -2165,13 +2454,28 @@ ${active.systemPrompt}`;
2165
2454
  },
2166
2455
  getActive() {
2167
2456
  return active;
2457
+ },
2458
+ getStatus(ctx) {
2459
+ return {
2460
+ active: active?.id,
2461
+ model: ctx.model ? { provider: ctx.model.provider, id: ctx.model.id } : undefined,
2462
+ thinkingLevel: pi.getThinkingLevel(),
2463
+ activeTools: pi.getActiveTools(),
2464
+ capabilityError: active ? validateModeRuntime(pi, active, ctx) : undefined
2465
+ };
2168
2466
  }
2169
2467
  };
2170
2468
  }
2171
2469
  var MODE_STATE_ENTRY = "diffpi-mode-state";
2172
2470
  var MODE_STATUS_KEY = "diffpi-mode";
2173
2471
  var MODE_WIDGET_KEY = "diffpi-mode-badge";
2174
- var MODE_CONTROL_TOOLS = ["ask_user_question", "diffpi_modes_list", "diffpi_modes_set", "diffpi_modes_unset"];
2472
+ var MODE_CONTROL_TOOLS = [
2473
+ "ask_user_question",
2474
+ "diffpi_modes_list",
2475
+ "diffpi_modes_set",
2476
+ "diffpi_modes_unset",
2477
+ "diffpi_modes_status"
2478
+ ];
2175
2479
  var BUNDLED_AGENTS_DIR = resolveBundledAgentsDir();
2176
2480
  var THINKING_LEVELS = new Set(["off", "minimal", "low", "medium", "high", "xhigh", "max"]);
2177
2481
  async function applyModeRuntime(pi, mode, ctx) {
@@ -2195,6 +2499,17 @@ async function applyModeRuntime(pi, mode, ctx) {
2195
2499
  if (selectedTools.length > 0)
2196
2500
  pi.setActiveTools(selectedTools);
2197
2501
  }
2502
+ const availableTools = new Set(pi.getAllTools().map((tool) => tool.name));
2503
+ const unavailableTools = mode.requiredTools.filter((tool) => !availableTools.has(tool));
2504
+ const activeTools = new Set(pi.getActiveTools());
2505
+ const missingTools = mode.requiredTools.filter((tool) => !activeTools.has(tool));
2506
+ const forbiddenTools = mode.forbiddenTools.filter((tool) => activeTools.has(tool));
2507
+ if (unavailableTools.length > 0 || missingTools.length > 0 || forbiddenTools.length > 0)
2508
+ throw new Error(`Profile preflight failed: missing required tools [${[...new Set([...unavailableTools, ...missingTools])].join(", ")}]${forbiddenTools.length > 0 ? `; forbidden tools active [${forbiddenTools.join(", ")}]` : ""}`);
2509
+ if (mode.requireModel && !selectedModel)
2510
+ throw new Error("Profile preflight failed: requested model was not selected.");
2511
+ if (mode.requireThinking && (!mode.thinkingLevel || pi.getThinkingLevel() !== mode.thinkingLevel))
2512
+ throw new Error(`Profile preflight failed: thinking level ${mode.thinkingLevel ?? "unset"} was not applied.`);
2198
2513
  const parts = [];
2199
2514
  if (mode.modelPreferences.length > 0) {
2200
2515
  parts.push(selectedModel ? `Model: ${selectedModel}.` : "No preferred model was available; kept the current model.");
@@ -2205,6 +2520,23 @@ async function applyModeRuntime(pi, mode, ctx) {
2205
2520
  parts.push("Applied the profile tool set.");
2206
2521
  return parts.join(" ") || "The profile changes the prompt only.";
2207
2522
  }
2523
+ function validateModeRuntime(pi, mode, ctx) {
2524
+ const activeTools = new Set(pi.getActiveTools());
2525
+ const missingTools = mode.requiredTools.filter((tool) => !activeTools.has(tool));
2526
+ const forbiddenTools = mode.forbiddenTools.filter((tool) => activeTools.has(tool));
2527
+ if (missingTools.length > 0 || forbiddenTools.length > 0)
2528
+ return `Profile capability check failed: missing required tools [${missingTools.join(", ")}]${forbiddenTools.length > 0 ? `; forbidden tools active [${forbiddenTools.join(", ")}]` : ""}`;
2529
+ if (mode.requireModel && mode.modelPreferences.length > 0) {
2530
+ const actual = ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : "unset";
2531
+ const available = ctx.scopedModels.length > 0 ? ctx.scopedModels.map((entry) => entry.model) : ctx.modelRegistry.getAvailable();
2532
+ const expected = mode.modelPreferences.map((preference) => findPreferredModel(available, preference)).find(Boolean);
2533
+ if (!expected || `${expected.provider}/${expected.id}` !== actual)
2534
+ return `Profile capability check failed: requested model was not active (actual ${actual}).`;
2535
+ }
2536
+ if (mode.requireThinking && (!mode.thinkingLevel || pi.getThinkingLevel() !== mode.thinkingLevel))
2537
+ return `Profile capability check failed: thinking level ${mode.thinkingLevel ?? "unset"} was not applied.`;
2538
+ return;
2539
+ }
2208
2540
  async function restoreRuntime(pi, state, ctx) {
2209
2541
  if (state.model) {
2210
2542
  const model = ctx.modelRegistry.find(state.model.provider, state.model.id);
@@ -2213,6 +2545,9 @@ async function restoreRuntime(pi, state, ctx) {
2213
2545
  }
2214
2546
  pi.setThinkingLevel(state.thinkingLevel);
2215
2547
  pi.setActiveTools(state.tools);
2548
+ const restoredTools = pi.getActiveTools();
2549
+ if (state.tools.some((tool) => !restoredTools.includes(tool)))
2550
+ throw new Error("Failed to restore the previous active tool set.");
2216
2551
  }
2217
2552
  function captureRuntime(pi, ctx) {
2218
2553
  return {
@@ -2232,7 +2567,7 @@ async function loadAgentModes(directory, source, modes, diagnostics, skillName)
2232
2567
  for (const entry of entries.filter((item) => item.isFile() && item.name.endsWith(".md")).sort((a, b) => a.name.localeCompare(b.name))) {
2233
2568
  const path = join11(directory, entry.name);
2234
2569
  try {
2235
- const content = await readFile8(path, "utf8");
2570
+ const content = await readFile9(path, "utf8");
2236
2571
  const { frontmatter, body } = parseFrontmatter2(content.startsWith("\uFEFF") ? content.slice(1) : content);
2237
2572
  if (frontmatter.enabled === false || frontmatter.inline === false)
2238
2573
  continue;
@@ -2254,6 +2589,10 @@ async function loadAgentModes(directory, source, modes, diagnostics, skillName)
2254
2589
  ...getFrontmatterList(frontmatter.model_fallbacks)
2255
2590
  ],
2256
2591
  thinkingLevel: getThinkingLevel(frontmatter.thinking),
2592
+ requiredTools: getFrontmatterList(frontmatter.required_tools),
2593
+ forbiddenTools: getFrontmatterList(frontmatter.forbidden_tools),
2594
+ requireModel: frontmatter.required_model === true,
2595
+ requireThinking: frontmatter.required_thinking === true,
2257
2596
  tools: getFrontmatterList(frontmatter.tools),
2258
2597
  source,
2259
2598
  sourcePath: path
@@ -2274,11 +2613,20 @@ function getThinkingLevel(value) {
2274
2613
  const level = getFrontmatterText(value);
2275
2614
  return level && THINKING_LEVELS.has(level) ? level : undefined;
2276
2615
  }
2616
+ function normalizeAgentModeSnapshot(value) {
2617
+ return {
2618
+ ...value,
2619
+ requiredTools: Array.isArray(value.requiredTools) ? value.requiredTools : [],
2620
+ forbiddenTools: Array.isArray(value.forbiddenTools) ? value.forbiddenTools : [],
2621
+ requireModel: value.requireModel === true,
2622
+ requireThinking: value.requireThinking === true
2623
+ };
2624
+ }
2277
2625
  function isAgentModeSnapshot(value) {
2278
2626
  if (!value || typeof value !== "object")
2279
2627
  return false;
2280
2628
  const candidate = value;
2281
- return typeof candidate.id === "string" && typeof candidate.label === "string" && typeof candidate.description === "string" && typeof candidate.systemPrompt === "string" && (candidate.promptStrategy === "append" || candidate.promptStrategy === "replace") && Array.isArray(candidate.modelPreferences) && (candidate.thinkingLevel === undefined || THINKING_LEVELS.has(candidate.thinkingLevel)) && Array.isArray(candidate.tools) && typeof candidate.source === "string" && typeof candidate.sourcePath === "string";
2629
+ return typeof candidate.id === "string" && typeof candidate.label === "string" && typeof candidate.description === "string" && typeof candidate.systemPrompt === "string" && (candidate.promptStrategy === "append" || candidate.promptStrategy === "replace") && Array.isArray(candidate.modelPreferences) && (candidate.thinkingLevel === undefined || THINKING_LEVELS.has(candidate.thinkingLevel)) && (candidate.requiredTools === undefined || Array.isArray(candidate.requiredTools)) && (candidate.forbiddenTools === undefined || Array.isArray(candidate.forbiddenTools)) && (candidate.requireModel === undefined || typeof candidate.requireModel === "boolean") && (candidate.requireThinking === undefined || typeof candidate.requireThinking === "boolean") && Array.isArray(candidate.tools) && typeof candidate.source === "string" && typeof candidate.sourcePath === "string";
2282
2630
  }
2283
2631
  function isModeBaseline(value) {
2284
2632
  if (!value || typeof value !== "object")
@@ -2288,7 +2636,7 @@ function isModeBaseline(value) {
2288
2636
  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
2637
  }
2290
2638
  // src/pi.ts
2291
- import { mkdir as mkdir6, writeFile as writeFile5 } from "node:fs/promises";
2639
+ import { mkdir as mkdir5, writeFile as writeFile6 } from "node:fs/promises";
2292
2640
  import { homedir as homedir7 } from "node:os";
2293
2641
  import { dirname as dirname6, join as join12 } from "node:path";
2294
2642
  var pi = {
@@ -2307,8 +2655,8 @@ async function ensurePiAgent(filename, content, agentDir = resolvePiAgentDir(),
2307
2655
  const currentText = await readTextIfExists(path);
2308
2656
  const changed = currentText !== content;
2309
2657
  if (changed && !dryRun) {
2310
- await mkdir6(dirname6(path), { recursive: true });
2311
- await writeFile5(path, content, "utf8");
2658
+ await mkdir5(dirname6(path), { recursive: true });
2659
+ await writeFile6(path, content, "utf8");
2312
2660
  }
2313
2661
  return { path, changed, existed: currentText !== undefined, planned: changed && dryRun };
2314
2662
  }
@@ -2344,8 +2692,8 @@ async function ensurePiConfig(path, update, dryRun = false) {
2344
2692
  const next = update(current);
2345
2693
  const changed = JSON.stringify(current) !== JSON.stringify(next);
2346
2694
  if (changed && !dryRun) {
2347
- await mkdir6(dirname6(path), { recursive: true });
2348
- await writeFile5(path, `${JSON.stringify(next, null, 2)}
2695
+ await mkdir5(dirname6(path), { recursive: true });
2696
+ await writeFile6(path, `${JSON.stringify(next, null, 2)}
2349
2697
  `, "utf8");
2350
2698
  }
2351
2699
  return { path, changed, existed: currentText !== undefined, planned: changed && dryRun };
@@ -2378,16 +2726,17 @@ function parseJsonObject(content, path) {
2378
2726
  throw new Error(`Expected valid JSON object in ${path}.`);
2379
2727
  }
2380
2728
  // 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";
2729
+ import { createHash as createHash3, randomUUID as randomUUID2 } from "node:crypto";
2730
+ import { mkdir as mkdir7, readdir as readdir2, readFile as readFile11, realpath as realpath4, rename as rename3, rm as rm2 } from "node:fs/promises";
2731
+ import { basename as basename6, dirname as dirname8, join as join15, resolve as resolve4, sep } from "node:path";
2383
2732
 
2384
2733
  // src/extensions/fsx.ts
2385
- import { mkdir as mkdir7, rename, rm, writeFile as writeFile6 } from "node:fs/promises";
2734
+ import { mkdir as mkdir6, rename as rename2, rm, writeFile as writeFile7 } from "node:fs/promises";
2386
2735
  import { basename as basename5, dirname as dirname7, join as join13 } from "node:path";
2387
2736
  async function atomicWrite(path, content) {
2388
2737
  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);
2738
+ await writeFile7(temp, content, { encoding: "utf8", mode: 384, flag: "wx" });
2739
+ await rename2(temp, path);
2391
2740
  }
2392
2741
  async function withDirectoryLock(lockDir, operation, options = {}) {
2393
2742
  const waitMs = options.waitMs ?? 5000;
@@ -2395,7 +2744,7 @@ async function withDirectoryLock(lockDir, operation, options = {}) {
2395
2744
  const started = Date.now();
2396
2745
  while (true) {
2397
2746
  try {
2398
- await mkdir7(lockDir);
2747
+ await mkdir6(lockDir);
2399
2748
  break;
2400
2749
  } catch (error) {
2401
2750
  if (error.code !== "EEXIST")
@@ -2417,7 +2766,7 @@ function sleep(ms) {
2417
2766
 
2418
2767
  // src/log.ts
2419
2768
  import { randomUUID } from "node:crypto";
2420
- import { appendFile, readFile as readFile9 } from "node:fs/promises";
2769
+ import { appendFile, readFile as readFile10 } from "node:fs/promises";
2421
2770
  async function appendLogEntry(logPath, event) {
2422
2771
  const entry = {
2423
2772
  ...event,
@@ -2430,42 +2779,6 @@ async function appendLogEntry(logPath, event) {
2430
2779
  return entry;
2431
2780
  }
2432
2781
 
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
2782
  // src/plan/markdown.ts
2470
2783
  var import_mdast_builder = __toESM(require_src(), 1);
2471
2784
 
@@ -3812,7 +4125,7 @@ var handle = {
3812
4125
  };
3813
4126
 
3814
4127
  // ../../node_modules/mdast-util-to-markdown/lib/join.js
3815
- var join15 = [joinDefaults];
4128
+ var join14 = [joinDefaults];
3816
4129
  function joinDefaults(left, right, parent, state) {
3817
4130
  if (right.type === "code" && formatCodeAsIndented(right, state) && (left.type === "list" || left.type === right.type && formatCodeAsIndented(left, state))) {
3818
4131
  return false;
@@ -6358,7 +6671,7 @@ function toMarkdown(tree, options) {
6358
6671
  handle: undefined,
6359
6672
  indentLines,
6360
6673
  indexStack: [],
6361
- join: [...join15],
6674
+ join: [...join14],
6362
6675
  options: {},
6363
6676
  safe: safeBound,
6364
6677
  stack: [],
@@ -7135,11 +7448,13 @@ function validatePlanDocument(plan, options = {}) {
7135
7448
  if (!taskIds.has(dependency))
7136
7449
  add("dangling-dependency", "error", `Task ${task.id} depends on missing ${dependency}.`);
7137
7450
  }
7138
- if (options.strict && task.acceptanceCriteria.length === 0)
7139
- add("acceptance", "error", `Task ${task.id} has no acceptance criteria.`, task.id);
7451
+ if (options.strict && isPlaceholder(task.title))
7452
+ add("placeholder", "error", `Task ${task.id} has placeholder content.`, task.id);
7140
7453
  }
7141
7454
  if (options.strict && phase.tasks.length === 0)
7142
7455
  add("empty-phase", "error", `Phase ${phase.id} has no tasks.`, phase.id);
7456
+ if (options.strict && (isPlaceholder(phase.title) || isPlaceholder(phase.objective)))
7457
+ add("placeholder", "error", `Phase ${phase.id} has placeholder content.`, phase.id);
7143
7458
  }
7144
7459
  for (const cycle of dependencyCycles(plan))
7145
7460
  add("dependency-cycle", "error", `Dependency cycle: ${cycle.join(" -> ")}.`);
@@ -7148,10 +7463,131 @@ function validatePlanDocument(plan, options = {}) {
7148
7463
  add("design-length", "warning", `Design is ${designWords} words; prefer 300 or fewer.`);
7149
7464
  if (options.strict && designWords > 800)
7150
7465
  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.");
7466
+ if (options.strict) {
7467
+ if (plan.phases.length === 0)
7468
+ add("no-phases", "error", "Finalization requires at least one phase.");
7469
+ if (isPlaceholder(plan.intent))
7470
+ add("placeholder", "error", "Intent must contain concrete content.", "intent");
7471
+ if (!plan.requirements.length || plan.requirements.some(isPlaceholder))
7472
+ add("requirements", "error", "Requirements must contain concrete content.", "requirements");
7473
+ for (const [key, value] of Object.entries(plan.design)) {
7474
+ if (isPlaceholder(value))
7475
+ add("design", "error", `Design/${key} must contain concrete content.`, `design.${key}`);
7476
+ }
7477
+ }
7478
+ return issues;
7479
+ }
7480
+ function validatePlanBriefs(plan, briefs) {
7481
+ const issues = [];
7482
+ const phaseIds = plan.phases.map((phase) => phase.id);
7483
+ const briefIds = briefs.map((brief) => brief.phaseId);
7484
+ if (!sameIds(phaseIds, briefIds))
7485
+ issues.push({
7486
+ code: "brief-phase-ids",
7487
+ severity: "error",
7488
+ message: `Brief phase IDs must exactly match plan phases in order: ${phaseIds.join(", ")}.`
7489
+ });
7490
+ for (const [index, phase] of plan.phases.entries()) {
7491
+ const brief = briefs[index];
7492
+ if (!brief || brief.phaseId !== phase.id)
7493
+ continue;
7494
+ if (isPlaceholder(brief.summary))
7495
+ issues.push({ code: "brief-placeholder", severity: "error", message: `Brief ${phase.id} needs a summary.` });
7496
+ const taskIds = phase.tasks.map((task) => task.id);
7497
+ const briefTaskIds = brief.tasks.map((task) => task.taskId);
7498
+ if (!sameIds(taskIds, briefTaskIds))
7499
+ issues.push({
7500
+ code: "brief-task-ids",
7501
+ severity: "error",
7502
+ message: `Brief ${phase.id} task IDs must exactly match phase tasks in order: ${taskIds.join(", ")}.`
7503
+ });
7504
+ for (const task of brief.tasks) {
7505
+ for (const [field, values] of [
7506
+ ["ordered steps", task.steps],
7507
+ ["file scopes", task.fileScopes],
7508
+ ["acceptance criteria", task.acceptanceCriteria]
7509
+ ]) {
7510
+ if (!values.length || values.some(isPlaceholder))
7511
+ issues.push({
7512
+ code: "brief-incomplete",
7513
+ severity: "error",
7514
+ message: `Brief task ${task.taskId} needs concrete ${field}.`,
7515
+ path: task.taskId
7516
+ });
7517
+ }
7518
+ }
7519
+ for (const [field, values] of [
7520
+ ["API changes", brief.apiChanges],
7521
+ ["libraries", brief.libraries],
7522
+ ["constraints", brief.constraints]
7523
+ ]) {
7524
+ if (values.some(isPlaceholder))
7525
+ issues.push({
7526
+ code: "brief-placeholder",
7527
+ severity: "error",
7528
+ message: `Brief ${phase.id} has placeholder ${field}.`,
7529
+ path: phase.id
7530
+ });
7531
+ }
7532
+ }
7153
7533
  return issues;
7154
7534
  }
7535
+ function renderImplementationBrief(planRevision, ordinal, phase, brief) {
7536
+ const marker = { schemaVersion: 1, planRevision, ordinal, phaseId: phase.id };
7537
+ const tasks = phase.tasks.map((task, index) => {
7538
+ const details = brief.tasks[index];
7539
+ if (!details || details.taskId !== task.id)
7540
+ throw new Error(`Missing implementation details for ${task.id}.`);
7541
+ return `<!-- diffpi-brief-task: ${json({ id: task.id })} -->
7542
+ ### Task: ${task.title}
7543
+
7544
+ #### Ordered Steps
7545
+
7546
+ ${bulletList(details.steps)}
7547
+
7548
+ #### File Scopes
7549
+
7550
+ ${bulletList(details.fileScopes)}
7551
+
7552
+ #### Acceptance Criteria
7553
+
7554
+ ${bulletList(details.acceptanceCriteria)}
7555
+
7556
+ <!-- /diffpi-brief-task -->`;
7557
+ }).join(`
7558
+
7559
+ `);
7560
+ return `<!-- diffpi-implementation: ${json(marker)} -->
7561
+ # Phase ${ordinal}: ${phase.title}
7562
+
7563
+ - **Phase ID:** ${phase.id}
7564
+ - **Plan Revision:** ${planRevision}
7565
+
7566
+ ## Summary
7567
+
7568
+ ${brief.summary}
7569
+
7570
+ ## Objective
7571
+
7572
+ ${phase.objective}
7573
+
7574
+ ## Tasks
7575
+
7576
+ ${tasks}
7577
+
7578
+ ## API Changes
7579
+
7580
+ ${bulletList(brief.apiChanges, "None.")}
7581
+
7582
+ ## Libraries and Algorithms
7583
+
7584
+ ${bulletList(brief.libraries, "None.")}
7585
+
7586
+ ## Implementation Constraints
7587
+
7588
+ ${bulletList(brief.constraints, "None.")}
7589
+ `;
7590
+ }
7155
7591
  function renderPlanDocument(plan, previousSource) {
7156
7592
  if (previousSource && canPatch(previousSource, plan))
7157
7593
  return patchMarkers(previousSource, plan);
@@ -7160,6 +7596,8 @@ function renderPlanDocument(plan, previousSource) {
7160
7596
  id: plan.id,
7161
7597
  revision: plan.revision,
7162
7598
  branch: plan.branch,
7599
+ issueId: plan.issueId,
7600
+ issueUrl: plan.issueUrl,
7163
7601
  status: plan.status,
7164
7602
  execution: plan.execution,
7165
7603
  createdAt: plan.createdAt,
@@ -7168,6 +7606,8 @@ function renderPlanDocument(plan, previousSource) {
7168
7606
  const metadata = import_mdast_builder.list("unordered", [
7169
7607
  ["Plan ID", plan.id],
7170
7608
  ["Branch", plan.branch],
7609
+ ...plan.issueId ? [["Issue", plan.issueId]] : [],
7610
+ ...plan.issueUrl ? [["Issue URL", plan.issueUrl]] : [],
7171
7611
  ["Status", plan.status],
7172
7612
  ["Revision", String(plan.revision)]
7173
7613
  ].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 +7629,7 @@ function renderPlanDocument(plan, previousSource) {
7189
7629
  import_mdast_builder.heading(3, import_mdast_builder.text("Consequences")),
7190
7630
  import_mdast_builder.html(plan.design.consequences || "<!-- Describe trade-offs and limitations. -->"),
7191
7631
  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. -->")],
7632
+ ...plan.phases.length ? plan.phases.flatMap((phase) => [import_mdast_builder.html(renderPhase(phase))]) : [import_mdast_builder.html("<!-- Add phases with plan_apply_revision. -->")],
7193
7633
  import_mdast_builder.heading(2, import_mdast_builder.text("References")),
7194
7634
  references
7195
7635
  ]);
@@ -7263,10 +7703,7 @@ function renderTask(task) {
7263
7703
  const checked = task.status === "completed" || task.status === "skipped" ? "x" : " ";
7264
7704
  return `<!-- diffpi-task: ${json(marker)} -->
7265
7705
  - [${checked}] **${task.title}**
7266
- ${taskList("Steps", task.steps ?? [])}
7267
7706
  - Dependencies: ${list3(task.dependencies)}
7268
- ${taskList("File scopes", task.fileScopes)}
7269
- ${taskList("Acceptance criteria", task.acceptanceCriteria)}
7270
7707
  <!-- /diffpi-task -->`;
7271
7708
  }
7272
7709
  function parsePhases(input, ref) {
@@ -7316,10 +7753,7 @@ function parseTasks(input, ref) {
7316
7753
  return {
7317
7754
  ...marker,
7318
7755
  title,
7319
- steps: parseListValue(body, "Steps"),
7320
- dependencies: parseListValue(body, "Dependencies"),
7321
- fileScopes: parseListValue(body, "File scopes"),
7322
- acceptanceCriteria: parseListValue(body, "Acceptance criteria")
7756
+ dependencies: parseListValue(body, "Dependencies")
7323
7757
  };
7324
7758
  });
7325
7759
  assertUniqueIds(tasks.map((task) => task.id), "task id");
@@ -7346,6 +7780,8 @@ function patchMarkers(source, plan) {
7346
7780
  id: plan.id,
7347
7781
  revision: plan.revision,
7348
7782
  branch: plan.branch,
7783
+ issueId: plan.issueId,
7784
+ issueUrl: plan.issueUrl,
7349
7785
  status: plan.status,
7350
7786
  execution: plan.execution,
7351
7787
  createdAt: plan.createdAt,
@@ -7395,19 +7831,12 @@ function contentShape(plan) {
7395
7831
  requirements: plan.requirements,
7396
7832
  design: plan.design,
7397
7833
  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
- }))
7834
+ phases: plan.phases.map((phase) => ({
7835
+ id: phase.id,
7836
+ title: phase.title,
7837
+ objective: phase.objective,
7838
+ dependencies: phase.dependencies,
7839
+ tasks: phase.tasks.map(({ id, title, dependencies }) => ({ id, title, dependencies }))
7411
7840
  }))
7412
7841
  };
7413
7842
  }
@@ -7507,6 +7936,13 @@ function assertUniqueIds(ids, label) {
7507
7936
  function cleanPlaceholder(value) {
7508
7937
  return value.replace(/<!--[^]*?-->/g, "").trim();
7509
7938
  }
7939
+ function isPlaceholder(value) {
7940
+ const normalized = cleanPlaceholder(value).trim();
7941
+ return normalized.length === 0 || /^(?:todo|tbd|none|n\/a)$/i.test(normalized) || /define during implementation|describe the|add requirements|add implementation/i.test(normalized);
7942
+ }
7943
+ function sameIds(expected, actual) {
7944
+ return expected.length === actual.length && expected.every((value, index) => actual[index] === value);
7945
+ }
7510
7946
  function parseMarker(value, label) {
7511
7947
  try {
7512
7948
  return JSON.parse(value);
@@ -7514,15 +7950,14 @@ function parseMarker(value, label) {
7514
7950
  throw new Error(`Malformed ${label} marker JSON.`);
7515
7951
  }
7516
7952
  }
7517
- function taskList(label, values) {
7953
+ function bulletList(values, empty = "") {
7518
7954
  if (!values.length)
7519
- return ` - ${label}: none`;
7520
- const items = values.flatMap((value) => {
7955
+ return empty;
7956
+ return values.flatMap((value) => {
7521
7957
  const [first = "", ...continuations] = value.split(`
7522
7958
  `);
7523
- return [` - ${first}`, ...continuations.map((line) => ` ${line}`)];
7524
- });
7525
- return [` - ${label}:`, ...items].join(`
7959
+ return [`- ${first}`, ...continuations.map((line) => ` ${line}`)];
7960
+ }).join(`
7526
7961
  `);
7527
7962
  }
7528
7963
  function list3(values) {
@@ -7542,7 +7977,7 @@ function planRecordName(shortSlug, date = new Date) {
7542
7977
  }
7543
7978
  async function resolvePlan(cwd, query, filters = {}, options = {}) {
7544
7979
  const root = await plansDir(cwd, options.homeDir);
7545
- const entries = await readdir3(root, { withFileTypes: true });
7980
+ const entries = await readdir2(root, { withFileTypes: true });
7546
7981
  const normalizedQuery = query ? normalizeQuery(query) : undefined;
7547
7982
  const records = [];
7548
7983
  for (const entry of entries) {
@@ -7550,7 +7985,7 @@ async function resolvePlan(cwd, query, filters = {}, options = {}) {
7550
7985
  continue;
7551
7986
  if (normalizedQuery && entry.name !== normalizedQuery && stripDate(entry.name) !== normalizedQuery)
7552
7987
  continue;
7553
- const dir = join16(root, entry.name);
7988
+ const dir = join15(root, entry.name);
7554
7989
  await assertContained(root, dir);
7555
7990
  try {
7556
7991
  const record = await readRecord(dir);
@@ -7569,6 +8004,15 @@ async function resolvePlan(cwd, query, filters = {}, options = {}) {
7569
8004
  }
7570
8005
  function createPlanStore(options = {}) {
7571
8006
  const now = options.now ?? (() => new Date);
8007
+ const read = async (cwd, query, filters) => {
8008
+ const result = await resolvePlan(cwd, query, filters, options);
8009
+ if (!result.record) {
8010
+ if (result.ambiguous)
8011
+ throw new Error(`Plan query "${query}" is ambiguous: ${result.candidates.map((item) => item.id).join(", ")}.`);
8012
+ throw new Error(`Plan "${query}" was not found.`);
8013
+ }
8014
+ return result.record;
8015
+ };
7572
8016
  return {
7573
8017
  context(cwd, query, filters) {
7574
8018
  return resolvePlan(cwd, query, filters, options);
@@ -7576,84 +8020,107 @@ function createPlanStore(options = {}) {
7576
8020
  async init(input) {
7577
8021
  const root = await plansDir(input.cwd, options.homeDir);
7578
8022
  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
- }
8023
+ const dir = join15(root, id);
8024
+ await createPlanDirectory(root, dir, id);
7588
8025
  try {
7589
8026
  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, {
8027
+ const title = input.title?.trim() || titleFromSlug(input.shortSlug);
8028
+ const document = {
8029
+ schemaVersion: 1,
7595
8030
  id,
8031
+ revision: 0,
8032
+ title,
7596
8033
  branch: input.branch,
7597
- title: input.title?.trim() || titleFromSlug(input.shortSlug),
8034
+ ...input.issueId?.trim() ? { issueId: input.issueId.trim() } : {},
8035
+ ...input.issueUrl?.trim() ? { issueUrl: input.issueUrl.trim() } : {},
7598
8036
  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
8037
+ requirements: [],
8038
+ design: { bigIdeas: "", keyApiUpdates: "", consequences: "" },
8039
+ phases: [],
8040
+ references: [],
8041
+ status: "draft",
8042
+ createdAt: timestamp,
8043
+ updatedAt: timestamp
7614
8044
  };
8045
+ const source = renderPlanDocument(document);
8046
+ parsePlanDocument(source, join15(dir, "PLAN.md"));
8047
+ const authoringRequest = input.request ?? { kind: "user", text: input.intent?.trim() || `Initialize phase-less plan ${title}.` };
8048
+ await writeRevisionSnapshot(dir, document, source, authoringRequest, []);
8049
+ await writeLatestView(dir, document, source, []);
8050
+ return await readRecord(dir);
7615
8051
  } catch (error) {
7616
8052
  await rm2(dir, { recursive: true, force: true });
7617
8053
  throw error;
7618
8054
  }
7619
8055
  },
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.`);
8056
+ read,
8057
+ async applyRevision(cwd, request) {
8058
+ if (request.mode === "create") {
8059
+ const root = await plansDir(cwd, options.homeDir);
8060
+ const id = planRecordName(request.shortSlug, now());
8061
+ const dir = join15(root, id);
8062
+ await createPlanDirectory(root, dir, id);
8063
+ try {
8064
+ const timestamp = now().toISOString();
8065
+ const document = normalizeRevision(undefined, request, id, 0, timestamp);
8066
+ assertCompleteRevision(document, request.briefs);
8067
+ const source = renderPlanDocument(document);
8068
+ parsePlanDocument(source, join15(dir, "PLAN.md"));
8069
+ await writeRevisionSnapshot(dir, document, source, request.request, request.briefs);
8070
+ await writeLatestView(dir, document, source, request.briefs);
8071
+ return await readRecord(dir);
8072
+ } catch (error) {
8073
+ await rm2(dir, { recursive: true, force: true });
8074
+ throw error;
8075
+ }
7626
8076
  }
7627
- return result.record;
8077
+ const initial = await read(cwd, request.plan);
8078
+ return withDirectoryLock(join15(initial.dir, ".lock"), async () => {
8079
+ const current = await readRecord(initial.dir);
8080
+ if (current.document.revision !== request.expectedPlanRevision)
8081
+ throw new Error(`Expected plan revision ${request.expectedPlanRevision}, found ${current.document.revision}.`);
8082
+ const revision = current.document.revision + 1;
8083
+ const document = normalizeRevision(current.document, request, current.id, revision, now().toISOString());
8084
+ assertCompleteRevision(document, request.briefs);
8085
+ const source = renderPlanDocument(document);
8086
+ parsePlanDocument(source, current.planPath);
8087
+ await writeRevisionSnapshot(current.dir, document, source, request.request, request.briefs);
8088
+ try {
8089
+ await writeLatestView(current.dir, document, source, request.briefs);
8090
+ } catch (error) {
8091
+ await rm2(join15(current.dir, "revisions", String(revision)), { recursive: true, force: true });
8092
+ throw error;
8093
+ }
8094
+ return await readRecord(current.dir);
8095
+ }, { operation: "apply revision" });
7628
8096
  },
7629
8097
  async mutate(cwd, query, operation, update) {
7630
- const initial = await this.read(cwd, query);
7631
- return withDirectoryLock(join16(initial.dir, ".lock"), async () => {
8098
+ const initial = await read(cwd, query);
8099
+ return withDirectoryLock(join15(initial.dir, ".lock"), async () => {
7632
8100
  const current = await readRecord(initial.dir);
7633
8101
  const next = await update(structuredClone(current.document));
7634
8102
  if (next.id !== current.id)
7635
8103
  throw new Error("A plan mutation cannot change the plan ID.");
7636
8104
  const document = {
7637
8105
  ...next,
7638
- revision: current.document.revision + 1,
8106
+ revision: current.document.revision,
7639
8107
  updatedAt: now().toISOString()
7640
8108
  };
7641
8109
  const source = renderPlanDocument(document, current.source);
7642
8110
  parsePlanDocument(source, current.planPath);
7643
8111
  await atomicWrite(current.planPath, source);
7644
- await ensureImplementationFiles(current.dir, document, options);
7645
- return { ...current, document, source, implementationDir: join16(current.dir, "implementation") };
8112
+ return { ...current, document, source };
7646
8113
  }, { operation });
7647
8114
  },
7648
8115
  async log(cwd, query, event) {
7649
- const record = await this.read(cwd, query);
8116
+ const record = await read(cwd, query);
7650
8117
  return appendLogEntry(record.logPath, event);
7651
8118
  }
7652
8119
  };
7653
8120
  }
7654
8121
  async function readRecord(dir) {
7655
- const planPath = join16(dir, "PLAN.md");
7656
- const logPath = join16(dir, "logs.txt");
8122
+ const planPath = join15(dir, "PLAN.md");
8123
+ const logPath = join15(dir, "logs.txt");
7657
8124
  const source = await readFile11(planPath, "utf8");
7658
8125
  const document = parsePlanDocument(source, planPath);
7659
8126
  return {
@@ -7661,34 +8128,213 @@ async function readRecord(dir) {
7661
8128
  dir,
7662
8129
  planPath,
7663
8130
  logPath,
7664
- implementationDir: join16(dir, "implementation"),
8131
+ implementationDir: join15(dir, "implementation"),
7665
8132
  document,
7666
8133
  source
7667
8134
  };
7668
8135
  }
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`);
8136
+ async function createPlanDirectory(root, dir, id) {
8137
+ await assertContained(root, dirname8(dir));
8138
+ try {
8139
+ await mkdir7(dir);
8140
+ } catch (error) {
8141
+ if (error.code === "EEXIST")
8142
+ throw new Error(`Plan ${id} already exists.`);
8143
+ throw error;
8144
+ }
8145
+ }
8146
+ async function writeRevisionSnapshot(planDir, document, source, request, briefs) {
8147
+ if (!request.text.trim())
8148
+ throw new Error("An exact non-empty authoring request is required.");
8149
+ if (request.kind === "annotation" && !request.response?.trim())
8150
+ throw new Error("Annotation requests require a non-empty response.");
8151
+ const revisionsDir = join15(planDir, "revisions");
8152
+ await mkdir7(revisionsDir, { recursive: true });
8153
+ const target = join15(revisionsDir, String(document.revision));
8154
+ try {
8155
+ await readFile11(join15(target, "metadata.json"), "utf8");
8156
+ throw new Error(`Revision ${document.revision} already exists and is immutable.`);
8157
+ } catch (error) {
8158
+ if (error.code !== "ENOENT")
8159
+ throw error;
8160
+ }
8161
+ const staging = join15(revisionsDir, `.tmp-${document.revision}-${randomUUID2()}`);
8162
+ await mkdir7(staging);
8163
+ try {
8164
+ const requestSource = request.kind === "annotation" ? `## Original annotation
8165
+ ${request.text}
8166
+
8167
+ ## LLM response
8168
+ ${request.response}` : request.text;
8169
+ await atomicWrite(join15(staging, "request.md"), requestSource);
8170
+ await atomicWrite(join15(staging, "metadata.json"), `${JSON.stringify({
8171
+ schemaVersion: 1,
8172
+ id: document.id,
8173
+ revision: document.revision,
8174
+ requestKind: request.kind,
8175
+ requestSha256: sha256(request.text),
8176
+ ...request.kind === "annotation" ? { originalAnnotationSha256: sha256(request.text), responseSha256: sha256(request.response) } : {},
8177
+ createdAt: document.updatedAt
8178
+ }, null, 2)}
8179
+ `);
8180
+ await atomicWrite(join15(staging, "PLAN.md"), source);
8181
+ await writeImplementationDirectory(staging, document, briefs);
8182
+ await rename3(staging, target);
8183
+ } catch (error) {
8184
+ await rm2(staging, { recursive: true, force: true });
8185
+ throw error;
8186
+ }
8187
+ }
8188
+ async function writeLatestView(planDir, document, source, briefs) {
8189
+ const current = join15(planDir, "implementation");
8190
+ const stagingRoot = join15(planDir, `.latest-${randomUUID2()}`);
8191
+ const stagedImplementation = join15(stagingRoot, "implementation");
8192
+ const backup = join15(planDir, `.implementation-backup-${randomUUID2()}`);
8193
+ await mkdir7(stagingRoot);
8194
+ await writeImplementationDirectory(stagingRoot, document, briefs);
8195
+ let backedUp = false;
8196
+ try {
7678
8197
  try {
7679
- await readFile11(path, "utf8");
8198
+ await rename3(current, backup);
8199
+ backedUp = true;
7680
8200
  } catch (error) {
7681
8201
  if (error.code !== "ENOENT")
7682
8202
  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
8203
  }
7691
- }
8204
+ await rename3(stagedImplementation, current);
8205
+ await atomicWrite(join15(planDir, "PLAN.md"), source);
8206
+ if (backedUp)
8207
+ await rm2(backup, { recursive: true, force: true });
8208
+ } catch (error) {
8209
+ await rm2(current, { recursive: true, force: true });
8210
+ if (backedUp)
8211
+ await rename3(backup, current);
8212
+ throw error;
8213
+ } finally {
8214
+ await rm2(stagingRoot, { recursive: true, force: true });
8215
+ }
8216
+ }
8217
+ async function writeImplementationDirectory(baseDir, document, briefs) {
8218
+ const implementationDir = join15(baseDir, "implementation");
8219
+ await mkdir7(implementationDir, { recursive: true });
8220
+ for (const [index, phase] of document.phases.entries()) {
8221
+ const brief = briefs[index];
8222
+ if (!brief)
8223
+ throw new Error(`Missing implementation brief for phase ${phase.id}.`);
8224
+ await atomicWrite(join15(implementationDir, `phase-${index + 1}.md`), renderImplementationBrief(document.revision, index + 1, phase, brief));
8225
+ }
8226
+ }
8227
+ function normalizeRevision(current, request, id, revision, updatedAt) {
8228
+ if (current?.status === "completed")
8229
+ throw new Error("Completed plans cannot be amended.");
8230
+ if (current?.execution?.active && request.request.kind !== "blocker")
8231
+ throw new Error("An active execution accepts only a blocker amendment.");
8232
+ const currentPhases = new Map(current?.phases.map((phase) => [phase.id, phase]) ?? []);
8233
+ for (const phase of current?.phases ?? []) {
8234
+ if (!request.phases.some((candidate) => candidate.id === phase.id) && (phase.status === "completed" || phase.status === "in_progress"))
8235
+ throw new Error(`${phase.status === "completed" ? "Completed" : "Active"} phase ${phase.id} cannot be removed.`);
8236
+ }
8237
+ const phases = request.phases.map((draft) => reconcilePhase(currentPhases.get(draft.id), draft, current));
8238
+ const createdAt = current?.createdAt ?? updatedAt;
8239
+ return {
8240
+ schemaVersion: 1,
8241
+ id,
8242
+ revision,
8243
+ title: request.title,
8244
+ branch: current?.branch ?? (request.mode === "create" ? request.branch : ""),
8245
+ issueId: current?.issueId ?? (request.mode === "create" ? request.issueId : undefined),
8246
+ issueUrl: current?.issueUrl ?? (request.mode === "create" ? request.issueUrl : undefined),
8247
+ intent: request.intent,
8248
+ requirements: request.requirements,
8249
+ design: request.design,
8250
+ references: request.references,
8251
+ phases,
8252
+ status: nextPlanStatus(current),
8253
+ execution: current?.execution,
8254
+ createdAt,
8255
+ updatedAt
8256
+ };
8257
+ }
8258
+ function reconcilePhase(current, draft, plan) {
8259
+ if (!current)
8260
+ return {
8261
+ ...draft,
8262
+ revision: 0,
8263
+ status: "pending",
8264
+ gate: { phaseRevision: 0, status: "pending", results: [] },
8265
+ tasks: draft.tasks.map(newTask)
8266
+ };
8267
+ if (current.status === "completed") {
8268
+ if (JSON.stringify(phaseContent(current)) !== JSON.stringify(draft))
8269
+ throw new Error(`Completed phase ${current.id} cannot be changed.`);
8270
+ return current;
8271
+ }
8272
+ const byId = new Map(current.tasks.map((task) => [task.id, task]));
8273
+ for (const task of current.tasks) {
8274
+ if (!draft.tasks.some((candidate) => candidate.id === task.id) && (task.status === "completed" || task.status === "in_progress"))
8275
+ throw new Error(`${task.status === "completed" ? "Completed" : "Active"} task ${task.id} cannot be removed.`);
8276
+ }
8277
+ const tasks = draft.tasks.map((task) => reconcileTask(byId.get(task.id), task));
8278
+ const changed = JSON.stringify(phaseContent(current)) !== JSON.stringify(draft);
8279
+ const revision = changed ? current.revision + 1 : current.revision;
8280
+ const wasBlocked = current.status === "blocked";
8281
+ return {
8282
+ ...current,
8283
+ ...draft,
8284
+ revision,
8285
+ tasks,
8286
+ status: wasBlocked ? plan?.execution?.active ? "in_progress" : "pending" : current.status,
8287
+ gate: changed || wasBlocked ? { phaseRevision: revision, status: "stale", results: [] } : current.gate,
8288
+ blocker: wasBlocked ? undefined : current.blocker
8289
+ };
8290
+ }
8291
+ function reconcileTask(current, draft) {
8292
+ if (!current)
8293
+ return newTask(draft);
8294
+ const changed = JSON.stringify(taskContent(current)) !== JSON.stringify(draft);
8295
+ if ((current.status === "completed" || current.status === "in_progress") && changed)
8296
+ throw new Error(`${current.status === "completed" ? "Completed" : "Active"} task ${current.id} cannot be changed.`);
8297
+ if (current.status === "completed" || current.status === "in_progress")
8298
+ return current;
8299
+ return {
8300
+ ...current,
8301
+ ...draft,
8302
+ revision: changed ? current.revision + 1 : current.revision,
8303
+ status: current.status === "blocked" ? "pending" : current.status,
8304
+ owner: current.status === "blocked" ? undefined : current.owner,
8305
+ executionId: current.status === "blocked" ? undefined : current.executionId,
8306
+ blocker: current.status === "blocked" ? undefined : current.blocker
8307
+ };
8308
+ }
8309
+ function newTask(draft) {
8310
+ return { ...draft, revision: 0, status: "pending" };
8311
+ }
8312
+ function phaseContent(phase) {
8313
+ return {
8314
+ id: phase.id,
8315
+ title: phase.title,
8316
+ objective: phase.objective,
8317
+ dependencies: phase.dependencies,
8318
+ tasks: phase.tasks.map(taskContent)
8319
+ };
8320
+ }
8321
+ function taskContent(task) {
8322
+ return { id: task.id, title: task.title, dependencies: task.dependencies };
8323
+ }
8324
+ function nextPlanStatus(current) {
8325
+ if (!current)
8326
+ return "draft";
8327
+ if (current.execution?.active)
8328
+ return current.status === "blocked" ? "in_progress" : current.status;
8329
+ return current.status === "ready" || current.status === "blocked" ? "draft" : current.status;
8330
+ }
8331
+ function assertCompleteRevision(document, briefs) {
8332
+ const errors = [...validatePlanDocument(document, { strict: true }), ...validatePlanBriefs(document, briefs)].filter((issue) => issue.severity === "error");
8333
+ if (errors.length)
8334
+ throw new Error(`Invalid plan revision: ${errors.map((issue) => issue.message).join(" ")}`);
8335
+ }
8336
+ function sha256(value) {
8337
+ return createHash3("sha256").update(value).digest("hex");
7692
8338
  }
7693
8339
  async function assertContained(root, candidate) {
7694
8340
  const canonicalRoot = await realpath4(root);
@@ -7721,13 +8367,16 @@ function stripDate(value) {
7721
8367
  return value.replace(/^\d{6}-/, "");
7722
8368
  }
7723
8369
  function titleFromSlug(value) {
7724
- return normalizeSlug(value).replace(/^(?:[a-z]+-\d+-)/, "").split("-").map((part) => part[0].toUpperCase() + part.slice(1)).join(" ");
8370
+ const slug = normalizeSlug(value).replace(/^(?:[a-z]+-\d+-)/, "");
8371
+ return slug.replace(/(^|-)([a-z])/g, (_match, separator, letter) => {
8372
+ return `${separator ? " " : ""}${letter.toUpperCase()}`;
8373
+ });
7725
8374
  }
7726
8375
  // src/setup.ts
7727
8376
  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";
8377
+ import { readdir as readdir3, readFile as readFile12 } from "node:fs/promises";
8378
+ import { homedir as homedir8 } from "node:os";
8379
+ import { basename as basename7, join as join16 } from "node:path";
7731
8380
  var MISE_DEPENDENCIES = [
7732
8381
  { name: "node", tool: "node", spec: "node@22", minimumVersion: "22.19.0" },
7733
8382
  { name: "zellij", tool: "zellij", spec: "zellij@latest", minimumVersion: undefined },
@@ -7765,8 +8414,8 @@ var FORGE_DEPENDENCIES = {
7765
8414
  gitlab: { name: "glab", tool: "glab", spec: "glab@latest", minimumVersion: undefined }
7766
8415
  };
7767
8416
  async function ensureMise(options = {}) {
7768
- const homeDir = options.homeDir ?? homedir9();
7769
- const current = await mise.executableCheck() ?? await mise.executableCheck(join17(homeDir, ".local", "bin", "mise"));
8417
+ const homeDir = options.homeDir ?? homedir8();
8418
+ const current = await mise.executableCheck() ?? await mise.executableCheck(join16(homeDir, ".local", "bin", "mise"));
7770
8419
  if (current)
7771
8420
  return { executable: current, action: createSetupAction("mise", "ready", current) };
7772
8421
  reportProgress(options, "Installing mise");
@@ -7815,9 +8464,9 @@ async function ensureMiseDeps(miseExecutable, options = {}) {
7815
8464
  async function ensurePiPlugins(options = {}) {
7816
8465
  const actions = await ensurePiPackages(PI_PACKAGES, options);
7817
8466
  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);
8467
+ const webSearch = await pi.configEnsure(join16(agentDir, "web-search.json"), (config) => ({ ...config, workflow: "auto-summary" }), options.dryRun);
7819
8468
  actions.push(getConfigSetupAction("web search settings", webSearch));
7820
- const lsp = await pi.configEnsure(join17(agentDir, "pi-lsp.json"), (config) => ({
8469
+ const lsp = await pi.configEnsure(join16(agentDir, "pi-lsp.json"), (config) => ({
7821
8470
  ...config,
7822
8471
  progressive: { ...getRecord(config.progressive), enabled: true, inject: "none" }
7823
8472
  }), options.dryRun);
@@ -7828,11 +8477,11 @@ async function ensurePiAgents(options = {}) {
7828
8477
  const agentDir = options.agentDir ?? pi.agentDir(options.homeDir);
7829
8478
  const bundledAgentsDir = options.bundledAgentsDir ?? BUNDLED_AGENTS_DIR2;
7830
8479
  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));
8480
+ 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
8481
  const actions = [];
7833
8482
  for (const entry of entries) {
7834
8483
  const id = basename7(entry.name, ".md").replace(/^diffpi-/, "");
7835
- const source = await readFile12(join17(bundledAgentsDir, entry.name), "utf8");
8484
+ const source = await readFile12(join16(bundledAgentsDir, entry.name), "utf8");
7836
8485
  const content = materializeAgentModels(source, id, userConfig.config, options.availableModels);
7837
8486
  const result = await pi.agentEnsure(entry.name, content, agentDir, options.dryRun);
7838
8487
  actions.push(getConfigSetupAction(`pi agent ${id}`, result));
@@ -7841,7 +8490,7 @@ async function ensurePiAgents(options = {}) {
7841
8490
  }
7842
8491
  async function ensurePiSkills(miseExecutable, options = {}) {
7843
8492
  const agentDir = options.agentDir ?? pi.agentDir(options.homeDir);
7844
- const sharedSkillsDir = join17(options.homeDir ?? homedir9(), ".agents", "skills");
8493
+ const sharedSkillsDir = join16(options.homeDir ?? homedir8(), ".agents", "skills");
7845
8494
  const actions = [];
7846
8495
  for (const source of PI_SKILL_SOURCES) {
7847
8496
  const missing = [];
@@ -8037,7 +8686,7 @@ function reportProgress(options, message) {
8037
8686
  options.onProgress?.(message);
8038
8687
  }
8039
8688
  async function packageVersion2() {
8040
- const source = await readFile12(join17(resolveBundledAgentsDir(), "..", "package.json"), "utf8");
8689
+ const source = await readFile12(join16(resolveBundledAgentsDir(), "..", "package.json"), "utf8");
8041
8690
  let value;
8042
8691
  try {
8043
8692
  value = JSON.parse(source);
@@ -8051,6 +8700,41 @@ async function packageVersion2() {
8051
8700
  function getRecord(value) {
8052
8701
  return value !== null && typeof value === "object" && !Array.isArray(value) ? value : {};
8053
8702
  }
8703
+ // src/templates.ts
8704
+ import { readFile as readFile13 } from "node:fs/promises";
8705
+ import { homedir as homedir9 } from "node:os";
8706
+ import { join as join17, normalize } from "node:path";
8707
+ async function loadTemplate(name, options = {}) {
8708
+ const relative = templateRelativePath(name);
8709
+ const userPath = join17(options.homeDir ?? homedir9(), ".difflab", "diffpi", "templates", relative);
8710
+ const bundledPath = join17(options.bundledDir ?? resolveBundledTemplatesDir(), relative);
8711
+ const user = await readOptionalFile(userPath);
8712
+ if (user !== undefined)
8713
+ return { name, path: userPath, source: "user", content: user };
8714
+ const bundled = await readOptionalFile(bundledPath);
8715
+ if (bundled !== undefined)
8716
+ return { name, path: bundledPath, source: "bundled", content: bundled };
8717
+ throw new Error(`Template "${name}" was not found at ${userPath} or ${bundledPath}.`);
8718
+ }
8719
+ function renderTemplate(content, variables) {
8720
+ return content.replace(/\{\{\s*([a-zA-Z0-9_.-]+)\s*\}\}/g, (match, key) => variables[key] ?? match);
8721
+ }
8722
+ function templateRelativePath(name) {
8723
+ const normalized = normalize(name.replaceAll("\\", "/")).replace(/^\.\//, "");
8724
+ if (!normalized || normalized.startsWith("../") || normalized.includes("/../") || normalized.startsWith("/")) {
8725
+ throw new Error(`Invalid template name: ${name}`);
8726
+ }
8727
+ return normalized.endsWith(".md") ? normalized : `${normalized}.md`;
8728
+ }
8729
+ async function readOptionalFile(path) {
8730
+ try {
8731
+ return await readFile13(path, "utf8");
8732
+ } catch (error) {
8733
+ if (error.code === "ENOENT")
8734
+ return;
8735
+ throw error;
8736
+ }
8737
+ }
8054
8738
  export {
8055
8739
  CONVENTIONAL_COMMIT,
8056
8740
  GitHubVcsBackend,
@@ -8062,11 +8746,11 @@ export {
8062
8746
  addComment,
8063
8747
  assertGitHubMergeReady,
8064
8748
  assertReviewEventSupported,
8065
- captureLocalReview,
8066
8749
  checkConventionalSubject,
8067
8750
  computeProjectSlug,
8068
8751
  createForge,
8069
8752
  createForgeBackend,
8753
+ createLocalReviewBackend,
8070
8754
  createModeController,
8071
8755
  createPlanStore,
8072
8756
  createRemoteReviewBackend,
@@ -8099,6 +8783,7 @@ export {
8099
8783
  launch,
8100
8784
  listSessions,
8101
8785
  loadDiffpiConfig,
8786
+ loadReviewPublicationState,
8102
8787
  loadTemplate,
8103
8788
  localReviewAuthor,
8104
8789
  mcp,
@@ -8107,28 +8792,37 @@ export {
8107
8792
  openInNewTab,
8108
8793
  parseGitlabDiffRefs,
8109
8794
  parseRemote,
8795
+ parseThreadArtifact,
8110
8796
  pi,
8111
8797
  plansDir,
8112
- readLocalReview,
8113
8798
  readSession,
8114
8799
  renderReviewDoc,
8115
8800
  renderTemplate,
8801
+ renderThreadArtifact,
8116
8802
  resolveAgentMode,
8117
8803
  resolveAgentModelPreferences,
8118
8804
  resolvePrSession,
8119
8805
  resolveReviewSession,
8120
8806
  resolveSession,
8807
+ reviewBodyFingerprint,
8808
+ reviewCommentFingerprint,
8121
8809
  reviewRecordName,
8810
+ reviewReplyFingerprint,
8122
8811
  reviewSlug,
8123
8812
  reviewsDir,
8124
8813
  runMiseGates,
8814
+ saveReviewPublicationState,
8815
+ sessionsDir,
8125
8816
  setupPi,
8126
8817
  severitySchema,
8127
8818
  storeDir,
8128
8819
  storeGlobalRoot,
8129
8820
  templateRelativePath,
8821
+ toFindings,
8130
8822
  toReviewComments,
8131
8823
  tuicrAvailable,
8824
+ unpublishedReviewComments,
8825
+ upsertThreadReply,
8132
8826
  withRemoteProvenance,
8133
8827
  yymmdd,
8134
8828
  zedKeymapPath,