@difflab/pi 0.5.0 → 0.6.0-rc.202609270756.7689e22

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 (70) hide show
  1. package/README.md +8 -6
  2. package/agents/diffpi-copilot.md +6 -3
  3. package/agents/diffpi-orchestrator.md +6 -4
  4. package/agents/diffpi-planner.md +3 -3
  5. package/agents/diffpi-reviewer.md +3 -3
  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 +417 -84
  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 -3
  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 +1147 -3634
  17. package/dist/index.js +437 -99
  18. package/dist/plan/markdown.d.ts +4 -1
  19. package/dist/plan/markdown.d.ts.map +1 -1
  20. package/dist/{tools/plan-schema.d.ts → plan/schema.d.ts} +113 -75
  21. package/dist/plan/schema.d.ts.map +1 -0
  22. package/dist/plan/store.d.ts +39 -1
  23. package/dist/plan/store.d.ts.map +1 -1
  24. package/dist/plan/types.d.ts +27 -3
  25. package/dist/plan/types.d.ts.map +1 -1
  26. package/dist/review/local-reviews.d.ts +31 -0
  27. package/dist/review/local-reviews.d.ts.map +1 -0
  28. package/dist/review/review-markdown.d.ts.map +1 -1
  29. package/dist/tools/index.js +879 -420
  30. package/dist/tools/plan.d.ts.map +1 -1
  31. package/dist/tools/review.d.ts +9 -1
  32. package/dist/tools/review.d.ts.map +1 -1
  33. package/package.json +2 -4
  34. package/skills/plan/SKILL.md +8 -14
  35. package/skills/plan/references/design-quality.md +41 -0
  36. package/skills/plan/references/workflows/annotate.md +11 -0
  37. package/skills/plan/references/workflows/finalize.md +7 -0
  38. package/skills/plan/references/workflows/go.md +8 -0
  39. package/skills/plan/references/workflows/help.md +10 -0
  40. package/skills/plan/references/workflows/init.md +5 -0
  41. package/skills/plan/references/workflows/new.md +7 -0
  42. package/skills/plan/references/workflows/update.md +7 -0
  43. package/skills/review/SKILL.md +7 -9
  44. package/skills/review/references/review-standards.md +8 -1
  45. package/skills/review/references/workflows/address.md +4 -4
  46. package/skills/review/references/workflows/auto.md +4 -4
  47. package/skills/review/references/workflows/complete.md +2 -2
  48. package/skills/review/references/workflows/edit.md +3 -3
  49. package/skills/review/references/workflows/help.md +8 -2
  50. package/skills/review/references/workflows/merge.md +2 -2
  51. package/skills/review/references/workflows/new.md +5 -5
  52. package/skills/review/references/workflows/open.md +1 -1
  53. package/skills/review/references/workflows/publish.md +2 -2
  54. package/skills/review/references/workflows/status.md +3 -0
  55. package/templates/plan/PLAN.md +2 -2
  56. package/templates/plan/implementation.md +16 -10
  57. package/dist/commands.d.ts +0 -22
  58. package/dist/commands.d.ts.map +0 -1
  59. package/dist/extensions/cmdtsx.d.ts +0 -2
  60. package/dist/extensions/cmdtsx.d.ts.map +0 -1
  61. package/dist/tools/plan-schema.d.ts.map +0 -1
  62. package/dist/workflow.d.ts +0 -33
  63. package/dist/workflow.d.ts.map +0 -1
  64. package/workflows/plan/annotate.md +0 -13
  65. package/workflows/plan/finalize.md +0 -15
  66. package/workflows/plan/go.md +0 -20
  67. package/workflows/plan/help.md +0 -20
  68. package/workflows/plan/init.md +0 -13
  69. package/workflows/plan/new.md +0 -20
  70. package/workflows/plan/update.md +0 -18
package/dist/index.js CHANGED
@@ -605,6 +605,16 @@ function diffpiLaunchName(cwd, workflow) {
605
605
  }
606
606
  async function openFileAdjacent(path, opts) {
607
607
  const env = opts.env ?? process.env;
608
+ const mux = detectMux(env);
609
+ if (mux !== "none") {
610
+ const editor = await resolveEditor(env);
611
+ if (editor) {
612
+ const command = persistentEditorArgs(editor, path, opts.cwd, env.SHELL);
613
+ const opened = await openMuxTab(mux, command, opts.cwd, opts.name ?? "plan", [editor, path].join(" "), command);
614
+ if (opened)
615
+ return opened;
616
+ }
617
+ }
608
618
  const ide = detectIde(env);
609
619
  if (ide !== "unknown") {
610
620
  const ideCommand = { zed: "zed", vscode: "code", cursor: "cursor", windsurf: "windsurf", jetbrains: "idea" }[ide];
@@ -614,16 +624,6 @@ async function openFileAdjacent(path, opts) {
614
624
  return { launched: true, via: ide, command: `${ideCommand} ${path}` };
615
625
  }
616
626
  }
617
- const mux = detectMux(env);
618
- if (mux !== "none") {
619
- const editor = await resolveEditor(env);
620
- if (editor) {
621
- const command = [editor, path];
622
- const opened = await openMuxTab(mux, command, opts.cwd, opts.name ?? "plan", command.join(" "));
623
- if (opened)
624
- return opened;
625
- }
626
- }
627
627
  return { launched: false, via: "print", command: path, reason: "No supported IDE or multiplexer was available." };
628
628
  }
