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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/README.md +11 -9
  2. package/agents/diffpi-orchestrator.md +6 -4
  3. package/agents/diffpi-plan-reviewer.md +27 -0
  4. package/agents/diffpi-planner.md +5 -5
  5. package/agents/diffpi-reviewer.md +4 -4
  6. package/agents/diffpi-worker.md +1 -1
  7. package/dist/assets.d.ts +0 -1
  8. package/dist/assets.d.ts.map +1 -1
  9. package/dist/cli.js +446 -160
  10. package/dist/commands/plan.d.ts +2 -2
  11. package/dist/commands/plan.d.ts.map +1 -1
  12. package/dist/commands/review.d.ts +2 -2
  13. package/dist/commands/review.d.ts.map +1 -1
  14. package/dist/environment.d.ts +2 -0
  15. package/dist/environment.d.ts.map +1 -1
  16. package/dist/extensions/index.js +1841 -3858
  17. package/dist/extensions/tuicrx.d.ts +8 -1
  18. package/dist/extensions/tuicrx.d.ts.map +1 -1
  19. package/dist/index.d.ts +8 -7
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +951 -349
  22. package/dist/plan/markdown.d.ts +4 -1
  23. package/dist/plan/markdown.d.ts.map +1 -1
  24. package/dist/{tools/plan-schema.d.ts → plan/schema.d.ts} +113 -75
  25. package/dist/plan/schema.d.ts.map +1 -0
  26. package/dist/plan/store.d.ts +39 -1
  27. package/dist/plan/store.d.ts.map +1 -1
  28. package/dist/plan/types.d.ts +27 -3
  29. package/dist/plan/types.d.ts.map +1 -1
  30. package/dist/review/github-review-backend.d.ts.map +1 -1
  31. package/dist/review/gitlab-review-backend.d.ts.map +1 -1
  32. package/dist/review/index.d.ts +6 -6
  33. package/dist/review/index.d.ts.map +1 -1
  34. package/dist/review/local-review-backend.d.ts +3 -0
  35. package/dist/review/local-review-backend.d.ts.map +1 -0
  36. package/dist/review/review-backend.d.ts +3 -2
  37. package/dist/review/review-backend.d.ts.map +1 -1
  38. package/dist/review/review-markdown.d.ts +4 -1
  39. package/dist/review/review-markdown.d.ts.map +1 -1
  40. package/dist/review/review-state.d.ts +20 -0
  41. package/dist/review/review-state.d.ts.map +1 -0
  42. package/dist/review/types.d.ts +28 -0
  43. package/dist/review/types.d.ts.map +1 -1
  44. package/dist/store.d.ts +2 -0
  45. package/dist/store.d.ts.map +1 -1
  46. package/dist/tools/index.js +1668 -607
  47. package/dist/tools/plan.d.ts.map +1 -1
  48. package/dist/tools/review.d.ts +38 -0
  49. package/dist/tools/review.d.ts.map +1 -1
  50. package/package.json +2 -4
  51. package/skills/plan/SKILL.md +8 -14
  52. package/skills/plan/references/design-quality.md +30 -0
  53. package/skills/plan/references/workflows/annotate.md +11 -0
  54. package/skills/plan/references/workflows/finalize.md +7 -0
  55. package/skills/plan/references/workflows/go.md +8 -0
  56. package/skills/plan/references/workflows/help.md +10 -0
  57. package/skills/plan/references/workflows/init.md +5 -0
  58. package/skills/plan/references/workflows/new.md +7 -0
  59. package/skills/plan/references/workflows/update.md +7 -0
  60. package/skills/review/SKILL.md +7 -7
  61. package/skills/review/references/review-standards.md +8 -1
  62. package/skills/review/references/workflows/address.md +11 -0
  63. package/skills/review/references/workflows/auto.md +18 -0
  64. package/skills/review/references/workflows/complete.md +7 -0
  65. package/skills/review/references/workflows/edit.md +7 -0
  66. package/skills/review/references/workflows/help.md +36 -0
  67. package/skills/review/references/workflows/merge.md +6 -0
  68. package/skills/review/references/workflows/new.md +19 -0
  69. package/skills/review/references/workflows/open.md +3 -0
  70. package/skills/review/references/workflows/publish.md +8 -0
  71. package/skills/review/references/workflows/status.md +3 -0
  72. package/templates/plan/PLAN.md +17 -11
  73. package/templates/plan/implementation.md +34 -12
  74. package/dist/commands.d.ts +0 -22
  75. package/dist/commands.d.ts.map +0 -1
  76. package/dist/extensions/cmdtsx.d.ts +0 -2
  77. package/dist/extensions/cmdtsx.d.ts.map +0 -1
  78. package/dist/tools/plan-schema.d.ts.map +0 -1
  79. package/dist/workflow.d.ts +0 -80
  80. package/dist/workflow.d.ts.map +0 -1
  81. package/workflows/plan/annotate.md +0 -13
  82. package/workflows/plan/finalize.md +0 -15
  83. package/workflows/plan/go.md +0 -20
  84. package/workflows/plan/help.md +0 -20
  85. package/workflows/plan/init.md +0 -13
  86. package/workflows/plan/new.md +0 -20
  87. package/workflows/plan/update.md +0 -18
  88. package/workflows/review/address.md +0 -8
  89. package/workflows/review/auto.md +0 -8
  90. package/workflows/review/complete.md +0 -7
  91. package/workflows/review/edit.md +0 -7
  92. package/workflows/review/help.md +0 -14
  93. package/workflows/review/merge.md +0 -6
  94. package/workflows/review/new.md +0 -7
  95. package/workflows/review/publish.md +0 -6
@@ -503,6 +503,16 @@ function diffpiLaunchName(cwd, workflow) {
503
503
  }
504
504
  async function openFileAdjacent(path, opts) {
505
505
  const env = opts.env ?? process.env;
506
+ const mux = detectMux(env);
507
+ if (mux !== "none") {
508
+ const editor = await resolveEditor(env);
509
+ if (editor) {
510
+ const command = persistentEditorArgs(editor, path, opts.cwd, env.SHELL);
511
+ const opened = await openMuxTab(mux, command, opts.cwd, opts.name ?? "plan", [editor, path].join(" "), command);
512
+ if (opened)
513
+ return opened;
514
+ }
515
+ }
506
516
  const ide = detectIde(env);
507
517
  if (ide !== "unknown") {
508
518
  const ideCommand = { zed: "zed", vscode: "code", cursor: "cursor", windsurf: "windsurf", jetbrains: "idea" }[ide];
@@ -512,16 +522,6 @@ async function openFileAdjacent(path, opts) {
512
522
  return { launched: true, via: ide, command: `${ideCommand} ${path}` };
513
523
  }
514
524
  }
515
- const mux = detectMux(env);
516
- if (mux !== "none") {
517
- const editor = await resolveEditor(env);
518
- if (editor) {
519
- const command = [editor, path];
520
- const opened = await openMuxTab(mux, command, opts.cwd, opts.name ?? "plan", command.join(" "));
521
- if (opened)
522
- return opened;
523
- }
524
- }
525
525
  return { launched: false, via: "print", command: path, reason: "No supported IDE or multiplexer was available." };
526
526
  }
