@difflab/pi 0.4.0 → 0.5.0-rc.202609231313.eaf7fa0

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 (128) hide show
  1. package/README.md +9 -9
  2. package/agents/diffpi-copilot.md +6 -3
  3. package/agents/diffpi-orchestrator.md +1 -1
  4. package/agents/diffpi-planner.md +3 -3
  5. package/agents/diffpi-reviewer.md +3 -3
  6. package/agents/diffpi-worker.md +2 -2
  7. package/dist/assets.d.ts +1 -0
  8. package/dist/assets.d.ts.map +1 -1
  9. package/dist/cli/plan.d.ts +7 -3
  10. package/dist/cli/plan.d.ts.map +1 -1
  11. package/dist/cli.js +306 -811
  12. package/dist/commands/plan.d.ts +0 -1
  13. package/dist/commands/plan.d.ts.map +1 -1
  14. package/dist/commands/review.d.ts +0 -1
  15. package/dist/commands/review.d.ts.map +1 -1
  16. package/dist/commands.d.ts +22 -0
  17. package/dist/commands.d.ts.map +1 -0
  18. package/dist/config.d.ts.map +1 -1
  19. package/dist/environment.d.ts +2 -1
  20. package/dist/environment.d.ts.map +1 -1
  21. package/dist/extensions/cmdtsx.d.ts +2 -0
  22. package/dist/extensions/cmdtsx.d.ts.map +1 -0
  23. package/dist/extensions/fsx.d.ts +8 -0
  24. package/dist/extensions/fsx.d.ts.map +1 -0
  25. package/dist/extensions/index.js +5057 -3069
  26. package/dist/extensions/processx.d.ts +1 -0
  27. package/dist/extensions/processx.d.ts.map +1 -1
  28. package/dist/extensions/subagentx.d.ts +1 -1
  29. package/dist/extensions/subagentx.d.ts.map +1 -1
  30. package/dist/extensions/tuicrx.d.ts +1 -8
  31. package/dist/extensions/tuicrx.d.ts.map +1 -1
  32. package/dist/extensions/zodx.d.ts +8 -0
  33. package/dist/extensions/zodx.d.ts.map +1 -0
  34. package/dist/index.d.ts +10 -11
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +352 -1149
  37. package/dist/plan/index.d.ts +2 -5
  38. package/dist/plan/index.d.ts.map +1 -1
  39. package/dist/plan/markdown.d.ts.map +1 -1
  40. package/dist/plan/operations.d.ts +43 -0
  41. package/dist/plan/operations.d.ts.map +1 -0
  42. package/dist/plan/reviews.d.ts +28 -0
  43. package/dist/plan/reviews.d.ts.map +1 -0
  44. package/dist/plan/store.d.ts +6 -3
  45. package/dist/plan/store.d.ts.map +1 -1
  46. package/dist/plan/types.d.ts +3 -22
  47. package/dist/plan/types.d.ts.map +1 -1
  48. package/dist/review/github-review-backend.d.ts.map +1 -1
  49. package/dist/review/gitlab-review-backend.d.ts.map +1 -1
  50. package/dist/review/index.d.ts +6 -6
  51. package/dist/review/index.d.ts.map +1 -1
  52. package/dist/review/local-reviews.d.ts +31 -0
  53. package/dist/review/local-reviews.d.ts.map +1 -0
  54. package/dist/review/review-backend.d.ts +2 -3
  55. package/dist/review/review-backend.d.ts.map +1 -1
  56. package/dist/review/review-markdown.d.ts +1 -4
  57. package/dist/review/review-markdown.d.ts.map +1 -1
  58. package/dist/review/types.d.ts +0 -28
  59. package/dist/review/types.d.ts.map +1 -1
  60. package/dist/store.d.ts +0 -2
  61. package/dist/store.d.ts.map +1 -1
  62. package/dist/tools/ci.d.ts +6 -0
  63. package/dist/tools/ci.d.ts.map +1 -0
  64. package/dist/tools/index.d.ts +1 -0
  65. package/dist/tools/index.d.ts.map +1 -1
  66. package/dist/tools/index.js +1887 -2805
  67. package/dist/tools/log.d.ts.map +1 -1
  68. package/dist/tools/modes.d.ts.map +1 -1
  69. package/dist/tools/plan-schema.d.ts +211 -0
  70. package/dist/tools/plan-schema.d.ts.map +1 -0
  71. package/dist/tools/plan.d.ts +2 -2
  72. package/dist/tools/plan.d.ts.map +1 -1
  73. package/dist/tools/review.d.ts +0 -30
  74. package/dist/tools/review.d.ts.map +1 -1
  75. package/dist/workflow.d.ts +80 -0
  76. package/dist/workflow.d.ts.map +1 -0
  77. package/package.json +4 -2
  78. package/skills/plan/SKILL.md +15 -7
  79. package/skills/review/SKILL.md +6 -8
  80. package/templates/plan/PLAN.md +2 -0
  81. package/workflows/plan/annotate.md +13 -0
  82. package/workflows/plan/finalize.md +15 -0
  83. package/workflows/plan/go.md +20 -0
  84. package/{skills/plan/references/workflows → workflows/plan}/help.md +11 -4
  85. package/workflows/plan/init.md +13 -0
  86. package/workflows/plan/new.md +20 -0
  87. package/workflows/plan/update.md +18 -0
  88. package/workflows/review/address.md +8 -0
  89. package/workflows/review/auto.md +8 -0
  90. package/workflows/review/complete.md +7 -0
  91. package/workflows/review/edit.md +7 -0
  92. package/workflows/review/help.md +14 -0
  93. package/workflows/review/merge.md +6 -0
  94. package/workflows/review/new.md +7 -0
  95. package/workflows/review/publish.md +6 -0
  96. package/dist/plan/annotations.d.ts +0 -25
  97. package/dist/plan/annotations.d.ts.map +0 -1
  98. package/dist/plan/controller.d.ts +0 -65
  99. package/dist/plan/controller.d.ts.map +0 -1
  100. package/dist/plan/execution.d.ts +0 -13
  101. package/dist/plan/execution.d.ts.map +0 -1
  102. package/dist/plan/ids.d.ts +0 -5
  103. package/dist/plan/ids.d.ts.map +0 -1
  104. package/dist/plan/lock.d.ts +0 -11
  105. package/dist/plan/lock.d.ts.map +0 -1
  106. package/dist/plan/log.d.ts +0 -6
  107. package/dist/plan/log.d.ts.map +0 -1
  108. package/dist/plan/parser.d.ts +0 -18
  109. package/dist/plan/parser.d.ts.map +0 -1
  110. package/dist/review/local-review-backend.d.ts +0 -3
  111. package/dist/review/local-review-backend.d.ts.map +0 -1
  112. package/dist/review/review-state.d.ts +0 -20
  113. package/dist/review/review-state.d.ts.map +0 -1
  114. package/skills/plan/references/workflows/annotate.md +0 -6
  115. package/skills/plan/references/workflows/finalize.md +0 -7
  116. package/skills/plan/references/workflows/go.md +0 -8
  117. package/skills/plan/references/workflows/init.md +0 -6
  118. package/skills/plan/references/workflows/new.md +0 -8
  119. package/skills/plan/references/workflows/update.md +0 -7
  120. package/skills/review/references/workflows/address.md +0 -11
  121. package/skills/review/references/workflows/auto.md +0 -18
  122. package/skills/review/references/workflows/complete.md +0 -7
  123. package/skills/review/references/workflows/edit.md +0 -7
  124. package/skills/review/references/workflows/help.md +0 -30
  125. package/skills/review/references/workflows/merge.md +0 -6
  126. package/skills/review/references/workflows/new.md +0 -19
  127. package/skills/review/references/workflows/open.md +0 -3
  128. package/skills/review/references/workflows/publish.md +0 -8