629
629
  async function openInNewTab(command, opts) {
@@ -658,10 +658,15 @@ async function openInNewTab(command, opts) {
658
658
  function screenWindowArgs(command, cwd, name) {
659
659
  return ["-X", "screen", "-t", name, "sh", "-lc", 'cd -- "$1" && shift && exec "$@"', "sh", cwd, ...command];
660
660
  }
661
+ function persistentEditorArgs(editor, path, cwd, shell = "sh") {
662
+ return ["bash", "-lc", 'cd -- "$1" && shift && "$@"; exec "$0" -i', shell, cwd, editor, path];
663
+ }
661
664
  async function resolveEditor(env) {
662
- const configured = (env.VISUAL ?? env.EDITOR)?.trim().split(/\s+/)[0];
663
- if (configured && await findExecutable(configured))
664
- return configured;
665
+ for (const configured of [env.EDITOR, env.VISUAL]) {
666
+ const editor = configured?.trim();
667
+ if (editor && await findExecutable(editor))
668
+ return editor;
669
+ }
665
670
  for (const candidate of ["hx", "nvim", "vim"])
666
671
  if (await findExecutable(candidate))
667
672
  return candidate;
@@ -678,7 +683,7 @@ async function packageVersion() {
678
683
  throw new Error("Cannot resolve the installed @difflab/pi version.");
679
684
  return value.version;
680
685
  }
681
- async function openMuxTab(mux, command, cwd, name, printable) {
686
+ async function openMuxTab(mux, command, cwd, name, printable, tmuxCommand) {
682
687
  if (mux === "zellij" && await findExecutable("zellij")) {
683
688
  const result = await run("zellij", ["action", "new-tab", "--cwd", cwd, "--name", name, "--", ...command]);
684
689
  if (result.code === 0)
@@ -688,7 +693,7 @@ async function openMuxTab(mux, command, cwd, name, printable) {
688
693
  return { launched: true, via: "zellij-run", command: printable };
689
694
  }
690
695
  if (mux === "tmux" && await findExecutable("tmux")) {
691
- const result = await run("tmux", ["new-window", "-c", cwd, "-n", name, printable]);
696
+ const result = tmuxCommand ? await run("tmux", ["new-window", "-c", cwd, "-n", name, ...tmuxCommand]) : await run("tmux", ["new-window", "-c", cwd, "-n", name, printable]);
692
697
  if (result.code === 0)
693
698
  return { launched: true, via: "tmux", command: printable };
694
699
  }
@@ -1433,7 +1438,7 @@ function renderThreadArtifact(title, target, threads, options = {}) {
1433
1438
  }
1434
1439
  lines.push("## Source comments", "");
1435
1440
  for (const thread of threads) {
1436
- lines.push(`### ${thread.file ?? "review"}:${thread.line ?? "n/a"} — ${thread.author ?? "unknown"}`, "", `Thread: ${thread.id}`, `Status: ${thread.resolved ? "resolved" : thread.addressed ? "addressed" : "open"}`, "", thread.body, "", "---", "");
1441
+ lines.push(`### ${thread.file ?? "review"}:${thread.line ?? "n/a"} — ${thread.author ?? "unknown"}`, "", `Thread: ${thread.id}`, `Status: ${thread.resolved ? "resolved" : thread.addressed ? "addressed" : "open"}`, "", "**Source comment**", "", thread.body, "", "**Recorded response**", "", thread.reply?.trim() || "_No response recorded._", "", "---", "");
1437
1442
  }
1438
1443
  return `${lines.join(`
1439
1444
  `).trimEnd()}
@@ -2629,7 +2634,8 @@ function parseJsonObject(content, path) {
2629
2634
  throw new Error(`Expected valid JSON object in ${path}.`);
2630
2635
  }
2631
2636
  // src/plan/store.ts
2632
- import { mkdir as mkdir7, readdir as readdir2, readFile as readFile12, realpath as realpath4, rm as rm2 } from "node:fs/promises";
2637
+ import { createHash as createHash3, randomUUID as randomUUID2 } from "node:crypto";
2638
+ import { mkdir as mkdir7, readdir as readdir2, readFile as readFile12, realpath as realpath4, rename as rename3, rm as rm2 } from "node:fs/promises";
2633
2639
  import { basename as basename6, dirname as dirname8, join as join16, resolve as resolve4, sep } from "node:path";
2634
2640
 
2635
2641
  // src/extensions/fsx.ts
@@ -7386,11 +7392,13 @@ function validatePlanDocument(plan, options = {}) {
7386
7392
  if (!taskIds.has(dependency))
7387
7393
  add("dangling-dependency", "error", `Task ${task.id} depends on missing ${dependency}.`);
7388
7394
  }
7389
- if (options.strict && task.acceptanceCriteria.length === 0)
7390
- add("acceptance", "error", `Task ${task.id} has no acceptance criteria.`, task.id);
7395
+ if (options.strict && isPlaceholder(task.title))
7396
+ add("placeholder", "error", `Task ${task.id} has placeholder content.`, task.id);
7391
7397
  }
7392
7398
  if (options.strict && phase.tasks.length === 0)
7393
7399
  add("empty-phase", "error", `Phase ${phase.id} has no tasks.`, phase.id);
7400
+ if (options.strict && (isPlaceholder(phase.title) || isPlaceholder(phase.objective)))
7401
+ add("placeholder", "error", `Phase ${phase.id} has placeholder content.`, phase.id);
7394
7402
  }
7395
7403
  for (const cycle of dependencyCycles(plan))
7396
7404
  add("dependency-cycle", "error", `Dependency cycle: ${cycle.join(" -> ")}.`);
@@ -7399,10 +7407,131 @@ function validatePlanDocument(plan, options = {}) {
7399
7407
  add("design-length", "warning", `Design is ${designWords} words; prefer 300 or fewer.`);
7400
7408
  if (options.strict && designWords > 800)
7401
7409
  add("design-too-long", "error", `Design is ${designWords} words; finalization allows at most 800.`);
7402
- if (options.strict && plan.phases.length === 0)
7403
- add("no-phases", "error", "Finalization requires at least one phase.");
7410
+ if (options.strict) {
7411
+ if (plan.phases.length === 0)
7412
+ add("no-phases", "error", "Finalization requires at least one phase.");
7413
+ if (isPlaceholder(plan.intent))
7414
+ add("placeholder", "error", "Intent must contain concrete content.", "intent");
7415
+ if (!plan.requirements.length || plan.requirements.some(isPlaceholder))
7416
+ add("requirements", "error", "Requirements must contain concrete content.", "requirements");
7417
+ for (const [key, value] of Object.entries(plan.design)) {
7418
+ if (isPlaceholder(value))
7419
+ add("design", "error", `Design/${key} must contain concrete content.`, `design.${key}`);
7420
+ }
7421
+ }
7422
+ return issues;
7423
+ }
7424
+ function validatePlanBriefs(plan, briefs) {
7425
+ const issues = [];
7426
+ const phaseIds = plan.phases.map((phase) => phase.id);
7427
+ const briefIds = briefs.map((brief) => brief.phaseId);
7428
+ if (!sameIds(phaseIds, briefIds))
7429
+ issues.push({
7430
+ code: "brief-phase-ids",
7431
+ severity: "error",
7432
+ message: `Brief phase IDs must exactly match plan phases in order: ${phaseIds.join(", ")}.`
7433
+ });
7434
+ for (const [index, phase] of plan.phases.entries()) {
7435
+ const brief = briefs[index];
7436
+ if (!brief || brief.phaseId !== phase.id)
7437
+ continue;
7438
+ if (isPlaceholder(brief.summary))
7439
+ issues.push({ code: "brief-placeholder", severity: "error", message: `Brief ${phase.id} needs a summary.` });
7440
+ const taskIds = phase.tasks.map((task) => task.id);
7441
+ const briefTaskIds = brief.tasks.map((task) => task.taskId);
7442
+ if (!sameIds(taskIds, briefTaskIds))
7443
+ issues.push({
7444
+ code: "brief-task-ids",
7445
+ severity: "error",
7446
+ message: `Brief ${phase.id} task IDs must exactly match phase tasks in order: ${taskIds.join(", ")}.`
7447
+ });
7448
+ for (const task of brief.tasks) {
7449
+ for (const [field, values] of [
7450
+ ["ordered steps", task.steps],
7451
+ ["file scopes", task.fileScopes],
7452
+ ["acceptance criteria", task.acceptanceCriteria]
7453
+ ]) {
7454
+ if (!values.length || values.some(isPlaceholder))
7455
+ issues.push({
7456
+ code: "brief-incomplete",
7457
+ severity: "error",
7458
+ message: `Brief task ${task.taskId} needs concrete ${field}.`,
7459
+ path: task.taskId
7460
+ });
7461
+ }
7462
+ }
7463
+ for (const [field, values] of [
7464
+ ["API changes", brief.apiChanges],
7465
+ ["libraries", brief.libraries],
7466
+ ["constraints", brief.constraints]
7467
+ ]) {
7468
+ if (values.some(isPlaceholder))
7469
+ issues.push({
7470
+ code: "brief-placeholder",
7471
+ severity: "error",
7472
+ message: `Brief ${phase.id} has placeholder ${field}.`,
7473
+ path: phase.id
7474
+ });
7475
+ }
7476
+ }
7404
7477
  return issues;
7405
7478
  }
7479
+ function renderImplementationBrief(planRevision, ordinal, phase, brief) {
7480
+ const marker = { schemaVersion: 1, planRevision, ordinal, phaseId: phase.id };
7481
+ const tasks = phase.tasks.map((task, index) => {
7482
+ const details = brief.tasks[index];
7483
+ if (!details || details.taskId !== task.id)
7484
+ throw new Error(`Missing implementation details for ${task.id}.`);
7485
+ return `<!-- diffpi-brief-task: ${json({ id: task.id })} -->
7486
+ ### Task: ${task.title}
7487
+
7488
+ #### Ordered Steps
7489
+
7490
+ ${bulletList(details.steps)}
7491
+
7492
+ #### File Scopes
7493
+
7494
+ ${bulletList(details.fileScopes)}
7495
+
7496
+ #### Acceptance Criteria
7497
+
7498
+ ${bulletList(details.acceptanceCriteria)}
7499
+
7500
+ <!-- /diffpi-brief-task -->`;
7501
+ }).join(`
7502
+
7503
+ `);
7504
+ return `<!-- diffpi-implementation: ${json(marker)} -->
7505
+ # Phase ${ordinal}: ${phase.title}
7506
+
7507
+ - **Phase ID:** ${phase.id}
7508
+ - **Plan Revision:** ${planRevision}
7509
+
7510
+ ## Summary
7511
+
7512
+ ${brief.summary}
7513
+
7514
+ ## Objective
7515
+
7516
+ ${phase.objective}
7517
+
7518
+ ## Tasks
7519
+
7520
+ ${tasks}
7521
+
7522
+ ## API Changes
7523
+
7524
+ ${bulletList(brief.apiChanges, "None.")}
7525
+
7526
+ ## Libraries and Algorithms
7527
+
7528
+ ${bulletList(brief.libraries, "None.")}
7529
+
7530
+ ## Implementation Constraints
7531
+
7532
+ ${bulletList(brief.constraints, "None.")}
7533
+ `;
7534
+ }
7406
7535
  function renderPlanDocument(plan, previousSource) {
7407
7536
  if (previousSource && canPatch(previousSource, plan))
7408
7537
  return patchMarkers(previousSource, plan);
@@ -7411,6 +7540,8 @@ function renderPlanDocument(plan, previousSource) {
7411
7540
  id: plan.id,
7412
7541
  revision: plan.revision,
7413
7542
  branch: plan.branch,
7543
+ issueId: plan.issueId,
7544
+ issueUrl: plan.issueUrl,
7414
7545
  status: plan.status,
7415
7546
  execution: plan.execution,
7416
7547
  createdAt: plan.createdAt,
@@ -7419,6 +7550,8 @@ function renderPlanDocument(plan, previousSource) {
7419
7550
  const metadata = import_mdast_builder.list("unordered", [
7420
7551
  ["Plan ID", plan.id],
7421
7552
  ["Branch", plan.branch],
7553
+ ...plan.issueId ? [["Issue", plan.issueId]] : [],
7554
+ ...plan.issueUrl ? [["Issue URL", plan.issueUrl]] : [],
7422
7555
  ["Status", plan.status],
7423
7556
  ["Revision", String(plan.revision)]
7424
7557
  ].map(([label, value]) => import_mdast_builder.listItem(import_mdast_builder.paragraph([import_mdast_builder.strong(import_mdast_builder.text(`${label}:`)), import_mdast_builder.text(` ${value}`)]))));
@@ -7440,7 +7573,7 @@ function renderPlanDocument(plan, previousSource) {
7440
7573
  import_mdast_builder.heading(3, import_mdast_builder.text("Consequences")),
7441
7574
  import_mdast_builder.html(plan.design.consequences || "<!-- Describe trade-offs and limitations. -->"),
7442
7575
  import_mdast_builder.heading(2, import_mdast_builder.text("Implementation")),
7443
- ...plan.phases.length ? plan.phases.flatMap((phase) => [import_mdast_builder.html(renderPhase(phase))]) : [import_mdast_builder.html("<!-- Add phases with plan_add_phase. -->")],
7576
+ ...plan.phases.length ? plan.phases.flatMap((phase) => [import_mdast_builder.html(renderPhase(phase))]) : [import_mdast_builder.html("<!-- Add phases with plan_apply_revision. -->")],
7444
7577
  import_mdast_builder.heading(2, import_mdast_builder.text("References")),
7445
7578
  references
7446
7579
  ]);
@@ -7514,10 +7647,7 @@ function renderTask(task) {
7514
7647
  const checked = task.status === "completed" || task.status === "skipped" ? "x" : " ";
7515
7648
  return `<!-- diffpi-task: ${json(marker)} -->
7516
7649
  - [${checked}] **${task.title}**
7517
- ${taskList("Steps", task.steps ?? [])}
7518
7650
  - Dependencies: ${list3(task.dependencies)}
7519
- ${taskList("File scopes", task.fileScopes)}
7520
- ${taskList("Acceptance criteria", task.acceptanceCriteria)}
7521
7651
  <!-- /diffpi-task -->`;
7522
7652
  }
7523
7653
  function parsePhases(input, ref) {
@@ -7567,10 +7697,7 @@ function parseTasks(input, ref) {
7567
7697
  return {
7568
7698
  ...marker,
7569
7699
  title,
7570
- steps: parseListValue(body, "Steps"),
7571
- dependencies: parseListValue(body, "Dependencies"),
7572
- fileScopes: parseListValue(body, "File scopes"),
7573
- acceptanceCriteria: parseListValue(body, "Acceptance criteria")
7700
+ dependencies: parseListValue(body, "Dependencies")
7574
7701
  };
7575
7702
  });
7576
7703
  assertUniqueIds(tasks.map((task) => task.id), "task id");
@@ -7597,6 +7724,8 @@ function patchMarkers(source, plan) {
7597
7724
  id: plan.id,
7598
7725
  revision: plan.revision,
7599
7726
  branch: plan.branch,
7727
+ issueId: plan.issueId,
7728
+ issueUrl: plan.issueUrl,
7600
7729
  status: plan.status,
7601
7730
  execution: plan.execution,
7602
7731
  createdAt: plan.createdAt,
@@ -7646,19 +7775,12 @@ function contentShape(plan) {
7646
7775
  requirements: plan.requirements,
7647
7776
  design: plan.design,
7648
7777
  references: plan.references,
7649
- phases: plan.phases.map(({ id, title, objective, dependencies, tasks }) => ({
7650
- id,
7651
- title,
7652
- objective,
7653
- dependencies,
7654
- tasks: tasks.map(({ id: taskId, title: taskTitle, steps, dependencies: taskDependencies, fileScopes, acceptanceCriteria }) => ({
7655
- id: taskId,
7656
- title: taskTitle,
7657
- steps,
7658
- dependencies: taskDependencies,
7659
- fileScopes,
7660
- acceptanceCriteria
7661
- }))
7778
+ phases: plan.phases.map((phase) => ({
7779
+ id: phase.id,
7780
+ title: phase.title,
7781
+ objective: phase.objective,
7782
+ dependencies: phase.dependencies,
7783
+ tasks: phase.tasks.map(({ id, title, dependencies }) => ({ id, title, dependencies }))
7662
7784
  }))
7663
7785
  };
7664
7786
  }
@@ -7758,6 +7880,13 @@ function assertUniqueIds(ids, label) {
7758
7880
  function cleanPlaceholder(value) {
7759
7881
  return value.replace(/<!--[^]*?-->/g, "").trim();
7760
7882
  }
7883
+ function isPlaceholder(value) {
7884
+ const normalized = cleanPlaceholder(value).trim();
7885
+ return normalized.length === 0 || /^(?:todo|tbd|none|n\/a)$/i.test(normalized) || /define during implementation|describe the|add requirements|add implementation/i.test(normalized);
7886
+ }
7887
+ function sameIds(expected, actual) {
7888
+ return expected.length === actual.length && expected.every((value, index) => actual[index] === value);
7889
+ }
7761
7890
  function parseMarker(value, label) {
7762
7891
  try {
7763
7892
  return JSON.parse(value);
@@ -7765,15 +7894,14 @@ function parseMarker(value, label) {
7765
7894
  throw new Error(`Malformed ${label} marker JSON.`);
7766
7895
  }
7767
7896
  }
7768
- function taskList(label, values) {
7897
+ function bulletList(values, empty = "") {
7769
7898
  if (!values.length)
7770
- return ` - ${label}: none`;
7771
- const items = values.flatMap((value) => {
7899
+ return empty;
7900
+ return values.flatMap((value) => {
7772
7901
  const [first = "", ...continuations] = value.split(`
7773
7902
  `);
7774
- return [` - ${first}`, ...continuations.map((line) => ` ${line}`)];
7775
- });
7776
- return [` - ${label}:`, ...items].join(`
7903
+ return [`- ${first}`, ...continuations.map((line) => ` ${line}`)];
7904
+ }).join(`
7777
7905
  `);
7778
7906
  }
7779
7907
  function list3(values) {
@@ -7820,6 +7948,15 @@ async function resolvePlan(cwd, query, filters = {}, options = {}) {
7820
7948
  }
7821
7949
  function createPlanStore(options = {}) {
7822
7950
  const now = options.now ?? (() => new Date);
7951
+ const read = async (cwd, query, filters) => {
7952
+ const result = await resolvePlan(cwd, query, filters, options);
7953
+ if (!result.record) {
7954
+ if (result.ambiguous)
7955
+ throw new Error(`Plan query "${query}" is ambiguous: ${result.candidates.map((item) => item.id).join(", ")}.`);
7956
+ throw new Error(`Plan "${query}" was not found.`);
7957
+ }
7958
+ return result.record;
7959
+ };
7823
7960
  return {
7824
7961
  context(cwd, query, filters) {
7825
7962
  return resolvePlan(cwd, query, filters, options);
@@ -7828,57 +7965,77 @@ function createPlanStore(options = {}) {
7828
7965
  const root = await plansDir(input.cwd, options.homeDir);
7829
7966
  const id = planRecordName(input.shortSlug, now());
7830
7967
  const dir = join16(root, id);
7831
- await assertContained(root, dirname8(dir));
7832
- try {
7833
- await mkdir7(dir);
7834
- } catch (error) {
7835
- if (error.code === "EEXIST")
7836
- throw new Error(`Plan ${id} already exists.`);
7837
- throw error;
7838
- }
7968
+ await createPlanDirectory(root, dir, id);
7839
7969
  try {
7840
7970
  const timestamp = now().toISOString();
7841
7971
  const template = await loadTemplate("plan/PLAN", {
7842
7972
  homeDir: options.homeDir,
7843
7973
  bundledDir: options.bundledTemplatesDir
7844
7974
  });
7975
+ const title = input.title?.trim() || titleFromSlug(input.shortSlug);
7845
7976
  const source = renderTemplate(template.content, {
7846
7977
  id,
7847
7978
  branch: input.branch,
7848
- title: input.title?.trim() || titleFromSlug(input.shortSlug),
7979
+ title,
7849
7980
  intent: input.intent?.trim() || "<!-- Describe the intended outcome. -->",
7850
- issue_id: input.issueId?.trim() || "<!-- Add issue tracker ID. -->",
7851
- issue_url: input.issueUrl?.trim() || "<!-- Add issue tracker URL. -->",
7981
+ issue_id: input.issueId?.trim() || "",
7982
+ issue_url: input.issueUrl?.trim() || "",
7852
7983
  created_at: timestamp,
7853
7984
  updated_at: timestamp
7854
7985
  });
7855
7986
  const document = parsePlanDocument(source, join16(dir, "PLAN.md"));
7856
- await atomicWrite(join16(dir, "PLAN.md"), source);
7857
- return {
7858
- id,
7859
- dir,
7860
- planPath: join16(dir, "PLAN.md"),
7861
- logPath: join16(dir, "logs.txt"),
7862
- implementationDir: join16(dir, "implementation"),
7863
- document,
7864
- source
7865
- };
7987
+ const authoringRequest = input.request ?? { kind: "user", text: input.intent?.trim() || `Initialize phase-less plan ${title}.` };
7988
+ await writeRevisionSnapshot(dir, document, source, authoringRequest, []);
7989
+ await writeLatestView(dir, document, source, []);
7990
+ return await readRecord(dir);
7866
7991
  } catch (error) {
7867
7992
  await rm2(dir, { recursive: true, force: true });
7868
7993
  throw error;
7869
7994
  }
7870
7995
  },
7871
- async read(cwd, query, filters) {
7872
- const result = await resolvePlan(cwd, query, filters, options);
7873
- if (!result.record) {
7874
- if (result.ambiguous)
7875
- throw new Error(`Plan query "${query}" is ambiguous: ${result.candidates.map((item) => item.id).join(", ")}.`);
7876
- throw new Error(`Plan "${query}" was not found.`);
7996
+ read,
7997
+ async applyRevision(cwd, request) {
7998
+ if (request.mode === "create") {
7999
+ const root = await plansDir(cwd, options.homeDir);
8000
+ const id = planRecordName(request.shortSlug, now());
8001
+ const dir = join16(root, id);
8002
+ await createPlanDirectory(root, dir, id);
8003
+ try {
8004
+ const timestamp = now().toISOString();
8005
+ const document = normalizeRevision(undefined, request, id, 0, timestamp);
8006
+ assertCompleteRevision(document, request.briefs);
8007
+ const source = renderPlanDocument(document);
8008
+ parsePlanDocument(source, join16(dir, "PLAN.md"));
8009
+ await writeRevisionSnapshot(dir, document, source, request.request, request.briefs);
8010
+ await writeLatestView(dir, document, source, request.briefs);
8011
+ return await readRecord(dir);
8012
+ } catch (error) {
8013
+ await rm2(dir, { recursive: true, force: true });
8014
+ throw error;
8015
+ }
7877
8016
  }
7878
- return result.record;
8017
+ const initial = await read(cwd, request.plan);
8018
+ return withDirectoryLock(join16(initial.dir, ".lock"), async () => {
8019
+ const current = await readRecord(initial.dir);
8020
+ if (current.document.revision !== request.expectedPlanRevision)
8021
+ throw new Error(`Expected plan revision ${request.expectedPlanRevision}, found ${current.document.revision}.`);
8022
+ const revision = current.document.revision + 1;
8023
+ const document = normalizeRevision(current.document, request, current.id, revision, now().toISOString());
8024
+ assertCompleteRevision(document, request.briefs);
8025
+ const source = renderPlanDocument(document);
8026
+ parsePlanDocument(source, current.planPath);
8027
+ await writeRevisionSnapshot(current.dir, document, source, request.request, request.briefs);
8028
+ try {
8029
+ await writeLatestView(current.dir, document, source, request.briefs);
8030
+ } catch (error) {
8031
+ await rm2(join16(current.dir, "revisions", String(revision)), { recursive: true, force: true });
8032
+ throw error;
8033
+ }
8034
+ return await readRecord(current.dir);
8035
+ }, { operation: "apply revision" });
7879
8036
  },
7880
8037
  async mutate(cwd, query, operation, update) {
7881
- const initial = await this.read(cwd, query);
8038
+ const initial = await read(cwd, query);
7882
8039
  return withDirectoryLock(join16(initial.dir, ".lock"), async () => {
7883
8040
  const current = await readRecord(initial.dir);
7884
8041
  const next = await update(structuredClone(current.document));
@@ -7886,18 +8043,17 @@ function createPlanStore(options = {}) {
7886
8043
  throw new Error("A plan mutation cannot change the plan ID.");
7887
8044
  const document = {
7888
8045
  ...next,
7889
- revision: current.document.revision + 1,
8046
+ revision: current.document.revision,
7890
8047
  updatedAt: now().toISOString()
7891
8048
  };
7892
8049
  const source = renderPlanDocument(document, current.source);
7893
8050
  parsePlanDocument(source, current.planPath);
7894
8051
  await atomicWrite(current.planPath, source);
7895
- await ensureImplementationFiles(current.dir, document, options);
7896
- return { ...current, document, source, implementationDir: join16(current.dir, "implementation") };
8052
+ return { ...current, document, source };
7897
8053
  }, { operation });
7898
8054
  },
7899
8055
  async log(cwd, query, event) {
7900
- const record = await this.read(cwd, query);
8056
+ const record = await read(cwd, query);
7901
8057
  return appendLogEntry(record.logPath, event);
7902
8058
  }
7903
8059
  };
@@ -7917,30 +8073,209 @@ async function readRecord(dir) {
7917
8073
  source
7918
8074
  };
7919
8075
  }
7920
- async function ensureImplementationFiles(dir, document, options) {
7921
- const implementationDir = join16(dir, "implementation");
7922
- await mkdir7(implementationDir, { recursive: true });
7923
- const template = await loadTemplate("plan/implementation", {
7924
- homeDir: options.homeDir,
7925
- bundledDir: options.bundledTemplatesDir
7926
- });
7927
- for (const phase of document.phases) {
7928
- const path = join16(implementationDir, `phase-${phase.id}.md`);
8076
+ async function createPlanDirectory(root, dir, id) {
8077
+ await assertContained(root, dirname8(dir));
8078
+ try {
8079
+ await mkdir7(dir);
8080
+ } catch (error) {
8081
+ if (error.code === "EEXIST")
8082
+ throw new Error(`Plan ${id} already exists.`);
8083
+ throw error;
8084
+ }
8085
+ }
8086
+ async function writeRevisionSnapshot(planDir, document, source, request, briefs) {
8087
+ if (!request.text.trim())
8088
+ throw new Error("An exact non-empty authoring request is required.");
8089
+ if (request.kind === "annotation" && !request.response?.trim())
8090
+ throw new Error("Annotation requests require a non-empty response.");
8091
+ const revisionsDir = join16(planDir, "revisions");
8092
+ await mkdir7(revisionsDir, { recursive: true });
8093
+ const target = join16(revisionsDir, String(document.revision));
8094
+ try {
8095
+ await readFile12(join16(target, "metadata.json"), "utf8");
8096
+ throw new Error(`Revision ${document.revision} already exists and is immutable.`);
8097
+ } catch (error) {
8098
+ if (error.code !== "ENOENT")
8099
+ throw error;
8100
+ }
8101
+ const staging = join16(revisionsDir, `.tmp-${document.revision}-${randomUUID2()}`);
8102
+ await mkdir7(staging);
8103
+ try {
8104
+ const requestSource = request.kind === "annotation" ? `## Original annotation
8105
+ ${request.text}
8106
+
8107
+ ## LLM response
8108
+ ${request.response}` : request.text;
8109
+ await atomicWrite(join16(staging, "request.md"), requestSource);
8110
+ await atomicWrite(join16(staging, "metadata.json"), `${JSON.stringify({
8111
+ schemaVersion: 1,
8112
+ id: document.id,
8113
+ revision: document.revision,
8114
+ requestKind: request.kind,
8115
+ requestSha256: sha256(request.text),
8116
+ ...request.kind === "annotation" ? { originalAnnotationSha256: sha256(request.text), responseSha256: sha256(request.response) } : {},
8117
+ createdAt: document.updatedAt
8118
+ }, null, 2)}
8119
+ `);
8120
+ await atomicWrite(join16(staging, "PLAN.md"), source);
8121
+ await writeImplementationDirectory(staging, document, briefs);
8122
+ await rename3(staging, target);
8123
+ } catch (error) {
8124
+ await rm2(staging, { recursive: true, force: true });
8125
+ throw error;
8126
+ }
8127
+ }
8128
+ async function writeLatestView(planDir, document, source, briefs) {
8129
+ const current = join16(planDir, "implementation");
8130
+ const stagingRoot = join16(planDir, `.latest-${randomUUID2()}`);
8131
+ const stagedImplementation = join16(stagingRoot, "implementation");
8132
+ const backup = join16(planDir, `.implementation-backup-${randomUUID2()}`);
8133
+ await mkdir7(stagingRoot);
8134
+ await writeImplementationDirectory(stagingRoot, document, briefs);
8135
+ let backedUp = false;
8136
+ try {
7929
8137
  try {
7930
- await readFile12(path, "utf8");
8138
+ await rename3(current, backup);
8139
+ backedUp = true;
7931
8140
  } catch (error) {
7932
8141
  if (error.code !== "ENOENT")
7933
8142
  throw error;
7934
- const tasks = phase.tasks.map((task) => `- ${task.id}: ${task.title}`).join("\\n") || "- Add implementation tasks.";
7935
- await atomicWrite(path, renderTemplate(template.content, {
7936
- phase_id: phase.id,
7937
- phase_title: phase.title,
7938
- phase_objective: phase.objective,
7939
- phase_tasks: tasks
7940
- }));
7941
8143
  }
8144
+ await rename3(stagedImplementation, current);
8145
+ await atomicWrite(join16(planDir, "PLAN.md"), source);
8146
+ if (backedUp)
8147
+ await rm2(backup, { recursive: true, force: true });
8148
+ } catch (error) {
8149
+ await rm2(current, { recursive: true, force: true });
8150
+ if (backedUp)
8151
+ await rename3(backup, current);
8152
+ throw error;
8153
+ } finally {
8154
+ await rm2(stagingRoot, { recursive: true, force: true });
7942
8155
  }
7943
8156
  }
8157
+ async function writeImplementationDirectory(baseDir, document, briefs) {
8158
+ const implementationDir = join16(baseDir, "implementation");
8159
+ await mkdir7(implementationDir, { recursive: true });
8160
+ for (const [index, phase] of document.phases.entries()) {
8161
+ const brief = briefs[index];
8162
+ if (!brief)
8163
+ throw new Error(`Missing implementation brief for phase ${phase.id}.`);
8164
+ await atomicWrite(join16(implementationDir, `phase-${index + 1}.md`), renderImplementationBrief(document.revision, index + 1, phase, brief));
8165
+ }
8166
+ }
8167
+ function normalizeRevision(current, request, id, revision, updatedAt) {
8168
+ if (current?.status === "completed")
8169
+ throw new Error("Completed plans cannot be amended.");
8170
+ if (current?.execution?.active && request.request.kind !== "blocker")
8171
+ throw new Error("An active execution accepts only a blocker amendment.");
8172
+ const currentPhases = new Map(current?.phases.map((phase) => [phase.id, phase]) ?? []);
8173
+ for (const phase of current?.phases ?? []) {
8174
+ if (!request.phases.some((candidate) => candidate.id === phase.id) && (phase.status === "completed" || phase.status === "in_progress"))
8175
+ throw new Error(`${phase.status === "completed" ? "Completed" : "Active"} phase ${phase.id} cannot be removed.`);
8176
+ }
8177
+ const phases = request.phases.map((draft) => reconcilePhase(currentPhases.get(draft.id), draft, current));
8178
+ const createdAt = current?.createdAt ?? updatedAt;
8179
+ return {
8180
+ schemaVersion: 1,
8181
+ id,
8182
+ revision,
8183
+ title: request.title,
8184
+ branch: current?.branch ?? (request.mode === "create" ? request.branch : ""),
8185
+ issueId: current?.issueId ?? (request.mode === "create" ? request.issueId : undefined),
8186
+ issueUrl: current?.issueUrl ?? (request.mode === "create" ? request.issueUrl : undefined),
8187
+ intent: request.intent,
8188
+ requirements: request.requirements,
8189
+ design: request.design,
8190
+ references: request.references,
8191
+ phases,
8192
+ status: nextPlanStatus(current),
8193
+ execution: current?.execution,
8194
+ createdAt,
8195
+ updatedAt
8196
+ };
8197
+ }
8198
+ function reconcilePhase(current, draft, plan) {
8199
+ if (!current)
8200
+ return {
8201
+ ...draft,
8202
+ revision: 0,
8203
+ status: "pending",
8204
+ gate: { phaseRevision: 0, status: "pending", results: [] },
8205
+ tasks: draft.tasks.map(newTask)
8206
+ };
8207
+ if (current.status === "completed") {
8208
+ if (JSON.stringify(phaseContent(current)) !== JSON.stringify(draft))
8209
+ throw new Error(`Completed phase ${current.id} cannot be changed.`);
8210
+ return current;
8211
+ }
8212
+ const byId = new Map(current.tasks.map((task) => [task.id, task]));
8213
+ for (const task of current.tasks) {
8214
+ if (!draft.tasks.some((candidate) => candidate.id === task.id) && (task.status === "completed" || task.status === "in_progress"))
8215
+ throw new Error(`${task.status === "completed" ? "Completed" : "Active"} task ${task.id} cannot be removed.`);
8216
+ }
8217
+ const tasks = draft.tasks.map((task) => reconcileTask(byId.get(task.id), task));
8218
+ const changed = JSON.stringify(phaseContent(current)) !== JSON.stringify(draft);
8219
+ const revision = changed ? current.revision + 1 : current.revision;
8220
+ const wasBlocked = current.status === "blocked";
8221
+ return {
8222
+ ...current,
8223
+ ...draft,
8224
+ revision,
8225
+ tasks,
8226
+ status: wasBlocked ? plan?.execution?.active ? "in_progress" : "pending" : current.status,
8227
+ gate: changed || wasBlocked ? { phaseRevision: revision, status: "stale", results: [] } : current.gate,
8228
+ blocker: wasBlocked ? undefined : current.blocker
8229
+ };
8230
+ }
8231
+ function reconcileTask(current, draft) {
8232
+ if (!current)
8233
+ return newTask(draft);
8234
+ const changed = JSON.stringify(taskContent(current)) !== JSON.stringify(draft);
8235
+ if ((current.status === "completed" || current.status === "in_progress") && changed)
8236
+ throw new Error(`${current.status === "completed" ? "Completed" : "Active"} task ${current.id} cannot be changed.`);
8237
+ if (current.status === "completed" || current.status === "in_progress")
8238
+ return current;
8239
+ return {
8240
+ ...current,
8241
+ ...draft,
8242
+ revision: changed ? current.revision + 1 : current.revision,
8243
+ status: current.status === "blocked" ? "pending" : current.status,
8244
+ owner: current.status === "blocked" ? undefined : current.owner,
8245
+ executionId: current.status === "blocked" ? undefined : current.executionId,
8246
+ blocker: current.status === "blocked" ? undefined : current.blocker
8247
+ };
8248
+ }
8249
+ function newTask(draft) {
8250
+ return { ...draft, revision: 0, status: "pending" };
8251
+ }
8252
+ function phaseContent(phase) {
8253
+ return {
8254
+ id: phase.id,
8255
+ title: phase.title,
8256
+ objective: phase.objective,
8257
+ dependencies: phase.dependencies,
8258
+ tasks: phase.tasks.map(taskContent)
8259
+ };
8260
+ }
8261
+ function taskContent(task) {
8262
+ return { id: task.id, title: task.title, dependencies: task.dependencies };
8263
+ }
8264
+ function nextPlanStatus(current) {
8265
+ if (!current)
8266
+ return "draft";
8267
+ if (current.execution?.active)
8268
+ return current.status === "blocked" ? "in_progress" : current.status;
8269
+ return current.status === "ready" || current.status === "blocked" ? "draft" : current.status;
8270
+ }
8271
+ function assertCompleteRevision(document, briefs) {
8272
+ const errors = [...validatePlanDocument(document, { strict: true }), ...validatePlanBriefs(document, briefs)].filter((issue) => issue.severity === "error");
8273
+ if (errors.length)
8274
+ throw new Error(`Invalid plan revision: ${errors.map((issue) => issue.message).join(" ")}`);
8275
+ }
8276
+ function sha256(value) {
8277
+ return createHash3("sha256").update(value).digest("hex");
8278
+ }
7944
8279
  async function assertContained(root, candidate) {
7945
8280
  const canonicalRoot = await realpath4(root);
7946
8281
  let canonicalCandidate;
@@ -7972,7 +8307,10 @@ function stripDate(value) {
7972
8307
  return value.replace(/^\d{6}-/, "");
7973
8308
  }
7974
8309
  function titleFromSlug(value) {
7975
- return normalizeSlug(value).replace(/^(?:[a-z]+-\d+-)/, "").split("-").map((part) => part[0].toUpperCase() + part.slice(1)).join(" ");
8310
+ const slug = normalizeSlug(value).replace(/^(?:[a-z]+-\d+-)/, "");
8311
+ return slug.replace(/(^|-)([a-z])/g, (_match, separator, letter) => {
8312
+ return `${separator ? " " : ""}${letter.toUpperCase()}`;
8313
+ });
7976
8314
  }
7977
8315
  // src/setup.ts
7978
8316
  import { parseFrontmatter as parseFrontmatter3 } from "@earendil-works/pi-coding-agent";