527
527
  async function openInNewTab(command, opts) {
@@ -556,10 +556,15 @@ async function openInNewTab(command, opts) {
556
556
  function screenWindowArgs(command, cwd, name) {
557
557
  return ["-X", "screen", "-t", name, "sh", "-lc", 'cd -- "$1" && shift && exec "$@"', "sh", cwd, ...command];
558
558
  }
559
+ function persistentEditorArgs(editor, path, cwd, shell = "sh") {
560
+ return ["bash", "-lc", 'cd -- "$1" && shift && "$@"; exec "$0" -i', shell, cwd, editor, path];
561
+ }
559
562
  async function resolveEditor(env) {
560
- const configured = (env.VISUAL ?? env.EDITOR)?.trim().split(/\s+/)[0];
561
- if (configured && await findExecutable(configured))
562
- return configured;
563
+ for (const configured of [env.EDITOR, env.VISUAL]) {
564
+ const editor = configured?.trim();
565
+ if (editor && await findExecutable(editor))
566
+ return editor;
567
+ }
563
568
  for (const candidate of ["hx", "nvim", "vim"])
564
569
  if (await findExecutable(candidate))
565
570
  return candidate;
@@ -576,7 +581,7 @@ async function packageVersion() {
576
581
  throw new Error("Cannot resolve the installed @difflab/pi version.");
577
582
  return value.version;
578
583
  }
579
- async function openMuxTab(mux, command, cwd, name, printable) {
584
+ async function openMuxTab(mux, command, cwd, name, printable, tmuxCommand) {
580
585
  if (mux === "zellij" && await findExecutable("zellij")) {
581
586
  const result = await run("zellij", ["action", "new-tab", "--cwd", cwd, "--name", name, "--", ...command]);
582
587
  if (result.code === 0)
@@ -586,7 +591,7 @@ async function openMuxTab(mux, command, cwd, name, printable) {
586
591
  return { launched: true, via: "zellij-run", command: printable };
587
592
  }
588
593
  if (mux === "tmux" && await findExecutable("tmux")) {
589
- const result = await run("tmux", ["new-window", "-c", cwd, "-n", name, printable]);
594
+ const result = tmuxCommand ? await run("tmux", ["new-window", "-c", cwd, "-n", name, ...tmuxCommand]) : await run("tmux", ["new-window", "-c", cwd, "-n", name, printable]);
590
595
  if (result.code === 0)
591
596
  return { launched: true, via: "tmux", command: printable };
592
597
  }
@@ -1069,6 +1074,18 @@ async function reviewsDir(cwd, homeDir = homedir2()) {
1069
1074
  await mkdir2(dir, { recursive: true });
1070
1075
  return dir;
1071
1076
  }
1077
+ async function completedReviewsDir(cwd, homeDir = homedir2()) {
1078
+ const store = await ensureStore(cwd, homeDir);
1079
+ const dir = join6(store.link, "reviews");
1080
+ await mkdir2(dir, { recursive: true });
1081
+ return dir;
1082
+ }
1083
+ async function sessionsDir(cwd, homeDir = homedir2()) {
1084
+ const store = await ensureStore(cwd, homeDir);
1085
+ const dir = join6(store.link, "sessions");
1086
+ await mkdir2(dir, { recursive: true });
1087
+ return dir;
1088
+ }
1072
1089
  async function assertStoreLink(path, dest) {
1073
1090
  const entry = await lstat(path);
1074
1091
  if (!entry.isSymbolicLink())
@@ -1510,6 +1527,9 @@ async function discoverMiseTasks(cwd) {
1510
1527
 
1511
1528
  // src/plan/markdown.ts
1512
1529
  var import_mdast_builder = __toESM(require_src(), 1);
1530
+ import { createHash as createHash2 } from "node:crypto";
1531
+ import { readFile as readFile5, readdir } from "node:fs/promises";
1532
+ import { join as join10 } from "node:path";
1513
1533
 
1514
1534
  // ../../node_modules/zwitch/index.js
1515
1535
  var own = {}.hasOwnProperty;
@@ -6149,6 +6169,8 @@ function isUint8Array2(value) {
6149
6169
  var PLAN_MARKER = /<!-- diffpi-plan: (\{[^\n]+\}) -->/;
6150
6170
  var PHASE_MARKER = /<!-- diffpi-phase: (\{[^\n]+\}) -->/g;
6151
6171
  var TASK_MARKER = /<!-- diffpi-task: (\{[^\n]+\}) -->/g;
6172
+ var BRIEF_MARKER = /<!-- diffpi-implementation: (\{[^\n]+\}) -->/;
6173
+ var BRIEF_TASK_MARKER = /<!-- diffpi-brief-task: (\{[^\n]+\}) -->/g;
6152
6174
  var SUPPORTED_SCHEMA = 1;
6153
6175
  function countDesignWords(plan) {
6154
6176
  return [plan.design.bigIdeas, plan.design.keyApiUpdates, plan.design.consequences].join(" ").trim().split(/\s+/).filter(Boolean).length;
@@ -6177,11 +6199,13 @@ function validatePlanDocument(plan, options = {}) {
6177
6199
  if (!taskIds.has(dependency))
6178
6200
  add("dangling-dependency", "error", `Task ${task.id} depends on missing ${dependency}.`);
6179
6201
  }
6180
- if (options.strict && task.acceptanceCriteria.length === 0)
6181
- add("acceptance", "error", `Task ${task.id} has no acceptance criteria.`, task.id);
6202
+ if (options.strict && isPlaceholder(task.title))
6203
+ add("placeholder", "error", `Task ${task.id} has placeholder content.`, task.id);
6182
6204
  }
6183
6205
  if (options.strict && phase.tasks.length === 0)
6184
6206
  add("empty-phase", "error", `Phase ${phase.id} has no tasks.`, phase.id);
6207
+ if (options.strict && (isPlaceholder(phase.title) || isPlaceholder(phase.objective)))
6208
+ add("placeholder", "error", `Phase ${phase.id} has placeholder content.`, phase.id);
6185
6209
  }
6186
6210
  for (const cycle of dependencyCycles(plan))
6187
6211
  add("dependency-cycle", "error", `Dependency cycle: ${cycle.join(" -> ")}.`);
@@ -6190,10 +6214,140 @@ function validatePlanDocument(plan, options = {}) {
6190
6214
  add("design-length", "warning", `Design is ${designWords} words; prefer 300 or fewer.`);
6191
6215
  if (options.strict && designWords > 800)
6192
6216
  add("design-too-long", "error", `Design is ${designWords} words; finalization allows at most 800.`);
6193
- if (options.strict && plan.phases.length === 0)
6194
- add("no-phases", "error", "Finalization requires at least one phase.");
6217
+ if (options.strict) {
6218
+ if (plan.phases.length === 0)
6219
+ add("no-phases", "error", "Finalization requires at least one phase.");
6220
+ if (isPlaceholder(plan.intent))
6221
+ add("placeholder", "error", "Intent must contain concrete content.", "intent");
6222
+ if (!plan.requirements.length || plan.requirements.some(isPlaceholder))
6223
+ add("requirements", "error", "Requirements must contain concrete content.", "requirements");
6224
+ for (const [key, value] of Object.entries(plan.design)) {
6225
+ if (isPlaceholder(value))
6226
+ add("design", "error", `Design/${key} must contain concrete content.`, `design.${key}`);
6227
+ }
6228
+ }
6229
+ return issues;
6230
+ }
6231
+ function validatePlanBriefs(plan, briefs) {
6232
+ const issues = [];
6233
+ const phaseIds = plan.phases.map((phase) => phase.id);
6234
+ const briefIds = briefs.map((brief) => brief.phaseId);
6235
+ if (!sameIds(phaseIds, briefIds))
6236
+ issues.push({
6237
+ code: "brief-phase-ids",
6238
+ severity: "error",
6239
+ message: `Brief phase IDs must exactly match plan phases in order: ${phaseIds.join(", ")}.`
6240
+ });
6241
+ for (const [index, phase] of plan.phases.entries()) {
6242
+ const brief = briefs[index];
6243
+ if (!brief || brief.phaseId !== phase.id)
6244
+ continue;
6245
+ if (isPlaceholder(brief.summary))
6246
+ issues.push({ code: "brief-placeholder", severity: "error", message: `Brief ${phase.id} needs a summary.` });
6247
+ const taskIds = phase.tasks.map((task) => task.id);
6248
+ const briefTaskIds = brief.tasks.map((task) => task.taskId);
6249
+ if (!sameIds(taskIds, briefTaskIds))
6250
+ issues.push({
6251
+ code: "brief-task-ids",
6252
+ severity: "error",
6253
+ message: `Brief ${phase.id} task IDs must exactly match phase tasks in order: ${taskIds.join(", ")}.`
6254
+ });
6255
+ for (const task of brief.tasks) {
6256
+ for (const [field, values] of [
6257
+ ["ordered steps", task.steps],
6258
+ ["file scopes", task.fileScopes],
6259
+ ["acceptance criteria", task.acceptanceCriteria]
6260
+ ]) {
6261
+ if (!values.length || values.some(isPlaceholder))
6262
+ issues.push({
6263
+ code: "brief-incomplete",
6264
+ severity: "error",
6265
+ message: `Brief task ${task.taskId} needs concrete ${field}.`,
6266
+ path: task.taskId
6267
+ });
6268
+ }
6269
+ }
6270
+ for (const [field, values] of [
6271
+ ["API changes", brief.apiChanges],
6272
+ ["libraries", brief.libraries],
6273
+ ["constraints", brief.constraints]
6274
+ ]) {
6275
+ if (values.some(isPlaceholder))
6276
+ issues.push({
6277
+ code: "brief-placeholder",
6278
+ severity: "error",
6279
+ message: `Brief ${phase.id} has placeholder ${field}.`,
6280
+ path: phase.id
6281
+ });
6282
+ }
6283
+ }
6195
6284
  return issues;
6196
6285
  }
6286
+ async function validatePlanRecord(record, options = {}) {
6287
+ const issues = validatePlanDocument(record.document, options);
6288
+ if (!options.strict)
6289
+ return issues;
6290
+ await validateImplementationDirectory(record.dir, record.document, issues, "latest");
6291
+ if (options.checkSnapshots !== false)
6292
+ await validateRevisionSnapshots(record, issues);
6293
+ return issues;
6294
+ }
6295
+ function renderImplementationBrief(planRevision, ordinal, phase, brief) {
6296
+ const marker = { schemaVersion: 1, planRevision, ordinal, phaseId: phase.id };
6297
+ const tasks = phase.tasks.map((task, index) => {
6298
+ const details = brief.tasks[index];
6299
+ if (!details || details.taskId !== task.id)
6300
+ throw new Error(`Missing implementation details for ${task.id}.`);
6301
+ return `<!-- diffpi-brief-task: ${json({ id: task.id })} -->
6302
+ ### Task: ${task.title}
6303
+
6304
+ #### Ordered Steps
6305
+
6306
+ ${bulletList(details.steps)}
6307
+
6308
+ #### File Scopes
6309
+
6310
+ ${bulletList(details.fileScopes)}
6311
+
6312
+ #### Acceptance Criteria
6313
+
6314
+ ${bulletList(details.acceptanceCriteria)}
6315
+
6316
+ <!-- /diffpi-brief-task -->`;
6317
+ }).join(`
6318
+
6319
+ `);
6320
+ return `<!-- diffpi-implementation: ${json(marker)} -->
6321
+ # Phase ${ordinal}: ${phase.title}
6322
+
6323
+ - **Phase ID:** ${phase.id}
6324
+ - **Plan Revision:** ${planRevision}
6325
+
6326
+ ## Summary
6327
+
6328
+ ${brief.summary}
6329
+
6330
+ ## Objective
6331
+
6332
+ ${phase.objective}
6333
+
6334
+ ## Tasks
6335
+
6336
+ ${tasks}
6337
+
6338
+ ## API Changes
6339
+
6340
+ ${bulletList(brief.apiChanges, "None.")}
6341
+
6342
+ ## Libraries and Algorithms
6343
+
6344
+ ${bulletList(brief.libraries, "None.")}
6345
+
6346
+ ## Implementation Constraints
6347
+
6348
+ ${bulletList(brief.constraints, "None.")}
6349
+ `;
6350
+ }
6197
6351
  function renderPlanDocument(plan, previousSource) {
6198
6352
  if (previousSource && canPatch(previousSource, plan))
6199
6353
  return patchMarkers(previousSource, plan);
@@ -6202,6 +6356,8 @@ function renderPlanDocument(plan, previousSource) {
6202
6356
  id: plan.id,
6203
6357
  revision: plan.revision,
6204
6358
  branch: plan.branch,
6359
+ issueId: plan.issueId,
6360
+ issueUrl: plan.issueUrl,
6205
6361
  status: plan.status,
6206
6362
  execution: plan.execution,
6207
6363
  createdAt: plan.createdAt,
@@ -6210,6 +6366,8 @@ function renderPlanDocument(plan, previousSource) {
6210
6366
  const metadata = import_mdast_builder.list("unordered", [
6211
6367
  ["Plan ID", plan.id],
6212
6368
  ["Branch", plan.branch],
6369
+ ...plan.issueId ? [["Issue", plan.issueId]] : [],
6370
+ ...plan.issueUrl ? [["Issue URL", plan.issueUrl]] : [],
6213
6371
  ["Status", plan.status],
6214
6372
  ["Revision", String(plan.revision)]
6215
6373
  ].map(([label, value]) => import_mdast_builder.listItem(import_mdast_builder.paragraph([import_mdast_builder.strong(import_mdast_builder.text(`${label}:`)), import_mdast_builder.text(` ${value}`)]))));
@@ -6231,7 +6389,7 @@ function renderPlanDocument(plan, previousSource) {
6231
6389
  import_mdast_builder.heading(3, import_mdast_builder.text("Consequences")),
6232
6390
  import_mdast_builder.html(plan.design.consequences || "<!-- Describe trade-offs and limitations. -->"),
6233
6391
  import_mdast_builder.heading(2, import_mdast_builder.text("Implementation")),
6234
- ...plan.phases.length ? plan.phases.flatMap((phase) => [import_mdast_builder.html(renderPhase(phase))]) : [import_mdast_builder.html("<!-- Add phases with plan_add_phase. -->")],
6392
+ ...plan.phases.length ? plan.phases.flatMap((phase) => [import_mdast_builder.html(renderPhase(phase))]) : [import_mdast_builder.html("<!-- Add phases with plan_apply_revision. -->")],
6235
6393
  import_mdast_builder.heading(2, import_mdast_builder.text("References")),
6236
6394
  references
6237
6395
  ]);
@@ -6305,10 +6463,7 @@ function renderTask(task) {
6305
6463
  const checked = task.status === "completed" || task.status === "skipped" ? "x" : " ";
6306
6464
  return `<!-- diffpi-task: ${json(marker)} -->
6307
6465
  - [${checked}] **${task.title}**
6308
- ${taskList("Steps", task.steps ?? [])}
6309
6466
  - Dependencies: ${list3(task.dependencies)}
6310
- ${taskList("File scopes", task.fileScopes)}
6311
- ${taskList("Acceptance criteria", task.acceptanceCriteria)}
6312
6467
  <!-- /diffpi-task -->`;
6313
6468
  }
6314
6469
  function parsePhases(input, ref) {
@@ -6358,10 +6513,7 @@ function parseTasks(input, ref) {
6358
6513
  return {
6359
6514
  ...marker,
6360
6515
  title,
6361
- steps: parseListValue(body, "Steps"),
6362
- dependencies: parseListValue(body, "Dependencies"),
6363
- fileScopes: parseListValue(body, "File scopes"),
6364
- acceptanceCriteria: parseListValue(body, "Acceptance criteria")
6516
+ dependencies: parseListValue(body, "Dependencies")
6365
6517
  };
6366
6518
  });
6367
6519
  assertUniqueIds(tasks.map((task) => task.id), "task id");
@@ -6388,6 +6540,8 @@ function patchMarkers(source, plan) {
6388
6540
  id: plan.id,
6389
6541
  revision: plan.revision,
6390
6542
  branch: plan.branch,
6543
+ issueId: plan.issueId,
6544
+ issueUrl: plan.issueUrl,
6391
6545
  status: plan.status,
6392
6546
  execution: plan.execution,
6393
6547
  createdAt: plan.createdAt,
@@ -6437,19 +6591,12 @@ function contentShape(plan) {
6437
6591
  requirements: plan.requirements,
6438
6592
  design: plan.design,
6439
6593
  references: plan.references,
6440
- phases: plan.phases.map(({ id, title, objective, dependencies, tasks }) => ({
6441
- id,
6442
- title,
6443
- objective,
6444
- dependencies,
6445
- tasks: tasks.map(({ id: taskId, title: taskTitle, steps, dependencies: taskDependencies, fileScopes, acceptanceCriteria }) => ({
6446
- id: taskId,
6447
- title: taskTitle,
6448
- steps,
6449
- dependencies: taskDependencies,
6450
- fileScopes,
6451
- acceptanceCriteria
6452
- }))
6594
+ phases: plan.phases.map((phase) => ({
6595
+ id: phase.id,
6596
+ title: phase.title,
6597
+ objective: phase.objective,
6598
+ dependencies: phase.dependencies,
6599
+ tasks: phase.tasks.map(({ id, title, dependencies }) => ({ id, title, dependencies }))
6453
6600
  }))
6454
6601
  };
6455
6602
  }
@@ -6479,6 +6626,152 @@ function dependencyCycles(plan) {
6479
6626
  walk(id, []);
6480
6627
  return cycles;
6481
6628
  }
6629
+ async function validateRevisionSnapshots(record, issues) {
6630
+ const revisionsDir = join10(record.dir, "revisions");
6631
+ let entries;
6632
+ try {
6633
+ entries = await readdir(revisionsDir);
6634
+ } catch {
6635
+ issues.push({ code: "snapshot-missing", severity: "error", message: "Missing revisions directory." });
6636
+ return;
6637
+ }
6638
+ const expected = Array.from({ length: record.document.revision + 1 }, (_, index) => String(index));
6639
+ if (!sameIds(expected, entries.filter((entry) => /^\d+$/.test(entry)).sort((a, b) => Number(a) - Number(b))))
6640
+ issues.push({
6641
+ code: "snapshot-sequence",
6642
+ severity: "error",
6643
+ message: `Revision snapshots must be exactly ${expected.join(", ")}.`,
6644
+ path: revisionsDir
6645
+ });
6646
+ for (const entry of entries) {
6647
+ if (!/^\d+$/.test(entry))
6648
+ issues.push({ code: "snapshot-entry", severity: "error", message: `Unexpected revisions entry: ${entry}.` });
6649
+ }
6650
+ for (const revision of expected) {
6651
+ const dir = join10(revisionsDir, revision);
6652
+ try {
6653
+ const [request, metadataSource, planSource] = await Promise.all([
6654
+ readFile5(join10(dir, "request.md"), "utf8"),
6655
+ readFile5(join10(dir, "metadata.json"), "utf8"),
6656
+ readFile5(join10(dir, "PLAN.md"), "utf8")
6657
+ ]);
6658
+ if (isPlaceholder(request))
6659
+ issues.push({ code: "snapshot-request", severity: "error", message: `Revision ${revision} request is empty.` });
6660
+ const metadata = JSON.parse(metadataSource);
6661
+ const annotation = metadata.requestKind === "annotation" ? /^## Original annotation\n([\s\S]*?)\n\n## LLM response\n([\s\S]*)$/.exec(request) : undefined;
6662
+ const requestText = annotation?.[1];
6663
+ const responseText = annotation?.[2];
6664
+ if (metadata.schemaVersion !== 1 || metadata.id !== record.id || metadata.revision !== Number(revision) || !["user", "annotation", "blocker"].includes(metadata.requestKind) || (metadata.requestKind === "annotation" ? !annotation || !requestText?.trim() || !responseText?.trim() || metadata.requestSha256 !== sha256(requestText) || metadata.originalAnnotationSha256 !== sha256(requestText) || metadata.responseSha256 !== sha256(responseText) : metadata.requestSha256 !== sha256(request)))
6665
+ issues.push({
6666
+ code: "snapshot-metadata",
6667
+ severity: "error",
6668
+ message: `Revision ${revision} metadata does not match its immutable request.`
6669
+ });
6670
+ const snapshotPlan = parsePlanDocument(planSource, join10(dir, "PLAN.md"));
6671
+ if (snapshotPlan.id !== record.id || snapshotPlan.revision !== Number(revision))
6672
+ issues.push({
6673
+ code: "snapshot-plan",
6674
+ severity: "error",
6675
+ message: `Revision ${revision} PLAN.md has mismatched identity or revision.`
6676
+ });
6677
+ await validateImplementationDirectory(dir, snapshotPlan, issues, `revision ${revision}`);
6678
+ } catch (error) {
6679
+ issues.push({
6680
+ code: "snapshot-missing",
6681
+ severity: "error",
6682
+ message: `Revision ${revision} is incomplete: ${error.message}`,
6683
+ path: dir
6684
+ });
6685
+ }
6686
+ }
6687
+ }
6688
+ async function validateImplementationDirectory(baseDir, plan, issues, label) {
6689
+ const dir = join10(baseDir, "implementation");
6690
+ let entries;
6691
+ try {
6692
+ entries = await readdir(dir);
6693
+ } catch {
6694
+ issues.push({
6695
+ code: "brief-directory",
6696
+ severity: "error",
6697
+ message: `${label} is missing implementation/.`,
6698
+ path: dir
6699
+ });
6700
+ return;
6701
+ }
6702
+ const expected = plan.phases.map((_phase, index) => `phase-${index + 1}.md`);
6703
+ const actual = entries.filter((entry) => entry.endsWith(".md")).sort((left, right) => left.localeCompare(right));
6704
+ if (!sameIds([...expected].sort((left, right) => left.localeCompare(right)), actual))
6705
+ issues.push({
6706
+ code: "brief-filenames",
6707
+ severity: "error",
6708
+ message: `${label} briefs must use exactly: ${expected.join(", ") || "(none)"}.`,
6709
+ path: dir
6710
+ });
6711
+ for (const [index, phase] of plan.phases.entries()) {
6712
+ const path = join10(dir, `phase-${index + 1}.md`);
6713
+ try {
6714
+ validateBriefSource(await readFile5(path, "utf8"), plan.revision, index + 1, phase, path, issues);
6715
+ } catch (error) {
6716
+ issues.push({
6717
+ code: "brief-missing",
6718
+ severity: "error",
6719
+ message: `${label} brief phase-${index + 1}.md is missing: ${error.message}`,
6720
+ path
6721
+ });
6722
+ }
6723
+ }
6724
+ }
6725
+ function validateBriefSource(source, planRevision, ordinal, phase, path, issues) {
6726
+ const matches = source.match(new RegExp(BRIEF_MARKER.source, "g")) ?? [];
6727
+ const markerMatch = source.match(BRIEF_MARKER);
6728
+ if (matches.length !== 1 || !markerMatch) {
6729
+ issues.push({ code: "brief-marker", severity: "error", message: `${path} needs one implementation marker.`, path });
6730
+ return;
6731
+ }
6732
+ const marker = parseMarker(markerMatch[1], `${path} implementation`);
6733
+ if (marker.schemaVersion !== 1 || marker.planRevision !== planRevision || marker.ordinal !== ordinal || marker.phaseId !== phase.id)
6734
+ issues.push({
6735
+ code: "brief-marker",
6736
+ severity: "error",
6737
+ message: `${path} marker must identify phase ${ordinal} (${phase.id}) at revision ${planRevision}.`,
6738
+ path
6739
+ });
6740
+ const taskIds = [...source.matchAll(BRIEF_TASK_MARKER)].map((match) => parseMarker(match[1], `${path} task`).id);
6741
+ if (!sameIds(phase.tasks.map((task) => task.id), taskIds))
6742
+ issues.push({
6743
+ code: "brief-task-ids",
6744
+ severity: "error",
6745
+ message: `${path} task markers must exactly match phase task IDs in order.`,
6746
+ path
6747
+ });
6748
+ const prose = source.replace(BRIEF_MARKER, "").replace(BRIEF_TASK_MARKER, "").replace(/<!-- \/diffpi-brief-task -->/g, "");
6749
+ if (/<!--[^]*?-->|\b(?:TODO|TBD)\b|define during implementation/i.test(prose))
6750
+ issues.push({
6751
+ code: "brief-placeholder",
6752
+ severity: "error",
6753
+ message: `${path} contains placeholder content.`,
6754
+ path
6755
+ });
6756
+ try {
6757
+ if (isPlaceholder(section(source, "Summary")))
6758
+ throw new Error("Summary is empty.");
6759
+ for (const task of phase.tasks) {
6760
+ const taskStart = source.indexOf(`<!-- diffpi-brief-task: ${json({ id: task.id })} -->`);
6761
+ const taskEnd = source.indexOf("<!-- /diffpi-brief-task -->", taskStart);
6762
+ if (taskStart < 0 || taskEnd < 0)
6763
+ throw new Error(`Task ${task.id} body is missing.`);
6764
+ const body = source.slice(taskStart, taskEnd);
6765
+ for (const heading of ["Ordered Steps", "File Scopes", "Acceptance Criteria"]) {
6766
+ const content = briefSubsection(body, heading);
6767
+ if (!parseBullets(content).length || parseBullets(content).some(isPlaceholder))
6768
+ throw new Error(`Task ${task.id} ${heading} is incomplete.`);
6769
+ }
6770
+ }
6771
+ } catch (error) {
6772
+ issues.push({ code: "brief-incomplete", severity: "error", message: `${path}: ${error.message}`, path });
6773
+ }
6774
+ }
6482
6775
  function section(source, heading) {
6483
6776
  const match = source.match(new RegExp(`^## ${escapeRegExp(heading)}\\s*$`, "m"));
6484
6777
  if (!match?.index) {
@@ -6503,6 +6796,14 @@ function subsection(source, parent, heading) {
6503
6796
  function parseBullets(input) {
6504
6797
  return [...input.matchAll(/^- (?!<!--)(.+)$/gm)].map((match) => match[1].trim());
6505
6798
  }
6799
+ function briefSubsection(source, heading) {
6800
+ const match = source.match(new RegExp(`^#### ${escapeRegExp(heading)}\\s*$`, "m"));
6801
+ if (match?.index === undefined)
6802
+ throw new Error(`Missing required brief heading: ${heading}.`);
6803
+ const rest = source.slice(match.index + match[0].length);
6804
+ const end = rest.search(/^#### |^<!-- \/diffpi-brief-task -->/m);
6805
+ return (end < 0 ? rest : rest.slice(0, end)).trim();
6806
+ }
6506
6807
  function parseListValue(body, label) {
6507
6808
  const match = new RegExp(`^ {2}- ${escapeRegExp(label)}:[ \\t]*(.*)$`, "m").exec(body);
6508
6809
  if (!match)
@@ -6549,6 +6850,16 @@ function assertUniqueIds(ids, label) {
6549
6850
  function cleanPlaceholder(value) {
6550
6851
  return value.replace(/<!--[^]*?-->/g, "").trim();
6551
6852
  }
6853
+ function isPlaceholder(value) {
6854
+ const normalized = cleanPlaceholder(value).trim();
6855
+ return normalized.length === 0 || /^(?:todo|tbd|none|n\/a)$/i.test(normalized) || /define during implementation|describe the|add requirements|add implementation/i.test(normalized);
6856
+ }
6857
+ function sameIds(expected, actual) {
6858
+ return expected.length === actual.length && expected.every((value, index) => actual[index] === value);
6859
+ }
6860
+ function sha256(value) {
6861
+ return createHash2("sha256").update(value).digest("hex");
6862
+ }
6552
6863
  function parseMarker(value, label) {
6553
6864
  try {
6554
6865
  return JSON.parse(value);
@@ -6556,15 +6867,14 @@ function parseMarker(value, label) {
6556
6867
  throw new Error(`Malformed ${label} marker JSON.`);
6557
6868
  }
6558
6869
  }
6559
- function taskList(label, values) {
6870
+ function bulletList(values, empty = "") {
6560
6871
  if (!values.length)
6561
- return ` - ${label}: none`;
6562
- const items = values.flatMap((value) => {
6872
+ return empty;
6873
+ return values.flatMap((value) => {
6563
6874
  const [first = "", ...continuations] = value.split(`
6564
6875
  `);
6565
- return [` - ${first}`, ...continuations.map((line) => ` ${line}`)];
6566
- });
6567
- return [` - ${label}:`, ...items].join(`
6876
+ return [`- ${first}`, ...continuations.map((line) => ` ${line}`)];
6877
+ }).join(`
6568
6878
  `);
6569
6879
  }
6570
6880
  function list3(values) {
@@ -6890,13 +7200,13 @@ function heartbeat(plan) {
6890
7200
  }
6891
7201
 
6892
7202
  // src/plan/reviews.ts
6893
- import { mkdir as mkdir4, readFile as readFile5, writeFile as writeFile3 } from "node:fs/promises";
6894
- import { dirname as dirname5, join as join10 } from "node:path";
7203
+ import { mkdir as mkdir4, readFile as readFile6, writeFile as writeFile3 } from "node:fs/promises";
7204
+ import { dirname as dirname5, join as join11 } from "node:path";
6895
7205
  async function readPlanReview(record) {
6896
7206
  const path = reviewPath(record);
6897
7207
  let source;
6898
7208
  try {
6899
- source = await readFile5(path, "utf8");
7209
+ source = await readFile6(path, "utf8");
6900
7210
  } catch (error) {
6901
7211
  if (error.code === "ENOENT")
6902
7212
  return;
@@ -6913,7 +7223,7 @@ async function readPlanReview(record) {
6913
7223
  return { path, dump };
6914
7224
  }
6915
7225
  function reviewPath(record) {
6916
- return join10(record.dir, "reviews", `${record.document.revision}.json`);
7226
+ return join11(record.dir, "reviews", `${record.document.revision}.json`);
6917
7227
  }
6918
7228
  function isPlanReviewDump(value) {
6919
7229
  if (!value || typeof value !== "object")
@@ -6923,14 +7233,15 @@ function isPlanReviewDump(value) {
6923
7233
  }
6924
7234
 
6925
7235
  // src/plan/store.ts
6926
- import { mkdir as mkdir6, readdir, readFile as readFile7, realpath as realpath3, rm as rm2 } from "node:fs/promises";
7236
+ import { createHash as createHash3, randomUUID as randomUUID2 } from "node:crypto";
7237
+ import { mkdir as mkdir6, readdir as readdir2, readFile as readFile7, realpath as realpath3, rename as rename2, rm as rm2 } from "node:fs/promises";
6927
7238
  import { basename as basename5, dirname as dirname7, join as join13, resolve as resolve3, sep } from "node:path";
6928
7239
 
6929
7240
  // src/extensions/fsx.ts
6930
7241
  import { mkdir as mkdir5, rename, rm, writeFile as writeFile4 } from "node:fs/promises";
6931
- import { basename as basename4, dirname as dirname6, join as join11 } from "node:path";
7242
+ import { basename as basename4, dirname as dirname6, join as join12 } from "node:path";
6932
7243
  async function atomicWrite(path, content) {
6933
- const temp = join11(dirname6(path), `.${basename4(path)}.${crypto.randomUUID()}.tmp`);
7244
+ const temp = join12(dirname6(path), `.${basename4(path)}.${crypto.randomUUID()}.tmp`);
6934
7245
  await writeFile4(temp, content, { encoding: "utf8", mode: 384, flag: "wx" });
6935
7246
  await rename(temp, path);
6936
7247
  }
@@ -6960,42 +7271,6 @@ function sleep(ms) {
6960
7271
  return new Promise((resolve) => setTimeout(resolve, ms));
6961
7272
  }
6962
7273
 
6963
- // src/templates.ts
6964
- import { readFile as readFile6 } from "node:fs/promises";
6965
- import { homedir as homedir4 } from "node:os";
6966
- import { join as join12, normalize } from "node:path";
6967
- async function loadTemplate(name, options = {}) {
6968
- const relative = templateRelativePath(name);
6969
- const userPath = join12(options.homeDir ?? homedir4(), ".difflab", "diffpi", "templates", relative);
6970
- const bundledPath = join12(options.bundledDir ?? resolveBundledTemplatesDir(), relative);
6971
- const user = await readOptionalFile(userPath);
6972
- if (user !== undefined)
6973
- return { name, path: userPath, source: "user", content: user };
6974
- const bundled = await readOptionalFile(bundledPath);
6975
- if (bundled !== undefined)
6976
- return { name, path: bundledPath, source: "bundled", content: bundled };
6977
- throw new Error(`Template "${name}" was not found at ${userPath} or ${bundledPath}.`);
6978
- }
6979
- function renderTemplate(content, variables) {
6980
- return content.replace(/\{\{\s*([a-zA-Z0-9_.-]+)\s*\}\}/g, (match, key) => variables[key] ?? match);
6981
- }
6982
- function templateRelativePath(name) {
6983
- const normalized = normalize(name.replaceAll("\\", "/")).replace(/^\.\//, "");
6984
- if (!normalized || normalized.startsWith("../") || normalized.includes("/../") || normalized.startsWith("/")) {
6985
- throw new Error(`Invalid template name: ${name}`);
6986
- }
6987
- return normalized.endsWith(".md") ? normalized : `${normalized}.md`;
6988
- }
6989
- async function readOptionalFile(path) {
6990
- try {
6991
- return await readFile6(path, "utf8");
6992
- } catch (error) {
6993
- if (error.code === "ENOENT")
6994
- return;
6995
- throw error;
6996
- }
6997
- }
6998
-
6999
7274
  // src/plan/store.ts
7000
7275
  function planRecordName(shortSlug, date = new Date) {
7001
7276
  const slug = normalizeSlug(shortSlug);
@@ -7003,7 +7278,7 @@ function planRecordName(shortSlug, date = new Date) {
7003
7278
  }
7004
7279
  async function resolvePlan(cwd, query, filters = {}, options = {}) {
7005
7280
  const root = await plansDir(cwd, options.homeDir);
7006
- const entries = await readdir(root, { withFileTypes: true });
7281
+ const entries = await readdir2(root, { withFileTypes: true });
7007
7282
  const normalizedQuery = query ? normalizeQuery(query) : undefined;
7008
7283
  const records = [];
7009
7284
  for (const entry of entries) {
@@ -7030,6 +7305,15 @@ async function resolvePlan(cwd, query, filters = {}, options = {}) {
7030
7305
  }
7031
7306
  function createPlanStore(options = {}) {
7032
7307
  const now = options.now ?? (() => new Date);
7308
+ const read = async (cwd, query, filters) => {
7309
+ const result = await resolvePlan(cwd, query, filters, options);
7310
+ if (!result.record) {
7311
+ if (result.ambiguous)
7312
+ throw new Error(`Plan query "${query}" is ambiguous: ${result.candidates.map((item) => item.id).join(", ")}.`);
7313
+ throw new Error(`Plan "${query}" was not found.`);
7314
+ }
7315
+ return result.record;
7316
+ };
7033
7317
  return {
7034
7318
  context(cwd, query, filters) {
7035
7319
  return resolvePlan(cwd, query, filters, options);
@@ -7038,57 +7322,81 @@ function createPlanStore(options = {}) {
7038
7322
  const root = await plansDir(input.cwd, options.homeDir);
7039
7323
  const id = planRecordName(input.shortSlug, now());
7040
7324
  const dir = join13(root, id);
7041
- await assertContained(root, dirname7(dir));
7042
- try {
7043
- await mkdir6(dir);
7044
- } catch (error) {
7045
- if (error.code === "EEXIST")
7046
- throw new Error(`Plan ${id} already exists.`);
7047
- throw error;
7048
- }
7325
+ await createPlanDirectory(root, dir, id);
7049
7326
  try {
7050
7327
  const timestamp = now().toISOString();
7051
- const template = await loadTemplate("plan/PLAN", {
7052
- homeDir: options.homeDir,
7053
- bundledDir: options.bundledTemplatesDir
7054
- });
7055
- const source = renderTemplate(template.content, {
7328
+ const title = input.title?.trim() || titleFromSlug(input.shortSlug);
7329
+ const document = {
7330
+ schemaVersion: 1,
7056
7331
  id,
7332
+ revision: 0,
7333
+ title,
7057
7334
  branch: input.branch,
7058
- title: input.title?.trim() || titleFromSlug(input.shortSlug),
7335
+ ...input.issueId?.trim() ? { issueId: input.issueId.trim() } : {},
7336
+ ...input.issueUrl?.trim() ? { issueUrl: input.issueUrl.trim() } : {},
7059
7337
  intent: input.intent?.trim() || "<!-- Describe the intended outcome. -->",
7060
- issue_id: input.issueId?.trim() || "<!-- Add issue tracker ID. -->",
7061
- issue_url: input.issueUrl?.trim() || "<!-- Add issue tracker URL. -->",
7062
- created_at: timestamp,
7063
- updated_at: timestamp
7064
- });
7065
- const document = parsePlanDocument(source, join13(dir, "PLAN.md"));
7066
- await atomicWrite(join13(dir, "PLAN.md"), source);
7067
- return {
7068
- id,
7069
- dir,
7070
- planPath: join13(dir, "PLAN.md"),
7071
- logPath: join13(dir, "logs.txt"),
7072
- implementationDir: join13(dir, "implementation"),
7073
- document,
7074
- source
7338
+ requirements: [],
7339
+ design: { bigIdeas: "", keyApiUpdates: "", consequences: "" },
7340
+ phases: [],
7341
+ references: [],
7342
+ status: "draft",
7343
+ createdAt: timestamp,
7344
+ updatedAt: timestamp
7075
7345
  };
7346
+ const source = renderPlanDocument(document);
7347
+ parsePlanDocument(source, join13(dir, "PLAN.md"));
7348
+ const authoringRequest = input.request ?? { kind: "user", text: input.intent?.trim() || `Initialize phase-less plan ${title}.` };
7349
+ await writeRevisionSnapshot(dir, document, source, authoringRequest, []);
7350
+ await writeLatestView(dir, document, source, []);
7351
+ return await readRecord(dir);
7076
7352
  } catch (error) {
7077
7353
  await rm2(dir, { recursive: true, force: true });
7078
7354
  throw error;
7079
7355
  }
7080
7356
  },
7081
- async read(cwd, query, filters) {
7082
- const result = await resolvePlan(cwd, query, filters, options);
7083
- if (!result.record) {
7084
- if (result.ambiguous)
7085
- throw new Error(`Plan query "${query}" is ambiguous: ${result.candidates.map((item) => item.id).join(", ")}.`);
7086
- throw new Error(`Plan "${query}" was not found.`);
7357
+ read,
7358
+ async applyRevision(cwd, request) {
7359
+ if (request.mode === "create") {
7360
+ const root = await plansDir(cwd, options.homeDir);
7361
+ const id = planRecordName(request.shortSlug, now());
7362
+ const dir = join13(root, id);
7363
+ await createPlanDirectory(root, dir, id);
7364
+ try {
7365
+ const timestamp = now().toISOString();
7366
+ const document = normalizeRevision(undefined, request, id, 0, timestamp);
7367
+ assertCompleteRevision(document, request.briefs);
7368
+ const source = renderPlanDocument(document);
7369
+ parsePlanDocument(source, join13(dir, "PLAN.md"));
7370
+ await writeRevisionSnapshot(dir, document, source, request.request, request.briefs);
7371
+ await writeLatestView(dir, document, source, request.briefs);
7372
+ return await readRecord(dir);
7373
+ } catch (error) {
7374
+ await rm2(dir, { recursive: true, force: true });
7375
+ throw error;
7376
+ }
7087
7377
  }
7088
- return result.record;
7378
+ const initial = await read(cwd, request.plan);
7379
+ return withDirectoryLock(join13(initial.dir, ".lock"), async () => {
7380
+ const current = await readRecord(initial.dir);
7381
+ if (current.document.revision !== request.expectedPlanRevision)
7382
+ throw new Error(`Expected plan revision ${request.expectedPlanRevision}, found ${current.document.revision}.`);
7383
+ const revision = current.document.revision + 1;
7384
+ const document = normalizeRevision(current.document, request, current.id, revision, now().toISOString());
7385
+ assertCompleteRevision(document, request.briefs);
7386
+ const source = renderPlanDocument(document);
7387
+ parsePlanDocument(source, current.planPath);
7388
+ await writeRevisionSnapshot(current.dir, document, source, request.request, request.briefs);
7389
+ try {
7390
+ await writeLatestView(current.dir, document, source, request.briefs);
7391
+ } catch (error) {
7392
+ await rm2(join13(current.dir, "revisions", String(revision)), { recursive: true, force: true });
7393
+ throw error;
7394
+ }
7395
+ return await readRecord(current.dir);
7396
+ }, { operation: "apply revision" });
7089
7397
  },
7090
7398
  async mutate(cwd, query, operation, update) {
7091
- const initial = await this.read(cwd, query);
7399
+ const initial = await read(cwd, query);
7092
7400
  return withDirectoryLock(join13(initial.dir, ".lock"), async () => {
7093
7401
  const current = await readRecord(initial.dir);
7094
7402
  const next = await update(structuredClone(current.document));
@@ -7096,18 +7404,17 @@ function createPlanStore(options = {}) {
7096
7404
  throw new Error("A plan mutation cannot change the plan ID.");
7097
7405
  const document = {
7098
7406
  ...next,
7099
- revision: current.document.revision + 1,
7407
+ revision: current.document.revision,
7100
7408
  updatedAt: now().toISOString()
7101
7409
  };
7102
7410
  const source = renderPlanDocument(document, current.source);
7103
7411
  parsePlanDocument(source, current.planPath);
7104
7412
  await atomicWrite(current.planPath, source);
7105
- await ensureImplementationFiles(current.dir, document, options);
7106
- return { ...current, document, source, implementationDir: join13(current.dir, "implementation") };
7413
+ return { ...current, document, source };
7107
7414
  }, { operation });
7108
7415
  },
7109
7416
  async log(cwd, query, event) {
7110
- const record = await this.read(cwd, query);
7417
+ const record = await read(cwd, query);
7111
7418
  return appendLogEntry(record.logPath, event);
7112
7419
  }
7113
7420
  };
@@ -7127,30 +7434,209 @@ async function readRecord(dir) {
7127
7434
  source
7128
7435
  };
7129
7436
  }
7130
- async function ensureImplementationFiles(dir, document, options) {
7131
- const implementationDir = join13(dir, "implementation");
7132
- await mkdir6(implementationDir, { recursive: true });
7133
- const template = await loadTemplate("plan/implementation", {
7134
- homeDir: options.homeDir,
7135
- bundledDir: options.bundledTemplatesDir
7136
- });
7137
- for (const phase of document.phases) {
7138
- const path = join13(implementationDir, `phase-${phase.id}.md`);
7437
+ async function createPlanDirectory(root, dir, id) {
7438
+ await assertContained(root, dirname7(dir));
7439
+ try {
7440
+ await mkdir6(dir);
7441
+ } catch (error) {
7442
+ if (error.code === "EEXIST")
7443
+ throw new Error(`Plan ${id} already exists.`);
7444
+ throw error;
7445
+ }
7446
+ }
7447
+ async function writeRevisionSnapshot(planDir, document, source, request, briefs) {
7448
+ if (!request.text.trim())
7449
+ throw new Error("An exact non-empty authoring request is required.");
7450
+ if (request.kind === "annotation" && !request.response?.trim())
7451
+ throw new Error("Annotation requests require a non-empty response.");
7452
+ const revisionsDir = join13(planDir, "revisions");
7453
+ await mkdir6(revisionsDir, { recursive: true });
7454
+ const target = join13(revisionsDir, String(document.revision));
7455
+ try {
7456
+ await readFile7(join13(target, "metadata.json"), "utf8");
7457
+ throw new Error(`Revision ${document.revision} already exists and is immutable.`);
7458
+ } catch (error) {
7459
+ if (error.code !== "ENOENT")
7460
+ throw error;
7461
+ }
7462
+ const staging = join13(revisionsDir, `.tmp-${document.revision}-${randomUUID2()}`);
7463
+ await mkdir6(staging);
7464
+ try {
7465
+ const requestSource = request.kind === "annotation" ? `## Original annotation
7466
+ ${request.text}
7467
+
7468
+ ## LLM response
7469
+ ${request.response}` : request.text;
7470
+ await atomicWrite(join13(staging, "request.md"), requestSource);
7471
+ await atomicWrite(join13(staging, "metadata.json"), `${JSON.stringify({
7472
+ schemaVersion: 1,
7473
+ id: document.id,
7474
+ revision: document.revision,
7475
+ requestKind: request.kind,
7476
+ requestSha256: sha2562(request.text),
7477
+ ...request.kind === "annotation" ? { originalAnnotationSha256: sha2562(request.text), responseSha256: sha2562(request.response) } : {},
7478
+ createdAt: document.updatedAt
7479
+ }, null, 2)}
7480
+ `);
7481
+ await atomicWrite(join13(staging, "PLAN.md"), source);
7482
+ await writeImplementationDirectory(staging, document, briefs);
7483
+ await rename2(staging, target);
7484
+ } catch (error) {
7485
+ await rm2(staging, { recursive: true, force: true });
7486
+ throw error;
7487
+ }
7488
+ }
7489
+ async function writeLatestView(planDir, document, source, briefs) {
7490
+ const current = join13(planDir, "implementation");
7491
+ const stagingRoot = join13(planDir, `.latest-${randomUUID2()}`);
7492
+ const stagedImplementation = join13(stagingRoot, "implementation");
7493
+ const backup = join13(planDir, `.implementation-backup-${randomUUID2()}`);
7494
+ await mkdir6(stagingRoot);
7495
+ await writeImplementationDirectory(stagingRoot, document, briefs);
7496
+ let backedUp = false;
7497
+ try {
7139
7498
  try {
7140
- await readFile7(path, "utf8");
7499
+ await rename2(current, backup);
7500
+ backedUp = true;
7141
7501
  } catch (error) {
7142
7502
  if (error.code !== "ENOENT")
7143
7503
  throw error;
7144
- const tasks = phase.tasks.map((task) => `- ${task.id}: ${task.title}`).join("\\n") || "- Add implementation tasks.";
7145
- await atomicWrite(path, renderTemplate(template.content, {
7146
- phase_id: phase.id,
7147
- phase_title: phase.title,
7148
- phase_objective: phase.objective,
7149
- phase_tasks: tasks
7150
- }));
7151
7504
  }
7505
+ await rename2(stagedImplementation, current);
7506
+ await atomicWrite(join13(planDir, "PLAN.md"), source);
7507
+ if (backedUp)
7508
+ await rm2(backup, { recursive: true, force: true });
7509
+ } catch (error) {
7510
+ await rm2(current, { recursive: true, force: true });
7511
+ if (backedUp)
7512
+ await rename2(backup, current);
7513
+ throw error;
7514
+ } finally {
7515
+ await rm2(stagingRoot, { recursive: true, force: true });
7152
7516
  }
7153
7517
  }
7518
+ async function writeImplementationDirectory(baseDir, document, briefs) {
7519
+ const implementationDir = join13(baseDir, "implementation");
7520
+ await mkdir6(implementationDir, { recursive: true });
7521
+ for (const [index, phase] of document.phases.entries()) {
7522
+ const brief = briefs[index];
7523
+ if (!brief)
7524
+ throw new Error(`Missing implementation brief for phase ${phase.id}.`);
7525
+ await atomicWrite(join13(implementationDir, `phase-${index + 1}.md`), renderImplementationBrief(document.revision, index + 1, phase, brief));
7526
+ }
7527
+ }
7528
+ function normalizeRevision(current, request, id, revision, updatedAt) {
7529
+ if (current?.status === "completed")
7530
+ throw new Error("Completed plans cannot be amended.");
7531
+ if (current?.execution?.active && request.request.kind !== "blocker")
7532
+ throw new Error("An active execution accepts only a blocker amendment.");
7533
+ const currentPhases = new Map(current?.phases.map((phase) => [phase.id, phase]) ?? []);
7534
+ for (const phase of current?.phases ?? []) {
7535
+ if (!request.phases.some((candidate) => candidate.id === phase.id) && (phase.status === "completed" || phase.status === "in_progress"))
7536
+ throw new Error(`${phase.status === "completed" ? "Completed" : "Active"} phase ${phase.id} cannot be removed.`);
7537
+ }
7538
+ const phases = request.phases.map((draft) => reconcilePhase(currentPhases.get(draft.id), draft, current));
7539
+ const createdAt = current?.createdAt ?? updatedAt;
7540
+ return {
7541
+ schemaVersion: 1,
7542
+ id,
7543
+ revision,
7544
+ title: request.title,
7545
+ branch: current?.branch ?? (request.mode === "create" ? request.branch : ""),
7546
+ issueId: current?.issueId ?? (request.mode === "create" ? request.issueId : undefined),
7547
+ issueUrl: current?.issueUrl ?? (request.mode === "create" ? request.issueUrl : undefined),
7548
+ intent: request.intent,
7549
+ requirements: request.requirements,
7550
+ design: request.design,
7551
+ references: request.references,
7552
+ phases,
7553
+ status: nextPlanStatus(current),
7554
+ execution: current?.execution,
7555
+ createdAt,
7556
+ updatedAt
7557
+ };
7558
+ }
7559
+ function reconcilePhase(current, draft, plan) {
7560
+ if (!current)
7561
+ return {
7562
+ ...draft,
7563
+ revision: 0,
7564
+ status: "pending",
7565
+ gate: { phaseRevision: 0, status: "pending", results: [] },
7566
+ tasks: draft.tasks.map(newTask)
7567
+ };
7568
+ if (current.status === "completed") {
7569
+ if (JSON.stringify(phaseContent(current)) !== JSON.stringify(draft))
7570
+ throw new Error(`Completed phase ${current.id} cannot be changed.`);
7571
+ return current;
7572
+ }
7573
+ const byId = new Map(current.tasks.map((task) => [task.id, task]));
7574
+ for (const task of current.tasks) {
7575
+ if (!draft.tasks.some((candidate) => candidate.id === task.id) && (task.status === "completed" || task.status === "in_progress"))
7576
+ throw new Error(`${task.status === "completed" ? "Completed" : "Active"} task ${task.id} cannot be removed.`);
7577
+ }
7578
+ const tasks = draft.tasks.map((task) => reconcileTask(byId.get(task.id), task));
7579
+ const changed = JSON.stringify(phaseContent(current)) !== JSON.stringify(draft);
7580
+ const revision = changed ? current.revision + 1 : current.revision;
7581
+ const wasBlocked = current.status === "blocked";
7582
+ return {
7583
+ ...current,
7584
+ ...draft,
7585
+ revision,
7586
+ tasks,
7587
+ status: wasBlocked ? plan?.execution?.active ? "in_progress" : "pending" : current.status,
7588
+ gate: changed || wasBlocked ? { phaseRevision: revision, status: "stale", results: [] } : current.gate,
7589
+ blocker: wasBlocked ? undefined : current.blocker
7590
+ };
7591
+ }
7592
+ function reconcileTask(current, draft) {
7593
+ if (!current)
7594
+ return newTask(draft);
7595
+ const changed = JSON.stringify(taskContent(current)) !== JSON.stringify(draft);
7596
+ if ((current.status === "completed" || current.status === "in_progress") && changed)
7597
+ throw new Error(`${current.status === "completed" ? "Completed" : "Active"} task ${current.id} cannot be changed.`);
7598
+ if (current.status === "completed" || current.status === "in_progress")
7599
+ return current;
7600
+ return {
7601
+ ...current,
7602
+ ...draft,
7603
+ revision: changed ? current.revision + 1 : current.revision,
7604
+ status: current.status === "blocked" ? "pending" : current.status,
7605
+ owner: current.status === "blocked" ? undefined : current.owner,
7606
+ executionId: current.status === "blocked" ? undefined : current.executionId,
7607
+ blocker: current.status === "blocked" ? undefined : current.blocker
7608
+ };
7609
+ }
7610
+ function newTask(draft) {
7611
+ return { ...draft, revision: 0, status: "pending" };
7612
+ }
7613
+ function phaseContent(phase) {
7614
+ return {
7615
+ id: phase.id,
7616
+ title: phase.title,
7617
+ objective: phase.objective,
7618
+ dependencies: phase.dependencies,
7619
+ tasks: phase.tasks.map(taskContent)
7620
+ };
7621
+ }
7622
+ function taskContent(task) {
7623
+ return { id: task.id, title: task.title, dependencies: task.dependencies };
7624
+ }
7625
+ function nextPlanStatus(current) {
7626
+ if (!current)
7627
+ return "draft";
7628
+ if (current.execution?.active)
7629
+ return current.status === "blocked" ? "in_progress" : current.status;
7630
+ return current.status === "ready" || current.status === "blocked" ? "draft" : current.status;
7631
+ }
7632
+ function assertCompleteRevision(document, briefs) {
7633
+ const errors = [...validatePlanDocument(document, { strict: true }), ...validatePlanBriefs(document, briefs)].filter((issue) => issue.severity === "error");
7634
+ if (errors.length)
7635
+ throw new Error(`Invalid plan revision: ${errors.map((issue) => issue.message).join(" ")}`);
7636
+ }
7637
+ function sha2562(value) {
7638
+ return createHash3("sha256").update(value).digest("hex");
7639
+ }
7154
7640
  async function assertContained(root, candidate) {
7155
7641
  const canonicalRoot = await realpath3(root);
7156
7642
  let canonicalCandidate;
@@ -7182,34 +7668,54 @@ function stripDate(value) {
7182
7668
  return value.replace(/^\d{6}-/, "");
7183
7669
  }
7184
7670
  function titleFromSlug(value) {
7185
- return normalizeSlug(value).replace(/^(?:[a-z]+-\d+-)/, "").split("-").map((part) => part[0].toUpperCase() + part.slice(1)).join(" ");
7671
+ const slug = normalizeSlug(value).replace(/^(?:[a-z]+-\d+-)/, "");
7672
+ return slug.replace(/(^|-)([a-z])/g, (_match, separator, letter) => {
7673
+ return `${separator ? " " : ""}${letter.toUpperCase()}`;
7674
+ });
7186
7675
  }
7187
7676
 
7188
- // src/tools/plan-schema.ts
7677
+ // src/plan/schema.ts
7189
7678
  import { z as z6 } from "zod";
7190
7679
  var planStatusSchema = z6.enum(["draft", "ready", "in_progress", "blocked", "completed"]);
7191
7680
  var planUnitStatusSchema = z6.enum(["draft", "ready", "in_progress", "blocked", "completed", "pending", "skipped"]);
7192
- var planTaskDraftSchema = z6.object({
7193
- id: zx.id,
7194
- title: zx.text,
7195
- steps: z6.array(zx.text).optional(),
7196
- dependencies: z6.array(zx.id).optional(),
7197
- fileScopes: z6.array(zx.text).optional(),
7198
- acceptanceCriteria: z6.array(zx.text).optional()
7199
- }).strict();
7681
+ var planTaskDraftSchema = z6.object({ id: zx.id, title: zx.text, dependencies: z6.array(zx.id) }).strict();
7200
7682
  var planPhaseDraftSchema = z6.object({
7201
7683
  id: zx.id,
7202
7684
  title: zx.text,
7203
7685
  objective: zx.text,
7204
- dependencies: z6.array(zx.id).optional(),
7205
- tasks: z6.array(planTaskDraftSchema).optional()
7686
+ dependencies: z6.array(zx.id),
7687
+ tasks: z6.array(planTaskDraftSchema).min(1)
7206
7688
  }).strict();
7207
- var planReferenceSchema = z6.object({ id: zx.id, value: zx.text }).strict();
7208
- var planDesignSchema = z6.object({
7209
- bigIdeas: z6.string(),
7210
- keyApiUpdates: z6.string(),
7211
- consequences: z6.string()
7689
+ var planBriefSchema = z6.object({
7690
+ phaseId: zx.id,
7691
+ summary: zx.text,
7692
+ apiChanges: z6.array(zx.text),
7693
+ libraries: z6.array(zx.text),
7694
+ constraints: z6.array(zx.text),
7695
+ tasks: z6.array(z6.object({
7696
+ taskId: zx.id,
7697
+ steps: z6.array(zx.text).min(1),
7698
+ fileScopes: z6.array(zx.text).min(1),
7699
+ acceptanceCriteria: z6.array(zx.text).min(1)
7700
+ }).strict()).min(1)
7212
7701
  }).strict();
7702
+ var planReferenceSchema = z6.object({ id: zx.id, value: zx.text }).strict();
7703
+ var planDesignSchema = z6.object({ bigIdeas: zx.text, keyApiUpdates: zx.text, consequences: zx.text }).strict();
7704
+ var planRequestSchema = z6.discriminatedUnion("kind", [
7705
+ z6.object({ kind: z6.literal("annotation"), text: zx.text, response: zx.text }).strict(),
7706
+ z6.object({ kind: z6.literal("user"), text: zx.text, response: zx.text.optional() }).strict(),
7707
+ z6.object({ kind: z6.literal("blocker"), text: zx.text, response: zx.text.optional() }).strict()
7708
+ ]);
7709
+ var revisionContentFields = {
7710
+ request: planRequestSchema,
7711
+ title: zx.text,
7712
+ intent: zx.text,
7713
+ requirements: z6.array(zx.text).min(1),
7714
+ design: planDesignSchema,
7715
+ references: z6.array(planReferenceSchema),
7716
+ phases: z6.array(planPhaseDraftSchema).min(1),
7717
+ briefs: z6.array(planBriefSchema).min(1)
7718
+ };
7213
7719
  var planContextParametersSchema = z6.object({
7214
7720
  cwd: zx.cwd,
7215
7721
  plan: z6.string().optional(),
@@ -7223,46 +7729,28 @@ var planInitParametersSchema = z6.object({
7223
7729
  title: zx.text.optional(),
7224
7730
  intent: zx.text.optional(),
7225
7731
  issueId: zx.text.optional(),
7226
- issueUrl: z6.string().url().optional(),
7732
+ issueUrl: z6.url().optional(),
7733
+ request: planRequestSchema.optional(),
7227
7734
  open: z6.boolean().optional()
7228
7735
  }).strict();
7229
- var planUpdateOverviewParametersSchema = z6.object({
7230
- cwd: zx.cwd,
7231
- plan: zx.text,
7232
- expectedPlanRevision: zx.revision,
7233
- intent: zx.text.optional(),
7234
- requirements: z6.array(zx.text).optional(),
7235
- design: planDesignSchema.optional(),
7236
- references: z6.array(planReferenceSchema).optional()
7237
- }).strict().refine((value) => value.intent || value.requirements || value.design || value.references, {
7238
- message: "At least one overview field is required."
7239
- });
7240
- var planAddPhaseParametersSchema = z6.object({
7241
- cwd: zx.cwd,
7242
- plan: zx.text,
7243
- expectedPlanRevision: zx.revision,
7244
- afterPhaseId: zx.id.optional(),
7245
- phase: planPhaseDraftSchema
7246
- }).strict();
7247
- var planRemovePhaseParametersSchema = z6.object({
7248
- cwd: zx.cwd,
7249
- plan: zx.text,
7250
- expectedPlanRevision: zx.revision,
7251
- phaseId: zx.id,
7252
- reason: zx.text
7253
- }).strict();
7254
- var planUpdatePhaseParametersSchema = z6.object({
7255
- cwd: zx.cwd,
7256
- plan: zx.text,
7257
- expectedPlanRevision: zx.revision,
7258
- phaseId: zx.id,
7259
- patch: z6.object({
7260
- title: zx.text.optional(),
7261
- objective: zx.text.optional(),
7262
- dependencies: z6.array(zx.id).optional(),
7263
- tasks: z6.array(planTaskDraftSchema).optional()
7736
+ var planApplyRevisionParametersSchema = z6.discriminatedUnion("mode", [
7737
+ z6.object({
7738
+ mode: z6.literal("create"),
7739
+ cwd: zx.cwd,
7740
+ shortSlug: zx.id,
7741
+ branch: z6.string().min(1).optional(),
7742
+ issueId: zx.text.optional(),
7743
+ issueUrl: z6.url().optional(),
7744
+ ...revisionContentFields
7745
+ }).strict(),
7746
+ z6.object({
7747
+ mode: z6.literal("amend"),
7748
+ cwd: zx.cwd,
7749
+ plan: zx.text,
7750
+ expectedPlanRevision: zx.revision,
7751
+ ...revisionContentFields
7264
7752
  }).strict()
7265
- }).strict();
7753
+ ]);
7266
7754
  var planValidateParametersSchema = z6.object({ cwd: zx.cwd, plan: zx.text, strict: z6.boolean().optional() }).strict();
7267
7755
  var planLogProgressParametersSchema = z6.object({
7268
7756
  cwd: zx.cwd,
@@ -7362,131 +7850,32 @@ function createPlanTools(_pi, _modes, store = createPlanStore()) {
7362
7850
  }
7363
7851
  }),
7364
7852
  defineTool5({
7365
- name: "plan_update_overview",
7366
- label: "plan update overview",
7367
- description: "Replace selected plan overview fields using an expected plan revision.",
7368
- parameters: parameters(planUpdateOverviewParametersSchema),
7853
+ name: "plan_apply_revision",
7854
+ label: "plan apply revision",
7855
+ description: "Create mode writes a complete revision 0 without plan_init; amend mode atomically writes the next complete authoring revision.",
7856
+ parameters: parameters(planApplyRevisionParametersSchema),
7369
7857
  executionMode: "sequential",
7370
7858
  async execute(_id, input) {
7371
- const params = planUpdateOverviewParametersSchema.parse(input);
7372
- const record = await store.mutate(params.cwd ?? process.cwd(), params.plan, "update overview", (plan) => {
7373
- assertAuthoringRevision(plan, params.expectedPlanRevision);
7374
- assertAuthorable(plan);
7375
- return resetDraft({
7376
- ...plan,
7377
- intent: params.intent ?? plan.intent,
7378
- requirements: params.requirements ?? plan.requirements,
7379
- design: params.design ?? plan.design,
7380
- references: params.references ?? plan.references
7381
- });
7382
- });
7383
- return result2(`Updated ${record.id} to revision ${record.document.revision}.`, { record });
7859
+ const params = planApplyRevisionParametersSchema.parse(input);
7860
+ const workingDirectory = params.cwd ?? process.cwd();
7861
+ const request = params.mode === "create" ? { ...params, branch: params.branch ?? await currentBranch(workingDirectory) } : params;
7862
+ const record = await store.applyRevision(workingDirectory, request);
7863
+ return result2(`Applied revision ${record.document.revision} to ${record.id}.`, { record });
7384
7864
  }
7385
7865
  }),
7386
7866
  defineTool5({
7387
- name: "plan_add_phase",
7388
- label: "plan add phase",
7389
- description: "Add an ordered phase with stable IDs.",
7390
- parameters: parameters(planAddPhaseParametersSchema),
7391
- executionMode: "sequential",
7867
+ name: "plan_validate",
7868
+ label: "plan validate",
7869
+ description: "Validate plan markers, dependencies, completeness, and Design length.",
7870
+ parameters: parameters(planValidateParametersSchema),
7871
+ executionMode: "parallel",
7392
7872
  async execute(_id, input) {
7393
- const params = planAddPhaseParametersSchema.parse(input);
7394
- const record = await store.mutate(params.cwd ?? process.cwd(), params.plan, "add phase", (plan) => {
7395
- assertAuthoringRevision(plan, params.expectedPlanRevision);
7396
- assertAuthorable(plan);
7397
- if (allIds(plan).has(params.phase.id))
7398
- throw new Error(`Duplicate stable ID: ${params.phase.id}.`);
7399
- const phase = newPhase(params.phase);
7400
- const phases = [...plan.phases];
7401
- if (params.afterPhaseId) {
7402
- const index = phases.findIndex((item) => item.id === params.afterPhaseId);
7403
- if (index < 0)
7404
- throw new Error(`Unknown phase: ${params.afterPhaseId}.`);
7405
- phases.splice(index + 1, 0, phase);
7406
- } else
7407
- phases.push(phase);
7408
- assertNewIds(plan, phase);
7409
- return resetDraft({ ...plan, phases });
7410
- });
7411
- return result2(`Added phase ${params.phase.id} to ${record.id}.`, { record });
7412
- }
7413
- }),
7414
- defineTool5({
7415
- name: "plan_remove_phase",
7416
- label: "plan remove phase",
7417
- description: "Remove an unfinished phase that has no dependents.",
7418
- parameters: parameters(planRemovePhaseParametersSchema),
7419
- executionMode: "sequential",
7420
- async execute(_id, input) {
7421
- const params = planRemovePhaseParametersSchema.parse(input);
7422
- const record = await store.mutate(params.cwd ?? process.cwd(), params.plan, "remove phase", (plan) => {
7423
- assertAuthoringRevision(plan, params.expectedPlanRevision);
7424
- assertAuthorable(plan);
7425
- const phase = getPhase2(plan, params.phaseId);
7426
- if (phase.status === "completed" || phase.tasks.some((task) => task.status === "completed"))
7427
- throw new Error(`Completed phase ${phase.id} cannot be removed.`);
7428
- if (plan.phases.some((item) => item.dependencies.includes(phase.id)))
7429
- throw new Error(`Phase ${phase.id} has dependents and cannot be removed.`);
7430
- return resetDraft({ ...plan, phases: plan.phases.filter((item) => item.id !== phase.id) });
7431
- });
7432
- await store.log(params.cwd ?? process.cwd(), record.id, {
7433
- planRevision: record.document.revision,
7434
- kind: "updated",
7435
- actor: "planner",
7436
- message: `Removed phase ${params.phaseId}: ${params.reason}`,
7437
- phaseId: params.phaseId
7438
- });
7439
- return result2(`Removed phase ${params.phaseId}.`, { record });
7440
- }
7441
- }),
7442
- defineTool5({
7443
- name: "plan_update_phase",
7444
- label: "plan update phase",
7445
- description: "Update an unfinished phase and its ordered tasks.",
7446
- parameters: parameters(planUpdatePhaseParametersSchema),
7447
- executionMode: "sequential",
7448
- async execute(_id, input) {
7449
- const params = planUpdatePhaseParametersSchema.parse(input);
7450
- const record = await store.mutate(params.cwd ?? process.cwd(), params.plan, "update phase", (plan) => {
7451
- assertAuthoringRevision(plan, params.expectedPlanRevision);
7452
- assertAuthorable(plan, params.phaseId);
7453
- const existing = getPhase2(plan, params.phaseId);
7454
- if (existing.status === "completed")
7455
- throw new Error(`Completed phase ${existing.id} cannot be changed.`);
7456
- const tasks = params.patch.tasks ? reconcileTasks(existing.tasks, params.patch.tasks) : existing.tasks;
7457
- const updated = {
7458
- ...existing,
7459
- title: params.patch.title ?? existing.title,
7460
- objective: params.patch.objective ?? existing.objective,
7461
- dependencies: params.patch.dependencies ?? existing.dependencies,
7462
- tasks,
7463
- revision: existing.revision + 1,
7464
- status: existing.status === "blocked" ? "in_progress" : existing.status,
7465
- gate: { ...existing.gate, status: "stale" },
7466
- blocker: existing.status === "blocked" ? undefined : existing.blocker
7467
- };
7468
- assertNewIds({ ...plan, phases: plan.phases.filter((phase) => phase.id !== existing.id) }, updated);
7469
- return resetDraft({
7470
- ...plan,
7471
- phases: plan.phases.map((phase) => phase.id === existing.id ? updated : phase)
7472
- });
7473
- });
7474
- return result2(`Updated phase ${params.phaseId}.`, { record });
7475
- }
7476
- }),
7477
- defineTool5({
7478
- name: "plan_validate",
7479
- label: "plan validate",
7480
- description: "Validate plan markers, dependencies, completeness, and Design length.",
7481
- parameters: parameters(planValidateParametersSchema),
7482
- executionMode: "parallel",
7483
- async execute(_id, input) {
7484
- const params = planValidateParametersSchema.parse(input);
7485
- const record = await store.read(params.cwd ?? process.cwd(), params.plan);
7486
- const issues = validatePlanDocument(record.document, { strict: params.strict });
7487
- const words = countDesignWords(record.document);
7488
- return result2(issues.length ? issues.map((issue) => `- ${issue.severity}: ${issue.message}`).join(`
7489
- `) : "Plan is valid.", { record, issues, designWordCount: words, ready: !issues.some((issue) => issue.severity === "error") });
7873
+ const params = planValidateParametersSchema.parse(input);
7874
+ const record = await store.read(params.cwd ?? process.cwd(), params.plan);
7875
+ const issues = await validatePlanRecord(record, { strict: params.strict });
7876
+ const words = countDesignWords(record.document);
7877
+ return result2(issues.length ? issues.map((issue) => `- ${issue.severity}: ${issue.message}`).join(`
7878
+ `) : "Plan is valid.", { record, issues, designWordCount: words, ready: !issues.some((issue) => issue.severity === "error") });
7490
7879
  }
7491
7880
  }),
7492
7881
  defineTool5({
@@ -7714,8 +8103,8 @@ function renderExecutionPrompt(packet) {
7714
8103
  return [
7715
8104
  `Execute the durable Diffpi plan using this packet: ${JSON.stringify(packet)}.`,
7716
8105
  "Call plan_context first and keep plan tools as the source of truth for eligibility, ownership, status, gates, and commits.",
7717
- "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.",
7718
- "The extension cannot launch workflow children over RPC, so the active orchestrator must invoke SubagentWorkflow itself.",
8106
+ "Use bounded Agent delegation for eligible tasks. Run dependent tasks in order; parallelize only tasks with non-overlapping declared file scopes. Collect and verify worker results before changing plan state.",
8107
+ "Use a multi-agent SubagentWorkflow only when the user explicitly opts into that orchestration.",
7719
8108
  "Persist every transition, progress event, issue, and deviation. Do not edit PLAN.md directly. Delegated implementation workers never commit or restructure the plan.",
7720
8109
  "In commit mode, create one local commit after each phase passes its gates and do not push. In push mode, commit and push each phase, record pending CI, and launch a bounded background Worker to call watch_ci for the exact SHA and then plan_record_ci for the phase 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."
7721
8110
  ].join(" ");
@@ -7779,81 +8168,6 @@ ${dirty.stdout.trim()}`);
7779
8168
  prompt: renderExecutionPrompt(packet)
7780
8169
  });
7781
8170
  }
7782
- function newPhase(input) {
7783
- return {
7784
- id: input.id,
7785
- revision: 0,
7786
- title: input.title,
7787
- objective: input.objective,
7788
- dependencies: input.dependencies ?? [],
7789
- tasks: (input.tasks ?? []).map(newTask),
7790
- status: "pending",
7791
- gate: { phaseRevision: 0, status: "pending", results: [] }
7792
- };
7793
- }
7794
- function newTask(input) {
7795
- return {
7796
- id: input.id,
7797
- revision: 0,
7798
- title: input.title,
7799
- steps: input.steps,
7800
- dependencies: input.dependencies ?? [],
7801
- fileScopes: input.fileScopes ?? [],
7802
- acceptanceCriteria: input.acceptanceCriteria ?? [],
7803
- status: "pending"
7804
- };
7805
- }
7806
- function reconcileTasks(existing, drafts) {
7807
- const byId = new Map(existing.map((task) => [task.id, task]));
7808
- const requested = new Set(drafts.map((task) => task.id));
7809
- const protectedTask = existing.find((task) => (task.status === "completed" || task.status === "in_progress") && !requested.has(task.id));
7810
- if (protectedTask)
7811
- throw new Error(`${protectedTask.status === "completed" ? "Completed" : "Active"} task ${protectedTask.id} cannot be removed.`);
7812
- return drafts.map((draft) => {
7813
- const current = byId.get(draft.id);
7814
- if (current?.status === "completed" || current?.status === "in_progress")
7815
- return current;
7816
- return current ? {
7817
- ...newTask(draft),
7818
- revision: current.revision + 1,
7819
- status: current.status === "blocked" ? "pending" : current.status,
7820
- owner: current.status === "blocked" ? undefined : current.owner,
7821
- executionId: current.status === "blocked" ? undefined : current.executionId,
7822
- blocker: current.status === "blocked" ? undefined : current.blocker
7823
- } : newTask(draft);
7824
- });
7825
- }
7826
- function assertAuthoringRevision(plan, expected) {
7827
- if (plan.revision !== expected)
7828
- throw new Error(`Stale plan revision: expected ${expected}, found ${plan.revision}.`);
7829
- }
7830
- function assertAuthorable(plan, blockedPhaseId) {
7831
- if (plan.status === "completed")
7832
- throw new Error("Completed plans cannot be structurally changed.");
7833
- if (!plan.execution?.active)
7834
- return;
7835
- if (!blockedPhaseId)
7836
- throw new Error("An active execution owns this plan.");
7837
- const phase = getPhase2(plan, blockedPhaseId);
7838
- if (phase.status !== "blocked" && !phase.tasks.some((task) => task.status === "blocked"))
7839
- throw new Error("Planner may amend only blocked work during an active execution.");
7840
- }
7841
- function resetDraft(plan) {
7842
- if (plan.status === "blocked" && plan.execution?.active)
7843
- return { ...plan, status: "in_progress" };
7844
- return plan.status === "ready" || plan.status === "blocked" ? { ...plan, status: "draft" } : plan;
7845
- }
7846
- function allIds(plan) {
7847
- return new Set([plan.id, ...plan.phases.flatMap((phase) => [phase.id, ...phase.tasks.map((task) => task.id)])]);
7848
- }
7849
- function assertNewIds(plan, phase) {
7850
- const known = allIds(plan);
7851
- for (const candidate of [phase.id, ...phase.tasks.map((task) => task.id)]) {
7852
- if (known.has(candidate))
7853
- throw new Error(`Duplicate stable ID: ${candidate}.`);
7854
- known.add(candidate);
7855
- }
7856
- }
7857
8171
  function getPhase2(plan, phaseId) {
7858
8172
  const phase = plan.phases.find((item) => item.id === phaseId);
7859
8173
  if (!phase)
@@ -7891,10 +8205,10 @@ function result2(text, details = {}) {
7891
8205
 
7892
8206
  // src/tools/review.ts
7893
8207
  import { existsSync as existsSync2 } from "node:fs";
7894
- import { mkdir as mkdir8, writeFile as writeFile6 } from "node:fs/promises";
7895
- import { join as join16 } from "node:path";
8208
+ import { mkdir as mkdir7, readdir as readdir3, readFile as readFile12, rename as rename4, stat, unlink as unlink2, writeFile as writeFile7 } from "node:fs/promises";
8209
+ import { join as join17 } from "node:path";
7896
8210
  import { defineTool as defineTool6 } from "@earendil-works/pi-coding-agent";
7897
- import { z as z9 } from "zod";
8211
+ import { z as z11 } from "zod";
7898
8212
 
7899
8213
  // src/review/types.ts
7900
8214
  import { z as z8 } from "zod";
@@ -7919,6 +8233,21 @@ function yymmdd(date = new Date) {
7919
8233
  function reviewRecordName(target, date = new Date) {
7920
8234
  return `${yymmdd(date)}-${reviewSlug(target) || "uncommitted"}`;
7921
8235
  }
8236
+ function localResponseMarker(threadId) {
8237
+ return `<!-- diffpi -->`;
8238
+ }
8239
+ function isLocalResponse(body) {
8240
+ return /^<!-- diffpi(?:-local-response:[A-Za-z0-9_-]+)? -->\n/m.test(body);
8241
+ }
8242
+ function parseReviewThreadAction(body) {
8243
+ const match = body.match(/^\[(REOPEN|RESOLVE|DELETE)\]\s*/i);
8244
+ if (!match)
8245
+ return { body };
8246
+ return {
8247
+ action: match[1].toLowerCase(),
8248
+ body: body.slice(match[0].length)
8249
+ };
8250
+ }
7922
8251
  function dedupeFindings(findings) {
7923
8252
  const rank = { BLOCKING: 3, CONSIDER: 2, NOTE: 1 };
7924
8253
  const byKey = new Map;
@@ -7964,6 +8293,18 @@ function renderCommentBody(finding) {
7964
8293
  return `${prefix}${finding.body}${reference}`;
7965
8294
  }
7966
8295
  // src/review/review-markdown.ts
8296
+ import { z as z9 } from "zod";
8297
+ var reviewThreadRecordSchema = z9.object({
8298
+ id: z9.string().min(1),
8299
+ file: z9.string().nullable().optional().transform((value) => value ?? undefined),
8300
+ line: z9.number().int().positive().nullable().optional().transform((value) => value ?? undefined),
8301
+ body: z9.string(),
8302
+ author: z9.string().optional(),
8303
+ resolved: z9.boolean(),
8304
+ addressed: z9.boolean().optional(),
8305
+ question: z9.boolean(),
8306
+ replies: z9.array(z9.string()).optional()
8307
+ });
7967
8308
  function renderReviewDoc(input) {
7968
8309
  const anchored = dedupeFindings(input.findings).filter((finding) => finding.line > 0);
7969
8310
  const lines = [`# Review: ${input.title}`, "", "## Metadata"];
@@ -8005,6 +8346,88 @@ function renderReviewDoc(input) {
8005
8346
  `).trimEnd()}
8006
8347
  `;
8007
8348
  }
8349
+ function renderThreadArtifact(title, target, threads, options = {}) {
8350
+ const records = threads.map((thread) => ({
8351
+ id: thread.id,
8352
+ file: thread.file,
8353
+ line: thread.line,
8354
+ body: thread.body,
8355
+ author: thread.author,
8356
+ resolved: thread.resolved,
8357
+ addressed: thread.addressed,
8358
+ question: thread.question,
8359
+ replies: thread.replies
8360
+ }));
8361
+ const payload = Buffer.from(JSON.stringify(records), "utf8").toString("base64url");
8362
+ const lines = [
8363
+ `<!-- diffpi-threads:${payload} -->`,
8364
+ `# Review threads: ${title}`,
8365
+ "",
8366
+ "## Metadata",
8367
+ "",
8368
+ `- Target: ${target}`,
8369
+ `- Pulled: ${options.timestamp ?? new Date().toISOString()}`
8370
+ ];
8371
+ if (options.number !== undefined)
8372
+ lines.push(`- PR/MR: #${options.number}${options.url ? ` — ${options.url}` : ""}`);
8373
+ lines.push("", "## Replies", "");
8374
+ for (const thread of threads) {
8375
+ const id = Buffer.from(thread.id, "utf8").toString("base64url");
8376
+ lines.push(`### ${thread.file ?? "review"}:${thread.line ?? "n/a"} (${thread.id})`, "", `<!-- diffpi-reply-start:${id} -->`, thread.reply ?? "", `<!-- diffpi-reply-end:${id} -->`, "");
8377
+ }
8378
+ lines.push("## Source comments", "");
8379
+ for (const thread of threads) {
8380
+ lines.push(`### ${thread.file ?? "review"}:${thread.line ?? "n/a"} — ${thread.author ?? "unknown"}`, "", `Thread: ${thread.id}`, `Status: ${thread.resolved ? "resolved" : thread.addressed ? "addressed" : "open"}`, "", "**Source comment**", "", thread.body, "", "**Recorded response**", "", thread.reply?.trim() || "_No response recorded._", "", "---", "");
8381
+ }
8382
+ return `${lines.join(`
8383
+ `).trimEnd()}
8384
+ `;
8385
+ }
8386
+ function parseThreadArtifact(content) {
8387
+ const payload = content.match(/^<!-- diffpi-threads:([A-Za-z0-9_-]+) -->$/m)?.[1];
8388
+ if (!payload)
8389
+ throw new Error("This file is not a Diffpi thread artifact.");
8390
+ let threads;
8391
+ try {
8392
+ const decoded = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
8393
+ threads = z9.array(reviewThreadRecordSchema).parse(decoded);
8394
+ } catch {
8395
+ throw new Error("Cannot parse the Diffpi thread artifact payload.");
8396
+ }
8397
+ const replies = content.split(/^## Source comments$/m, 1)[0] ?? "";
8398
+ return threads.map((thread) => {
8399
+ const id = Buffer.from(thread.id, "utf8").toString("base64url");
8400
+ const startMarker = `<!-- diffpi-reply-start:${id} -->
8401
+ `;
8402
+ const endMarker = `
8403
+ <!-- diffpi-reply-end:${id} -->`;
8404
+ const start = replies.indexOf(startMarker);
8405
+ const end = start < 0 ? -1 : replies.indexOf(endMarker, start + startMarker.length);
8406
+ const reply = start >= 0 && end >= 0 ? replies.slice(start + startMarker.length, end).trim() : "";
8407
+ return reply ? { ...thread, reply } : thread;
8408
+ });
8409
+ }
8410
+ function upsertThreadReply(content, threadId, body, question, resolved) {
8411
+ const threads = parseThreadArtifact(content);
8412
+ const thread = threads.find((candidate) => candidate.id === threadId);
8413
+ if (!thread)
8414
+ throw new Error(`Review thread ${threadId} was not found in the local artifact.`);
8415
+ thread.reply = body;
8416
+ thread.addressed = true;
8417
+ if (question !== undefined)
8418
+ thread.question = question;
8419
+ if (resolved !== undefined)
8420
+ thread.resolved = resolved;
8421
+ const title = content.match(/^# Review threads: (.+)$/m)?.[1] ?? "review";
8422
+ const target = content.match(/^- Target: (.+)$/m)?.[1] ?? "local";
8423
+ const timestamp = content.match(/^- Pulled: (.+)$/m)?.[1];
8424
+ const pr = content.match(/^- PR\/MR: #(\d+)(?: — (.+))?$/m);
8425
+ return renderThreadArtifact(title, target, threads, {
8426
+ timestamp,
8427
+ number: pr ? Number.parseInt(pr[1], 10) : undefined,
8428
+ url: pr?.[2]
8429
+ });
8430
+ }
8008
8431
  // src/review/github-review-backend.ts
8009
8432
  function GitHubReviewBackend(vcs, number) {
8010
8433
  const pendingReview = async () => {
@@ -8066,6 +8489,9 @@ function GitHubReviewBackend(vcs, number) {
8066
8489
  id: thread.id,
8067
8490
  file: thread.path ?? undefined,
8068
8491
  line: thread.line ?? undefined,
8492
+ rootCommentId: comment?.id,
8493
+ commentIds: nodes.flatMap((node) => [node.id, node.databaseId?.toString()].filter((id) => Boolean(id))),
8494
+ commentNodeIds: nodes.flatMap((node) => node.id ? [node.id] : []),
8069
8495
  body,
8070
8496
  author: comment?.author?.login,
8071
8497
  resolved: thread.isResolved,
@@ -8075,6 +8501,7 @@ function GitHubReviewBackend(vcs, number) {
8075
8501
  });
8076
8502
  };
8077
8503
  return {
8504
+ kind: "remote",
8078
8505
  async stage(draft) {
8079
8506
  const pending = await pendingReview();
8080
8507
  if (!pending) {
@@ -8132,6 +8559,33 @@ function GitHubReviewBackend(vcs, number) {
8132
8559
  await ghChecked(args);
8133
8560
  }
8134
8561
  },
8562
+ async readDraft() {
8563
+ const pending = await pendingReview();
8564
+ if (!pending)
8565
+ return { comments: [], body: "" };
8566
+ const review = await ghChecked(["api", `/repos/${vcs.owner}/${vcs.repo}/pulls/${number}/reviews/${pending.id}`]);
8567
+ const comments = await ghChecked([
8568
+ "api",
8569
+ `/repos/${vcs.owner}/${vcs.repo}/pulls/${number}/reviews/${pending.id}/comments`
8570
+ ]);
8571
+ let reviewData;
8572
+ let commentData;
8573
+ try {
8574
+ reviewData = JSON.parse(review.stdout);
8575
+ commentData = JSON.parse(comments.stdout);
8576
+ } catch {
8577
+ throw new Error("Cannot parse the pending GitHub review as JSON.");
8578
+ }
8579
+ return {
8580
+ body: reviewData.body ?? "",
8581
+ comments: commentData.map((comment) => ({
8582
+ file: comment.path,
8583
+ line: comment.line ?? comment.original_line ?? undefined,
8584
+ side: comment.side ?? undefined,
8585
+ body: comment.body
8586
+ }))
8587
+ };
8588
+ },
8135
8589
  listThreads,
8136
8590
  async reply(input) {
8137
8591
  const threads = await listThreads();
@@ -8148,14 +8602,32 @@ function GitHubReviewBackend(vcs, number) {
8148
8602
  `body=${input.body}`
8149
8603
  ]);
8150
8604
  }
8151
- if (input.resolve) {
8605
+ if (input.resolve)
8606
+ await this.setResolved?.(input.threadId, true);
8607
+ },
8608
+ async setResolved(threadId, resolved) {
8609
+ await ghChecked([
8610
+ "api",
8611
+ "graphql",
8612
+ "-f",
8613
+ `query=${resolved ? GITHUB_RESOLVE_MUTATION : GITHUB_UNRESOLVE_MUTATION}`,
8614
+ "-f",
8615
+ `threadId=${threadId}`
8616
+ ]);
8617
+ },
8618
+ async deleteThread(threadId) {
8619
+ const thread = (await listThreads()).find((candidate) => candidate.id === threadId);
8620
+ const commentNodeIds = thread?.commentNodeIds ?? (thread?.rootCommentId ? [thread.rootCommentId] : []);
8621
+ if (!commentNodeIds.length)
8622
+ throw new Error(`Cannot delete GitHub thread ${threadId}: comment node IDs are missing.`);
8623
+ for (const commentId of commentNodeIds) {
8152
8624
  await ghChecked([
8153
8625
  "api",
8154
8626
  "graphql",
8155
8627
  "-f",
8156
- `query=${GITHUB_RESOLVE_MUTATION}`,
8628
+ `query=${GITHUB_DELETE_COMMENT_MUTATION}`,
8157
8629
  "-f",
8158
- `threadId=${input.threadId}`
8630
+ `commentId=${commentId}`
8159
8631
  ]);
8160
8632
  }
8161
8633
  },
@@ -8174,11 +8646,13 @@ function GitHubReviewBackend(vcs, number) {
8174
8646
  function githubReviewSubmissionEndpoint(owner, repo, id, pendingReviewId) {
8175
8647
  return pendingReviewId ? `/repos/${owner}/${repo}/pulls/${id}/reviews/${pendingReviewId}/events` : `/repos/${owner}/${repo}/pulls/${id}/reviews`;
8176
8648
  }
8177
- 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}}}}}`;
8649
+ 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}}}}}`;
8178
8650
  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}}}`;
8179
8651
  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}}}`;
8180
8652
  var GITHUB_REPLY_MUTATION = `mutation($threadId:ID!,$body:String!){addPullRequestReviewThreadReply(input:{pullRequestReviewThreadId:$threadId,body:$body}){comment{id}}}`;
8181
8653
  var GITHUB_RESOLVE_MUTATION = `mutation($threadId:ID!){resolveReviewThread(input:{threadId:$threadId}){thread{isResolved}}}`;
8654
+ var GITHUB_UNRESOLVE_MUTATION = `mutation($threadId:ID!){unresolveReviewThread(input:{threadId:$threadId}){thread{isResolved}}}`;
8655
+ var GITHUB_DELETE_COMMENT_MUTATION = `mutation($commentId:ID!){deletePullRequestReviewComment(input:{id:$commentId}){clientMutationId}}`;
8182
8656
 
8183
8657
  // src/review/gitlab-review-backend.ts
8184
8658
  function GitLabReviewBackend(vcs, number) {
@@ -8215,6 +8689,7 @@ function GitLabReviewBackend(vcs, number) {
8215
8689
  });
8216
8690
  };
8217
8691
  return {
8692
+ kind: "remote",
8218
8693
  async stage(draft) {
8219
8694
  const endpoint = `${mergeRequestEndpoint()}/draft_notes`;
8220
8695
  if (draft.body.trim()) {
@@ -8243,6 +8718,36 @@ function GitLabReviewBackend(vcs, number) {
8243
8718
  });
8244
8719
  }
8245
8720
  },
8721
+ async readDraft() {
8722
+ const result = await glabChecked(["api", `${mergeRequestEndpoint()}/draft_notes`]);
8723
+ let notes;
8724
+ try {
8725
+ notes = JSON.parse(result.stdout);
8726
+ } catch {
8727
+ throw new Error("Cannot parse GitLab draft notes as JSON.");
8728
+ }
8729
+ const comments = [];
8730
+ const body = [];
8731
+ for (const note of notes) {
8732
+ if (!note.note)
8733
+ continue;
8734
+ const file = note.position?.new_path ?? note.position?.old_path;
8735
+ const line = note.position?.new_line ?? note.position?.old_line;
8736
+ if (file && line) {
8737
+ comments.push({
8738
+ file,
8739
+ line,
8740
+ side: note.position?.new_line ? "RIGHT" : "LEFT",
8741
+ body: note.note
8742
+ });
8743
+ } else {
8744
+ body.push(note.note);
8745
+ }
8746
+ }
8747
+ return { comments, body: body.join(`
8748
+
8749
+ `) };
8750
+ },
8246
8751
  listThreads,
8247
8752
  async reply(input) {
8248
8753
  const endpoint = `${mergeRequestEndpoint()}/discussions/${encodeURIComponent(input.threadId)}`;
@@ -8254,7 +8759,15 @@ function GitLabReviewBackend(vcs, number) {
8254
8759
  });
8255
8760
  }
8256
8761
  if (input.resolve)
8257
- await glabChecked(["api", "--method", "PUT", `${endpoint}?resolved=true`]);
8762
+ await this.setResolved?.(input.threadId, true);
8763
+ },
8764
+ async setResolved(threadId, resolved) {
8765
+ const endpoint = `${mergeRequestEndpoint()}/discussions/${encodeURIComponent(threadId)}`;
8766
+ await glabChecked(["api", "--method", "PUT", `${endpoint}?resolved=${resolved}`]);
8767
+ },
8768
+ async deleteThread(threadId) {
8769
+ const endpoint = `${mergeRequestEndpoint()}/discussions/${encodeURIComponent(threadId)}`;
8770
+ await glabChecked(["api", "--method", "DELETE", endpoint]);
8258
8771
  },
8259
8772
  async publish(event) {
8260
8773
  assertReviewEventSupported(vcs.provider, event);
@@ -8294,17 +8807,8 @@ function hasGitlabDraftNotes(input) {
8294
8807
  }
8295
8808
  }
8296
8809
 
8297
- // src/review/review-backend.ts
8298
- function createRemoteReviewBackend(vcs, number) {
8299
- if (vcs.provider === "github")
8300
- return GitHubReviewBackend(vcs, number);
8301
- if (vcs.provider === "gitlab")
8302
- return GitLabReviewBackend(vcs, number);
8303
- throw new Error("A remote review backend requires a GitHub or GitLab repository.");
8304
- }
8305
- // src/review/local-reviews.ts
8306
- import { mkdir as mkdir7, readdir as readdir2, readFile as readFile9, unlink as unlink2, writeFile as writeFile5 } from "node:fs/promises";
8307
- import { join as join15 } from "node:path";
8810
+ // src/review/local-review-backend.ts
8811
+ import { readFile as readFile9, writeFile as writeFile5 } from "node:fs/promises";
8308
8812
 
8309
8813
  // src/extensions/tuicrx.ts
8310
8814
  import { readFile as readFile8, realpath as realpath4 } from "node:fs/promises";
@@ -8375,6 +8879,11 @@ async function resolveReviewSession(cwd, target) {
8375
8879
  }
8376
8880
  return resolveSession(cwd, target.branch);
8377
8881
  }
8882
+ async function resolvePublishSession(cwd, target) {
8883
+ if (target.owner && target.repo && target.number !== undefined)
8884
+ return resolvePrSession(cwd, target.owner, target.repo, target.number);
8885
+ return resolveSession(cwd, target.branch);
8886
+ }
8378
8887
  async function resolvePrSession(cwd, owner, repo, number) {
8379
8888
  const coordinate = `${owner}/${repo}`.toLowerCase();
8380
8889
  const sessions = await listSessions(cwd);
@@ -8409,6 +8918,8 @@ async function readSession(path) {
8409
8918
  function toLocalReviewThreads(session) {
8410
8919
  const threads = [];
8411
8920
  const add = (comment, location = {}) => {
8921
+ if (isLocalResponse(comment.content))
8922
+ return;
8412
8923
  const author = commentAuthor(comment);
8413
8924
  threads.push({
8414
8925
  id: comment.id ?? `local-${threads.length + 1}`,
@@ -8434,6 +8945,54 @@ function toLocalReviewThreads(session) {
8434
8945
  }
8435
8946
  return threads;
8436
8947
  }
8948
+ function toFindings(session, options = {}) {
8949
+ const comments = [];
8950
+ const include = (comment) => (!options.agentOnly || commentAuthor(comment)?.startsWith("Agent: ")) && (!options.excludeLocalResponses || !isLocalResponse(comment.content));
8951
+ const publishableBody = (comment) => {
8952
+ const author = commentAuthor(comment);
8953
+ const route = author?.match(/^Agent:\s*(.+)$/)?.[1];
8954
+ return route ? withRemoteProvenance(comment.content, route) : comment.content;
8955
+ };
8956
+ const bodyParts = (session.review_comments ?? []).flatMap((comment) => include(comment) ? [publishableBody(comment)] : []);
8957
+ for (const [file, entry] of Object.entries(session.files ?? {})) {
8958
+ for (const fileComment of entry.file_comments ?? []) {
8959
+ if (!include(fileComment))
8960
+ continue;
8961
+ const comment = {
8962
+ file,
8963
+ body: fileComment.content,
8964
+ ...fileComment.id ? { sourceCommentId: String(fileComment.id) } : {}
8965
+ };
8966
+ const author = commentAuthor(fileComment);
8967
+ if (author)
8968
+ comment.author = author;
8969
+ comments.push(comment);
8970
+ }
8971
+ for (const [lineKey, lineComments] of Object.entries(entry.line_comments ?? {})) {
8972
+ const line = Number.parseInt(lineKey, 10);
8973
+ if (!Number.isFinite(line))
8974
+ continue;
8975
+ for (const lineComment of lineComments) {
8976
+ if (!include(lineComment))
8977
+ continue;
8978
+ const comment = {
8979
+ file,
8980
+ line,
8981
+ side: lineComment.side === "old" ? "LEFT" : "RIGHT",
8982
+ body: lineComment.content,
8983
+ ...lineComment.id ? { sourceCommentId: String(lineComment.id) } : {}
8984
+ };
8985
+ const author = commentAuthor(lineComment);
8986
+ if (author)
8987
+ comment.author = author;
8988
+ comments.push(comment);
8989
+ }
8990
+ }
8991
+ }
8992
+ return { comments, body: bodyParts.join(`
8993
+
8994
+ `) };
8995
+ }
8437
8996
  async function requireTuicr() {
8438
8997
  if (!await tuicrAvailable())
8439
8998
  throw new Error("tuicr is not installed or is not available on PATH.");
@@ -8449,83 +9008,231 @@ async function canonicalPath3(path) {
8449
9008
  }
8450
9009
  }
8451
9010
 
8452
- // src/review/local-reviews.ts
8453
- async function captureLocalReview(input) {
8454
- const id = reviewSlug(input.branch) || "local-review";
8455
- const dir = join15(await reviewsDir(input.cwd, input.homeDir), id);
8456
- await mkdir7(dir, { recursive: true });
8457
- const revision = await nextRevision(dir);
8458
- const output = await readFile9(input.session.path, "utf8");
8459
- const comments = toLocalReviewThreads(await readSession(input.session.path));
8460
- const dump = {
8461
- schemaVersion: 1,
8462
- id,
8463
- revision,
8464
- branch: input.branch,
8465
- base: input.base,
8466
- createdAt: (input.now ?? (() => new Date))().toISOString(),
8467
- diff: input.diff,
8468
- comments,
8469
- tuicr: { slug: input.session.slug, output }
9011
+ // src/review/local-review-backend.ts
9012
+ function LocalReviewBackend(options) {
9013
+ return {
9014
+ kind: "local",
9015
+ async stage(draft) {
9016
+ for (const comment of draft.comments) {
9017
+ await addComment(options.session, comment.body, {
9018
+ targetFile: comment.file,
9019
+ line: comment.line,
9020
+ side: comment.side === "LEFT" ? "old" : "new",
9021
+ username: options.author
9022
+ });
9023
+ }
9024
+ if (draft.body.trim())
9025
+ await addComment(options.session, draft.body, { username: options.author });
9026
+ },
9027
+ async readDraft() {
9028
+ return toFindings(await readSession(options.session), { excludeLocalResponses: true });
9029
+ },
9030
+ async listThreads() {
9031
+ try {
9032
+ return parseThreadArtifact(await readFile9(options.artifactPath, "utf8"));
9033
+ } catch (error) {
9034
+ if (error.code === "ENOENT")
9035
+ return [];
9036
+ throw error;
9037
+ }
9038
+ },
9039
+ async reply(input) {
9040
+ const content = await readFile9(options.artifactPath, "utf8");
9041
+ await writeFile5(options.artifactPath, upsertThreadReply(content, input.threadId, input.body, input.question, input.resolve), "utf8");
9042
+ },
9043
+ async publish() {
9044
+ throw new Error("Promote a local draft through a remote review backend before publishing it.");
9045
+ }
8470
9046
  };
8471
- const path = join15(dir, `${revision}.json`);
8472
- await writeFile5(path, `${JSON.stringify(dump, null, 2)}
8473
- `, { encoding: "utf8", flag: "wx" });
8474
- await unlink2(input.session.path);
8475
- return { path, dump };
8476
9047
  }
8477
- async function nextRevision(dir) {
8478
- return await latestRevision(dir) + 1;
9048
+
9049
+ // src/review/review-backend.ts
9050
+ function createRemoteReviewBackend(vcs, number) {
9051
+ if (vcs.provider === "github")
9052
+ return GitHubReviewBackend(vcs, number);
9053
+ if (vcs.provider === "gitlab")
9054
+ return GitLabReviewBackend(vcs, number);
9055
+ throw new Error("A remote review backend requires a GitHub or GitLab repository.");
9056
+ }
9057
+ function createLocalReviewBackend(options) {
9058
+ return LocalReviewBackend(options);
9059
+ }
9060
+ // src/review/review-state.ts
9061
+ import { createHash as createHash4 } from "node:crypto";
9062
+ import { readFile as readFile10, rename as rename3, writeFile as writeFile6 } from "node:fs/promises";
9063
+ import { join as join15 } from "node:path";
9064
+ import { z as z10 } from "zod";
9065
+ var reviewPublicationStateSchema = z10.object({
9066
+ target: z10.string().optional(),
9067
+ bodies: z10.array(z10.string()).default([]),
9068
+ comments: z10.array(z10.string()).default([]),
9069
+ stagedComments: z10.array(z10.string()).default([]),
9070
+ replies: z10.array(z10.string()).default([]),
9071
+ overlayPath: z10.string().optional()
9072
+ });
9073
+ function reviewBodyFingerprint(body) {
9074
+ return digest(body);
9075
+ }
9076
+ function reviewCommentFingerprint(comment) {
9077
+ return digest([comment.file, String(comment.line), comment.side ?? "RIGHT", comment.body].join("\x00"));
9078
+ }
9079
+ function reviewReplyFingerprint(threadId, body) {
9080
+ return digest(`${threadId}\x00${body}`);
9081
+ }
9082
+ function unpublishedReviewComments(comments, knownFingerprints) {
9083
+ return comments.filter((comment) => !knownFingerprints.has(reviewCommentFingerprint(comment)));
9084
+ }
9085
+ async function loadReviewPublicationState(cwd, vcs, number, homeDir) {
9086
+ const target = `${vcs.provider}:${vcs.owner}/${vcs.repo}#${number}`;
9087
+ const path = join15(await sessionsDir(cwd, homeDir), `review-publish-${digest(target).slice(0, 16)}.json`);
9088
+ try {
9089
+ const parsed = reviewPublicationStateSchema.parse(JSON.parse(await readFile10(path, "utf8")));
9090
+ return { path, state: { ...parsed, target } };
9091
+ } catch (error) {
9092
+ if (error.code === "ENOENT") {
9093
+ return { path, state: { target, bodies: [], comments: [], stagedComments: [], replies: [] } };
9094
+ }
9095
+ if (error instanceof SyntaxError || error instanceof z10.ZodError) {
9096
+ throw new Error(`Cannot parse review publication state: ${path}`);
9097
+ }
9098
+ throw error;
9099
+ }
9100
+ }
9101
+ async function saveReviewPublicationState(path, state) {
9102
+ const temp = `${path}.${process.pid}.tmp`;
9103
+ await writeFile6(temp, `${JSON.stringify(state, null, 2)}
9104
+ `, "utf8");
9105
+ await rename3(temp, path);
9106
+ }
9107
+ function digest(value) {
9108
+ return createHash4("sha256").update(value).digest("hex");
9109
+ }
9110
+ // src/templates.ts
9111
+ import { readFile as readFile11 } from "node:fs/promises";
9112
+ import { homedir as homedir4 } from "node:os";
9113
+ import { join as join16, normalize } from "node:path";
9114
+ async function loadTemplate(name, options = {}) {
9115
+ const relative = templateRelativePath(name);
9116
+ const userPath = join16(options.homeDir ?? homedir4(), ".difflab", "diffpi", "templates", relative);
9117
+ const bundledPath = join16(options.bundledDir ?? resolveBundledTemplatesDir(), relative);
9118
+ const user = await readOptionalFile(userPath);
9119
+ if (user !== undefined)
9120
+ return { name, path: userPath, source: "user", content: user };
9121
+ const bundled = await readOptionalFile(bundledPath);
9122
+ if (bundled !== undefined)
9123
+ return { name, path: bundledPath, source: "bundled", content: bundled };
9124
+ throw new Error(`Template "${name}" was not found at ${userPath} or ${bundledPath}.`);
9125
+ }
9126
+ function renderTemplate(content, variables) {
9127
+ return content.replace(/\{\{\s*([a-zA-Z0-9_.-]+)\s*\}\}/g, (match, key) => variables[key] ?? match);
9128
+ }
9129
+ function templateRelativePath(name) {
9130
+ const normalized = normalize(name.replaceAll("\\", "/")).replace(/^\.\//, "");
9131
+ if (!normalized || normalized.startsWith("../") || normalized.includes("/../") || normalized.startsWith("/")) {
9132
+ throw new Error(`Invalid template name: ${name}`);
9133
+ }
9134
+ return normalized.endsWith(".md") ? normalized : `${normalized}.md`;
8479
9135
  }
8480
- async function latestRevision(dir) {
9136
+ async function readOptionalFile(path) {
8481
9137
  try {
8482
- return (await readdir2(dir)).reduce((latest, name) => {
8483
- const match = name.match(/^(\d+)\.json$/);
8484
- return match ? Math.max(latest, Number(match[1])) : latest;
8485
- }, 0);
9138
+ return await readFile11(path, "utf8");
8486
9139
  } catch (error) {
8487
9140
  if (error.code === "ENOENT")
8488
- return 0;
9141
+ return;
8489
9142
  throw error;
8490
9143
  }
8491
9144
  }
9145
+
8492
9146
  // src/tools/review.ts
8493
- var contextSchema = z9.object({
8494
- cwd: z9.string().optional(),
8495
- target: z9.string().optional(),
8496
- workingTree: z9.boolean().optional()
9147
+ var contextSchema = z11.object({
9148
+ cwd: z11.string().optional(),
9149
+ target: z11.string().optional(),
9150
+ workingTree: z11.boolean().optional()
8497
9151
  });
8498
- var localSchema = contextSchema.extend({ local: z9.boolean().optional() });
9152
+ var localSchema = contextSchema.extend({ local: z11.boolean().optional() });
8499
9153
  var openSchema = localSchema.extend({
8500
- title: z9.string().optional(),
8501
- intent: z9.string().optional(),
8502
- issueUrl: z9.string().url().optional(),
8503
- base: z9.string().optional()
9154
+ title: z11.string().optional(),
9155
+ intent: z11.string().optional(),
9156
+ issueUrl: z11.string().url().optional(),
9157
+ base: z11.string().optional()
8504
9158
  });
8505
9159
  var submitSchema = localSchema.extend({
8506
9160
  findings: findingsSchema,
8507
- overallIssues: z9.array(z9.string()).optional(),
8508
- notVerified: z9.array(z9.string()).optional(),
8509
- title: z9.string().optional()
9161
+ overallIssues: z11.array(z11.string()).optional(),
9162
+ notVerified: z11.array(z11.string()).optional(),
9163
+ title: z11.string().optional()
8510
9164
  });
8511
9165
  var addCommentSchema = localSchema.extend({
8512
- body: z9.string().min(1),
8513
- file: z9.string().min(1),
8514
- line: z9.number().int().positive(),
8515
- side: z9.enum(["LEFT", "RIGHT"]).optional()
9166
+ body: z11.string().min(1),
9167
+ file: z11.string().min(1),
9168
+ line: z11.number().int().positive(),
9169
+ side: z11.enum(["LEFT", "RIGHT"]).optional()
8516
9170
  });
8517
9171
  var respondSchema = localSchema.extend({
8518
- threadId: z9.string().min(1),
8519
- body: z9.string().min(1),
8520
- question: z9.boolean().optional(),
8521
- resolve: z9.boolean().optional()
9172
+ threadId: z11.string().min(1),
9173
+ body: z11.string().min(1),
9174
+ question: z11.boolean().optional(),
9175
+ resolve: z11.boolean().optional()
8522
9176
  });
8523
9177
  var publishSchema = localSchema.extend({
8524
- status: z9.enum(["COMMENT", "APPROVE", "REQUEST_CHANGES", "CLOSE"]).optional()
9178
+ status: z11.enum(["COMMENT", "APPROVE", "REQUEST_CHANGES", "CLOSE"]).optional()
8525
9179
  });
8526
9180
  var completeSchema = localSchema.extend({
8527
- action: z9.enum(["approve", "reject", "abandon"]).optional()
9181
+ action: z11.enum(["approve", "reject", "abandon"]).optional()
8528
9182
  });
9183
+ function hasReviewDraft(comments, body) {
9184
+ return comments.length > 0 || body.trim().length > 0;
9185
+ }
9186
+ function reviewBrowserCommand(url, platform = process.platform) {
9187
+ return platform === "darwin" ? { command: "open", args: [url] } : platform === "win32" ? { command: "cmd", args: ["/c", "start", "", url] } : { command: "xdg-open", args: [url] };
9188
+ }
9189
+ async function openReviewUrl(url, execute = run, platform = process.platform) {
9190
+ const launch = reviewBrowserCommand(url, platform);
9191
+ try {
9192
+ return (await execute(launch.command, launch.args)).code === 0;
9193
+ } catch {
9194
+ return false;
9195
+ }
9196
+ }
9197
+ function partitionReviewComments(comments, knownFingerprints, model) {
9198
+ const normalized = comments.map((comment) => {
9199
+ const published = { ...comment, body: withCommentProvenance(comment, model) };
9200
+ return { source: comment, published, fingerprint: reviewCommentFingerprint(published) };
9201
+ });
9202
+ return {
9203
+ checkpointed: normalized.filter((comment) => knownFingerprints.has(comment.fingerprint)),
9204
+ candidates: normalized.filter((comment) => !knownFingerprints.has(comment.fingerprint))
9205
+ };
9206
+ }
9207
+ function reviewResponseResolution(question, requested) {
9208
+ return question ? false : requested ?? false;
9209
+ }
9210
+ function workingTreeReplyDraft(thread, body) {
9211
+ if (!thread.file)
9212
+ return { comments: [], body };
9213
+ return {
9214
+ comments: [
9215
+ {
9216
+ file: thread.file,
9217
+ ...thread.line === undefined ? {} : { line: thread.line, side: "RIGHT" },
9218
+ body
9219
+ }
9220
+ ],
9221
+ body: ""
9222
+ };
9223
+ }
9224
+ function resolveReviewThread(comment, threads) {
9225
+ const sourceCommentId = comment.sourceCommentId;
9226
+ if (sourceCommentId) {
9227
+ const identified = threads.find((thread) => thread.id === sourceCommentId || thread.rootCommentId === sourceCommentId || thread.commentIds?.includes(sourceCommentId));
9228
+ if (identified)
9229
+ return identified;
9230
+ }
9231
+ const matches = threads.filter((thread) => thread.file === comment.file && (thread.line ?? undefined) === (comment.line ?? undefined));
9232
+ if (matches.length > 1)
9233
+ throw new Error(`Ambiguous remote review threads at ${comment.file}:${comment.line ?? "file"}: ${matches.map((thread) => thread.id).join(", ")}.`);
9234
+ return matches[0];
9235
+ }
8529
9236
  async function workingTreeDiff(cwd) {
8530
9237
  const tracked = await run("git", ["-C", cwd, "diff", "HEAD"], { capture: "unbounded" });
8531
9238
  if (tracked.code !== 0)
@@ -8558,7 +9265,9 @@ function createReviewTools() {
8558
9265
  const review = await resolveReviewContext(resolveWorkingDirectory(params), params.target);
8559
9266
  const store = await ensureStore(review.cwd);
8560
9267
  const env = { ide: detectIde(), mux: detectMux(), shell: detectShell() };
8561
- const session = await resolveTuicrSession(review, Boolean(params.local || params.workingTree));
9268
+ const session = await resolveTuicrSession(review, Boolean(params.local || params.workingTree)).catch(() => {
9269
+ return;
9270
+ });
8562
9271
  const baseRef = review.pr?.baseRef ?? (review.forge ? await review.forge.defaultBranch() : "local");
8563
9272
  const backend = params.local || params.workingTree ? "tuicr" : review.forge?.provider ?? "unsupported";
8564
9273
  return result3([
@@ -8582,6 +9291,21 @@ function createReviewTools() {
8582
9291
  });
8583
9292
  }
8584
9293
  }),
9294
+ defineTool6({
9295
+ name: "review_status",
9296
+ label: "review status",
9297
+ description: "Report local and remote review lifecycle status without requiring a forge or tuicr.",
9298
+ promptSnippet: "Call review_status for review lifecycle state",
9299
+ promptGuidelines: ["Use this for status even when no forge or tuicr binary is available."],
9300
+ parameters: parameters2(localSchema),
9301
+ executionMode: "parallel",
9302
+ async execute(_id, input) {
9303
+ const params = localSchema.parse(input);
9304
+ const review = await resolveReviewContext(resolveWorkingDirectory(params), params.target);
9305
+ const status = await collectReviewStatus(review);
9306
+ return result3(status.text, status.details);
9307
+ }
9308
+ }),
8585
9309
  defineTool6({
8586
9310
  name: "review_new",
8587
9311
  label: "review new",
@@ -8625,6 +9349,31 @@ ${openMessage}`, {
8625
9349
  });
8626
9350
  }
8627
9351
  }),
9352
+ defineTool6({
9353
+ name: "review_open",
9354
+ label: "review open",
9355
+ description: "Open an existing remote PR/MR in the system browser; never creates one.",
9356
+ promptSnippet: "Call review_open to open an existing PR/MR",
9357
+ promptGuidelines: ["Direct users to review_new when no remote review exists."],
9358
+ parameters: parameters2(localSchema),
9359
+ executionMode: "sequential",
9360
+ async execute(_id, input) {
9361
+ const params = localSchema.parse(input);
9362
+ const review = await resolveReviewContext(resolveWorkingDirectory(params), params.target);
9363
+ if (params.local || params.workingTree)
9364
+ return launchLocalReview(review, true);
9365
+ if (!review.forge)
9366
+ return result3(`${unsupportedForgeMessage()} Use review_new with local=true for an offline review.`);
9367
+ if (!review.pr)
9368
+ return result3("No remote PR/MR exists. Use review_new to create a draft review first.");
9369
+ const opened = await openReviewUrl(review.pr.url);
9370
+ return result3(`${opened ? "Opened" : "Open this URL"} PR/MR #${review.pr.number}: ${review.pr.url}`, {
9371
+ pr: review.pr,
9372
+ opened,
9373
+ url: review.pr.url
9374
+ });
9375
+ }
9376
+ }),
8628
9377
  defineTool6({
8629
9378
  name: "review_edit",
8630
9379
  label: "review edit",
@@ -8721,7 +9470,7 @@ ${openMessage}`, {
8721
9470
  const gates = await runMiseGates(review.cwd);
8722
9471
  const baseRef = review.pr?.baseRef ?? (review.forge ? await review.forge.defaultBranch() : "local");
8723
9472
  const artifact = await newReviewArtifactPath(review.cwd, params.local || params.workingTree || !review.pr ? "uncommitted" : await reviewTargetId(review));
8724
- await writeFile6(artifact, renderReviewDoc({
9473
+ await writeFile7(artifact, renderReviewDoc({
8725
9474
  title: params.title ?? review.pr?.title ?? review.vcs.branch,
8726
9475
  number: useLocalBackend ? undefined : review.pr?.number,
8727
9476
  url: useLocalBackend ? undefined : review.pr?.url,
@@ -8744,21 +9493,17 @@ ${openMessage}`, {
8744
9493
  count: findings.length
8745
9494
  });
8746
9495
  }
8747
- for (const comment of comments) {
8748
- await addComment(session.path, comment.body, {
8749
- targetFile: comment.file,
8750
- line: comment.line,
8751
- side: comment.side === "LEFT" ? "old" : "new",
8752
- username: localReviewAuthor(model)
8753
- });
8754
- }
8755
- if (body.trim())
8756
- await addComment(session.path, body, { username: localReviewAuthor(model) });
9496
+ const backend = createLocalReviewBackend({
9497
+ session: session.path,
9498
+ artifactPath: artifact,
9499
+ author: localReviewAuthor(model)
9500
+ });
9501
+ await backend.stage({ comments, body });
8757
9502
  return result3(`Local review staged in tuicr: ${artifact}`, { artifact, count: findings.length, session });
8758
9503
  }
8759
9504
  if (!review.pr)
8760
9505
  return result3(`Review written: ${artifact}. No PR/MR matches this remote target.`, { artifact });
8761
- const hasDraft = comments.length > 0 || body.trim().length > 0;
9506
+ const hasDraft = hasReviewDraft(comments, body);
8762
9507
  if (hasDraft) {
8763
9508
  await createRemoteReviewBackend(review.vcs, review.pr.number).stage({ comments, body });
8764
9509
  }
@@ -8794,12 +9539,12 @@ ${openMessage}`, {
8794
9539
  const session = await resolveTuicrSession(review, Boolean(params.local || params.workingTree));
8795
9540
  if (!session)
8796
9541
  return result3("No matching tuicr session. Open review_new or review_launch_ui first.");
8797
- await addComment(session.path, comment.body, {
8798
- targetFile: comment.file,
8799
- line: comment.line,
8800
- side: comment.side === "LEFT" ? "old" : "new",
8801
- username: localReviewAuthor(model)
9542
+ const backend = createLocalReviewBackend({
9543
+ session: session.path,
9544
+ artifactPath: "",
9545
+ author: localReviewAuthor(model)
8802
9546
  });
9547
+ await backend.stage({ comments: [comment], body: "" });
8803
9548
  return result3(`Comment added to tuicr session ${session.slug}.`, { session, comment });
8804
9549
  }
8805
9550
  if (!review.pr)
@@ -8811,70 +9556,88 @@ ${openMessage}`, {
8811
9556
  defineTool6({
8812
9557
  name: "review_comments",
8813
9558
  label: "review comments",
8814
- description: "Read remote GitHub or GitLab review threads.",
8815
- promptSnippet: "Call review_comments before addressing remote findings",
8816
- promptGuidelines: ["Use review_dump for a local tuicr review."],
9559
+ description: "Pull review threads or local tuicr comments and write a target-named artifact.",
9560
+ promptSnippet: "Call review_comments before addressing findings",
9561
+ promptGuidelines: ["Pass local=true to prepare the local reply overlay."],
8817
9562
  parameters: parameters2(localSchema),
8818
- executionMode: "parallel",
9563
+ executionMode: "sequential",
8819
9564
  async execute(_id, input) {
8820
9565
  const params = localSchema.parse(input);
8821
- if (params.local || params.workingTree)
8822
- return result3("Use review_dump for a local tuicr review.");
8823
9566
  const review = await resolveReviewContext(resolveWorkingDirectory(params), params.target);
8824
- if (!review.pr || !review.forge)
9567
+ let threads;
9568
+ let artifact;
9569
+ if (params.local || params.workingTree) {
9570
+ const session = await resolveTuicrSession(review, true);
9571
+ if (!session)
9572
+ return result3("No matching tuicr session found.");
9573
+ artifact = await localThreadArtifactPath(review.cwd, session.slug);
9574
+ threads = await syncLocalThreadArtifact(artifact, review.pr?.title ?? review.vcs.branch, session.slug, toLocalReviewThreads(await readSession(session.path)));
9575
+ } else if (review.pr && review.forge) {
9576
+ threads = await createRemoteReviewBackend(review.vcs, review.pr.number).listThreads();
9577
+ const target = await reviewTargetId(review);
9578
+ artifact = await newReviewArtifactPath(review.cwd, target);
9579
+ await writeFile7(artifact, renderThreadArtifact(review.pr.title, target, threads, { number: review.pr.number, url: review.pr.url }), "utf8");
9580
+ } else {
8825
9581
  return result3(review.forge ? "No PR/MR matches this remote review target." : unsupportedForgeMessage());
8826
- const threads = await createRemoteReviewBackend(review.vcs, review.pr.number).listThreads();
9582
+ }
8827
9583
  return result3(threads.map((thread) => `${thread.id} ${thread.file ?? "review"}:${thread.line ?? "-"} — ${thread.body}`).join(`
8828
- `) || "No comments.", { pr: review.pr, threads });
8829
- }
8830
- }),
8831
- defineTool6({
8832
- name: "review_dump",
8833
- label: "review dump",
8834
- description: "Save one immutable local tuicr review revision and remove its completed session.",
8835
- promptSnippet: "Call review_dump before applying local review feedback",
8836
- promptGuidelines: ["Each dump advances the local review revision."],
8837
- parameters: parameters2(localSchema),
8838
- executionMode: "sequential",
8839
- async execute(_id, input) {
8840
- const params = localSchema.parse(input);
8841
- const review = await resolveReviewContext(resolveWorkingDirectory(params), params.target);
8842
- const session = await resolveTuicrSession(review, true);
8843
- if (!session)
8844
- return result3("No matching local tuicr session found.");
8845
- const base = review.pr?.baseRef ?? (review.forge ? await review.forge.defaultBranch() : "HEAD");
8846
- const captured = await captureLocalReview({
8847
- cwd: review.cwd,
8848
- branch: review.vcs.branch,
8849
- base,
8850
- diff: await workingTreeDiff(review.cwd),
8851
- session
8852
- });
8853
- return result3(captured.dump.comments.map((thread) => `${thread.id} ${thread.file ?? "review"}:${thread.line ?? "-"} — ${thread.body}`).join(`
8854
- `) || "No comments.", { artifact: captured.path, review: captured.dump, threads: captured.dump.comments });
9584
+ `) || "No comments.", { artifact, threads });
8855
9585
  }
8856
9586
  }),
8857
9587
  defineTool6({
8858
9588
  name: "review_respond",
8859
9589
  label: "review respond",
8860
- description: "Reply directly to a remote GitHub or GitLab review thread.",
8861
- promptSnippet: "Call review_respond after addressing a remote comment",
8862
- promptGuidelines: ["Leave threads open unless the user explicitly requests resolution."],
9590
+ description: "Post local question replies to tuicr and record their publish overlay, or reply to remote threads.",
9591
+ promptSnippet: "Call review_respond after addressing a comment",
9592
+ promptGuidelines: ["Question replies remain unresolved."],
8863
9593
  parameters: parameters2(respondSchema),
8864
9594
  executionMode: "sequential",
8865
9595
  async execute(_id, input, _signal, _onUpdate, ctx) {
8866
9596
  const params = respondSchema.parse(input);
8867
- if (params.local)
8868
- return result3("Local reviews use immutable revision dumps, not thread replies.");
8869
9597
  const review = await resolveReviewContext(resolveWorkingDirectory(params), params.target);
9598
+ const model = modelRoute(ctx);
9599
+ if (params.local) {
9600
+ const tuicrSession = await resolveTuicrSession(review, true);
9601
+ if (!tuicrSession)
9602
+ return result3("No matching tuicr session. Open review_new or review_launch_ui first.");
9603
+ const artifact = await localThreadArtifactPath(review.cwd, tuicrSession.slug);
9604
+ if (!existsSync2(artifact))
9605
+ return result3("No local review artifact. Run review_comments with local=true first.");
9606
+ const threads = await readThreadArtifact(artifact);
9607
+ const known = threads.find((thread) => thread.id === params.threadId);
9608
+ const question = known?.question === true || params.question === true || !known && params.question === undefined;
9609
+ const awaitUserDeletion = !question && params.resolve === true;
9610
+ await addComment(tuicrSession.path, `${localResponseMarker(params.threadId)}
9611
+ ${params.body}`, {
9612
+ targetFile: known?.file,
9613
+ line: known?.line,
9614
+ side: "new",
9615
+ username: localReviewAuthor(model)
9616
+ });
9617
+ await createLocalReviewBackend({
9618
+ session: tuicrSession.path,
9619
+ artifactPath: artifact,
9620
+ author: localReviewAuthor(model)
9621
+ }).reply({
9622
+ threadId: params.threadId,
9623
+ body: params.body,
9624
+ resolve: false,
9625
+ question
9626
+ });
9627
+ return result3(`Local ${question ? "answer" : "response"} posted to tuicr and recorded in ${artifact}${awaitUserDeletion ? "; delete the source comment in tuicr to resolve it" : ""}.`, { artifact, question, awaitUserDeletion, session: tuicrSession });
9628
+ }
8870
9629
  if (!review.pr || !review.forge)
8871
9630
  return result3("No remote PR/MR for this reply.");
8872
- const resolve = params.question ? false : params.resolve ?? false;
8873
- await createRemoteReviewBackend(review.vcs, review.pr.number).reply({
9631
+ const remote = createRemoteReviewBackend(review.vcs, review.pr.number);
9632
+ const remoteThreads = await remote.listThreads();
9633
+ const known = remoteThreads.find((thread) => thread.id === params.threadId);
9634
+ const question = known?.question === true || params.question === true || !known && params.question === undefined;
9635
+ const resolve = reviewResponseResolution(question, params.resolve);
9636
+ await remote.reply({
8874
9637
  threadId: params.threadId,
8875
- body: withRemoteProvenance(params.body, modelRoute(ctx)),
9638
+ body: withRemoteProvenance(params.body, model),
8876
9639
  resolve,
8877
- question: params.question
9640
+ question
8878
9641
  });
8879
9642
  return result3(`Replied to ${params.threadId}${resolve ? " and resolved it" : " and left it open"}.`, {
8880
9643
  pr: review.pr,
@@ -8885,52 +9648,66 @@ ${openMessage}`, {
8885
9648
  defineTool6({
8886
9649
  name: "review_publish",
8887
9650
  label: "review publish",
8888
- description: "Publish pending remote review comments and status.",
9651
+ description: "Publish pending remote draft PR/MR review comments and status, or promote a local tuicr draft before publishing.",
8889
9652
  promptSnippet: "Call review_publish to make remote review work public",
8890
- promptGuidelines: ["Local reviews use revision dumps and are never published remotely."],
9653
+ promptGuidelines: ["Statuses are COMMENT, APPROVE, REQUEST_CHANGES, or CLOSE."],
8891
9654
  parameters: parameters2(publishSchema),
8892
9655
  executionMode: "sequential",
8893
- async execute(_id, input) {
9656
+ async execute(_id, input, _signal, _onUpdate, ctx) {
8894
9657
  const params = publishSchema.parse(input);
8895
- if (params.local)
8896
- return result3("Local reviews use immutable revision dumps and have no publish step.");
8897
9658
  const review = await resolveReviewContext(resolveWorkingDirectory(params), params.target);
8898
9659
  if (!review.pr || !review.forge)
8899
9660
  return result3("No remote PR/MR to publish.");
8900
- return publishResolvedReview(review, params);
9661
+ return publishResolvedReview(review, params, modelRoute(ctx));
8901
9662
  }
8902
9663
  }),
8903
9664
  defineTool6({
8904
9665
  name: "review_complete",
8905
9666
  label: "review complete",
8906
- description: "Approve, reject, or abandon a remote review.",
8907
- promptSnippet: "Call review_complete to finish a remote review without merging it.",
8908
- promptGuidelines: ["Use review_dump to finish a local review revision."],
9667
+ description: "Approve, reject, or abandon a remote review, or archive a local review artifact.",
9668
+ promptSnippet: "Call review_complete to finish a review without merging it.",
9669
+ promptGuidelines: ["A local completion archives the Diffpi artifact and deletes the matching tuicr session."],
8909
9670
  parameters: parameters2(completeSchema),
8910
9671
  executionMode: "sequential",
8911
- async execute(_id, input) {
9672
+ async execute(_id, input, _signal, _onUpdate, ctx) {
8912
9673
  const params = completeSchema.parse(input);
8913
- if (params.local)
8914
- return result3("Use review_dump to finish a local review revision.");
8915
9674
  const review = await resolveReviewContext(resolveWorkingDirectory(params), params.target);
9675
+ if (params.local) {
9676
+ const session = await resolveTuicrSession(review, true);
9677
+ if (!session)
9678
+ return result3("No matching local tuicr session to close.");
9679
+ const artifact = await localThreadArtifactPath(review.cwd, session.slug);
9680
+ if (!existsSync2(artifact))
9681
+ return result3("No local review artifact. Run review_comments with local=true first.");
9682
+ const archived = await uniqueRecordPath(await completedReviewsDir(review.cwd), reviewSlug(session.slug));
9683
+ await rename4(artifact, archived);
9684
+ await unlink2(session.path);
9685
+ return result3(`Archived ${archived} and deleted the tuicr session ${session.slug}.`, {
9686
+ artifact: archived,
9687
+ session
9688
+ });
9689
+ }
8916
9690
  if (!review.pr || !review.forge)
8917
9691
  return result3("No remote PR/MR to complete.");
8918
9692
  if (!params.action)
8919
9693
  return result3("Choose a complete action: approve, reject, or abandon.");
8920
9694
  const status = { approve: "APPROVE", reject: "REQUEST_CHANGES", abandon: "CLOSE" }[params.action];
8921
- return publishResolvedReview(review, { ...params, status });
9695
+ return publishResolvedReview(review, { ...params, status }, modelRoute(ctx));
8922
9696
  }
8923
9697
  }),
8924
9698
  defineTool6({
8925
9699
  name: "review_merge",
8926
9700
  label: "review merge",
8927
- description: "Squash-merge an open, ready GitHub PR after checking its conventional subject.",
8928
- promptSnippet: "Call review_merge after confirming the PR is open, ready, and CI is settled; approval is optional",
8929
- promptGuidelines: ["This is intentionally GitHub-only until GitLab merge support is added."],
8930
- parameters: parameters2(contextSchema.extend({ subject: z9.string().optional() })),
9701
+ description: "Squash-merge an open, ready GitHub PR through gh after checking its conventional subject.",
9702
+ promptSnippet: "Call review_merge directly; it uses gh to confirm the PR is open, ready, and CI is settled before merging",
9703
+ promptGuidelines: [
9704
+ "Do not query GitHub through MCP or duplicate readiness checks before calling review_merge.",
9705
+ "This is intentionally GitHub-only until GitLab merge support is added."
9706
+ ],
9707
+ parameters: parameters2(contextSchema.extend({ subject: z11.string().optional() })),
8931
9708
  executionMode: "sequential",
8932
9709
  async execute(_id, input) {
8933
- const params = contextSchema.extend({ subject: z9.string().optional() }).parse(input);
9710
+ const params = contextSchema.extend({ subject: z11.string().optional() }).parse(input);
8934
9711
  const review = await resolveReviewContext(resolveWorkingDirectory(params), params.target);
8935
9712
  if (review.vcs.provider !== "github" || !review.forge)
8936
9713
  return result3("review_merge currently supports GitHub only.");
@@ -8963,8 +9740,32 @@ ${openMessage}`, {
8963
9740
  })
8964
9741
  ];
8965
9742
  }
9743
+ async function syncLocalThreadArtifact(path, title, sessionSlug, threads) {
9744
+ let previous = [];
9745
+ try {
9746
+ previous = parseThreadArtifact(await readFile12(path, "utf8"));
9747
+ } catch (error) {
9748
+ if (error.code !== "ENOENT")
9749
+ throw error;
9750
+ }
9751
+ const previousById = new Map(previous.map((thread) => [thread.id, thread]));
9752
+ const currentIds = new Set(threads.map((thread) => thread.id));
9753
+ const current = threads.map((thread) => {
9754
+ const existing = previousById.get(thread.id);
9755
+ return {
9756
+ ...thread,
9757
+ resolved: false,
9758
+ addressed: existing?.addressed ?? Boolean(existing?.reply),
9759
+ reply: existing?.reply
9760
+ };
9761
+ });
9762
+ const removed = previous.filter((thread) => !currentIds.has(thread.id)).map((thread) => ({ ...thread, resolved: true }));
9763
+ const merged = [...current, ...removed];
9764
+ await writeFile7(path, renderThreadArtifact(title, sessionSlug, merged), "utf8");
9765
+ return merged;
9766
+ }
8966
9767
  function parameters2(schema) {
8967
- return z9.toJSONSchema(schema, { io: "input" });
9768
+ return z11.toJSONSchema(schema, { io: "input" });
8968
9769
  }
8969
9770
  function resolveWorkingDirectory(params) {
8970
9771
  return params.cwd ?? process.cwd();
@@ -8975,22 +9776,216 @@ function result3(text, details = {}) {
8975
9776
  function unsupportedForgeMessage() {
8976
9777
  return "No supported GitHub or GitLab remote was detected. Use local=true for an offline tuicr review.";
8977
9778
  }
9779
+ async function collectReviewStatus(review) {
9780
+ const [porcelain, branch, localSession, remoteSession] = await Promise.all([
9781
+ runChecked("git", ["-C", review.cwd, "status", "--porcelain"]),
9782
+ runChecked("git", ["-C", review.cwd, "branch", "--show-current"]),
9783
+ resolveTuicrSession(review, true).catch(() => {
9784
+ return;
9785
+ }),
9786
+ resolveTuicrSession(review, false).catch(() => {
9787
+ return;
9788
+ })
9789
+ ]);
9790
+ const entries = porcelain.stdout.split("\\n").filter(Boolean);
9791
+ const staged = entries.filter((line) => !line.startsWith("??") && line[0] !== " ").length;
9792
+ const unstaged = entries.filter((line) => !line.startsWith("??") && line[1] !== " ").length;
9793
+ const untracked = entries.filter((line) => line.startsWith("??")).length;
9794
+ const remote = review.pr ? `yes (#${review.pr.number})` : "no";
9795
+ const url = review.pr?.url ?? "none";
9796
+ const text = [
9797
+ `Branch: ${branch.stdout.trim() || review.vcs.branch}`,
9798
+ `Worktree: ${entries.length ? "dirty" : "clean"} (staged=${staged}, unstaged=${unstaged}, untracked=${untracked})`,
9799
+ `Local working-tree tuicr review: ${localSession ? `yes (${localSession.slug}, ${localSession.commentCount} comments)` : "no"}`,
9800
+ `Remote PR/MR: ${remote}`,
9801
+ `Remote URL: ${url}`,
9802
+ `Remote tuicr session: ${remoteSession ? `yes (${remoteSession.slug}, ${remoteSession.commentCount} comments)` : "no"}`
9803
+ ].join("\\n");
9804
+ return {
9805
+ text,
9806
+ details: {
9807
+ branch: branch.stdout.trim(),
9808
+ staged,
9809
+ unstaged,
9810
+ untracked,
9811
+ localSession,
9812
+ remoteSession,
9813
+ pr: review.pr,
9814
+ url
9815
+ }
9816
+ };
9817
+ }
8978
9818
  function modelRoute(ctx) {
8979
9819
  if (!ctx.model)
8980
9820
  throw new Error("Cannot record review provenance because Pi did not provide an active model route.");
8981
9821
  return `${ctx.model.provider}/${ctx.model.id}`;
8982
9822
  }
8983
- async function publishResolvedReview(review, params) {
9823
+ async function publishResolvedReview(review, params, model) {
8984
9824
  const status = params.status ?? "COMMENT";
8985
9825
  const event = status === "CLOSE" ? "COMMENT" : status;
8986
9826
  assertReviewEventSupported(review.vcs.provider, event);
9827
+ const remote = createRemoteReviewBackend(review.vcs, review.pr.number);
9828
+ const promotion = params.local ? await promoteLocalReview(review, remote, model) : undefined;
8987
9829
  if (status !== "CLOSE" && review.pr.isDraft)
8988
9830
  await review.forge.markReady(review.pr.number);
8989
- await createRemoteReviewBackend(review.vcs, review.pr.number).publish(event);
9831
+ await remote.publish(event);
9832
+ if (promotion) {
9833
+ promotion.publication.state.bodies = [
9834
+ ...new Set([...promotion.publication.state.bodies, ...promotion.bodyFingerprints])
9835
+ ];
9836
+ promotion.publication.state.comments = [
9837
+ ...new Set([...promotion.publication.state.comments, ...promotion.commentFingerprints])
9838
+ ];
9839
+ const published = new Set(promotion.commentFingerprints);
9840
+ promotion.publication.state.stagedComments = promotion.publication.state.stagedComments.filter((fingerprint) => !published.has(fingerprint));
9841
+ await saveReviewPublicationState(promotion.publication.path, promotion.publication.state);
9842
+ }
8990
9843
  if (status === "CLOSE")
8991
9844
  await review.forge.closePr(review.pr.number);
8992
9845
  const finalPr = await review.forge.viewPr(String(review.pr.number));
8993
- return result3(`Published #${review.pr.number} (${status}).`, { pr: finalPr ?? review.pr, status });
9846
+ const promotedBodies = promotion?.promotedBodies ?? 0;
9847
+ const promotedComments = promotion?.promotedComments ?? 0;
9848
+ const promotedReplies = promotion?.promotedReplies ?? 0;
9849
+ return result3(`Published #${review.pr.number} (${status}); promoted ${promotedBodies} review bodies, ${promotedComments} comments, and ${promotedReplies} replies.`, { pr: finalPr ?? review.pr, status, promotedBodies, promotedComments, promotedReplies });
9850
+ }
9851
+ async function promoteLocalReview(review, remote, model) {
9852
+ const publication = await loadReviewPublicationState(review.cwd, review.vcs, review.pr.number);
9853
+ const session = await resolvePublishSession(review.cwd, {
9854
+ branch: review.vcs.branch,
9855
+ owner: review.vcs.provider === "none" ? undefined : review.vcs.owner,
9856
+ repo: review.vcs.provider === "none" ? undefined : review.vcs.repo,
9857
+ number: review.pr?.number
9858
+ });
9859
+ if (!session)
9860
+ throw new Error("No matching tuicr session to publish.");
9861
+ const sessionIsWorkingTree = session.kind === "local";
9862
+ const local = createLocalReviewBackend({
9863
+ session: session.path,
9864
+ artifactPath: "",
9865
+ author: localReviewAuthor(model)
9866
+ });
9867
+ const draft = await local.readDraft();
9868
+ const remoteDraft = await remote.readDraft();
9869
+ const knownRootComments = new Set([
9870
+ ...publication.state.comments,
9871
+ ...publication.state.stagedComments,
9872
+ ...remoteDraft.comments.map(reviewCommentFingerprint)
9873
+ ]);
9874
+ const { checkpointed, candidates } = partitionReviewComments(draft.comments, knownRootComments, model);
9875
+ const remoteThreads = await remote.listThreads();
9876
+ const threadReplies = sessionIsWorkingTree ? [] : candidates.flatMap(({ source: comment }) => {
9877
+ const thread = resolveReviewThread(comment, remoteThreads);
9878
+ return thread ? [{ comment, thread }] : [];
9879
+ });
9880
+ const unmatchedActions = sessionIsWorkingTree ? [] : candidates.map(({ source }) => source).filter((comment) => parseReviewThreadAction(comment.body).action && !threadReplies.some((reply) => reply.comment === comment));
9881
+ if (unmatchedActions.length > 0) {
9882
+ const locations = unmatchedActions.map(({ file, line }) => `${file}:${line ?? "?"}`).join(", ");
9883
+ throw new Error(`Cannot apply review thread action: no matching remote thread at ${locations}.`);
9884
+ }
9885
+ const comments = candidates.filter(({ source }) => !threadReplies.some((reply) => reply.comment === source)).map(({ published }) => published);
9886
+ const body = draft.body.trim();
9887
+ const bodyFingerprints = body ? [reviewBodyFingerprint(body)] : [];
9888
+ const commentFingerprints = [
9889
+ ...checkpointed.map(({ fingerprint }) => fingerprint),
9890
+ ...comments.map(reviewCommentFingerprint)
9891
+ ];
9892
+ const knownBodies = new Set(publication.state.bodies);
9893
+ if (remoteDraft.body.trim())
9894
+ knownBodies.add(reviewBodyFingerprint(remoteDraft.body));
9895
+ const unpublishedBody = body && !knownBodies.has(reviewBodyFingerprint(body)) ? body : "";
9896
+ const unpublished = unpublishedReviewComments(comments, knownRootComments);
9897
+ if (unpublishedBody)
9898
+ await remote.stage({ comments: [], body: unpublishedBody });
9899
+ let promotedComments = 0;
9900
+ for (const comment of unpublished) {
9901
+ await remote.stage({ comments: [comment], body: "" });
9902
+ publication.state.stagedComments.push(reviewCommentFingerprint(comment));
9903
+ await saveReviewPublicationState(publication.path, publication.state);
9904
+ promotedComments += 1;
9905
+ }
9906
+ const promotedReplies = await promoteLocalReplies(review, remote, publication, model, sessionIsWorkingTree) + await promoteLocationReplies(remote, publication, threadReplies, model);
9907
+ return {
9908
+ publication,
9909
+ bodyFingerprints,
9910
+ commentFingerprints,
9911
+ promotedBodies: unpublishedBody ? 1 : 0,
9912
+ promotedComments,
9913
+ promotedReplies
9914
+ };
9915
+ }
9916
+ async function promoteLocationReplies(remote, publication, replies, model) {
9917
+ let count = 0;
9918
+ for (const { comment, thread } of replies) {
9919
+ const parsed = parseReviewThreadAction(comment.body);
9920
+ const body = parsed.body.trim();
9921
+ const action = parsed.action;
9922
+ const fingerprint = reviewReplyFingerprint(thread.id, `${action ?? "reply"}\x00${body}`);
9923
+ if (publication.state.replies.includes(fingerprint))
9924
+ continue;
9925
+ if (action === "delete") {
9926
+ if (!remote.deleteThread)
9927
+ throw new Error(`The review backend cannot delete thread ${thread.id}.`);
9928
+ await remote.deleteThread(thread.id);
9929
+ publication.state.replies.push(fingerprint);
9930
+ await saveReviewPublicationState(publication.path, publication.state);
9931
+ count += 1;
9932
+ continue;
9933
+ }
9934
+ const remoteBody = withCommentProvenance({ ...comment, body }, model);
9935
+ if (body && !thread.replies?.some((reply) => reply === body || reply === remoteBody)) {
9936
+ await remote.reply({
9937
+ threadId: thread.id,
9938
+ body: remoteBody,
9939
+ resolve: false
9940
+ });
9941
+ }
9942
+ if (action && remote.setResolved)
9943
+ await remote.setResolved(thread.id, action === "resolve");
9944
+ publication.state.replies.push(fingerprint);
9945
+ await saveReviewPublicationState(publication.path, publication.state);
9946
+ count += 1;
9947
+ }
9948
+ return count;
9949
+ }
9950
+ async function promoteLocalReplies(review, remote, publication, model, workingTree) {
9951
+ const target = workingTree ? "uncommitted" : await reviewTargetId(review);
9952
+ const artifact = workingTree ? await latestThreadArtifact(review.cwd, review, target) : publication.state.overlayPath ?? await latestThreadArtifact(review.cwd, review, target);
9953
+ if (!artifact)
9954
+ return 0;
9955
+ if (!workingTree)
9956
+ publication.state.overlayPath = artifact;
9957
+ let count = 0;
9958
+ for (const thread of await readThreadArtifact(artifact)) {
9959
+ if (!thread.reply)
9960
+ continue;
9961
+ const body = withRemoteProvenance(thread.reply, model);
9962
+ const fingerprint = reviewReplyFingerprint(thread.id, body);
9963
+ if (publication.state.replies.includes(fingerprint))
9964
+ continue;
9965
+ if (workingTree) {
9966
+ const draft = workingTreeReplyDraft(thread, body);
9967
+ const comment = draft.comments[0];
9968
+ if (comment) {
9969
+ const commentFingerprint = reviewCommentFingerprint(comment);
9970
+ if (!publication.state.stagedComments.includes(commentFingerprint)) {
9971
+ await remote.stage(draft);
9972
+ publication.state.stagedComments.push(commentFingerprint);
9973
+ }
9974
+ } else {
9975
+ const bodyFingerprint = reviewBodyFingerprint(draft.body);
9976
+ if (!publication.state.bodies.includes(bodyFingerprint)) {
9977
+ await remote.stage(draft);
9978
+ publication.state.bodies.push(bodyFingerprint);
9979
+ }
9980
+ }
9981
+ } else {
9982
+ await remote.reply({ threadId: thread.id, body, resolve: false, question: thread.question });
9983
+ }
9984
+ publication.state.replies.push(fingerprint);
9985
+ await saveReviewPublicationState(publication.path, publication.state);
9986
+ count += 1;
9987
+ }
9988
+ return count;
8994
9989
  }
8995
9990
  async function resolveReviewContext(cwd, target) {
8996
9991
  const vcs = await detectVcs(cwd);
@@ -9031,6 +10026,10 @@ async function assertRemoteBranchReady(cwd) {
9031
10026
  if (head.stdout.trim() !== pushed.stdout.trim())
9032
10027
  throw new Error("Push the current branch before opening a remote review.");
9033
10028
  }
10029
+ function withCommentProvenance(comment, fallbackModel) {
10030
+ const route = comment.author?.match(/^Agent:\s*(.+)$/)?.[1];
10031
+ return route ? withRemoteProvenance(comment.body, route || fallbackModel) : comment.body;
10032
+ }
9034
10033
  async function reviewTargetId(review) {
9035
10034
  if (!review.pr)
9036
10035
  return "uncommitted";
@@ -9047,41 +10046,103 @@ async function headSha(cwd) {
9047
10046
  }
9048
10047
  async function newReviewArtifactPath(cwd, target) {
9049
10048
  const dir = await reviewsDir(cwd);
9050
- await mkdir8(dir, { recursive: true });
10049
+ await mkdir7(dir, { recursive: true });
9051
10050
  return uniqueRecordPath(dir, reviewRecordName(target));
9052
10051
  }
10052
+ async function localThreadArtifactPath(cwd, sessionSlug) {
10053
+ const dir = await reviewsDir(cwd);
10054
+ return join17(dir, `${reviewSlug(sessionSlug) || "local-review"}.md`);
10055
+ }
10056
+ async function readThreadArtifact(path) {
10057
+ try {
10058
+ return parseThreadArtifact(await readFile12(path, "utf8"));
10059
+ } catch (error) {
10060
+ if (error.code === "ENOENT") {
10061
+ throw new Error(`Expected local reply overlay is missing: ${path}`);
10062
+ }
10063
+ throw error;
10064
+ }
10065
+ }
10066
+ async function latestThreadArtifact(cwd, review, target) {
10067
+ try {
10068
+ return await findLatestThreadArtifact(cwd, review, target);
10069
+ } catch (error) {
10070
+ throw new Error(`Cannot locate the latest review thread artifact for ${target}.`, { cause: error });
10071
+ }
10072
+ }
10073
+ async function findLatestThreadArtifact(cwd, review, target) {
10074
+ const dir = await reviewsDir(cwd);
10075
+ const suffix = reviewSlug(target) || "uncommitted";
10076
+ const names = await listReviewArtifactNames(dir);
10077
+ const candidates = await Promise.all(names.flatMap((name) => {
10078
+ if (!name.endsWith(".md"))
10079
+ return [];
10080
+ return [
10081
+ (async () => {
10082
+ const path = join17(dir, name);
10083
+ const info = await stat(path);
10084
+ return { path, name, content: await readFile12(path, "utf8"), modified: info.mtimeMs };
10085
+ })()
10086
+ ];
10087
+ }));
10088
+ return candidates.filter(({ name, content }) => {
10089
+ if (!content.startsWith("<!-- diffpi-threads:"))
10090
+ return false;
10091
+ if (review.pr && target !== "local" && target !== "uncommitted")
10092
+ return content.includes(`- PR/MR: #${review.pr.number}`);
10093
+ return artifactNameMatches(name, suffix);
10094
+ }).sort((a, b) => b.modified - a.modified)[0]?.path;
10095
+ }
10096
+ async function listReviewArtifactNames(dir) {
10097
+ try {
10098
+ return await readdir3(dir);
10099
+ } catch (error) {
10100
+ throw new Error(`Cannot read review artifacts in ${dir}.`, { cause: error });
10101
+ }
10102
+ }
9053
10103
  function deriveTitle(branch) {
9054
10104
  return branch.replace(/^(feature|feat|fix|bug|chore)\//, "").replace(/^eng-\d+-/i, "").replace(/[-_]+/g, " ").replace(/^\w/, (char) => char.toUpperCase());
9055
10105
  }
9056
10106
  function uniqueRecordPath(dir, base) {
9057
- let path = join16(dir, `${base}.md`);
10107
+ let path = join17(dir, `${base}.md`);
9058
10108
  let count = 2;
9059
10109
  while (existsSync2(path))
9060
- path = join16(dir, `${base}-${count++}.md`);
10110
+ path = join17(dir, `${base}-${count++}.md`);
9061
10111
  return path;
9062
10112
  }
10113
+ function artifactNameMatches(name, suffix) {
10114
+ if (!name.endsWith(".md"))
10115
+ return false;
10116
+ const stem = name.slice(0, -3);
10117
+ const marker = `-${suffix}`;
10118
+ const markerIndex = stem.lastIndexOf(marker);
10119
+ if (markerIndex < 0)
10120
+ return false;
10121
+ const tail = stem.slice(markerIndex + marker.length);
10122
+ return tail === "" || /^-\d+$/.test(tail);
10123
+ }
9063
10124
 
9064
10125
  // src/tools/setup.ts
9065
10126
  import { defineTool as defineTool7 } from "@earendil-works/pi-coding-agent";
9066
- import { z as z11 } from "zod";
10127
+ import { z as z13 } from "zod";
9067
10128
 
9068
10129
  // src/setup.ts
9069
10130
  import { parseFrontmatter as parseFrontmatter2 } from "@earendil-works/pi-coding-agent";
9070
- import { readdir as readdir4, readFile as readFile12 } from "node:fs/promises";
10131
+ import { readdir as readdir5, readFile as readFile15 } from "node:fs/promises";
9071
10132
  import { homedir as homedir8 } from "node:os";
9072
- import { basename as basename6, join as join20 } from "node:path";
10133
+ import { basename as basename6, join as join21 } from "node:path";
9073
10134
 
9074
10135
  // src/config.ts
9075
10136
  import { parseFrontmatter } from "@earendil-works/pi-coding-agent";
9076
- import { z as z10 } from "zod";
10137
+ import { z as z12 } from "zod";
9077
10138
  import { homedir as homedir5 } from "node:os";
9078
- import { join as join17 } from "node:path";
10139
+ import { join as join18 } from "node:path";
9079
10140
 
9080
10141
  // src/fsx.ts
9081
- import { readdir as readdir3, readFile as readFile10 } from "node:fs/promises";
10142
+ import { readdir as readdir4, readFile as readFile13 } from "node:fs/promises";
9082
10143
  async function readTextIfExists(path) {
9083
10144
  try {
9084
- return await readFile10(path, "utf8");
10145
+ return await readFile13(path, "utf8");
9085
10146
  } catch (error) {
9086
10147
  if (isMissingPath(error))
9087
10148
  return;
@@ -9094,17 +10155,17 @@ function isMissingPath(error) {
9094
10155
 
9095
10156
  // src/config.ts
9096
10157
  var modelReferenceSchema = zx.text;
9097
- var agentConfigSchema = z10.object({
9098
- models: z10.array(modelReferenceSchema).optional()
10158
+ var agentConfigSchema = z12.object({
10159
+ models: z12.array(modelReferenceSchema).optional()
9099
10160
  }).strict();
9100
- var diffpiConfigSchema = z10.object({
9101
- agents: z10.record(z10.string(), agentConfigSchema).optional()
10161
+ var diffpiConfigSchema = z12.object({
10162
+ agents: z12.record(z12.string(), agentConfigSchema).optional()
9102
10163
  }).strict();
9103
10164
  function diffpiConfigPaths(homeDir = homedir5()) {
9104
- const directory = join17(homeDir, ".difflab", "diffpi");
10165
+ const directory = join18(homeDir, ".difflab", "diffpi");
9105
10166
  return {
9106
- yaml: join17(directory, "config.yaml"),
9107
- json: join17(directory, "config.json")
10167
+ yaml: join18(directory, "config.yaml"),
10168
+ json: join18(directory, "config.json")
9108
10169
  };
9109
10170
  }
9110
10171
  async function loadDiffpiConfig(options = {}) {
@@ -9160,12 +10221,12 @@ function normalizeModelReference(value) {
9160
10221
  }
9161
10222
 
9162
10223
  // src/mcp.ts
9163
- import { mkdir as mkdir9, readFile as readFile11, writeFile as writeFile7 } from "node:fs/promises";
10224
+ import { mkdir as mkdir8, readFile as readFile14, writeFile as writeFile8 } from "node:fs/promises";
9164
10225
  import { homedir as homedir6 } from "node:os";
9165
- import { dirname as dirname8, join as join18 } from "node:path";
10226
+ import { dirname as dirname8, join as join19 } from "node:path";
9166
10227
  var mcp = {
9167
10228
  globalConfigPath(homeDir = homedir6()) {
9168
- return join18(homeDir, ".config", "mcp", "mcp.json");
10229
+ return join19(homeDir, ".config", "mcp", "mcp.json");
9169
10230
  },
9170
10231
  async serversEnsure(servers, options = {}) {
9171
10232
  const path = options.path ?? mcp.globalConfigPath();
@@ -9178,8 +10239,8 @@ var mcp = {
9178
10239
  const next = { ...current, mcpServers: nextServers };
9179
10240
  const changed = JSON.stringify(current) !== JSON.stringify(next);
9180
10241
  if (changed && !options.dryRun) {
9181
- await mkdir9(dirname8(path), { recursive: true });
9182
- await writeFile7(path, `${JSON.stringify(next, null, 2)}
10242
+ await mkdir8(dirname8(path), { recursive: true });
10243
+ await writeFile8(path, `${JSON.stringify(next, null, 2)}
9183
10244
  `, "utf8");
9184
10245
  }
9185
10246
  return { path, changed, existed: currentText !== undefined, planned: changed && options.dryRun === true };
@@ -9208,7 +10269,7 @@ function getParsedConfig(content, path) {
9208
10269
  }
9209
10270
  async function getOptionalFile2(path) {
9210
10271
  try {
9211
- return await readFile11(path, "utf8");
10272
+ return await readFile14(path, "utf8");
9212
10273
  } catch (error) {
9213
10274
  if (error instanceof Error && "code" in error && error.code === "ENOENT")
9214
10275
  return;
@@ -9220,9 +10281,9 @@ function isRecord(value) {
9220
10281
  }
9221
10282
 
9222
10283
  // src/pi.ts
9223
- import { mkdir as mkdir10, writeFile as writeFile8 } from "node:fs/promises";
10284
+ import { mkdir as mkdir9, writeFile as writeFile9 } from "node:fs/promises";
9224
10285
  import { homedir as homedir7 } from "node:os";
9225
- import { dirname as dirname9, join as join19 } from "node:path";
10286
+ import { dirname as dirname9, join as join20 } from "node:path";
9226
10287
  var pi = {
9227
10288
  executableCheck: findPiExecutable,
9228
10289
  packageList: listPiPackages,
@@ -9235,19 +10296,19 @@ var pi = {
9235
10296
  configEnsure: ensurePiConfig
9236
10297
  };
9237
10298
  async function ensurePiAgent(filename, content, agentDir = resolvePiAgentDir(), dryRun = false) {
9238
- const path = join19(agentDir, "agents", filename);
10299
+ const path = join20(agentDir, "agents", filename);
9239
10300
  const currentText = await readTextIfExists(path);
9240
10301
  const changed = currentText !== content;
9241
10302
  if (changed && !dryRun) {
9242
- await mkdir10(dirname9(path), { recursive: true });
9243
- await writeFile8(path, content, "utf8");
10303
+ await mkdir9(dirname9(path), { recursive: true });
10304
+ await writeFile9(path, content, "utf8");
9244
10305
  }
9245
10306
  return { path, changed, existed: currentText !== undefined, planned: changed && dryRun };
9246
10307
  }
9247
- async function checkGlobalPiSkill(name, agentDir = resolvePiAgentDir(), sharedSkillsDir = join19(homedir7(), ".agents", "skills")) {
9248
- const roots = [join19(agentDir, "skills"), sharedSkillsDir];
10308
+ async function checkGlobalPiSkill(name, agentDir = resolvePiAgentDir(), sharedSkillsDir = join20(homedir7(), ".agents", "skills")) {
10309
+ const roots = [join20(agentDir, "skills"), sharedSkillsDir];
9249
10310
  for (const root of roots) {
9250
- if (await readTextIfExists(join19(root, name, "SKILL.md")) !== undefined)
10311
+ if (await readTextIfExists(join20(root, name, "SKILL.md")) !== undefined)
9251
10312
  return true;
9252
10313
  }
9253
10314
  return false;
@@ -9276,8 +10337,8 @@ async function ensurePiConfig(path, update, dryRun = false) {
9276
10337
  const next = update(current);
9277
10338
  const changed = JSON.stringify(current) !== JSON.stringify(next);
9278
10339
  if (changed && !dryRun) {
9279
- await mkdir10(dirname9(path), { recursive: true });
9280
- await writeFile8(path, `${JSON.stringify(next, null, 2)}
10340
+ await mkdir9(dirname9(path), { recursive: true });
10341
+ await writeFile9(path, `${JSON.stringify(next, null, 2)}
9281
10342
  `, "utf8");
9282
10343
  }
9283
10344
  return { path, changed, existed: currentText !== undefined, planned: changed && dryRun };
@@ -9297,7 +10358,7 @@ async function installPiPackage(executable, source) {
9297
10358
  await runChecked(executable, ["install", source]);
9298
10359
  }
9299
10360
  function resolvePiAgentDir(homeDir = homedir7()) {
9300
- return process.env.PI_CODING_AGENT_DIR ?? (process.env.XDG_CONFIG_HOME ? join19(process.env.XDG_CONFIG_HOME, "pi") : join19(homeDir, ".pi", "agent"));
10361
+ return process.env.PI_CODING_AGENT_DIR ?? (process.env.XDG_CONFIG_HOME ? join20(process.env.XDG_CONFIG_HOME, "pi") : join20(homeDir, ".pi", "agent"));
9301
10362
  }
9302
10363
  function parseJsonObject(content, path) {
9303
10364
  if (!content?.trim())
@@ -9349,7 +10410,7 @@ var FORGE_DEPENDENCIES = {
9349
10410
  };
9350
10411
  async function ensureMise(options = {}) {
9351
10412
  const homeDir = options.homeDir ?? homedir8();
9352
- const current = await mise.executableCheck() ?? await mise.executableCheck(join20(homeDir, ".local", "bin", "mise"));
10413
+ const current = await mise.executableCheck() ?? await mise.executableCheck(join21(homeDir, ".local", "bin", "mise"));
9353
10414
  if (current)
9354
10415
  return { executable: current, action: createSetupAction("mise", "ready", current) };
9355
10416
  reportProgress(options, "Installing mise");
@@ -9398,9 +10459,9 @@ async function ensureMiseDeps(miseExecutable, options = {}) {
9398
10459
  async function ensurePiPlugins(options = {}) {
9399
10460
  const actions = await ensurePiPackages(PI_PACKAGES, options);
9400
10461
  const agentDir = options.agentDir ?? pi.agentDir(options.homeDir);
9401
- const webSearch = await pi.configEnsure(join20(agentDir, "web-search.json"), (config) => ({ ...config, workflow: "auto-summary" }), options.dryRun);
10462
+ const webSearch = await pi.configEnsure(join21(agentDir, "web-search.json"), (config) => ({ ...config, workflow: "auto-summary" }), options.dryRun);
9402
10463
  actions.push(getConfigSetupAction("web search settings", webSearch));
9403
- const lsp = await pi.configEnsure(join20(agentDir, "pi-lsp.json"), (config) => ({
10464
+ const lsp = await pi.configEnsure(join21(agentDir, "pi-lsp.json"), (config) => ({
9404
10465
  ...config,
9405
10466
  progressive: { ...getRecord(config.progressive), enabled: true, inject: "none" }
9406
10467
  }), options.dryRun);
@@ -9411,11 +10472,11 @@ async function ensurePiAgents(options = {}) {
9411
10472
  const agentDir = options.agentDir ?? pi.agentDir(options.homeDir);
9412
10473
  const bundledAgentsDir = options.bundledAgentsDir ?? BUNDLED_AGENTS_DIR;
9413
10474
  const userConfig = await loadDiffpiConfig({ homeDir: options.homeDir });
9414
- 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));
10475
+ const entries = (await readdir5(bundledAgentsDir, { withFileTypes: true })).filter((entry) => entry.isFile() && entry.name.startsWith("diffpi-") && entry.name.endsWith(".md")).sort((left, right) => left.name.localeCompare(right.name));
9415
10476
  const actions = [];
9416
10477
  for (const entry of entries) {
9417
10478
  const id = basename6(entry.name, ".md").replace(/^diffpi-/, "");
9418
- const source = await readFile12(join20(bundledAgentsDir, entry.name), "utf8");
10479
+ const source = await readFile15(join21(bundledAgentsDir, entry.name), "utf8");
9419
10480
  const content = materializeAgentModels(source, id, userConfig.config, options.availableModels);
9420
10481
  const result = await pi.agentEnsure(entry.name, content, agentDir, options.dryRun);
9421
10482
  actions.push(getConfigSetupAction(`pi agent ${id}`, result));
@@ -9424,7 +10485,7 @@ async function ensurePiAgents(options = {}) {
9424
10485
  }
9425
10486
  async function ensurePiSkills(miseExecutable, options = {}) {
9426
10487
  const agentDir = options.agentDir ?? pi.agentDir(options.homeDir);
9427
- const sharedSkillsDir = join20(options.homeDir ?? homedir8(), ".agents", "skills");
10488
+ const sharedSkillsDir = join21(options.homeDir ?? homedir8(), ".agents", "skills");
9428
10489
  const actions = [];
9429
10490
  for (const source of PI_SKILL_SOURCES) {
9430
10491
  const missing = [];
@@ -9620,7 +10681,7 @@ function reportProgress(options, message) {
9620
10681
  options.onProgress?.(message);
9621
10682
  }
9622
10683
  async function packageVersion2() {
9623
- const source = await readFile12(join20(resolveBundledAgentsDir(), "..", "package.json"), "utf8");
10684
+ const source = await readFile15(join21(resolveBundledAgentsDir(), "..", "package.json"), "utf8");
9624
10685
  let value;
9625
10686
  try {
9626
10687
  value = JSON.parse(source);
@@ -9636,13 +10697,13 @@ function getRecord(value) {
9636
10697
  }
9637
10698
 
9638
10699
  // src/tools/setup.ts
9639
- var setupParametersSchema = z11.object({
9640
- issueTracker: z11.enum(["none", "linear", "jira"]).default("none").describe("Issue tracker MCP server to configure. Use none unless the user explicitly selects Linear or Jira."),
9641
- forges: z11.array(z11.enum(["github", "gitlab"])).default([]).describe("Hosted VCS MCP servers to configure. Installs matching CLIs and registers each MCP server."),
9642
- forge: z11.enum(["none", "github", "gitlab"]).optional().describe("Deprecated single-host alias. Prefer forges for one or more VCS MCP servers."),
9643
- bindZedKey: z11.boolean().default(false).describe("Opt in to a Zed keybinding for the tuicr review task.")
10700
+ var setupParametersSchema = z13.object({
10701
+ issueTracker: z13.enum(["none", "linear", "jira"]).default("none").describe("Issue tracker MCP server to configure. Use none unless the user explicitly selects Linear or Jira."),
10702
+ forges: z13.array(z13.enum(["github", "gitlab"])).default([]).describe("Hosted VCS MCP servers to configure. Installs matching CLIs and registers each MCP server."),
10703
+ forge: z13.enum(["none", "github", "gitlab"]).optional().describe("Deprecated single-host alias. Prefer forges for one or more VCS MCP servers."),
10704
+ bindZedKey: z13.boolean().default(false).describe("Opt in to a Zed keybinding for the tuicr review task.")
9644
10705
  });
9645
- var setupParameters = z11.toJSONSchema(setupParametersSchema, { io: "input" });
10706
+ var setupParameters = z13.toJSONSchema(setupParametersSchema, { io: "input" });
9646
10707
  var diffpiSetupTool = defineTool7({
9647
10708
  name: "diffpi_setup",
9648
10709
  label: "diffpi setup",
@@ -9715,11 +10776,11 @@ ${lines.join(`
9715
10776
 
9716
10777
  // src/tools/templates.ts
9717
10778
  import { defineTool as defineTool8 } from "@earendil-works/pi-coding-agent";
9718
- import { z as z12 } from "zod";
9719
- var templateSchema = z12.object({
9720
- name: z12.string().min(1),
9721
- variables: z12.record(z12.string(), z12.string()).optional(),
9722
- homeDir: z12.string().optional()
10779
+ import { z as z14 } from "zod";
10780
+ var templateSchema = z14.object({
10781
+ name: z14.string().min(1),
10782
+ variables: z14.record(z14.string(), z14.string()).optional(),
10783
+ homeDir: z14.string().optional()
9723
10784
  });
9724
10785
  var diffpiTemplateTool = defineTool8({
9725
10786
  name: "diffpi_template",
@@ -9727,7 +10788,7 @@ var diffpiTemplateTool = defineTool8({
9727
10788
  description: "Load a bundled Diffpi template or a user override and render named variables.",
9728
10789
  promptSnippet: "Use diffpi_template for package workflow templates",
9729
10790
  promptGuidelines: ["User overrides live under ~/.difflab/diffpi/templates."],
9730
- parameters: z12.toJSONSchema(templateSchema, { io: "input" }),
10791
+ parameters: z14.toJSONSchema(templateSchema, { io: "input" }),
9731
10792
  executionMode: "parallel",
9732
10793
  async execute(_id, input) {
9733
10794
  const params = templateSchema.parse(input);