package/dist/index.js CHANGED
@@ -176,10 +176,19 @@ var require_extend = __commonJS(function(exports, module) {
176
176
 
177
177
  // src/config.ts
178
178
  import { parseFrontmatter } from "@earendil-works/pi-coding-agent";
179
- import { z } from "zod";
179
+ import { z as z2 } from "zod";
180
180
  import { homedir } from "node:os";
181
181
  import { join } from "node:path";
182
182
 
183
+ // src/extensions/zodx.ts
184
+ import { z } from "zod";
185
+ var zx = {
186
+ cwd: z.string().optional(),
187
+ id: z.string().min(1).max(80).regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),
188
+ revision: z.number().int().nonnegative(),
189
+ text: z.string().trim().min(1)
190
+ };
191
+
183
192
  // src/fsx.ts
184
193
  import { readdir, readFile } from "node:fs/promises";
185
194
  async function readDirectoryIfExists(path) {
@@ -205,12 +214,12 @@ function isMissingPath(error) {
205
214
  }
206
215
 
207
216
  // src/config.ts
208
- var modelReferenceSchema = z.string().trim().min(1);
209
- var agentConfigSchema = z.object({
210
- models: z.array(modelReferenceSchema).optional()
217
+ var modelReferenceSchema = zx.text;
218
+ var agentConfigSchema = z2.object({
219
+ models: z2.array(modelReferenceSchema).optional()
211
220
  }).strict();
212
- var diffpiConfigSchema = z.object({
213
- agents: z.record(z.string(), agentConfigSchema).optional()
221
+ var diffpiConfigSchema = z2.object({
222
+ agents: z2.record(z2.string(), agentConfigSchema).optional()
214
223
  }).strict();
215
224
  function diffpiConfigPaths(homeDir = homedir()) {
216
225
  const directory = join(homeDir, ".difflab", "diffpi");
@@ -320,7 +329,11 @@ function run(command, args, options = {}) {
320
329
  const child = spawn(command, args, {
321
330
  cwd: options.cwd,
322
331
  env: options.env ?? process.env,
323
- stdio: [options.input === undefined ? "ignore" : "pipe", "pipe", "pipe"],
332
+ stdio: [
333
+ options.interactive ? "inherit" : options.input === undefined ? "ignore" : "pipe",
334
+ options.interactive ? "inherit" : "pipe",
335
+ "pipe"
336
+ ],
324
337
  signal: options.signal
325
338
  });
326
339
  let stdout = "";
@@ -337,6 +350,8 @@ function run(command, args, options = {}) {
337
350
  });
338
351
  child.stderr?.on("data", (chunk) => {
339
352
  const text = chunk.toString();
353
+ if (options.interactive)
354
+ process.stderr.write(text);
340
355
  if (unbounded)
341
356
  stderrChunks.push(text);
342
357
  else
@@ -588,6 +603,29 @@ function parseRemote(remote) {
588
603
  function diffpiLaunchName(cwd, workflow) {
589
604
  return `diffpi: ${basename(cwd)} / ${workflow}`;
590
605
  }
606
+ async function openFileAdjacent(path, opts) {
607
+ const env = opts.env ?? process.env;
608
+ const ide = detectIde(env);
609
+ if (ide !== "unknown") {
610
+ const ideCommand = { zed: "zed", vscode: "code", cursor: "cursor", windsurf: "windsurf", jetbrains: "idea" }[ide];
611
+ if (await findExecutable(ideCommand)) {
612
+ const result = await run(ideCommand, [path], { cwd: opts.cwd, env });
613
+ if (result.code === 0)
614
+ return { launched: true, via: ide, command: `${ideCommand} ${path}` };
615
+ }
616
+ }
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
+ return { launched: false, via: "print", command: path, reason: "No supported IDE or multiplexer was available." };
628
+ }
591
629
  async function openInNewTab(command, opts) {
592
630
  const env = opts.env ?? process.env;
593
631
  const name = opts.name ?? "review";
@@ -620,6 +658,15 @@ async function openInNewTab(command, opts) {
620
658
  function screenWindowArgs(command, cwd, name) {
621
659
  return ["-X", "screen", "-t", name, "sh", "-lc", 'cd -- "$1" && shift && exec "$@"', "sh", cwd, ...command];
622
660
  }
661
+ 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 candidate of ["hx", "nvim", "vim"])
666
+ if (await findExecutable(candidate))
667
+ return candidate;
668
+ return;
669
+ }
623
670
  async function packageVersion() {
624
671
  let value;
625
672
  try {
@@ -1232,16 +1279,16 @@ async function discoverMiseTasks(cwd) {
1232
1279
  return parseMiseTasks(result.stdout);
1233
1280
  }
1234
1281
  // src/review/types.ts
1235
- import { z as z2 } from "zod";
1236
- var severitySchema = z2.enum(["BLOCKING", "CONSIDER", "NOTE"]);
1237
- var findingSchema = z2.object({
1238
- file: z2.string().min(1),
1239
- line: z2.number().int().nonnegative(),
1282
+ import { z as z3 } from "zod";
1283
+ var severitySchema = z3.enum(["BLOCKING", "CONSIDER", "NOTE"]);
1284
+ var findingSchema = z3.object({
1285
+ file: z3.string().min(1),
1286
+ line: z3.number().int().nonnegative(),
1240
1287
  severity: severitySchema,
1241
- body: z2.string().min(1),
1242
- reference: z2.string().optional().default("")
1288
+ body: z3.string().min(1),
1289
+ reference: z3.string().optional().default("")
1243
1290
  });
1244
- var findingsSchema = z2.array(findingSchema);
1291
+ var findingsSchema = z3.array(findingSchema);
1245
1292
  function reviewSlug(input) {
1246
1293
  return input.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 50);
1247
1294
  }
@@ -1254,9 +1301,6 @@ function yymmdd(date = new Date) {
1254
1301
  function reviewRecordName(target, date = new Date) {
1255
1302
  return `${yymmdd(date)}-${reviewSlug(target) || "uncommitted"}`;
1256
1303
  }
1257
- function isLocalResponse(body) {
1258
- return /^<!-- diffpi(?:-local-response:[A-Za-z0-9_-]+)? -->\n/m.test(body);
1259
- }
1260
1304
  function dedupeFindings(findings) {
1261
1305
  const rank = { BLOCKING: 3, CONSIDER: 2, NOTE: 1 };
1262
1306
  const byKey = new Map;
@@ -1302,18 +1346,6 @@ function renderCommentBody(finding) {
1302
1346
  return `${prefix}${finding.body}${reference}`;
1303
1347
  }
1304
1348
  // src/review/review-markdown.ts
1305
- import { z as z3 } from "zod";
1306
- var reviewThreadRecordSchema = z3.object({
1307
- id: z3.string().min(1),
1308
- file: z3.string().nullable().optional().transform((value) => value ?? undefined),
1309
- line: z3.number().int().positive().nullable().optional().transform((value) => value ?? undefined),
1310
- body: z3.string(),
1311
- author: z3.string().optional(),
1312
- resolved: z3.boolean(),
1313
- addressed: z3.boolean().optional(),
1314
- question: z3.boolean(),
1315
- replies: z3.array(z3.string()).optional()
1316
- });
1317
1349
  function renderReviewDoc(input) {
1318
1350
  const anchored = dedupeFindings(input.findings).filter((finding) => finding.line > 0);
1319
1351
  const lines = [`# Review: ${input.title}`, "", "## Metadata"];
@@ -1355,88 +1387,6 @@ function renderReviewDoc(input) {
1355
1387
  `).trimEnd()}
1356
1388
  `;
1357
1389
  }
1358
- function renderThreadArtifact(title, target, threads, options = {}) {
1359
- const records = threads.map((thread) => ({
1360
- id: thread.id,
1361
- file: thread.file,
1362
- line: thread.line,
1363
- body: thread.body,
1364
- author: thread.author,
1365
- resolved: thread.resolved,
1366
- addressed: thread.addressed,
1367
- question: thread.question,
1368
- replies: thread.replies
1369
- }));
1370
- const payload = Buffer.from(JSON.stringify(records), "utf8").toString("base64url");
1371
- const lines = [
1372
- `<!-- diffpi-threads:${payload} -->`,
1373
- `# Review threads: ${title}`,
1374
- "",
1375
- "## Metadata",
1376
- "",
1377
- `- Target: ${target}`,
1378
- `- Pulled: ${options.timestamp ?? new Date().toISOString()}`
1379
- ];
1380
- if (options.number !== undefined)
1381
- lines.push(`- PR/MR: #${options.number}${options.url ? ` — ${options.url}` : ""}`);
1382
- lines.push("", "## Replies", "");
1383
- for (const thread of threads) {
1384
- const id = Buffer.from(thread.id, "utf8").toString("base64url");
1385
- lines.push(`### ${thread.file ?? "review"}:${thread.line ?? "n/a"} (${thread.id})`, "", `<!-- diffpi-reply-start:${id} -->`, thread.reply ?? "", `<!-- diffpi-reply-end:${id} -->`, "");
1386
- }
1387
- lines.push("## Source comments", "");
1388
- for (const thread of threads) {
1389
- lines.push(`### ${thread.file ?? "review"}:${thread.line ?? "n/a"} — ${thread.author ?? "unknown"}`, "", `Thread: ${thread.id}`, `Status: ${thread.resolved ? "resolved" : thread.addressed ? "addressed" : "open"}`, "", thread.body, "", "---", "");
1390
- }
1391
- return `${lines.join(`
1392
- `).trimEnd()}
1393
- `;
1394
- }
1395
- function parseThreadArtifact(content) {
1396
- const payload = content.match(/^<!-- diffpi-threads:([A-Za-z0-9_-]+) -->$/m)?.[1];
1397
- if (!payload)
1398
- throw new Error("This file is not a Diffpi thread artifact.");
1399
- let threads;
1400
- try {
1401
- const decoded = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
1402
- threads = z3.array(reviewThreadRecordSchema).parse(decoded);
1403
- } catch {
1404
- throw new Error("Cannot parse the Diffpi thread artifact payload.");
1405
- }
1406
- const replies = content.split(/^## Source comments$/m, 1)[0] ?? "";
1407
- return threads.map((thread) => {
1408
- const id = Buffer.from(thread.id, "utf8").toString("base64url");
1409
- const startMarker = `<!-- diffpi-reply-start:${id} -->
1410
- `;
1411
- const endMarker = `
1412
- <!-- diffpi-reply-end:${id} -->`;
1413
- const start = replies.indexOf(startMarker);
1414
- const end = start < 0 ? -1 : replies.indexOf(endMarker, start + startMarker.length);
1415
- const reply = start >= 0 && end >= 0 ? replies.slice(start + startMarker.length, end).trim() : "";
1416
- return reply ? { ...thread, reply } : thread;
1417
- });
1418
- }
1419
- function upsertThreadReply(content, threadId, body, question, resolved) {
1420
- const threads = parseThreadArtifact(content);
1421
- const thread = threads.find((candidate) => candidate.id === threadId);
1422
- if (!thread)
1423
- throw new Error(`Review thread ${threadId} was not found in the local artifact.`);
1424
- thread.reply = body;
1425
- thread.addressed = true;
1426
- if (question !== undefined)
1427
- thread.question = question;
1428
- if (resolved !== undefined)
1429
- thread.resolved = resolved;
1430
- const title = content.match(/^# Review threads: (.+)$/m)?.[1] ?? "review";
1431
- const target = content.match(/^- Target: (.+)$/m)?.[1] ?? "local";
1432
- const timestamp = content.match(/^- Pulled: (.+)$/m)?.[1];
1433
- const pr = content.match(/^- PR\/MR: #(\d+)(?: — (.+))?$/m);
1434
- return renderThreadArtifact(title, target, threads, {
1435
- timestamp,
1436
- number: pr ? Number.parseInt(pr[1], 10) : undefined,
1437
- url: pr?.[2]
1438
- });
1439
- }
1440
1390
  // src/review/github-review-backend.ts
1441
1391
  function GitHubReviewBackend(vcs, number) {
1442
1392
  const pendingReview = async () => {
@@ -1498,9 +1448,6 @@ function GitHubReviewBackend(vcs, number) {
1498
1448
  id: thread.id,
1499
1449
  file: thread.path ?? undefined,
1500
1450
  line: thread.line ?? undefined,
1501
- rootCommentId: comment?.id,
1502
- commentIds: nodes.flatMap((node) => [node.id, node.databaseId?.toString()].filter((id) => Boolean(id))),
1503
- commentNodeIds: nodes.flatMap((node) => node.id ? [node.id] : []),
1504
1451
  body,
1505
1452
  author: comment?.author?.login,
1506
1453
  resolved: thread.isResolved,
@@ -1510,7 +1457,6 @@ function GitHubReviewBackend(vcs, number) {
1510
1457
  });
1511
1458
  };
1512
1459
  return {
1513
- kind: "remote",
1514
1460
  async stage(draft) {
1515
1461
  const pending = await pendingReview();
1516
1462
  if (!pending) {
@@ -1568,33 +1514,6 @@ function GitHubReviewBackend(vcs, number) {
1568
1514
  await ghChecked(args);
1569
1515
  }
1570
1516
  },
1571
- async readDraft() {
1572
- const pending = await pendingReview();
1573
- if (!pending)
1574
- return { comments: [], body: "" };
1575
- const review = await ghChecked(["api", `/repos/${vcs.owner}/${vcs.repo}/pulls/${number}/reviews/${pending.id}`]);
1576
- const comments = await ghChecked([
1577
- "api",
1578
- `/repos/${vcs.owner}/${vcs.repo}/pulls/${number}/reviews/${pending.id}/comments`
1579
- ]);
1580
- let reviewData;
1581
- let commentData;
1582
- try {
1583
- reviewData = JSON.parse(review.stdout);
1584
- commentData = JSON.parse(comments.stdout);
1585
- } catch {
1586
- throw new Error("Cannot parse the pending GitHub review as JSON.");
1587
- }
1588
- return {
1589
- body: reviewData.body ?? "",
1590
- comments: commentData.map((comment) => ({
1591
- file: comment.path,
1592
- line: comment.line ?? comment.original_line ?? undefined,
1593
- side: comment.side ?? undefined,
1594
- body: comment.body
1595
- }))
1596
- };
1597
- },
1598
1517
  listThreads,
1599
1518
  async reply(input) {
1600
1519
  const threads = await listThreads();
@@ -1611,32 +1530,14 @@ function GitHubReviewBackend(vcs, number) {
1611
1530
  `body=${input.body}`
1612
1531
  ]);
1613
1532
  }
1614
- if (input.resolve)
1615
- await this.setResolved?.(input.threadId, true);
1616
- },
1617
- async setResolved(threadId, resolved) {
1618
- await ghChecked([
1619
- "api",
1620
- "graphql",
1621
- "-f",
1622
- `query=${resolved ? GITHUB_RESOLVE_MUTATION : GITHUB_UNRESOLVE_MUTATION}`,
1623
- "-f",
1624
- `threadId=${threadId}`
1625
- ]);
1626
- },
1627
- async deleteThread(threadId) {
1628
- const thread = (await listThreads()).find((candidate) => candidate.id === threadId);
1629
- const commentNodeIds = thread?.commentNodeIds ?? (thread?.rootCommentId ? [thread.rootCommentId] : []);
1630
- if (!commentNodeIds.length)
1631
- throw new Error(`Cannot delete GitHub thread ${threadId}: comment node IDs are missing.`);
1632
- for (const commentId of commentNodeIds) {
1533
+ if (input.resolve) {
1633
1534
  await ghChecked([
1634
1535
  "api",
1635
1536
  "graphql",
1636
1537
  "-f",
1637
- `query=${GITHUB_DELETE_COMMENT_MUTATION}`,
1538
+ `query=${GITHUB_RESOLVE_MUTATION}`,
1638
1539
  "-f",
1639
- `commentId=${commentId}`
1540
+ `threadId=${input.threadId}`
1640
1541
  ]);
1641
1542
  }
1642
1543
  },
@@ -1655,13 +1556,11 @@ function GitHubReviewBackend(vcs, number) {
1655
1556
  function githubReviewSubmissionEndpoint(owner, repo, id, pendingReviewId) {
1656
1557
  return pendingReviewId ? `/repos/${owner}/${repo}/pulls/${id}/reviews/${pendingReviewId}/events` : `/repos/${owner}/${repo}/pulls/${id}/reviews`;
1657
1558
  }
1658
- 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}}}}}`;
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}}}}}`;
1659
1560
  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}}}`;
1660
1561
  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}}}`;
1661
1562
  var GITHUB_REPLY_MUTATION = `mutation($threadId:ID!,$body:String!){addPullRequestReviewThreadReply(input:{pullRequestReviewThreadId:$threadId,body:$body}){comment{id}}}`;
1662
1563
  var GITHUB_RESOLVE_MUTATION = `mutation($threadId:ID!){resolveReviewThread(input:{threadId:$threadId}){thread{isResolved}}}`;
1663
- var GITHUB_UNRESOLVE_MUTATION = `mutation($threadId:ID!){unresolveReviewThread(input:{threadId:$threadId}){thread{isResolved}}}`;
1664
- var GITHUB_DELETE_COMMENT_MUTATION = `mutation($commentId:ID!){deletePullRequestReviewComment(input:{id:$commentId}){clientMutationId}}`;
1665
1564
 
1666
1565
  // src/review/gitlab-review-backend.ts
1667
1566
  function GitLabReviewBackend(vcs, number) {
@@ -1698,7 +1597,6 @@ function GitLabReviewBackend(vcs, number) {
1698
1597
  });
1699
1598
  };
1700
1599
  return {
1701
- kind: "remote",
1702
1600
  async stage(draft) {
1703
1601
  const endpoint = `${mergeRequestEndpoint()}/draft_notes`;
1704
1602
  if (draft.body.trim()) {
@@ -1727,36 +1625,6 @@ function GitLabReviewBackend(vcs, number) {
1727
1625
  });
1728
1626
  }
1729
1627
  },
1730
- async readDraft() {
1731
- const result = await glabChecked(["api", `${mergeRequestEndpoint()}/draft_notes`]);
1732
- let notes;
1733
- try {
1734
- notes = JSON.parse(result.stdout);
1735
- } catch {
1736
- throw new Error("Cannot parse GitLab draft notes as JSON.");
1737
- }
1738
- const comments = [];
1739
- const body = [];
1740
- for (const note of notes) {
1741
- if (!note.note)
1742
- continue;
1743
- const file = note.position?.new_path ?? note.position?.old_path;
1744
- const line = note.position?.new_line ?? note.position?.old_line;
1745
- if (file && line) {
1746
- comments.push({
1747
- file,
1748
- line,
1749
- side: note.position?.new_line ? "RIGHT" : "LEFT",
1750
- body: note.note
1751
- });
1752
- } else {
1753
- body.push(note.note);
1754
- }
1755
- }
1756
- return { comments, body: body.join(`
1757
-
1758
- `) };
1759
- },
1760
1628
  listThreads,
1761
1629
  async reply(input) {
1762
1630
  const endpoint = `${mergeRequestEndpoint()}/discussions/${encodeURIComponent(input.threadId)}`;
@@ -1768,15 +1636,7 @@ function GitLabReviewBackend(vcs, number) {
1768
1636
  });
1769
1637
  }
1770
1638
  if (input.resolve)
1771
- await this.setResolved?.(input.threadId, true);
1772
- },
1773
- async setResolved(threadId, resolved) {
1774
- const endpoint = `${mergeRequestEndpoint()}/discussions/${encodeURIComponent(threadId)}`;
1775
- await glabChecked(["api", "--method", "PUT", `${endpoint}?resolved=${resolved}`]);
1776
- },
1777
- async deleteThread(threadId) {
1778
- const endpoint = `${mergeRequestEndpoint()}/discussions/${encodeURIComponent(threadId)}`;
1779
- await glabChecked(["api", "--method", "DELETE", endpoint]);
1639
+ await glabChecked(["api", "--method", "PUT", `${endpoint}?resolved=true`]);
1780
1640
  },
1781
1641
  async publish(event) {
1782
1642
  assertReviewEventSupported(vcs.provider, event);
@@ -1816,12 +1676,105 @@ function hasGitlabDraftNotes(input) {
1816
1676
  }
1817
1677
  }
1818
1678
 
1819
- // src/review/local-review-backend.ts
1820
- import { readFile as readFile6, writeFile as writeFile3 } from "node:fs/promises";
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
+ }
1821
1774
 
1822
1775
  // src/extensions/tuicrx.ts
1823
- import { readFile as readFile5, realpath as realpath2 } from "node:fs/promises";
1824
- import { resolve as resolve2 } from "node:path";
1776
+ import { readFile as readFile5, realpath as realpath3 } from "node:fs/promises";
1777
+ import { resolve as resolve3 } from "node:path";
1825
1778
  async function tuicrAvailable() {
1826
1779
  return Boolean(await findExecutable("tuicr"));
1827
1780
  }
@@ -1897,7 +1850,7 @@ async function resolvePrSession(cwd, owner, repo, number) {
1897
1850
  });
1898
1851
  }
1899
1852
  async function findMatchingSession(sessions, cwd, branch) {
1900
- const repository = await canonicalPath2(await gitToplevel(cwd));
1853
+ const repository = await canonicalPath3(await gitToplevel(cwd));
1901
1854
  for (const session of sessions) {
1902
1855
  if (session.kind !== "local")
1903
1856
  continue;
@@ -1905,7 +1858,7 @@ async function findMatchingSession(sessions, cwd, branch) {
1905
1858
  const data = await readSession(session.path);
1906
1859
  if (data.branch_name !== branch || !data.repo_path)
1907
1860
  continue;
1908
- if (await canonicalPath2(data.repo_path) === repository)
1861
+ if (await canonicalPath3(data.repo_path) === repository)
1909
1862
  return session;
1910
1863
  } catch {}
1911
1864
  }
@@ -1919,53 +1872,33 @@ async function readSession(path) {
1919
1872
  throw new Error(`Cannot parse tuicr session JSON: ${path}`);
1920
1873
  }
1921
1874
  }
1922
- function toFindings(session, options = {}) {
1923
- const comments = [];
1924
- const include = (comment) => (!options.agentOnly || commentAuthor(comment)?.startsWith("Agent: ")) && (!options.excludeLocalResponses || !isLocalResponse(comment.content));
1925
- const publishableBody = (comment) => {
1875
+ function toLocalReviewThreads(session) {
1876
+ const threads = [];
1877
+ const add = (comment, location = {}) => {
1926
1878
  const author = commentAuthor(comment);
1927
- const route = author?.match(/^Agent:\s*(.+)$/)?.[1];
1928
- return route ? withRemoteProvenance(comment.content, route) : comment.content;
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
+ });
1929
1887
  };
1930
- const bodyParts = (session.review_comments ?? []).flatMap((comment) => include(comment) ? [publishableBody(comment)] : []);
1888
+ for (const comment of session.review_comments ?? [])
1889
+ add(comment);
1931
1890
  for (const [file, entry] of Object.entries(session.files ?? {})) {
1932
- for (const fileComment of entry.file_comments ?? []) {
1933
- if (!include(fileComment))
1934
- continue;
1935
- const comment = {
1936
- file,
1937
- body: fileComment.content,
1938
- ...fileComment.id ? { sourceCommentId: String(fileComment.id) } : {}
1939
- };
1940
- const author = commentAuthor(fileComment);
1941
- if (author)
1942
- comment.author = author;
1943
- comments.push(comment);
1944
- }
1891
+ for (const comment of entry.file_comments ?? [])
1892
+ add(comment, { file });
1945
1893
  for (const [lineKey, lineComments] of Object.entries(entry.line_comments ?? {})) {
1946
1894
  const line = Number.parseInt(lineKey, 10);
1947
1895
  if (!Number.isFinite(line))
1948
1896
  continue;
1949
- for (const lineComment of lineComments) {
1950
- if (!include(lineComment))
1951
- continue;
1952
- const comment = {
1953
- file,
1954
- line,
1955
- side: lineComment.side === "old" ? "LEFT" : "RIGHT",
1956
- body: lineComment.content,
1957
- ...lineComment.id ? { sourceCommentId: String(lineComment.id) } : {}
1958
- };
1959
- const author = commentAuthor(lineComment);
1960
- if (author)
1961
- comment.author = author;
1962
- comments.push(comment);
1963
- }
1897
+ for (const comment of lineComments)
1898
+ add(comment, { file, line });
1964
1899
  }
1965
1900
  }
1966
- return { comments, body: bodyParts.join(`
1967
-
1968
- `) };
1901
+ return threads;
1969
1902
  }
1970
1903
  async function requireTuicr() {
1971
1904
  if (!await tuicrAvailable())
@@ -1974,207 +1907,70 @@ async function requireTuicr() {
1974
1907
  function commentAuthor(comment) {
1975
1908
  return comment.username ?? comment.author;
1976
1909
  }
1977
- async function canonicalPath2(path) {
1910
+ async function canonicalPath3(path) {
1978
1911
  try {
1979
- return await realpath2(path);
1912
+ return await realpath3(path);
1980
1913
  } catch {
1981
- return resolve2(path);
1914
+ return resolve3(path);
1982
1915
  }
1983
1916
  }
1984
1917
 
1985
- // src/review/local-review-backend.ts
1986
- function LocalReviewBackend(options) {
1987
- return {
1988
- kind: "local",
1989
- async stage(draft) {
1990
- for (const comment of draft.comments) {
1991
- await addComment(options.session, comment.body, {
1992
- targetFile: comment.file,
1993
- line: comment.line,
1994
- side: comment.side === "LEFT" ? "old" : "new",
1995
- username: options.author
1996
- });
1997
- }
1998
- if (draft.body.trim())
1999
- await addComment(options.session, draft.body, { username: options.author });
2000
- },
2001
- async readDraft() {
2002
- return toFindings(await readSession(options.session), { excludeLocalResponses: true });
2003
- },
2004
- async listThreads() {
2005
- try {
2006
- return parseThreadArtifact(await readFile6(options.artifactPath, "utf8"));
2007
- } catch (error) {
2008
- if (error.code === "ENOENT")
2009
- return [];
2010
- throw error;
2011
- }
2012
- },
2013
- async reply(input) {
2014
- const content = await readFile6(options.artifactPath, "utf8");
2015
- await writeFile3(options.artifactPath, upsertThreadReply(content, input.threadId, input.body, input.question, input.resolve), "utf8");
2016
- },
2017
- async publish() {
2018
- throw new Error("Promote a local draft through a remote review backend before publishing it.");
2019
- }
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 }
2020
1936
  };
2021
- }
2022
-
2023
- // src/review/review-backend.ts
2024
- function createRemoteReviewBackend(vcs, number) {
2025
- if (vcs.provider === "github")
2026
- return GitHubReviewBackend(vcs, number);
2027
- if (vcs.provider === "gitlab")
2028
- return GitLabReviewBackend(vcs, number);
2029
- throw new Error("A remote review backend requires a GitHub or GitLab repository.");
2030
- }
2031
- function createLocalReviewBackend(options) {
2032
- return LocalReviewBackend(options);
2033
- }
2034
- // src/review/review-state.ts
2035
- import { createHash as createHash2 } from "node:crypto";
2036
- import { readFile as readFile7, rename, writeFile as writeFile4 } from "node:fs/promises";
2037
- import { join as join9 } from "node:path";
2038
- import { z as z4 } from "zod";
2039
-
2040
- // src/store.ts
2041
- import { createHash } from "node:crypto";
2042
- import { lstat, mkdir as mkdir3, readlink, realpath as realpath3, symlink, unlink } from "node:fs/promises";
2043
- import { homedir as homedir4 } from "node:os";
2044
- import { dirname as dirname4, isAbsolute as isAbsolute2, join as join8, resolve as resolve3 } from "node:path";
2045
- var STORE_LINK = ".diffpi";
2046
- var LEGACY_STORE_LINK = join8(".pi", "diffpi");
2047
- async function computeProjectSlug(cwd) {
2048
- return projectSlug(await inspectGitRepository(cwd));
2049
- }
2050
- function storeGlobalRoot(homeDir = homedir4()) {
2051
- return join8(homeDir, ".difflab", "diffpi", "projects");
2052
- }
2053
- async function ensureStore(cwd, homeDir = homedir4()) {
2054
- const repository = await inspectGitRepository(cwd);
2055
- const root = repository.root;
2056
- const slug = projectSlug(repository);
2057
- const dest = join8(storeGlobalRoot(homeDir), slug);
2058
- const link = join8(root, STORE_LINK);
2059
- await mkdir3(dest, { recursive: true });
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;
2060
1949
  try {
2061
- await assertStoreLink(link, dest);
1950
+ return JSON.parse(await readFile6(join9(dir, `${selected}.json`), "utf8"));
2062
1951
  } catch (error) {
2063
- if (error.code !== "ENOENT")
2064
- throw error;
2065
- await symlink(dest, link);
2066
- }
2067
- await removeLegacyStoreLink(join8(root, LEGACY_STORE_LINK), dest);
2068
- return { slug, root, dest, link, linked: true };
2069
- }
2070
- async function storeDir(cwd, homeDir = homedir4()) {
2071
- const store = await ensureStore(cwd, homeDir);
2072
- return store.dest;
2073
- }
2074
- async function plansDir(cwd, homeDir = homedir4()) {
2075
- const store = await ensureStore(cwd, homeDir);
2076
- const dir = join8(store.link, "plan");
2077
- await mkdir3(dir, { recursive: true });
2078
- return dir;
2079
- }
2080
- async function reviewsDir(cwd, homeDir = homedir4()) {
2081
- const store = await ensureStore(cwd, homeDir);
2082
- const dir = join8(store.link, "review");
2083
- await mkdir3(dir, { recursive: true });
2084
- return dir;
2085
- }
2086
- async function sessionsDir(cwd, homeDir = homedir4()) {
2087
- const store = await ensureStore(cwd, homeDir);
2088
- const dir = join8(store.link, "sessions");
2089
- await mkdir3(dir, { recursive: true });
2090
- return dir;
2091
- }
2092
- async function assertStoreLink(path, dest) {
2093
- const entry = await lstat(path);
2094
- if (!entry.isSymbolicLink())
2095
- throw new Error(`${path} exists and is not a symlink.`);
2096
- const target = await symlinkTarget(path);
2097
- if (target !== await canonicalPath3(dest))
2098
- throw new Error(`${path} points to ${target}, not ${dest}.`);
2099
- }
2100
- async function removeLegacyStoreLink(path, dest) {
2101
- try {
2102
- const entry = await lstat(path);
2103
- if (!entry.isSymbolicLink())
1952
+ if (error.code === "ENOENT")
2104
1953
  return;
2105
- const target = await symlinkTarget(path);
2106
- if (target === await canonicalPath3(dest))
2107
- await unlink(path);
2108
- } catch (error) {
2109
- if (error.code !== "ENOENT")
2110
- throw error;
2111
- }
2112
- }
2113
- async function symlinkTarget(path) {
2114
- const target = await readlink(path);
2115
- return canonicalPath3(isAbsolute2(target) ? target : resolve3(dirname4(path), target));
2116
- }
2117
- async function canonicalPath3(path) {
2118
- try {
2119
- return await realpath3(path);
2120
- } catch {
2121
- return resolve3(path);
1954
+ throw error;
2122
1955
  }
2123
1956
  }
2124
- function projectSlug(repository) {
2125
- const readable = repository.name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "unnamed";
2126
- const digest = createHash("sha256").update(repository.identity).digest("hex").slice(0, 12);
2127
- return `${readable}-${digest}`;
2128
- }
2129
-
2130
- // src/review/review-state.ts
2131
- var reviewPublicationStateSchema = z4.object({
2132
- target: z4.string().optional(),
2133
- bodies: z4.array(z4.string()).default([]),
2134
- comments: z4.array(z4.string()).default([]),
2135
- stagedComments: z4.array(z4.string()).default([]),
2136
- replies: z4.array(z4.string()).default([]),
2137
- overlayPath: z4.string().optional()
2138
- });
2139
- function reviewBodyFingerprint(body) {
2140
- return digest(body);
2141
- }
2142
- function reviewCommentFingerprint(comment) {
2143
- return digest([comment.file, String(comment.line), comment.side ?? "RIGHT", comment.body].join("\x00"));
2144
- }
2145
- function reviewReplyFingerprint(threadId, body) {
2146
- return digest(`${threadId}\x00${body}`);
1957
+ async function nextRevision(dir) {
1958
+ return await latestRevision(dir) + 1;
2147
1959
  }
2148
- function unpublishedReviewComments(comments, knownFingerprints) {
2149
- return comments.filter((comment) => !knownFingerprints.has(reviewCommentFingerprint(comment)));
2150
- }
2151
- async function loadReviewPublicationState(cwd, vcs, number, homeDir) {
2152
- const target = `${vcs.provider}:${vcs.owner}/${vcs.repo}#${number}`;
2153
- const path = join9(await sessionsDir(cwd, homeDir), `review-publish-${digest(target).slice(0, 16)}.json`);
1960
+ async function latestRevision(dir) {
2154
1961
  try {
2155
- const parsed = reviewPublicationStateSchema.parse(JSON.parse(await readFile7(path, "utf8")));
2156
- return { path, state: { ...parsed, target } };
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);
2157
1966
  } catch (error) {
2158
- if (error.code === "ENOENT") {
2159
- return { path, state: { target, bodies: [], comments: [], stagedComments: [], replies: [] } };
2160
- }
2161
- if (error instanceof SyntaxError || error instanceof z4.ZodError) {
2162
- throw new Error(`Cannot parse review publication state: ${path}`);
2163
- }
1967
+ if (error.code === "ENOENT")
1968
+ return 0;
2164
1969
  throw error;
2165
1970
  }
2166
1971
  }
2167
- async function saveReviewPublicationState(path, state) {
2168
- const temp = `${path}.${process.pid}.tmp`;
2169
- await writeFile4(temp, `${JSON.stringify(state, null, 2)}
2170
- `, "utf8");
2171
- await rename(temp, path);
2172
- }
2173
- function digest(value) {
2174
- return createHash2("sha256").update(value).digest("hex");
2175
- }
2176
1972
  // src/mcp.ts
2177
- import { mkdir as mkdir4, readFile as readFile8, writeFile as writeFile5 } from "node:fs/promises";
1973
+ import { mkdir as mkdir5, readFile as readFile7, writeFile as writeFile4 } from "node:fs/promises";
2178
1974
  import { homedir as homedir5 } from "node:os";
2179
1975
  import { dirname as dirname5, join as join10 } from "node:path";
2180
1976
  var mcp = {
@@ -2192,8 +1988,8 @@ var mcp = {
2192
1988
  const next = { ...current, mcpServers: nextServers };
2193
1989
  const changed = JSON.stringify(current) !== JSON.stringify(next);
2194
1990
  if (changed && !options.dryRun) {
2195
- await mkdir4(dirname5(path), { recursive: true });
2196
- await writeFile5(path, `${JSON.stringify(next, null, 2)}
1991
+ await mkdir5(dirname5(path), { recursive: true });
1992
+ await writeFile4(path, `${JSON.stringify(next, null, 2)}
2197
1993
  `, "utf8");
2198
1994
  }
2199
1995
  return { path, changed, existed: currentText !== undefined, planned: changed && options.dryRun === true };
@@ -2222,7 +2018,7 @@ function getParsedConfig(content, path) {
2222
2018
  }
2223
2019
  async function getOptionalFile2(path) {
2224
2020
  try {
2225
- return await readFile8(path, "utf8");
2021
+ return await readFile7(path, "utf8");
2226
2022
  } catch (error) {
2227
2023
  if (error instanceof Error && "code" in error && error.code === "ENOENT")
2228
2024
  return;
@@ -2237,7 +2033,7 @@ import {
2237
2033
  getAgentDir,
2238
2034
  parseFrontmatter as parseFrontmatter2
2239
2035
  } from "@earendil-works/pi-coding-agent";
2240
- import { readFile as readFile9 } from "node:fs/promises";
2036
+ import { readFile as readFile8 } from "node:fs/promises";
2241
2037
  import { homedir as homedir6 } from "node:os";
2242
2038
  import { basename as basename4, extname, join as join11 } from "node:path";
2243
2039
  async function discoverAgentModes(options) {
@@ -2436,7 +2232,7 @@ async function loadAgentModes(directory, source, modes, diagnostics, skillName)
2436
2232
  for (const entry of entries.filter((item) => item.isFile() && item.name.endsWith(".md")).sort((a, b) => a.name.localeCompare(b.name))) {
2437
2233
  const path = join11(directory, entry.name);
2438
2234
  try {
2439
- const content = await readFile9(path, "utf8");
2235
+ const content = await readFile8(path, "utf8");
2440
2236
  const { frontmatter, body } = parseFrontmatter2(content.startsWith("\uFEFF") ? content.slice(1) : content);
2441
2237
  if (frontmatter.enabled === false || frontmatter.inline === false)
2442
2238
  continue;
@@ -2492,7 +2288,7 @@ function isModeBaseline(value) {
2492
2288
  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");
2493
2289
  }
2494
2290
  // src/pi.ts
2495
- import { mkdir as mkdir5, writeFile as writeFile6 } from "node:fs/promises";
2291
+ import { mkdir as mkdir6, writeFile as writeFile5 } from "node:fs/promises";
2496
2292
  import { homedir as homedir7 } from "node:os";
2497
2293
  import { dirname as dirname6, join as join12 } from "node:path";
2498
2294
  var pi = {
@@ -2511,8 +2307,8 @@ async function ensurePiAgent(filename, content, agentDir = resolvePiAgentDir(),
2511
2307
  const currentText = await readTextIfExists(path);
2512
2308
  const changed = currentText !== content;
2513
2309
  if (changed && !dryRun) {
2514
- await mkdir5(dirname6(path), { recursive: true });
2515
- await writeFile6(path, content, "utf8");
2310
+ await mkdir6(dirname6(path), { recursive: true });
2311
+ await writeFile5(path, content, "utf8");
2516
2312
  }
2517
2313
  return { path, changed, existed: currentText !== undefined, planned: changed && dryRun };
2518
2314
  }
@@ -2548,8 +2344,8 @@ async function ensurePiConfig(path, update, dryRun = false) {
2548
2344
  const next = update(current);
2549
2345
  const changed = JSON.stringify(current) !== JSON.stringify(next);
2550
2346
  if (changed && !dryRun) {
2551
- await mkdir5(dirname6(path), { recursive: true });
2552
- await writeFile6(path, `${JSON.stringify(next, null, 2)}
2347
+ await mkdir6(dirname6(path), { recursive: true });
2348
+ await writeFile5(path, `${JSON.stringify(next, null, 2)}
2553
2349
  `, "utf8");
2554
2350
  }
2555
2351
  return { path, changed, existed: currentText !== undefined, planned: changed && dryRun };
@@ -2581,271 +2377,93 @@ function parseJsonObject(content, path) {
2581
2377
  } catch {}
2582
2378
  throw new Error(`Expected valid JSON object in ${path}.`);
2583
2379
  }
2584
- // src/extensions/subagentx.ts
2585
- import { z as z5 } from "zod";
2586
- var escalationSchema = z5.object({
2587
- correlation: z5.object({
2588
- workflow: z5.string().min(1),
2589
- executionId: z5.string().min(1).optional(),
2590
- unitId: z5.string().min(1).optional(),
2591
- parentUnitId: z5.string().min(1).optional(),
2592
- metadata: z5.record(z5.string(), z5.string()).optional()
2593
- }).strict(),
2594
- blocker: z5.string().min(1),
2595
- attempts: z5.array(z5.string()),
2596
- evidence: z5.array(z5.string()),
2597
- needsUserDecision: z5.boolean()
2598
- }).strict();
2599
- var ESCALATION_OPEN = "<diffpi-subagent-escalation>";
2600
- var ESCALATION_CLOSE = "</diffpi-subagent-escalation>";
2601
- function renderSubagentEscalation(value) {
2602
- return `${ESCALATION_OPEN}${JSON.stringify(escalationSchema.parse(value))}${ESCALATION_CLOSE}`;
2603
- }
2604
-
2605
- // src/plan/annotations.ts
2606
- import { spawn as spawn2 } from "node:child_process";
2607
- import { readFile as readFile12, rename as rename2, writeFile as writeFile8 } from "node:fs/promises";
2608
- import { basename as basename5, dirname as dirname7, join as join14 } from "node:path";
2609
- import { z as z6 } from "zod";
2610
-
2611
- // src/log.ts
2612
- import { randomUUID } from "node:crypto";
2613
- import { appendFile, readFile as readFile10 } from "node:fs/promises";
2614
- async function appendLogEntry(logPath, event) {
2615
- const entry = {
2616
- ...event,
2617
- version: 1,
2618
- eventId: event.eventId ?? randomUUID(),
2619
- timestamp: event.timestamp ?? new Date().toISOString()
2620
- };
2621
- await appendFile(logPath, `${JSON.stringify(entry)}
2622
- `, { encoding: "utf8", mode: 384 });
2623
- return entry;
2624
- }
2380
+ // 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";
2625
2383
 
2626
- // src/plan/log.ts
2627
- async function appendPlanLog(logPath, event) {
2628
- return appendLogEntry(logPath, event);
2384
+ // src/extensions/fsx.ts
2385
+ import { mkdir as mkdir7, rename, rm, writeFile as writeFile6 } from "node:fs/promises";
2386
+ import { basename as basename5, dirname as dirname7, join as join13 } from "node:path";
2387
+ async function atomicWrite(path, content) {
2388
+ 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);
2629
2391
  }
2630
-
2631
- // src/plan/lock.ts
2632
- import { randomUUID as randomUUID2 } from "node:crypto";
2633
- import { mkdir as mkdir6, readFile as readFile11, rm, writeFile as writeFile7 } from "node:fs/promises";
2634
- import { hostname } from "node:os";
2635
- import { join as join13 } from "node:path";
2636
- async function withPlanLock(planDir, operation, options = {}) {
2637
- const lockDir = join13(planDir, ".lock");
2392
+ async function withDirectoryLock(lockDir, operation, options = {}) {
2638
2393
  const waitMs = options.waitMs ?? 5000;
2639
2394
  const pollMs = options.pollMs ?? 25;
2640
- const owner = {
2641
- token: randomUUID2(),
2642
- pid: process.pid,
2643
- hostname: hostname(),
2644
- operation: options.operation ?? "plan mutation",
2645
- acquiredAt: new Date().toISOString()
2646
- };
2647
2395
  const started = Date.now();
2648
2396
  while (true) {
2649
2397
  try {
2650
- await mkdir6(lockDir);
2651
- await writeFile7(join13(lockDir, "owner.json"), `${JSON.stringify(owner)}
2652
- `, { mode: 384 });
2398
+ await mkdir7(lockDir);
2653
2399
  break;
2654
2400
  } catch (error) {
2655
2401
  if (error.code !== "EEXIST")
2656
2402
  throw error;
2657
- if (Date.now() - started >= waitMs) {
2658
- let existing = "unknown owner";
2659
- try {
2660
- existing = (await readFile11(join13(lockDir, "owner.json"), "utf8")).trim();
2661
- } catch {}
2662
- throw new Error(`Timed out waiting for plan lock ${lockDir}; owner: ${existing}. Remove it only after confirming the owner is stale.`);
2663
- }
2664
- await new Promise((resolve) => setTimeout(resolve, pollMs));
2403
+ if (Date.now() - started >= waitMs)
2404
+ throw new Error(`Timed out waiting to ${options.operation ?? "acquire lock"}: ${lockDir}`);
2405
+ await sleep(pollMs);
2665
2406
  }
2666
2407
  }
2667
2408
  try {
2668
2409
  return await operation();
2669
2410
  } finally {
2670
- let current;
2671
- try {
2672
- current = JSON.parse(await readFile11(join13(lockDir, "owner.json"), "utf8"));
2673
- } catch {}
2674
- if (current?.token === owner.token)
2675
- await rm(lockDir, { recursive: true, force: true });
2411
+ await rm(lockDir, { recursive: true, force: true });
2676
2412
  }
2677
2413
  }
2414
+ function sleep(ms) {
2415
+ return new Promise((resolve) => setTimeout(resolve, ms));
2416
+ }
2678
2417
 
2679
- // src/plan/annotations.ts
2680
- var commentSchema = z6.object({
2681
- id: z6.string(),
2682
- content: z6.string(),
2683
- path: z6.string().optional(),
2684
- start_line: z6.number().int().optional(),
2685
- line: z6.number().int().optional(),
2686
- end_line: z6.number().int().optional()
2687
- }).loose();
2688
- async function annotatePlan(record, runtime = {}) {
2689
- const execute = runtime.execute ?? executeProcess;
2690
- const result = await execute("tuicr", ["--file", record.planPath], dirname7(record.planPath), true);
2691
- const sessionSlug = [...result.stderr.matchAll(/^tuicr-session:\s*(\S+)\s*$/gm)].at(-1)?.[1];
2692
- if (result.code !== 0)
2693
- throw new Error(result.stderr.trim() || `tuicr exited with status ${result.code}.`);
2694
- if (!sessionSlug)
2695
- throw new Error("tuicr did not report a tuicr-session marker.");
2696
- const state = await withPlanLock(record.dir, async () => {
2697
- const previous = await readAnnotationState(record).catch((error) => {
2698
- if (error.code === "ENOENT")
2699
- return;
2700
- throw error;
2701
- });
2702
- const next = {
2703
- schemaVersion: 1,
2704
- sessionSlug,
2705
- updatedAt: new Date().toISOString(),
2706
- appliedCommentIds: previous?.appliedCommentIds ?? []
2707
- };
2708
- await atomicJson(annotationStatePath(record), next);
2709
- return next;
2710
- }, { operation: "save annotation session" });
2711
- return { sessionSlug, code: result.code, state };
2418
+ // src/log.ts
2419
+ import { randomUUID } from "node:crypto";
2420
+ import { appendFile, readFile as readFile9 } from "node:fs/promises";
2421
+ async function appendLogEntry(logPath, event) {
2422
+ const entry = {
2423
+ ...event,
2424
+ version: 1,
2425
+ eventId: event.eventId ?? randomUUID(),
2426
+ timestamp: event.timestamp ?? new Date().toISOString()
2427
+ };
2428
+ await appendFile(logPath, `${JSON.stringify(entry)}
2429
+ `, { encoding: "utf8", mode: 384 });
2430
+ return entry;
2712
2431
  }
2713
- async function readPlanAnnotations(record, options = {}) {
2714
- const state = await readAnnotationState(record).catch((error) => {
2715
- if (error.code === "ENOENT")
2716
- return;
2717
- throw error;
2718
- });
2719
- if (!state)
2720
- return { comments: [], pending: [] };
2721
- const execute = options.runtime?.execute ?? executeProcess;
2722
- const response = await execute("tuicr", ["review", "comments", "--session", state.sessionSlug], record.dir, false);
2723
- if (response.code !== 0) {
2724
- if (/not found|no session|deleted/i.test(response.stderr))
2725
- return { state, comments: [], pending: [] };
2726
- throw new Error(response.stderr.trim() || "Cannot read tuicr comments.");
2727
- }
2728
- let raw;
2729
- try {
2730
- raw = JSON.parse(response.stdout);
2731
- } catch {
2732
- throw new Error("tuicr returned malformed comment JSON.");
2733
- }
2734
- if (!Array.isArray(raw))
2735
- throw new Error("tuicr comment output must be a JSON array.");
2736
- const lines = record.source.split(`
2737
- `);
2738
- const applied = new Set(state.appliedCommentIds);
2739
- const comments = raw.map((value, index) => normalizeComment(value, index, lines, applied));
2740
- const pending = comments.filter((comment) => !comment.applied);
2741
- return { state, comments: options.includeApplied ? comments : pending, pending };
2742
- }
2743
- async function acknowledgePlanAnnotations(record, commentIds, summary) {
2744
- if (!summary.trim())
2745
- throw new Error("Annotation acknowledgement summary is required.");
2746
- return withPlanLock(record.dir, async () => {
2747
- const state = await readAnnotationState(record);
2748
- const next = {
2749
- ...state,
2750
- updatedAt: new Date().toISOString(),
2751
- appliedCommentIds: [...new Set([...state.appliedCommentIds, ...commentIds])]
2752
- };
2753
- await atomicJson(annotationStatePath(record), next);
2754
- await appendPlanLog(record.logPath, {
2755
- planRevision: record.document.revision,
2756
- kind: "annotation",
2757
- actor: "planner",
2758
- message: summary,
2759
- data: { commentIds: [...commentIds], sessionSlug: state.sessionSlug }
2760
- });
2761
- return next;
2762
- }, { operation: "acknowledge annotations" });
2432
+
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}.`);
2763
2448
  }
2764
- function annotationStatePath(record) {
2765
- return join14(record.dir, "annotations.json");
2449
+ function renderTemplate(content, variables) {
2450
+ return content.replace(/\{\{\s*([a-zA-Z0-9_.-]+)\s*\}\}/g, (match, key) => variables[key] ?? match);
2766
2451
  }
2767
- async function readAnnotationState(record) {
2768
- const source = await readFile12(annotationStatePath(record), "utf8");
2769
- let value;
2770
- try {
2771
- value = JSON.parse(source);
2772
- } catch {
2773
- throw new Error(`Malformed annotation state: ${annotationStatePath(record)}.`);
2774
- }
2775
- if (value.schemaVersion !== 1 || typeof value.sessionSlug !== "string" || !Array.isArray(value.appliedCommentIds)) {
2776
- throw new Error(`Malformed annotation state: ${annotationStatePath(record)}.`);
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}`);
2777
2456
  }
2778
- return value;
2779
- }
2780
- function normalizeComment(value, index, lines, applied) {
2781
- const parsed = commentSchema.safeParse(value);
2782
- if (!parsed.success)
2783
- throw new Error(`Malformed tuicr comment at index ${index}.`);
2784
- const raw = parsed.data;
2785
- const line = raw.start_line ?? raw.line;
2786
- const endLine = raw.end_line ?? line;
2787
- const targetPath = raw.path;
2788
- const appliesToPlan = !targetPath || basename5(targetPath) === basename5("PLAN.md");
2789
- const validAnchor = appliesToPlan && line !== undefined && line > 0 && line <= lines.length;
2790
- return {
2791
- id: raw.id,
2792
- body: raw.content,
2793
- file: targetPath,
2794
- line,
2795
- endLine,
2796
- context: validAnchor ? lines.slice(line - 1, Math.min(endLine ?? line, lines.length)).join(`
2797
- `) : undefined,
2798
- stale: line !== undefined && !validAnchor,
2799
- applied: applied.has(raw.id)
2800
- };
2457
+ return normalized.endsWith(".md") ? normalized : `${normalized}.md`;
2801
2458
  }
2802
- async function executeProcess(command, args, cwd, interactive) {
2803
- if (!interactive) {
2804
- const result = await run(command, args, { cwd, capture: "unbounded" });
2805
- return { code: result.code, stdout: result.stdout, stderr: result.stderr };
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;
2806
2466
  }
2807
- return new Promise((resolve, reject) => {
2808
- const child = spawn2(command, args, { cwd, stdio: ["inherit", "inherit", "pipe"] });
2809
- let stderr = "";
2810
- child.stderr.on("data", (chunk) => {
2811
- const text = chunk.toString();
2812
- stderr += text;
2813
- process.stderr.write(text);
2814
- });
2815
- child.on("error", reject);
2816
- child.on("close", (code) => resolve({ code: code ?? 1, stdout: "", stderr }));
2817
- });
2818
- }
2819
- async function atomicJson(path, value) {
2820
- const temp = join14(dirname7(path), `.${basename5(path)}.${crypto.randomUUID()}.tmp`);
2821
- await writeFile8(temp, `${JSON.stringify(value, null, 2)}
2822
- `, { mode: 384, flag: "wx" });
2823
- await rename2(temp, path);
2824
- }
2825
-
2826
- // src/plan/execution.ts
2827
- function createExecutionPacket(plan, coordinator) {
2828
- if (!plan.execution?.active)
2829
- throw new Error(`Plan ${plan.id} has no active execution.`);
2830
- return {
2831
- version: 1,
2832
- planId: plan.id,
2833
- executionId: plan.execution.id,
2834
- policy: plan.execution.policy,
2835
- cwd: plan.execution.cwd,
2836
- branch: plan.execution.branch,
2837
- coordinator
2838
- };
2839
- }
2840
- function renderExecutionPrompt(packet) {
2841
- return [
2842
- `Execute the durable Diffpi plan using this packet: ${JSON.stringify(packet)}.`,
2843
- "Call plan_context first and keep plan tools as the source of truth for eligibility, ownership, status, gates, and commits.",
2844
- "Use SubagentWorkflow for deterministic multi-task coordination: pipeline dependent work, parallelize only tasks with non-overlapping declared file scopes, and use structured schemas for worker outcomes.",
2845
- "The extension cannot launch workflow children over RPC, so the active orchestrator must invoke SubagentWorkflow itself.",
2846
- "Persist every transition, progress event, issue, and deviation. Do not edit PLAN.md directly. Delegated implementation workers never commit or restructure the plan.",
2847
- "For commit-per-phase execution, commit after local gates, push immediately, record pending CI on the completed phase, and launch a bounded background Worker to call plan_watch_ci for the exact SHA while the next phase executes. Collect that monitor before the next push and collect all monitors before plan completion; failed or timed-out CI blocks execution."
2848
- ].join(" ");
2849
2467
  }
2850
2468
 
2851
2469
  // src/plan/markdown.ts
@@ -7485,25 +7103,6 @@ function looksLikeAValue(value) {
7485
7103
  function isUint8Array2(value) {
7486
7104
  return Boolean(value && typeof value === "object" && "byteLength" in value && "byteOffset" in value);
7487
7105
  }
7488
- // src/plan/ids.ts
7489
- var STABLE_ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
7490
- function isStableId(value) {
7491
- return STABLE_ID.test(value) && value.length <= 80;
7492
- }
7493
- function assertStableId(value, label = "identifier") {
7494
- if (!isStableId(value))
7495
- throw new Error(`${label} must be a lowercase stable slug, not a path: ${value}`);
7496
- }
7497
- function assertUniqueIds(ids, label) {
7498
- const seen = new Set;
7499
- for (const id of ids) {
7500
- assertStableId(id, label);
7501
- if (seen.has(id))
7502
- throw new Error(`Duplicate ${label}: ${id}.`);
7503
- seen.add(id);
7504
- }
7505
- }
7506
-
7507
7106
  // src/plan/markdown.ts
7508
7107
  var PLAN_MARKER = /<!-- diffpi-plan: (\{[^\n]+\}) -->/;
7509
7108
  var PHASE_MARKER = /<!-- diffpi-phase: (\{[^\n]+\}) -->/g;
@@ -7551,8 +7150,6 @@ function validatePlanDocument(plan, options = {}) {
7551
7150
  add("design-too-long", "error", `Design is ${designWords} words; finalization allows at most 800.`);
7552
7151
  if (options.strict && plan.phases.length === 0)
7553
7152
  add("no-phases", "error", "Finalization requires at least one phase.");
7554
- if (options.strict && (options.pendingAnnotations ?? 0) > 0)
7555
- add("pending-annotations", "error", `${options.pendingAnnotations} annotations remain pending.`);
7556
7153
  return issues;
7557
7154
  }
7558
7155
  function renderPlanDocument(plan, previousSource) {
@@ -7894,6 +7491,19 @@ function parseCsv(value) {
7894
7491
  return [];
7895
7492
  return value.split(",").map((item) => item.trim()).filter(Boolean);
7896
7493
  }
7494
+ function assertStableId(value, label) {
7495
+ if (!zx.id.safeParse(value).success)
7496
+ throw new Error(`${label} must be a lowercase stable slug, not a path: ${value}`);
7497
+ }
7498
+ function assertUniqueIds(ids, label) {
7499
+ const seen = new Set;
7500
+ for (const id of ids) {
7501
+ assertStableId(id, label);
7502
+ if (seen.has(id))
7503
+ throw new Error(`Duplicate ${label}: ${id}.`);
7504
+ seen.add(id);
7505
+ }
7506
+ }
7897
7507
  function cleanPlaceholder(value) {
7898
7508
  return value.replace(/<!--[^]*?-->/g, "").trim();
7899
7509
  }
@@ -7925,46 +7535,6 @@ function escapeRegExp(value) {
7925
7535
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
7926
7536
  }
7927
7537
 
7928
- // src/plan/store.ts
7929
- import { open, mkdir as mkdir7, readdir as readdir2, readFile as readFile14, realpath as realpath4, rename as rename3, rm as rm2 } from "node:fs/promises";
7930
- import { basename as basename6, dirname as dirname8, join as join17, resolve as resolve4, sep } from "node:path";
7931
-
7932
- // src/templates.ts
7933
- import { readFile as readFile13 } from "node:fs/promises";
7934
- import { homedir as homedir8 } from "node:os";
7935
- import { join as join16, normalize } from "node:path";
7936
- async function loadTemplate(name, options = {}) {
7937
- const relative = templateRelativePath(name);
7938
- const userPath = join16(options.homeDir ?? homedir8(), ".difflab", "diffpi", "templates", relative);
7939
- const bundledPath = join16(options.bundledDir ?? resolveBundledTemplatesDir(), relative);
7940
- const user = await readOptionalFile(userPath);
7941
- if (user !== undefined)
7942
- return { name, path: userPath, source: "user", content: user };
7943
- const bundled = await readOptionalFile(bundledPath);
7944
- if (bundled !== undefined)
7945
- return { name, path: bundledPath, source: "bundled", content: bundled };
7946
- throw new Error(`Template "${name}" was not found at ${userPath} or ${bundledPath}.`);
7947
- }
7948
- function renderTemplate(content, variables) {
7949
- return content.replace(/\{\{\s*([a-zA-Z0-9_.-]+)\s*\}\}/g, (match, key) => variables[key] ?? match);
7950
- }
7951
- function templateRelativePath(name) {
7952
- const normalized = normalize(name.replaceAll("\\", "/")).replace(/^\.\//, "");
7953
- if (!normalized || normalized.startsWith("../") || normalized.includes("/../") || normalized.startsWith("/")) {
7954
- throw new Error(`Invalid template name: ${name}`);
7955
- }
7956
- return normalized.endsWith(".md") ? normalized : `${normalized}.md`;
7957
- }
7958
- async function readOptionalFile(path) {
7959
- try {
7960
- return await readFile13(path, "utf8");
7961
- } catch (error) {
7962
- if (error.code === "ENOENT")
7963
- return;
7964
- throw error;
7965
- }
7966
- }
7967
-
7968
7538
  // src/plan/store.ts
7969
7539
  function planRecordName(shortSlug, date = new Date) {
7970
7540
  const slug = normalizeSlug(shortSlug);
@@ -7972,7 +7542,7 @@ function planRecordName(shortSlug, date = new Date) {
7972
7542
  }
7973
7543
  async function resolvePlan(cwd, query, filters = {}, options = {}) {
7974
7544
  const root = await plansDir(cwd, options.homeDir);
7975
- const entries = await readdir2(root, { withFileTypes: true });
7545
+ const entries = await readdir3(root, { withFileTypes: true });
7976
7546
  const normalizedQuery = query ? normalizeQuery(query) : undefined;
7977
7547
  const records = [];
7978
7548
  for (const entry of entries) {
@@ -7980,7 +7550,7 @@ async function resolvePlan(cwd, query, filters = {}, options = {}) {
7980
7550
  continue;
7981
7551
  if (normalizedQuery && entry.name !== normalizedQuery && stripDate(entry.name) !== normalizedQuery)
7982
7552
  continue;
7983
- const dir = join17(root, entry.name);
7553
+ const dir = join16(root, entry.name);
7984
7554
  await assertContained(root, dir);
7985
7555
  try {
7986
7556
  const record = await readRecord(dir);
@@ -8006,10 +7576,10 @@ function createPlanStore(options = {}) {
8006
7576
  async init(input) {
8007
7577
  const root = await plansDir(input.cwd, options.homeDir);
8008
7578
  const id = planRecordName(input.shortSlug, now());
8009
- const dir = join17(root, id);
7579
+ const dir = join16(root, id);
8010
7580
  await assertContained(root, dirname8(dir));
8011
7581
  try {
8012
- await mkdir7(dir);
7582
+ await mkdir8(dir);
8013
7583
  } catch (error) {
8014
7584
  if (error.code === "EEXIST")
8015
7585
  throw new Error(`Plan ${id} already exists.`);
@@ -8026,25 +7596,19 @@ function createPlanStore(options = {}) {
8026
7596
  branch: input.branch,
8027
7597
  title: input.title?.trim() || titleFromSlug(input.shortSlug),
8028
7598
  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. -->",
8029
7601
  created_at: timestamp,
8030
7602
  updated_at: timestamp
8031
7603
  });
8032
- const document = parsePlanDocument(source, join17(dir, "PLAN.md"));
8033
- await atomicWrite(join17(dir, "PLAN.md"), source);
8034
- await ensureImplementationFiles(dir, document, options);
8035
- await atomicWrite(join17(dir, "logs.txt"), "");
8036
- await appendPlanLog(join17(dir, "logs.txt"), {
8037
- planRevision: document.revision,
8038
- kind: "created",
8039
- actor: "diffpi",
8040
- message: `Created plan ${id}.`
8041
- });
7604
+ const document = parsePlanDocument(source, join16(dir, "PLAN.md"));
7605
+ await atomicWrite(join16(dir, "PLAN.md"), source);
8042
7606
  return {
8043
7607
  id,
8044
7608
  dir,
8045
- planPath: join17(dir, "PLAN.md"),
8046
- logPath: join17(dir, "logs.txt"),
8047
- implementationDir: join17(dir, "implementation"),
7609
+ planPath: join16(dir, "PLAN.md"),
7610
+ logPath: join16(dir, "logs.txt"),
7611
+ implementationDir: join16(dir, "implementation"),
8048
7612
  document,
8049
7613
  source
8050
7614
  };
@@ -8064,7 +7628,7 @@ function createPlanStore(options = {}) {
8064
7628
  },
8065
7629
  async mutate(cwd, query, operation, update) {
8066
7630
  const initial = await this.read(cwd, query);
8067
- return withPlanLock(initial.dir, async () => {
7631
+ return withDirectoryLock(join16(initial.dir, ".lock"), async () => {
8068
7632
  const current = await readRecord(initial.dir);
8069
7633
  const next = await update(structuredClone(current.document));
8070
7634
  if (next.id !== current.id)
@@ -8078,41 +7642,41 @@ function createPlanStore(options = {}) {
8078
7642
  parsePlanDocument(source, current.planPath);
8079
7643
  await atomicWrite(current.planPath, source);
8080
7644
  await ensureImplementationFiles(current.dir, document, options);
8081
- return { ...current, document, source, implementationDir: join17(current.dir, "implementation") };
7645
+ return { ...current, document, source, implementationDir: join16(current.dir, "implementation") };
8082
7646
  }, { operation });
8083
7647
  },
8084
7648
  async log(cwd, query, event) {
8085
7649
  const record = await this.read(cwd, query);
8086
- return withPlanLock(record.dir, () => appendPlanLog(record.logPath, event), { operation: "append log" });
7650
+ return appendLogEntry(record.logPath, event);
8087
7651
  }
8088
7652
  };
8089
7653
  }
8090
7654
  async function readRecord(dir) {
8091
- const planPath = join17(dir, "PLAN.md");
8092
- const logPath = join17(dir, "logs.txt");
8093
- const source = await readFile14(planPath, "utf8");
7655
+ const planPath = join16(dir, "PLAN.md");
7656
+ const logPath = join16(dir, "logs.txt");
7657
+ const source = await readFile11(planPath, "utf8");
8094
7658
  const document = parsePlanDocument(source, planPath);
8095
7659
  return {
8096
7660
  id: basename6(dir),
8097
7661
  dir,
8098
7662
  planPath,
8099
7663
  logPath,
8100
- implementationDir: join17(dir, "implementation"),
7664
+ implementationDir: join16(dir, "implementation"),
8101
7665
  document,
8102
7666
  source
8103
7667
  };
8104
7668
  }
8105
7669
  async function ensureImplementationFiles(dir, document, options) {
8106
- const implementationDir = join17(dir, "implementation");
8107
- await mkdir7(implementationDir, { recursive: true });
7670
+ const implementationDir = join16(dir, "implementation");
7671
+ await mkdir8(implementationDir, { recursive: true });
8108
7672
  const template = await loadTemplate("plan/implementation", {
8109
7673
  homeDir: options.homeDir,
8110
7674
  bundledDir: options.bundledTemplatesDir
8111
7675
  });
8112
7676
  for (const phase of document.phases) {
8113
- const path = join17(implementationDir, `phase-${phase.id}.md`);
7677
+ const path = join16(implementationDir, `phase-${phase.id}.md`);
8114
7678
  try {
8115
- await readFile14(path, "utf8");
7679
+ await readFile11(path, "utf8");
8116
7680
  } catch (error) {
8117
7681
  if (error.code !== "ENOENT")
8118
7682
  throw error;
@@ -8126,25 +7690,6 @@ async function ensureImplementationFiles(dir, document, options) {
8126
7690
  }
8127
7691
  }
8128
7692
  }
8129
- async function atomicWrite(path, content) {
8130
- const temp = join17(dirname8(path), `.${basename6(path)}.${process.pid}.${crypto.randomUUID()}.tmp`);
8131
- const file = await open(temp, "wx", 384);
8132
- try {
8133
- await file.writeFile(content, "utf8");
8134
- await file.sync();
8135
- } finally {
8136
- await file.close();
8137
- }
8138
- await rename3(temp, path);
8139
- try {
8140
- const directory = await open(dirname8(path), "r");
8141
- try {
8142
- await directory.sync();
8143
- } finally {
8144
- await directory.close();
8145
- }
8146
- } catch {}
8147
- }
8148
7693
  async function assertContained(root, candidate) {
8149
7694
  const canonicalRoot = await realpath4(root);
8150
7695
  let canonicalCandidate;
@@ -8162,7 +7707,8 @@ function normalizeSlug(value) {
8162
7707
  throw new Error(`Invalid plan slug: ${value}.`);
8163
7708
  }
8164
7709
  const slug = value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
8165
- assertStableId(slug, "plan slug");
7710
+ if (!zx.id.safeParse(slug).success)
7711
+ throw new Error(`Invalid plan slug: ${value}.`);
8166
7712
  return slug;
8167
7713
  }
8168
7714
  function normalizeQuery(value) {
@@ -8177,345 +7723,11 @@ function stripDate(value) {
8177
7723
  function titleFromSlug(value) {
8178
7724
  return normalizeSlug(value).replace(/^(?:[a-z]+-\d+-)/, "").split("-").map((part) => part[0].toUpperCase() + part.slice(1)).join(" ");
8179
7725
  }
8180
-
8181
- // src/plan/controller.ts
8182
- function createPlanController(options = {}) {
8183
- const store = createPlanStore(options);
8184
- const controller = {
8185
- context: store.context,
8186
- create: store.init,
8187
- read: store.read,
8188
- update: store.mutate,
8189
- appendLog: store.log,
8190
- updateStatus: (cwd, query, input) => updateStatus(store, cwd, query, input),
8191
- updateCi: (cwd, query, input) => updateCi(store, cwd, query, input),
8192
- retryCi: (cwd, query, input) => retryCi(store, cwd, query, input),
8193
- assertPhasePushEligible,
8194
- validate: validatePlanDocument,
8195
- countDesignWords,
8196
- annotate: annotatePlan,
8197
- annotations: readPlanAnnotations,
8198
- acknowledgeAnnotations: acknowledgePlanAnnotations,
8199
- executionPacket: createExecutionPacket,
8200
- executionPrompt: renderExecutionPrompt,
8201
- renderEscalation: renderSubagentEscalation,
8202
- assertStableId
8203
- };
8204
- return controller;
8205
- }
8206
- async function updateStatus(store, cwd, query, input) {
8207
- let escalation;
8208
- const record = await store.mutate(cwd, query, "update status", (plan) => {
8209
- if (input.target.type === "plan") {
8210
- if (plan.status !== input.expectedStatus)
8211
- throw new Error(`Expected plan status ${input.expectedStatus}, found ${plan.status}.`);
8212
- assertPlanTransition(plan.status, input.status);
8213
- if (input.status === "ready") {
8214
- const errors = validatePlanDocument(plan, { strict: true }).filter((issue) => issue.severity === "error");
8215
- if (errors.length)
8216
- throw new Error(`Plan cannot become ready: ${errors.map((issue) => issue.message).join(" ")}`);
8217
- }
8218
- if ((input.status === "blocked" || input.status === "completed") && plan.status === "in_progress")
8219
- assertExecutionOwner(plan, input.executionId);
8220
- if (input.status === "completed" && plan.phases.some((phase) => phase.status !== "completed" && phase.status !== "skipped"))
8221
- throw new Error("Every phase must be complete or skipped before the plan completes.");
8222
- if (input.status === "completed" && plan.execution?.policy === "commit-per-phase") {
8223
- const unsettled = plan.phases.filter((phase) => phase.status === "completed" && (!phase.commit?.pushedAt || !["passed", "skipped"].includes(phase.commit.ci?.status ?? "pending")));
8224
- if (unsettled.length)
8225
- throw new Error(`CI must settle successfully for phases: ${unsettled.map((phase) => phase.id).join(", ")}.`);
8226
- }
8227
- return {
8228
- ...heartbeat(plan),
8229
- status: input.status,
8230
- execution: input.status === "completed" || input.status === "blocked" ? plan.execution && { ...plan.execution, active: false, heartbeatAt: new Date().toISOString() } : plan.execution
8231
- };
8232
- }
8233
- if (!input.target.id)
8234
- throw new Error("Target ID is required.");
8235
- const phase = input.target.type === "phase" ? getPhase(plan, input.target.id) : findTaskPhase(plan, input.target.id);
8236
- if (input.target.type === "phase") {
8237
- if (phase.status !== input.expectedStatus)
8238
- throw new Error(`Expected phase status ${input.expectedStatus}, found ${phase.status}.`);
8239
- if (phase.status === "completed")
8240
- throw new Error(`Completed phase ${phase.id} cannot transition again.`);
8241
- assertExecutionOwner(plan, input.executionId);
8242
- assertPhaseTransition(phase.status, input.status);
8243
- if (input.status === "in_progress" || input.status === "completed")
8244
- assertPhaseDependenciesSatisfied(plan, phase);
8245
- if (input.status === "completed") {
8246
- if (phase.tasks.some((task) => task.status !== "completed" && task.status !== "skipped"))
8247
- throw new Error("All phase tasks must be complete or skipped.");
8248
- if (phase.gate.status !== "passed")
8249
- throw new Error("Phase gates must pass before completion.");
8250
- if (plan.execution?.policy === "commit-per-phase")
8251
- assertPhasePushEligible(plan, phase.id, input.executionId);
8252
- if (plan.execution?.policy === "commit-per-phase" && !input.commit)
8253
- throw new Error("Commit-per-phase execution requires commit metadata before phase completion.");
8254
- if (plan.execution?.policy === "commit-per-phase" && (!input.commit?.pushedAt || input.commit.ci?.status !== "pending"))
8255
- throw new Error("Commit-per-phase execution requires a pushed commit with pending CI monitoring.");
8256
- if (plan.execution?.policy === "no-commit" && input.commit)
8257
- throw new Error("No-commit execution cannot record a phase commit.");
8258
- }
8259
- const updated = {
8260
- ...phase,
8261
- status: input.status,
8262
- revision: phase.revision + 1,
8263
- commit: input.commit ?? phase.commit,
8264
- blocker: input.blockedReason ? blocker(input) : phase.blocker
8265
- };
8266
- if (input.status === "blocked" && input.blockedReason)
8267
- escalation = escalationFor(plan, updated.id, undefined, input);
8268
- const phases = plan.phases.map((item) => {
8269
- if (item.id === updated.id)
8270
- return updated;
8271
- if (input.status === "completed" && plan.execution?.policy === "commit-per-phase" && input.commit && item.status !== "completed" && item.status !== "skipped" && item.gate.status === "passed")
8272
- return { ...item, gate: { ...item.gate, status: "stale" } };
8273
- return item;
8274
- });
8275
- return { ...heartbeat(plan), phases };
8276
- }
8277
- const task = getTask(phase, input.target.id);
8278
- if (task.status !== input.expectedStatus)
8279
- throw new Error(`Expected task status ${input.expectedStatus}, found ${task.status}.`);
8280
- assertExecutionOwner(plan, input.executionId);
8281
- assertTaskTransition(task, input.status, input.executionId, input.actor);
8282
- if (input.status === "in_progress" || input.status === "completed") {
8283
- if (phase.status !== "in_progress")
8284
- throw new Error(`Phase ${phase.id} is not in progress.`);
8285
- assertTaskDependenciesSatisfied(plan, task);
8286
- }
8287
- const updatedTask = {
8288
- ...task,
8289
- status: input.status,
8290
- revision: task.revision + 1,
8291
- owner: input.status === "in_progress" ? input.actor : task.owner,
8292
- executionId: input.status === "in_progress" ? input.executionId : task.executionId,
8293
- blocker: input.blockedReason ? blocker(input) : task.blocker
8294
- };
8295
- if (input.status === "blocked" && input.blockedReason)
8296
- escalation = escalationFor(plan, phase.id, task.id, input);
8297
- const updatedPhase = {
8298
- ...phase,
8299
- revision: phase.revision + 1,
8300
- tasks: phase.tasks.map((item) => item.id === updatedTask.id ? updatedTask : item)
8301
- };
8302
- return {
8303
- ...heartbeat(plan),
8304
- phases: plan.phases.map((item) => item.id === updatedPhase.id ? updatedPhase : item)
8305
- };
8306
- });
8307
- await store.log(cwd, record.id, {
8308
- planRevision: record.document.revision,
8309
- kind: input.status === "blocked" ? "blocker" : "status",
8310
- actor: input.actor,
8311
- message: input.message,
8312
- executionId: input.executionId,
8313
- phaseId: input.target.type === "phase" ? input.target.id : escalation?.correlation.parentUnitId ?? escalation?.correlation.unitId,
8314
- taskId: input.target.type === "task" ? input.target.id : undefined,
8315
- evidence: input.evidence
8316
- });
8317
- return { record, escalation };
8318
- }
8319
- async function updateCi(store, cwd, query, input) {
8320
- const record = await store.mutate(cwd, query, "update CI status", (plan) => {
8321
- assertExecutionOwner(plan, input.executionId);
8322
- if (plan.execution?.policy !== "commit-per-phase")
8323
- throw new Error("CI status applies only to commit-per-phase execution.");
8324
- const phase = getPhase(plan, input.phaseId);
8325
- if (phase.status !== "completed")
8326
- throw new Error(`Phase ${phase.id} must be completed before CI can settle.`);
8327
- if (!phase.commit || phase.commit.sha !== input.sha)
8328
- throw new Error(`Phase ${phase.id} is not committed at ${input.sha}.`);
8329
- const current = phase.commit.ci?.status;
8330
- if (current !== "pending")
8331
- throw new Error(`CI for phase ${phase.id} is already ${current ?? "untracked"}.`);
8332
- const timestamp = new Date().toISOString();
8333
- const updated = {
8334
- ...phase,
8335
- revision: phase.revision + 1,
8336
- commit: {
8337
- ...phase.commit,
8338
- ci: {
8339
- ...phase.commit.ci,
8340
- status: input.status,
8341
- startedAt: phase.commit.ci?.startedAt ?? timestamp,
8342
- completedAt: timestamp,
8343
- detail: input.detail
8344
- }
8345
- }
8346
- };
8347
- return {
8348
- ...heartbeat(plan),
8349
- phases: plan.phases.map((item) => item.id === updated.id ? updated : item)
8350
- };
8351
- });
8352
- await store.log(cwd, record.id, {
8353
- planRevision: record.document.revision,
8354
- kind: "gate",
8355
- actor: input.actor,
8356
- message: `CI ${input.status} for ${input.phaseId}: ${input.detail}`,
8357
- executionId: input.executionId,
8358
- phaseId: input.phaseId,
8359
- evidence: [input.sha]
8360
- });
8361
- return record;
8362
- }
8363
- async function retryCi(store, cwd, query, input) {
8364
- let previousDetail;
8365
- const record = await store.mutate(cwd, query, "retry CI", (plan) => {
8366
- assertExecutionOwner(plan, input.executionId);
8367
- if (plan.execution?.policy !== "commit-per-phase")
8368
- throw new Error("CI retry applies only to commit-per-phase execution.");
8369
- const phase = getPhase(plan, input.phaseId);
8370
- if (phase.status !== "completed")
8371
- throw new Error(`Phase ${phase.id} must be completed before CI can retry.`);
8372
- if (!phase.commit || phase.commit.sha !== input.sha)
8373
- throw new Error(`Phase ${phase.id} is not committed at ${input.sha}.`);
8374
- if (phase.commit.ci?.status !== "failed")
8375
- throw new Error(`CI for phase ${phase.id} cannot retry from ${phase.commit.ci?.status ?? "untracked"}.`);
8376
- previousDetail = phase.commit.ci.detail;
8377
- const updated = {
8378
- ...phase,
8379
- revision: phase.revision + 1,
8380
- commit: {
8381
- ...phase.commit,
8382
- ci: { status: "pending", startedAt: new Date().toISOString() }
8383
- }
8384
- };
8385
- return {
8386
- ...heartbeat(plan),
8387
- phases: plan.phases.map((item) => item.id === updated.id ? updated : item)
8388
- };
8389
- });
8390
- await store.log(cwd, record.id, {
8391
- planRevision: record.document.revision,
8392
- kind: "gate",
8393
- actor: input.actor,
8394
- message: `Retrying CI for ${input.phaseId}: ${input.reason}`,
8395
- executionId: input.executionId,
8396
- phaseId: input.phaseId,
8397
- evidence: [input.sha],
8398
- data: { previousStatus: "failed", previousDetail }
8399
- });
8400
- return record;
8401
- }
8402
- function assertPhasePushEligible(plan, phaseId, executionId) {
8403
- assertExecutionOwner(plan, executionId);
8404
- if (plan.execution?.policy !== "commit-per-phase")
8405
- return;
8406
- const phaseIndex = plan.phases.findIndex((phase) => phase.id === phaseId);
8407
- if (phaseIndex < 0)
8408
- throw new Error(`Unknown phase: ${phaseId}.`);
8409
- const unsettled = plan.phases.slice(0, phaseIndex).filter((phase) => {
8410
- if (phase.status === "skipped")
8411
- return false;
8412
- return phase.status !== "completed" || !phase.commit?.pushedAt || !["passed", "skipped"].includes(phase.commit.ci?.status ?? "pending");
8413
- });
8414
- if (unsettled.length)
8415
- throw new Error(`Prior phase CI must settle successfully before another push: ${unsettled.map((phase) => phase.id).join(", ")}.`);
8416
- }
8417
- function assertPhaseDependenciesSatisfied(plan, phase) {
8418
- const unsettled = phase.dependencies.map((id) => getPhase(plan, id)).filter((dependency) => !dependencySatisfied(dependency.status));
8419
- if (unsettled.length)
8420
- throw new Error(`Phase ${phase.id} has incomplete dependencies: ${unsettled.map((dependency) => `${dependency.id} (${dependency.status})`).join(", ")}.`);
8421
- }
8422
- function assertTaskDependenciesSatisfied(plan, task) {
8423
- const tasks = plan.phases.flatMap((phase) => phase.tasks);
8424
- const unsettled = task.dependencies.map((id) => {
8425
- const dependency = tasks.find((candidate) => candidate.id === id);
8426
- if (!dependency)
8427
- throw new Error(`Unknown task dependency: ${id}.`);
8428
- return dependency;
8429
- }).filter((dependency) => !dependencySatisfied(dependency.status));
8430
- if (unsettled.length)
8431
- throw new Error(`Task ${task.id} has incomplete dependencies: ${unsettled.map((dependency) => `${dependency.id} (${dependency.status})`).join(", ")}.`);
8432
- }
8433
- function dependencySatisfied(status) {
8434
- return status === "completed" || status === "skipped";
8435
- }
8436
- var PLAN_TRANSITIONS = {
8437
- draft: ["ready"],
8438
- ready: ["draft", "in_progress"],
8439
- in_progress: ["blocked", "completed"],
8440
- blocked: ["draft", "ready", "in_progress"],
8441
- completed: []
8442
- };
8443
- var WORK_TRANSITIONS = {
8444
- pending: ["in_progress", "skipped"],
8445
- in_progress: ["blocked", "completed"],
8446
- blocked: ["pending", "in_progress", "skipped"],
8447
- completed: [],
8448
- skipped: []
8449
- };
8450
- function assertPlanTransition(from, to) {
8451
- if (from !== to && !PLAN_TRANSITIONS[from].includes(to))
8452
- throw new Error(`Invalid plan status transition: ${from} -> ${to}.`);
8453
- }
8454
- function assertPhaseTransition(from, to) {
8455
- if (from !== to && !WORK_TRANSITIONS[from].includes(to))
8456
- throw new Error(`Invalid phase status transition: ${from} -> ${to}.`);
8457
- }
8458
- function assertTaskTransition(task, to, executionId, actor) {
8459
- if (task.status !== to && !WORK_TRANSITIONS[task.status].includes(to))
8460
- throw new Error(`Invalid task status transition: ${task.status} -> ${to}.`);
8461
- if (task.status === "in_progress" && task.executionId && executionId !== task.executionId)
8462
- throw new Error(`Task ${task.id} is owned by execution ${task.executionId}.`);
8463
- if (task.status === "in_progress" && task.owner && actor && actor !== task.owner)
8464
- throw new Error(`Task ${task.id} is owned by ${task.owner}.`);
8465
- }
8466
- function getPhase(plan, phaseId) {
8467
- const phase = plan.phases.find((item) => item.id === phaseId);
8468
- if (!phase)
8469
- throw new Error(`Unknown phase: ${phaseId}.`);
8470
- return phase;
8471
- }
8472
- function findTaskPhase(plan, taskId) {
8473
- const phase = plan.phases.find((item) => item.tasks.some((task) => task.id === taskId));
8474
- if (!phase)
8475
- throw new Error(`Unknown task: ${taskId}.`);
8476
- return phase;
8477
- }
8478
- function getTask(phase, taskId) {
8479
- const task = phase.tasks.find((item) => item.id === taskId);
8480
- if (!task)
8481
- throw new Error(`Unknown task: ${taskId}.`);
8482
- return task;
8483
- }
8484
- function assertExecutionOwner(plan, executionId) {
8485
- if (!plan.execution?.active || !executionId || plan.execution.id !== executionId)
8486
- throw new Error(`Execution ${executionId ?? "(missing)"} does not own this plan.`);
8487
- }
8488
- function blocker(input) {
8489
- return {
8490
- reason: input.blockedReason,
8491
- attempts: input.attempts,
8492
- evidence: input.evidence,
8493
- needsUserDecision: input.needsUserDecision
8494
- };
8495
- }
8496
- function escalationFor(plan, phaseId, taskId, input) {
8497
- return {
8498
- correlation: {
8499
- workflow: "plan",
8500
- executionId: input.executionId,
8501
- unitId: taskId ?? phaseId,
8502
- parentUnitId: taskId ? phaseId : undefined,
8503
- metadata: { planId: plan.id }
8504
- },
8505
- blocker: input.blockedReason,
8506
- attempts: input.attempts ?? [],
8507
- evidence: input.evidence ?? [],
8508
- needsUserDecision: input.needsUserDecision ?? false
8509
- };
8510
- }
8511
- function heartbeat(plan) {
8512
- return plan.execution?.active ? { ...plan, execution: { ...plan.execution, heartbeatAt: new Date().toISOString() } } : plan;
8513
- }
8514
7726
  // src/setup.ts
8515
7727
  import { parseFrontmatter as parseFrontmatter3 } from "@earendil-works/pi-coding-agent";
8516
- import { readdir as readdir3, readFile as readFile15 } from "node:fs/promises";
7728
+ import { readdir as readdir4, readFile as readFile12 } from "node:fs/promises";
8517
7729
  import { homedir as homedir9 } from "node:os";
8518
- import { basename as basename7, join as join18 } from "node:path";
7730
+ import { basename as basename7, join as join17 } from "node:path";
8519
7731
  var MISE_DEPENDENCIES = [
8520
7732
  { name: "node", tool: "node", spec: "node@22", minimumVersion: "22.19.0" },
8521
7733
  { name: "zellij", tool: "zellij", spec: "zellij@latest", minimumVersion: undefined },
@@ -8554,7 +7766,7 @@ var FORGE_DEPENDENCIES = {
8554
7766
  };
8555
7767
  async function ensureMise(options = {}) {
8556
7768
  const homeDir = options.homeDir ?? homedir9();
8557
- const current = await mise.executableCheck() ?? await mise.executableCheck(join18(homeDir, ".local", "bin", "mise"));
7769
+ const current = await mise.executableCheck() ?? await mise.executableCheck(join17(homeDir, ".local", "bin", "mise"));
8558
7770
  if (current)
8559
7771
  return { executable: current, action: createSetupAction("mise", "ready", current) };
8560
7772
  reportProgress(options, "Installing mise");
@@ -8603,9 +7815,9 @@ async function ensureMiseDeps(miseExecutable, options = {}) {
8603
7815
  async function ensurePiPlugins(options = {}) {
8604
7816
  const actions = await ensurePiPackages(PI_PACKAGES, options);
8605
7817
  const agentDir = options.agentDir ?? pi.agentDir(options.homeDir);
8606
- const webSearch = await pi.configEnsure(join18(agentDir, "web-search.json"), (config) => ({ ...config, workflow: "auto-summary" }), options.dryRun);
7818
+ const webSearch = await pi.configEnsure(join17(agentDir, "web-search.json"), (config) => ({ ...config, workflow: "auto-summary" }), options.dryRun);
8607
7819
  actions.push(getConfigSetupAction("web search settings", webSearch));
8608
- const lsp = await pi.configEnsure(join18(agentDir, "pi-lsp.json"), (config) => ({
7820
+ const lsp = await pi.configEnsure(join17(agentDir, "pi-lsp.json"), (config) => ({
8609
7821
  ...config,
8610
7822
  progressive: { ...getRecord(config.progressive), enabled: true, inject: "none" }
8611
7823
  }), options.dryRun);
@@ -8616,11 +7828,11 @@ async function ensurePiAgents(options = {}) {
8616
7828
  const agentDir = options.agentDir ?? pi.agentDir(options.homeDir);
8617
7829
  const bundledAgentsDir = options.bundledAgentsDir ?? BUNDLED_AGENTS_DIR2;
8618
7830
  const userConfig = await loadDiffpiConfig({ homeDir: options.homeDir });
8619
- 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));
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));
8620
7832
  const actions = [];
8621
7833
  for (const entry of entries) {
8622
7834
  const id = basename7(entry.name, ".md").replace(/^diffpi-/, "");
8623
- const source = await readFile15(join18(bundledAgentsDir, entry.name), "utf8");
7835
+ const source = await readFile12(join17(bundledAgentsDir, entry.name), "utf8");
8624
7836
  const content = materializeAgentModels(source, id, userConfig.config, options.availableModels);
8625
7837
  const result = await pi.agentEnsure(entry.name, content, agentDir, options.dryRun);
8626
7838
  actions.push(getConfigSetupAction(`pi agent ${id}`, result));
@@ -8629,7 +7841,7 @@ async function ensurePiAgents(options = {}) {
8629
7841
  }
8630
7842
  async function ensurePiSkills(miseExecutable, options = {}) {
8631
7843
  const agentDir = options.agentDir ?? pi.agentDir(options.homeDir);
8632
- const sharedSkillsDir = join18(options.homeDir ?? homedir9(), ".agents", "skills");
7844
+ const sharedSkillsDir = join17(options.homeDir ?? homedir9(), ".agents", "skills");
8633
7845
  const actions = [];
8634
7846
  for (const source of PI_SKILL_SOURCES) {
8635
7847
  const missing = [];
@@ -8825,7 +8037,7 @@ function reportProgress(options, message) {
8825
8037
  options.onProgress?.(message);
8826
8038
  }
8827
8039
  async function packageVersion2() {
8828
- const source = await readFile15(join18(resolveBundledAgentsDir(), "..", "package.json"), "utf8");
8040
+ const source = await readFile12(join17(resolveBundledAgentsDir(), "..", "package.json"), "utf8");
8829
8041
  let value;
8830
8042
  try {
8831
8043
  value = JSON.parse(source);
@@ -8850,13 +8062,13 @@ export {
8850
8062
  addComment,
8851
8063
  assertGitHubMergeReady,
8852
8064
  assertReviewEventSupported,
8065
+ captureLocalReview,
8853
8066
  checkConventionalSubject,
8854
8067
  computeProjectSlug,
8855
8068
  createForge,
8856
8069
  createForgeBackend,
8857
- createLocalReviewBackend,
8858
8070
  createModeController,
8859
- createPlanController,
8071
+ createPlanStore,
8860
8072
  createRemoteReviewBackend,
8861
8073
  createVcsBackend,
8862
8074
  dedupeFindings,
@@ -8887,45 +8099,36 @@ export {
8887
8099
  launch,
8888
8100
  listSessions,
8889
8101
  loadDiffpiConfig,
8890
- loadReviewPublicationState,
8891
8102
  loadTemplate,
8892
8103
  localReviewAuthor,
8893
8104
  mcp,
8894
8105
  mise,
8106
+ openFileAdjacent,
8895
8107
  openInNewTab,
8896
8108
  parseGitlabDiffRefs,
8897
8109
  parseRemote,
8898
- parseThreadArtifact,
8899
8110
  pi,
8900
8111
  plansDir,
8112
+ readLocalReview,
8901
8113
  readSession,
8902
8114
  renderReviewDoc,
8903
8115
  renderTemplate,
8904
- renderThreadArtifact,
8905
8116
  resolveAgentMode,
8906
8117
  resolveAgentModelPreferences,
8907
8118
  resolvePrSession,
8908
8119
  resolveReviewSession,
8909
8120
  resolveSession,
8910
- reviewBodyFingerprint,
8911
- reviewCommentFingerprint,
8912
8121
  reviewRecordName,
8913
- reviewReplyFingerprint,
8914
8122
  reviewSlug,
8915
8123
  reviewsDir,
8916
8124
  runMiseGates,
8917
- saveReviewPublicationState,
8918
- sessionsDir,
8919
8125
  setupPi,
8920
8126
  severitySchema,
8921
8127
  storeDir,
8922
8128
  storeGlobalRoot,
8923
8129
  templateRelativePath,
8924
- toFindings,
8925
8130
  toReviewComments,
8926
8131
  tuicrAvailable,
8927
- unpublishedReviewComments,
8928
- upsertThreadReply,
8929
8132
  withRemoteProvenance,
8930
8133
  yymmdd,
8931
8134
  zedKeymapPath,