@difflab/pi 0.4.0-rc.202609231122.7b4400b → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/README.md +4 -4
  2. package/agents/diffpi-orchestrator.md +1 -1
  3. package/agents/diffpi-planner.md +3 -3
  4. package/agents/diffpi-worker.md +2 -2
  5. package/dist/assets.d.ts +0 -1
  6. package/dist/assets.d.ts.map +1 -1
  7. package/dist/cli/plan.d.ts +3 -7
  8. package/dist/cli/plan.d.ts.map +1 -1
  9. package/dist/cli.js +811 -306
  10. package/dist/commands/plan.d.ts +1 -0
  11. package/dist/commands/plan.d.ts.map +1 -1
  12. package/dist/config.d.ts.map +1 -1
  13. package/dist/environment.d.ts +1 -2
  14. package/dist/environment.d.ts.map +1 -1
  15. package/dist/extensions/index.js +2458 -6098
  16. package/dist/extensions/processx.d.ts +0 -1
  17. package/dist/extensions/processx.d.ts.map +1 -1
  18. package/dist/extensions/subagentx.d.ts +1 -1
  19. package/dist/extensions/subagentx.d.ts.map +1 -1
  20. package/dist/index.d.ts +3 -3
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +724 -188
  23. package/dist/plan/annotations.d.ts +25 -0
  24. package/dist/plan/annotations.d.ts.map +1 -0
  25. package/dist/plan/controller.d.ts +65 -0
  26. package/dist/plan/controller.d.ts.map +1 -0
  27. package/dist/plan/execution.d.ts +13 -0
  28. package/dist/plan/execution.d.ts.map +1 -0
  29. package/dist/plan/ids.d.ts +5 -0
  30. package/dist/plan/ids.d.ts.map +1 -0
  31. package/dist/plan/index.d.ts +5 -2
  32. package/dist/plan/index.d.ts.map +1 -1
  33. package/dist/plan/lock.d.ts +11 -0
  34. package/dist/plan/lock.d.ts.map +1 -0
  35. package/dist/plan/log.d.ts +6 -0
  36. package/dist/plan/log.d.ts.map +1 -0
  37. package/dist/plan/markdown.d.ts.map +1 -1
  38. package/dist/plan/parser.d.ts +18 -0
  39. package/dist/plan/parser.d.ts.map +1 -0
  40. package/dist/plan/store.d.ts +3 -6
  41. package/dist/plan/store.d.ts.map +1 -1
  42. package/dist/plan/types.d.ts +22 -3
  43. package/dist/plan/types.d.ts.map +1 -1
  44. package/dist/tools/index.d.ts +0 -1
  45. package/dist/tools/index.d.ts.map +1 -1
  46. package/dist/tools/index.js +2096 -1776
  47. package/dist/tools/log.d.ts.map +1 -1
  48. package/dist/tools/modes.d.ts.map +1 -1
  49. package/dist/tools/plan.d.ts +2 -2
  50. package/dist/tools/plan.d.ts.map +1 -1
  51. package/package.json +2 -4
  52. package/skills/plan/SKILL.md +7 -15
  53. package/skills/plan/references/workflows/annotate.md +6 -0
  54. package/skills/plan/references/workflows/finalize.md +7 -0
  55. package/skills/plan/references/workflows/go.md +8 -0
  56. package/{workflows/plan → skills/plan/references/workflows}/help.md +4 -11
  57. package/skills/plan/references/workflows/init.md +6 -0
  58. package/skills/plan/references/workflows/new.md +8 -0
  59. package/skills/plan/references/workflows/update.md +7 -0
  60. package/templates/plan/PLAN.md +0 -2
  61. package/dist/commands.d.ts +0 -22
  62. package/dist/commands.d.ts.map +0 -1
  63. package/dist/extensions/fsx.d.ts +0 -8
  64. package/dist/extensions/fsx.d.ts.map +0 -1
  65. package/dist/extensions/zodx.d.ts +0 -8
  66. package/dist/extensions/zodx.d.ts.map +0 -1
  67. package/dist/plan/operations.d.ts +0 -43
  68. package/dist/plan/operations.d.ts.map +0 -1
  69. package/dist/plan/reviews.d.ts +0 -28
  70. package/dist/plan/reviews.d.ts.map +0 -1
  71. package/dist/tools/ci.d.ts +0 -6
  72. package/dist/tools/ci.d.ts.map +0 -1
  73. package/dist/tools/plan-schema.d.ts +0 -211
  74. package/dist/tools/plan-schema.d.ts.map +0 -1
  75. package/dist/workflow.d.ts +0 -33
  76. package/dist/workflow.d.ts.map +0 -1
  77. package/workflows/plan/annotate.md +0 -13
  78. package/workflows/plan/finalize.md +0 -15
  79. package/workflows/plan/go.md +0 -20
  80. package/workflows/plan/init.md +0 -13
  81. package/workflows/plan/new.md +0 -20
  82. package/workflows/plan/update.md +0 -18
package/dist/index.js CHANGED
@@ -176,19 +176,10 @@ var require_extend = __commonJS(function(exports, module) {
176
176
 
177
177
  // src/config.ts
178
178
  import { parseFrontmatter } from "@earendil-works/pi-coding-agent";
179
- import { z as z2 } from "zod";
179
+ import { z } from "zod";
180
180
  import { homedir } from "node:os";
181
181
  import { join } from "node:path";
182
182
 
183
- // src/extensions/zodx.ts
184
- import { z } from "zod";
185
- var zx = {
186
- cwd: z.string().optional(),
187
- id: z.string().min(1).max(80).regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),
188
- revision: z.number().int().nonnegative(),
189
- text: z.string().trim().min(1)
190
- };
191
-
192
183
  // src/fsx.ts
193
184
  import { readdir, readFile } from "node:fs/promises";
194
185
  async function readDirectoryIfExists(path) {
@@ -214,12 +205,12 @@ function isMissingPath(error) {
214
205
  }
215
206
 
216
207
  // src/config.ts
217
- var modelReferenceSchema = zx.text;
218
- var agentConfigSchema = z2.object({
219
- models: z2.array(modelReferenceSchema).optional()
208
+ var modelReferenceSchema = z.string().trim().min(1);
209
+ var agentConfigSchema = z.object({
210
+ models: z.array(modelReferenceSchema).optional()
220
211
  }).strict();
221
- var diffpiConfigSchema = z2.object({
222
- agents: z2.record(z2.string(), agentConfigSchema).optional()
212
+ var diffpiConfigSchema = z.object({
213
+ agents: z.record(z.string(), agentConfigSchema).optional()
223
214
  }).strict();
224
215
  function diffpiConfigPaths(homeDir = homedir()) {
225
216
  const directory = join(homeDir, ".difflab", "diffpi");
@@ -329,11 +320,7 @@ function run(command, args, options = {}) {
329
320
  const child = spawn(command, args, {
330
321
  cwd: options.cwd,
331
322
  env: options.env ?? process.env,
332
- stdio: [
333
- options.interactive ? "inherit" : options.input === undefined ? "ignore" : "pipe",
334
- options.interactive ? "inherit" : "pipe",
335
- "pipe"
336
- ],
323
+ stdio: [options.input === undefined ? "ignore" : "pipe", "pipe", "pipe"],
337
324
  signal: options.signal
338
325
  });
339
326
  let stdout = "";
@@ -350,8 +337,6 @@ function run(command, args, options = {}) {
350
337
  });
351
338
  child.stderr?.on("data", (chunk) => {
352
339
  const text = chunk.toString();
353
- if (options.interactive)
354
- process.stderr.write(text);
355
340
  if (unbounded)
356
341
  stderrChunks.push(text);
357
342
  else
@@ -603,29 +588,6 @@ function parseRemote(remote) {
603
588
  function diffpiLaunchName(cwd, workflow) {
604
589
  return `diffpi: ${basename(cwd)} / ${workflow}`;
605
590
  }
606
- async function openFileAdjacent(path, opts) {
607
- const env = opts.env ?? process.env;
608
- const ide = detectIde(env);
609
- if (ide !== "unknown") {
610
- const ideCommand = { zed: "zed", vscode: "code", cursor: "cursor", windsurf: "windsurf", jetbrains: "idea" }[ide];
611
- if (await findExecutable(ideCommand)) {
612
- const result = await run(ideCommand, [path], { cwd: opts.cwd, env });
613
- if (result.code === 0)
614
- return { launched: true, via: ide, command: `${ideCommand} ${path}` };
615
- }
616
- }
617
- const mux = detectMux(env);
618
- if (mux !== "none") {
619
- const editor = await resolveEditor(env);
620
- if (editor) {
621
- const command = [editor, path];
622
- const opened = await openMuxTab(mux, command, opts.cwd, opts.name ?? "plan", command.join(" "));
623
- if (opened)
624
- return opened;
625
- }
626
- }
627
- return { launched: false, via: "print", command: path, reason: "No supported IDE or multiplexer was available." };
628
- }
629
591
  async function openInNewTab(command, opts) {
630
592
  const env = opts.env ?? process.env;
631
593
  const name = opts.name ?? "review";
@@ -658,15 +620,6 @@ async function openInNewTab(command, opts) {
658
620
  function screenWindowArgs(command, cwd, name) {
659
621
  return ["-X", "screen", "-t", name, "sh", "-lc", 'cd -- "$1" && shift && exec "$@"', "sh", cwd, ...command];
660
622
  }
661
- async function resolveEditor(env) {
662
- const configured = (env.VISUAL ?? env.EDITOR)?.trim().split(/\s+/)[0];
663
- if (configured && await findExecutable(configured))
664
- return configured;
665
- for (const candidate of ["hx", "nvim", "vim"])
666
- if (await findExecutable(candidate))
667
- return candidate;
668
- return;
669
- }
670
623
  async function packageVersion() {
671
624
  let value;
672
625
  try {
@@ -1279,16 +1232,16 @@ async function discoverMiseTasks(cwd) {
1279
1232
  return parseMiseTasks(result.stdout);
1280
1233
  }
1281
1234
  // src/review/types.ts
1282
- import { z as z3 } from "zod";
1283
- var severitySchema = z3.enum(["BLOCKING", "CONSIDER", "NOTE"]);
1284
- var findingSchema = z3.object({
1285
- file: z3.string().min(1),
1286
- line: z3.number().int().nonnegative(),
1235
+ import { z as z2 } from "zod";
1236
+ var severitySchema = z2.enum(["BLOCKING", "CONSIDER", "NOTE"]);
1237
+ var findingSchema = z2.object({
1238
+ file: z2.string().min(1),
1239
+ line: z2.number().int().nonnegative(),
1287
1240
  severity: severitySchema,
1288
- body: z3.string().min(1),
1289
- reference: z3.string().optional().default("")
1241
+ body: z2.string().min(1),
1242
+ reference: z2.string().optional().default("")
1290
1243
  });
1291
- var findingsSchema = z3.array(findingSchema);
1244
+ var findingsSchema = z2.array(findingSchema);
1292
1245
  function reviewSlug(input) {
1293
1246
  return input.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 50);
1294
1247
  }
@@ -1349,17 +1302,17 @@ function renderCommentBody(finding) {
1349
1302
  return `${prefix}${finding.body}${reference}`;
1350
1303
  }
1351
1304
  // src/review/review-markdown.ts
1352
- import { z as z4 } from "zod";
1353
- var reviewThreadRecordSchema = z4.object({
1354
- id: z4.string().min(1),
1355
- file: z4.string().nullable().optional().transform((value) => value ?? undefined),
1356
- line: z4.number().int().positive().nullable().optional().transform((value) => value ?? undefined),
1357
- body: z4.string(),
1358
- author: z4.string().optional(),
1359
- resolved: z4.boolean(),
1360
- addressed: z4.boolean().optional(),
1361
- question: z4.boolean(),
1362
- replies: z4.array(z4.string()).optional()
1305
+ import { z as z3 } from "zod";
1306
+ var reviewThreadRecordSchema = z3.object({
1307
+ id: z3.string().min(1),
1308
+ file: z3.string().nullable().optional().transform((value) => value ?? undefined),
1309
+ line: z3.number().int().positive().nullable().optional().transform((value) => value ?? undefined),
1310
+ body: z3.string(),
1311
+ author: z3.string().optional(),
1312
+ resolved: z3.boolean(),
1313
+ addressed: z3.boolean().optional(),
1314
+ question: z3.boolean(),
1315
+ replies: z3.array(z3.string()).optional()
1363
1316
  });
1364
1317
  function renderReviewDoc(input) {
1365
1318
  const anchored = dedupeFindings(input.findings).filter((finding) => finding.line > 0);
@@ -1446,7 +1399,7 @@ function parseThreadArtifact(content) {
1446
1399
  let threads;
1447
1400
  try {
1448
1401
  const decoded = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
1449
- threads = z4.array(reviewThreadRecordSchema).parse(decoded);
1402
+ threads = z3.array(reviewThreadRecordSchema).parse(decoded);
1450
1403
  } catch {
1451
1404
  throw new Error("Cannot parse the Diffpi thread artifact payload.");
1452
1405
  }
@@ -2082,7 +2035,7 @@ function createLocalReviewBackend(options) {
2082
2035
  import { createHash as createHash2 } from "node:crypto";
2083
2036
  import { readFile as readFile7, rename, writeFile as writeFile4 } from "node:fs/promises";
2084
2037
  import { join as join9 } from "node:path";
2085
- import { z as z5 } from "zod";
2038
+ import { z as z4 } from "zod";
2086
2039
 
2087
2040
  // src/store.ts
2088
2041
  import { createHash } from "node:crypto";
@@ -2175,13 +2128,13 @@ function projectSlug(repository) {
2175
2128
  }
2176
2129
 
2177
2130
  // src/review/review-state.ts
2178
- var reviewPublicationStateSchema = z5.object({
2179
- target: z5.string().optional(),
2180
- bodies: z5.array(z5.string()).default([]),
2181
- comments: z5.array(z5.string()).default([]),
2182
- stagedComments: z5.array(z5.string()).default([]),
2183
- replies: z5.array(z5.string()).default([]),
2184
- overlayPath: z5.string().optional()
2131
+ var reviewPublicationStateSchema = z4.object({
2132
+ target: z4.string().optional(),
2133
+ bodies: z4.array(z4.string()).default([]),
2134
+ comments: z4.array(z4.string()).default([]),
2135
+ stagedComments: z4.array(z4.string()).default([]),
2136
+ replies: z4.array(z4.string()).default([]),
2137
+ overlayPath: z4.string().optional()
2185
2138
  });
2186
2139
  function reviewBodyFingerprint(body) {
2187
2140
  return digest(body);
@@ -2205,7 +2158,7 @@ async function loadReviewPublicationState(cwd, vcs, number, homeDir) {
2205
2158
  if (error.code === "ENOENT") {
2206
2159
  return { path, state: { target, bodies: [], comments: [], stagedComments: [], replies: [] } };
2207
2160
  }
2208
- if (error instanceof SyntaxError || error instanceof z5.ZodError) {
2161
+ if (error instanceof SyntaxError || error instanceof z4.ZodError) {
2209
2162
  throw new Error(`Cannot parse review publication state: ${path}`);
2210
2163
  }
2211
2164
  throw error;
@@ -2628,93 +2581,271 @@ function parseJsonObject(content, path) {
2628
2581
  } catch {}
2629
2582
  throw new Error(`Expected valid JSON object in ${path}.`);
2630
2583
  }
2631
- // 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";
2633
- import { basename as basename6, dirname as dirname8, join as join16, resolve as resolve4, sep } from "node:path";
2584
+ // src/extensions/subagentx.ts
2585
+ import { z as z5 } from "zod";
2586
+ var escalationSchema = z5.object({
2587
+ correlation: z5.object({
2588
+ workflow: z5.string().min(1),
2589
+ executionId: z5.string().min(1).optional(),
2590
+ unitId: z5.string().min(1).optional(),
2591
+ parentUnitId: z5.string().min(1).optional(),
2592
+ metadata: z5.record(z5.string(), z5.string()).optional()
2593
+ }).strict(),
2594
+ blocker: z5.string().min(1),
2595
+ attempts: z5.array(z5.string()),
2596
+ evidence: z5.array(z5.string()),
2597
+ needsUserDecision: z5.boolean()
2598
+ }).strict();
2599
+ var ESCALATION_OPEN = "<diffpi-subagent-escalation>";
2600
+ var ESCALATION_CLOSE = "</diffpi-subagent-escalation>";
2601
+ function renderSubagentEscalation(value) {
2602
+ return `${ESCALATION_OPEN}${JSON.stringify(escalationSchema.parse(value))}${ESCALATION_CLOSE}`;
2603
+ }
2634
2604
 
2635
- // src/extensions/fsx.ts
2636
- import { mkdir as mkdir6, rename as rename2, rm, writeFile as writeFile7 } from "node:fs/promises";
2637
- import { basename as basename5, dirname as dirname7, join as join13 } from "node:path";
2638
- async function atomicWrite(path, content) {
2639
- const temp = join13(dirname7(path), `.${basename5(path)}.${crypto.randomUUID()}.tmp`);
2640
- await writeFile7(temp, content, { encoding: "utf8", mode: 384, flag: "wx" });
2641
- await rename2(temp, path);
2605
+ // src/plan/annotations.ts
2606
+ import { spawn as spawn2 } from "node:child_process";
2607
+ import { readFile as readFile12, rename as rename2, writeFile as writeFile8 } from "node:fs/promises";
2608
+ import { basename as basename5, dirname as dirname7, join as join14 } from "node:path";
2609
+ import { z as z6 } from "zod";
2610
+
2611
+ // src/log.ts
2612
+ import { randomUUID } from "node:crypto";
2613
+ import { appendFile, readFile as readFile10 } from "node:fs/promises";
2614
+ async function appendLogEntry(logPath, event) {
2615
+ const entry = {
2616
+ ...event,
2617
+ version: 1,
2618
+ eventId: event.eventId ?? randomUUID(),
2619
+ timestamp: event.timestamp ?? new Date().toISOString()
2620
+ };
2621
+ await appendFile(logPath, `${JSON.stringify(entry)}
2622
+ `, { encoding: "utf8", mode: 384 });
2623
+ return entry;
2642
2624
  }
2643
- async function withDirectoryLock(lockDir, operation, options = {}) {
2625
+
2626
+ // src/plan/log.ts
2627
+ async function appendPlanLog(logPath, event) {
2628
+ return appendLogEntry(logPath, event);
2629
+ }
2630
+
2631
+ // src/plan/lock.ts
2632
+ import { randomUUID as randomUUID2 } from "node:crypto";
2633
+ import { mkdir as mkdir6, readFile as readFile11, rm, writeFile as writeFile7 } from "node:fs/promises";
2634
+ import { hostname } from "node:os";
2635
+ import { join as join13 } from "node:path";
2636
+ async function withPlanLock(planDir, operation, options = {}) {
2637
+ const lockDir = join13(planDir, ".lock");
2644
2638
  const waitMs = options.waitMs ?? 5000;
2645
2639
  const pollMs = options.pollMs ?? 25;
2640
+ const owner = {
2641
+ token: randomUUID2(),
2642
+ pid: process.pid,
2643
+ hostname: hostname(),
2644
+ operation: options.operation ?? "plan mutation",
2645
+ acquiredAt: new Date().toISOString()
2646
+ };
2646
2647
  const started = Date.now();
2647
2648
  while (true) {
2648
2649
  try {
2649
2650
  await mkdir6(lockDir);
2651
+ await writeFile7(join13(lockDir, "owner.json"), `${JSON.stringify(owner)}
2652
+ `, { mode: 384 });
2650
2653
  break;
2651
2654
  } catch (error) {
2652
2655
  if (error.code !== "EEXIST")
2653
2656
  throw error;
2654
- if (Date.now() - started >= waitMs)
2655
- throw new Error(`Timed out waiting to ${options.operation ?? "acquire lock"}: ${lockDir}`);
2656
- await sleep(pollMs);
2657
+ if (Date.now() - started >= waitMs) {
2658
+ let existing = "unknown owner";
2659
+ try {
2660
+ existing = (await readFile11(join13(lockDir, "owner.json"), "utf8")).trim();
2661
+ } catch {}
2662
+ throw new Error(`Timed out waiting for plan lock ${lockDir}; owner: ${existing}. Remove it only after confirming the owner is stale.`);
2663
+ }
2664
+ await new Promise((resolve) => setTimeout(resolve, pollMs));
2657
2665
  }
2658
2666
  }
2659
2667
  try {
2660
2668
  return await operation();
2661
2669
  } finally {
2662
- await rm(lockDir, { recursive: true, force: true });
2670
+ let current;
2671
+ try {
2672
+ current = JSON.parse(await readFile11(join13(lockDir, "owner.json"), "utf8"));
2673
+ } catch {}
2674
+ if (current?.token === owner.token)
2675
+ await rm(lockDir, { recursive: true, force: true });
2663
2676
  }
2664
2677
  }
2665
- function sleep(ms) {
2666
- return new Promise((resolve) => setTimeout(resolve, ms));
2667
- }
2668
2678
 
2669
- // src/log.ts
2670
- import { randomUUID } from "node:crypto";
2671
- import { appendFile, readFile as readFile10 } from "node:fs/promises";
2672
- async function appendLogEntry(logPath, event) {
2673
- const entry = {
2674
- ...event,
2675
- version: 1,
2676
- eventId: event.eventId ?? randomUUID(),
2677
- timestamp: event.timestamp ?? new Date().toISOString()
2678
- };
2679
- await appendFile(logPath, `${JSON.stringify(entry)}
2680
- `, { encoding: "utf8", mode: 384 });
2681
- return entry;
2679
+ // src/plan/annotations.ts
2680
+ var commentSchema = z6.object({
2681
+ id: z6.string(),
2682
+ content: z6.string(),
2683
+ path: z6.string().optional(),
2684
+ start_line: z6.number().int().optional(),
2685
+ line: z6.number().int().optional(),
2686
+ end_line: z6.number().int().optional()
2687
+ }).loose();
2688
+ async function annotatePlan(record, runtime = {}) {
2689
+ const execute = runtime.execute ?? executeProcess;
2690
+ const result = await execute("tuicr", ["--file", record.planPath], dirname7(record.planPath), true);
2691
+ const sessionSlug = [...result.stderr.matchAll(/^tuicr-session:\s*(\S+)\s*$/gm)].at(-1)?.[1];
2692
+ if (result.code !== 0)
2693
+ throw new Error(result.stderr.trim() || `tuicr exited with status ${result.code}.`);
2694
+ if (!sessionSlug)
2695
+ throw new Error("tuicr did not report a tuicr-session marker.");
2696
+ const state = await withPlanLock(record.dir, async () => {
2697
+ const previous = await readAnnotationState(record).catch((error) => {
2698
+ if (error.code === "ENOENT")
2699
+ return;
2700
+ throw error;
2701
+ });
2702
+ const next = {
2703
+ schemaVersion: 1,
2704
+ sessionSlug,
2705
+ updatedAt: new Date().toISOString(),
2706
+ appliedCommentIds: previous?.appliedCommentIds ?? []
2707
+ };
2708
+ await atomicJson(annotationStatePath(record), next);
2709
+ return next;
2710
+ }, { operation: "save annotation session" });
2711
+ return { sessionSlug, code: result.code, state };
2682
2712
  }
2683
-
2684
- // src/templates.ts
2685
- import { readFile as readFile11 } from "node:fs/promises";
2686
- import { homedir as homedir8 } from "node:os";
2687
- import { join as join14, normalize } from "node:path";
2688
- async function loadTemplate(name, options = {}) {
2689
- const relative = templateRelativePath(name);
2690
- const userPath = join14(options.homeDir ?? homedir8(), ".difflab", "diffpi", "templates", relative);
2691
- const bundledPath = join14(options.bundledDir ?? resolveBundledTemplatesDir(), relative);
2692
- const user = await readOptionalFile(userPath);
2693
- if (user !== undefined)
2694
- return { name, path: userPath, source: "user", content: user };
2695
- const bundled = await readOptionalFile(bundledPath);
2696
- if (bundled !== undefined)
2697
- return { name, path: bundledPath, source: "bundled", content: bundled };
2698
- throw new Error(`Template "${name}" was not found at ${userPath} or ${bundledPath}.`);
2713
+ async function readPlanAnnotations(record, options = {}) {
2714
+ const state = await readAnnotationState(record).catch((error) => {
2715
+ if (error.code === "ENOENT")
2716
+ return;
2717
+ throw error;
2718
+ });
2719
+ if (!state)
2720
+ return { comments: [], pending: [] };
2721
+ const execute = options.runtime?.execute ?? executeProcess;
2722
+ const response = await execute("tuicr", ["review", "comments", "--session", state.sessionSlug], record.dir, false);
2723
+ if (response.code !== 0) {
2724
+ if (/not found|no session|deleted/i.test(response.stderr))
2725
+ return { state, comments: [], pending: [] };
2726
+ throw new Error(response.stderr.trim() || "Cannot read tuicr comments.");
2727
+ }
2728
+ let raw;
2729
+ try {
2730
+ raw = JSON.parse(response.stdout);
2731
+ } catch {
2732
+ throw new Error("tuicr returned malformed comment JSON.");
2733
+ }
2734
+ if (!Array.isArray(raw))
2735
+ throw new Error("tuicr comment output must be a JSON array.");
2736
+ const lines = record.source.split(`
2737
+ `);
2738
+ const applied = new Set(state.appliedCommentIds);
2739
+ const comments = raw.map((value, index) => normalizeComment(value, index, lines, applied));
2740
+ const pending = comments.filter((comment) => !comment.applied);
2741
+ return { state, comments: options.includeApplied ? comments : pending, pending };
2742
+ }
2743
+ async function acknowledgePlanAnnotations(record, commentIds, summary) {
2744
+ if (!summary.trim())
2745
+ throw new Error("Annotation acknowledgement summary is required.");
2746
+ return withPlanLock(record.dir, async () => {
2747
+ const state = await readAnnotationState(record);
2748
+ const next = {
2749
+ ...state,
2750
+ updatedAt: new Date().toISOString(),
2751
+ appliedCommentIds: [...new Set([...state.appliedCommentIds, ...commentIds])]
2752
+ };
2753
+ await atomicJson(annotationStatePath(record), next);
2754
+ await appendPlanLog(record.logPath, {
2755
+ planRevision: record.document.revision,
2756
+ kind: "annotation",
2757
+ actor: "planner",
2758
+ message: summary,
2759
+ data: { commentIds: [...commentIds], sessionSlug: state.sessionSlug }
2760
+ });
2761
+ return next;
2762
+ }, { operation: "acknowledge annotations" });
2699
2763
  }
2700
- function renderTemplate(content, variables) {
2701
- return content.replace(/\{\{\s*([a-zA-Z0-9_.-]+)\s*\}\}/g, (match, key) => variables[key] ?? match);
2764
+ function annotationStatePath(record) {
2765
+ return join14(record.dir, "annotations.json");
2702
2766
  }
2703
- function templateRelativePath(name) {
2704
- const normalized = normalize(name.replaceAll("\\", "/")).replace(/^\.\//, "");
2705
- if (!normalized || normalized.startsWith("../") || normalized.includes("/../") || normalized.startsWith("/")) {
2706
- throw new Error(`Invalid template name: ${name}`);
2767
+ async function readAnnotationState(record) {
2768
+ const source = await readFile12(annotationStatePath(record), "utf8");
2769
+ let value;
2770
+ try {
2771
+ value = JSON.parse(source);
2772
+ } catch {
2773
+ throw new Error(`Malformed annotation state: ${annotationStatePath(record)}.`);
2707
2774
  }
2708
- return normalized.endsWith(".md") ? normalized : `${normalized}.md`;
2775
+ if (value.schemaVersion !== 1 || typeof value.sessionSlug !== "string" || !Array.isArray(value.appliedCommentIds)) {
2776
+ throw new Error(`Malformed annotation state: ${annotationStatePath(record)}.`);
2777
+ }
2778
+ return value;
2709
2779
  }
2710
- async function readOptionalFile(path) {
2711
- try {
2712
- return await readFile11(path, "utf8");
2713
- } catch (error) {
2714
- if (error.code === "ENOENT")
2715
- return;
2716
- throw error;
2780
+ function normalizeComment(value, index, lines, applied) {
2781
+ const parsed = commentSchema.safeParse(value);
2782
+ if (!parsed.success)
2783
+ throw new Error(`Malformed tuicr comment at index ${index}.`);
2784
+ const raw = parsed.data;
2785
+ const line = raw.start_line ?? raw.line;
2786
+ const endLine = raw.end_line ?? line;
2787
+ const targetPath = raw.path;
2788
+ const appliesToPlan = !targetPath || basename5(targetPath) === basename5("PLAN.md");
2789
+ const validAnchor = appliesToPlan && line !== undefined && line > 0 && line <= lines.length;
2790
+ return {
2791
+ id: raw.id,
2792
+ body: raw.content,
2793
+ file: targetPath,
2794
+ line,
2795
+ endLine,
2796
+ context: validAnchor ? lines.slice(line - 1, Math.min(endLine ?? line, lines.length)).join(`
2797
+ `) : undefined,
2798
+ stale: line !== undefined && !validAnchor,
2799
+ applied: applied.has(raw.id)
2800
+ };
2801
+ }
2802
+ async function executeProcess(command, args, cwd, interactive) {
2803
+ if (!interactive) {
2804
+ const result = await run(command, args, { cwd, capture: "unbounded" });
2805
+ return { code: result.code, stdout: result.stdout, stderr: result.stderr };
2717
2806
  }
2807
+ return new Promise((resolve, reject) => {
2808
+ const child = spawn2(command, args, { cwd, stdio: ["inherit", "inherit", "pipe"] });
2809
+ let stderr = "";
2810
+ child.stderr.on("data", (chunk) => {
2811
+ const text = chunk.toString();
2812
+ stderr += text;
2813
+ process.stderr.write(text);
2814
+ });
2815
+ child.on("error", reject);
2816
+ child.on("close", (code) => resolve({ code: code ?? 1, stdout: "", stderr }));
2817
+ });
2818
+ }
2819
+ async function atomicJson(path, value) {
2820
+ const temp = join14(dirname7(path), `.${basename5(path)}.${crypto.randomUUID()}.tmp`);
2821
+ await writeFile8(temp, `${JSON.stringify(value, null, 2)}
2822
+ `, { mode: 384, flag: "wx" });
2823
+ await rename2(temp, path);
2824
+ }
2825
+
2826
+ // src/plan/execution.ts
2827
+ function createExecutionPacket(plan, coordinator) {
2828
+ if (!plan.execution?.active)
2829
+ throw new Error(`Plan ${plan.id} has no active execution.`);
2830
+ return {
2831
+ version: 1,
2832
+ planId: plan.id,
2833
+ executionId: plan.execution.id,
2834
+ policy: plan.execution.policy,
2835
+ cwd: plan.execution.cwd,
2836
+ branch: plan.execution.branch,
2837
+ coordinator
2838
+ };
2839
+ }
2840
+ function renderExecutionPrompt(packet) {
2841
+ return [
2842
+ `Execute the durable Diffpi plan using this packet: ${JSON.stringify(packet)}.`,
2843
+ "Call plan_context first and keep plan tools as the source of truth for eligibility, ownership, status, gates, and commits.",
2844
+ "Use SubagentWorkflow for deterministic multi-task coordination: pipeline dependent work, parallelize only tasks with non-overlapping declared file scopes, and use structured schemas for worker outcomes.",
2845
+ "The extension cannot launch workflow children over RPC, so the active orchestrator must invoke SubagentWorkflow itself.",
2846
+ "Persist every transition, progress event, issue, and deviation. Do not edit PLAN.md directly. Delegated implementation workers never commit or restructure the plan.",
2847
+ "For commit-per-phase execution, commit after local gates, push immediately, record pending CI on the completed phase, and launch a bounded background Worker to call plan_watch_ci for the exact SHA while the next phase executes. Collect that monitor before the next push and collect all monitors before plan completion; failed or timed-out CI blocks execution."
2848
+ ].join(" ");
2718
2849
  }
2719
2850
 
2720
2851
  // src/plan/markdown.ts
@@ -7354,6 +7485,25 @@ function looksLikeAValue(value) {
7354
7485
  function isUint8Array2(value) {
7355
7486
  return Boolean(value && typeof value === "object" && "byteLength" in value && "byteOffset" in value);
7356
7487
  }
7488
+ // src/plan/ids.ts
7489
+ var STABLE_ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
7490
+ function isStableId(value) {
7491
+ return STABLE_ID.test(value) && value.length <= 80;
7492
+ }
7493
+ function assertStableId(value, label = "identifier") {
7494
+ if (!isStableId(value))
7495
+ throw new Error(`${label} must be a lowercase stable slug, not a path: ${value}`);
7496
+ }
7497
+ function assertUniqueIds(ids, label) {
7498
+ const seen = new Set;
7499
+ for (const id of ids) {
7500
+ assertStableId(id, label);
7501
+ if (seen.has(id))
7502
+ throw new Error(`Duplicate ${label}: ${id}.`);
7503
+ seen.add(id);
7504
+ }
7505
+ }
7506
+
7357
7507
  // src/plan/markdown.ts
7358
7508
  var PLAN_MARKER = /<!-- diffpi-plan: (\{[^\n]+\}) -->/;
7359
7509
  var PHASE_MARKER = /<!-- diffpi-phase: (\{[^\n]+\}) -->/g;
@@ -7401,6 +7551,8 @@ function validatePlanDocument(plan, options = {}) {
7401
7551
  add("design-too-long", "error", `Design is ${designWords} words; finalization allows at most 800.`);
7402
7552
  if (options.strict && plan.phases.length === 0)
7403
7553
  add("no-phases", "error", "Finalization requires at least one phase.");
7554
+ if (options.strict && (options.pendingAnnotations ?? 0) > 0)
7555
+ add("pending-annotations", "error", `${options.pendingAnnotations} annotations remain pending.`);
7404
7556
  return issues;
7405
7557
  }
7406
7558
  function renderPlanDocument(plan, previousSource) {
@@ -7742,19 +7894,6 @@ function parseCsv(value) {
7742
7894
  return [];
7743
7895
  return value.split(",").map((item) => item.trim()).filter(Boolean);
7744
7896
  }
7745
- function assertStableId(value, label) {
7746
- if (!zx.id.safeParse(value).success)
7747
- throw new Error(`${label} must be a lowercase stable slug, not a path: ${value}`);
7748
- }
7749
- function assertUniqueIds(ids, label) {
7750
- const seen = new Set;
7751
- for (const id of ids) {
7752
- assertStableId(id, label);
7753
- if (seen.has(id))
7754
- throw new Error(`Duplicate ${label}: ${id}.`);
7755
- seen.add(id);
7756
- }
7757
- }
7758
7897
  function cleanPlaceholder(value) {
7759
7898
  return value.replace(/<!--[^]*?-->/g, "").trim();
7760
7899
  }
@@ -7786,6 +7925,46 @@ function escapeRegExp(value) {
7786
7925
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
7787
7926
  }
7788
7927
 
7928
+ // src/plan/store.ts
7929
+ import { open, mkdir as mkdir7, readdir as readdir2, readFile as readFile14, realpath as realpath4, rename as rename3, rm as rm2 } from "node:fs/promises";
7930
+ import { basename as basename6, dirname as dirname8, join as join17, resolve as resolve4, sep } from "node:path";
7931
+
7932
+ // src/templates.ts
7933
+ import { readFile as readFile13 } from "node:fs/promises";
7934
+ import { homedir as homedir8 } from "node:os";
7935
+ import { join as join16, normalize } from "node:path";
7936
+ async function loadTemplate(name, options = {}) {
7937
+ const relative = templateRelativePath(name);
7938
+ const userPath = join16(options.homeDir ?? homedir8(), ".difflab", "diffpi", "templates", relative);
7939
+ const bundledPath = join16(options.bundledDir ?? resolveBundledTemplatesDir(), relative);
7940
+ const user = await readOptionalFile(userPath);
7941
+ if (user !== undefined)
7942
+ return { name, path: userPath, source: "user", content: user };
7943
+ const bundled = await readOptionalFile(bundledPath);
7944
+ if (bundled !== undefined)
7945
+ return { name, path: bundledPath, source: "bundled", content: bundled };
7946
+ throw new Error(`Template "${name}" was not found at ${userPath} or ${bundledPath}.`);
7947
+ }
7948
+ function renderTemplate(content, variables) {
7949
+ return content.replace(/\{\{\s*([a-zA-Z0-9_.-]+)\s*\}\}/g, (match, key) => variables[key] ?? match);
7950
+ }
7951
+ function templateRelativePath(name) {
7952
+ const normalized = normalize(name.replaceAll("\\", "/")).replace(/^\.\//, "");
7953
+ if (!normalized || normalized.startsWith("../") || normalized.includes("/../") || normalized.startsWith("/")) {
7954
+ throw new Error(`Invalid template name: ${name}`);
7955
+ }
7956
+ return normalized.endsWith(".md") ? normalized : `${normalized}.md`;
7957
+ }
7958
+ async function readOptionalFile(path) {
7959
+ try {
7960
+ return await readFile13(path, "utf8");
7961
+ } catch (error) {
7962
+ if (error.code === "ENOENT")
7963
+ return;
7964
+ throw error;
7965
+ }
7966
+ }
7967
+
7789
7968
  // src/plan/store.ts
7790
7969
  function planRecordName(shortSlug, date = new Date) {
7791
7970
  const slug = normalizeSlug(shortSlug);
@@ -7801,7 +7980,7 @@ async function resolvePlan(cwd, query, filters = {}, options = {}) {
7801
7980
  continue;
7802
7981
  if (normalizedQuery && entry.name !== normalizedQuery && stripDate(entry.name) !== normalizedQuery)
7803
7982
  continue;
7804
- const dir = join16(root, entry.name);
7983
+ const dir = join17(root, entry.name);
7805
7984
  await assertContained(root, dir);
7806
7985
  try {
7807
7986
  const record = await readRecord(dir);
@@ -7827,7 +8006,7 @@ function createPlanStore(options = {}) {
7827
8006
  async init(input) {
7828
8007
  const root = await plansDir(input.cwd, options.homeDir);
7829
8008
  const id = planRecordName(input.shortSlug, now());
7830
- const dir = join16(root, id);
8009
+ const dir = join17(root, id);
7831
8010
  await assertContained(root, dirname8(dir));
7832
8011
  try {
7833
8012
  await mkdir7(dir);
@@ -7847,19 +8026,25 @@ function createPlanStore(options = {}) {
7847
8026
  branch: input.branch,
7848
8027
  title: input.title?.trim() || titleFromSlug(input.shortSlug),
7849
8028
  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. -->",
7852
8029
  created_at: timestamp,
7853
8030
  updated_at: timestamp
7854
8031
  });
7855
- const document = parsePlanDocument(source, join16(dir, "PLAN.md"));
7856
- await atomicWrite(join16(dir, "PLAN.md"), source);
8032
+ const document = parsePlanDocument(source, join17(dir, "PLAN.md"));
8033
+ await atomicWrite(join17(dir, "PLAN.md"), source);
8034
+ await ensureImplementationFiles(dir, document, options);
8035
+ await atomicWrite(join17(dir, "logs.txt"), "");
8036
+ await appendPlanLog(join17(dir, "logs.txt"), {
8037
+ planRevision: document.revision,
8038
+ kind: "created",
8039
+ actor: "diffpi",
8040
+ message: `Created plan ${id}.`
8041
+ });
7857
8042
  return {
7858
8043
  id,
7859
8044
  dir,
7860
- planPath: join16(dir, "PLAN.md"),
7861
- logPath: join16(dir, "logs.txt"),
7862
- implementationDir: join16(dir, "implementation"),
8045
+ planPath: join17(dir, "PLAN.md"),
8046
+ logPath: join17(dir, "logs.txt"),
8047
+ implementationDir: join17(dir, "implementation"),
7863
8048
  document,
7864
8049
  source
7865
8050
  };
@@ -7879,7 +8064,7 @@ function createPlanStore(options = {}) {
7879
8064
  },
7880
8065
  async mutate(cwd, query, operation, update) {
7881
8066
  const initial = await this.read(cwd, query);
7882
- return withDirectoryLock(join16(initial.dir, ".lock"), async () => {
8067
+ return withPlanLock(initial.dir, async () => {
7883
8068
  const current = await readRecord(initial.dir);
7884
8069
  const next = await update(structuredClone(current.document));
7885
8070
  if (next.id !== current.id)
@@ -7893,41 +8078,41 @@ function createPlanStore(options = {}) {
7893
8078
  parsePlanDocument(source, current.planPath);
7894
8079
  await atomicWrite(current.planPath, source);
7895
8080
  await ensureImplementationFiles(current.dir, document, options);
7896
- return { ...current, document, source, implementationDir: join16(current.dir, "implementation") };
8081
+ return { ...current, document, source, implementationDir: join17(current.dir, "implementation") };
7897
8082
  }, { operation });
7898
8083
  },
7899
8084
  async log(cwd, query, event) {
7900
8085
  const record = await this.read(cwd, query);
7901
- return appendLogEntry(record.logPath, event);
8086
+ return withPlanLock(record.dir, () => appendPlanLog(record.logPath, event), { operation: "append log" });
7902
8087
  }
7903
8088
  };
7904
8089
  }
7905
8090
  async function readRecord(dir) {
7906
- const planPath = join16(dir, "PLAN.md");
7907
- const logPath = join16(dir, "logs.txt");
7908
- const source = await readFile12(planPath, "utf8");
8091
+ const planPath = join17(dir, "PLAN.md");
8092
+ const logPath = join17(dir, "logs.txt");
8093
+ const source = await readFile14(planPath, "utf8");
7909
8094
  const document = parsePlanDocument(source, planPath);
7910
8095
  return {
7911
8096
  id: basename6(dir),
7912
8097
  dir,
7913
8098
  planPath,
7914
8099
  logPath,
7915
- implementationDir: join16(dir, "implementation"),
8100
+ implementationDir: join17(dir, "implementation"),
7916
8101
  document,
7917
8102
  source
7918
8103
  };
7919
8104
  }
7920
8105
  async function ensureImplementationFiles(dir, document, options) {
7921
- const implementationDir = join16(dir, "implementation");
8106
+ const implementationDir = join17(dir, "implementation");
7922
8107
  await mkdir7(implementationDir, { recursive: true });
7923
8108
  const template = await loadTemplate("plan/implementation", {
7924
8109
  homeDir: options.homeDir,
7925
8110
  bundledDir: options.bundledTemplatesDir
7926
8111
  });
7927
8112
  for (const phase of document.phases) {
7928
- const path = join16(implementationDir, `phase-${phase.id}.md`);
8113
+ const path = join17(implementationDir, `phase-${phase.id}.md`);
7929
8114
  try {
7930
- await readFile12(path, "utf8");
8115
+ await readFile14(path, "utf8");
7931
8116
  } catch (error) {
7932
8117
  if (error.code !== "ENOENT")
7933
8118
  throw error;
@@ -7941,6 +8126,25 @@ async function ensureImplementationFiles(dir, document, options) {
7941
8126
  }
7942
8127
  }
7943
8128
  }
8129
+ async function atomicWrite(path, content) {
8130
+ const temp = join17(dirname8(path), `.${basename6(path)}.${process.pid}.${crypto.randomUUID()}.tmp`);
8131
+ const file = await open(temp, "wx", 384);
8132
+ try {
8133
+ await file.writeFile(content, "utf8");
8134
+ await file.sync();
8135
+ } finally {
8136
+ await file.close();
8137
+ }
8138
+ await rename3(temp, path);
8139
+ try {
8140
+ const directory = await open(dirname8(path), "r");
8141
+ try {
8142
+ await directory.sync();
8143
+ } finally {
8144
+ await directory.close();
8145
+ }
8146
+ } catch {}
8147
+ }
7944
8148
  async function assertContained(root, candidate) {
7945
8149
  const canonicalRoot = await realpath4(root);
7946
8150
  let canonicalCandidate;
@@ -7958,8 +8162,7 @@ function normalizeSlug(value) {
7958
8162
  throw new Error(`Invalid plan slug: ${value}.`);
7959
8163
  }
7960
8164
  const slug = value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
7961
- if (!zx.id.safeParse(slug).success)
7962
- throw new Error(`Invalid plan slug: ${value}.`);
8165
+ assertStableId(slug, "plan slug");
7963
8166
  return slug;
7964
8167
  }
7965
8168
  function normalizeQuery(value) {
@@ -7974,11 +8177,345 @@ function stripDate(value) {
7974
8177
  function titleFromSlug(value) {
7975
8178
  return normalizeSlug(value).replace(/^(?:[a-z]+-\d+-)/, "").split("-").map((part) => part[0].toUpperCase() + part.slice(1)).join(" ");
7976
8179
  }
8180
+
8181
+ // src/plan/controller.ts
8182
+ function createPlanController(options = {}) {
8183
+ const store = createPlanStore(options);
8184
+ const controller = {
8185
+ context: store.context,
8186
+ create: store.init,
8187
+ read: store.read,
8188
+ update: store.mutate,
8189
+ appendLog: store.log,
8190
+ updateStatus: (cwd, query, input) => updateStatus(store, cwd, query, input),
8191
+ updateCi: (cwd, query, input) => updateCi(store, cwd, query, input),
8192
+ retryCi: (cwd, query, input) => retryCi(store, cwd, query, input),
8193
+ assertPhasePushEligible,
8194
+ validate: validatePlanDocument,
8195
+ countDesignWords,
8196
+ annotate: annotatePlan,
8197
+ annotations: readPlanAnnotations,
8198
+ acknowledgeAnnotations: acknowledgePlanAnnotations,
8199
+ executionPacket: createExecutionPacket,
8200
+ executionPrompt: renderExecutionPrompt,
8201
+ renderEscalation: renderSubagentEscalation,
8202
+ assertStableId
8203
+ };
8204
+ return controller;
8205
+ }
8206
+ async function updateStatus(store, cwd, query, input) {
8207
+ let escalation;
8208
+ const record = await store.mutate(cwd, query, "update status", (plan) => {
8209
+ if (input.target.type === "plan") {
8210
+ if (plan.status !== input.expectedStatus)
8211
+ throw new Error(`Expected plan status ${input.expectedStatus}, found ${plan.status}.`);
8212
+ assertPlanTransition(plan.status, input.status);
8213
+ if (input.status === "ready") {
8214
+ const errors = validatePlanDocument(plan, { strict: true }).filter((issue) => issue.severity === "error");
8215
+ if (errors.length)
8216
+ throw new Error(`Plan cannot become ready: ${errors.map((issue) => issue.message).join(" ")}`);
8217
+ }
8218
+ if ((input.status === "blocked" || input.status === "completed") && plan.status === "in_progress")
8219
+ assertExecutionOwner(plan, input.executionId);
8220
+ if (input.status === "completed" && plan.phases.some((phase) => phase.status !== "completed" && phase.status !== "skipped"))
8221
+ throw new Error("Every phase must be complete or skipped before the plan completes.");
8222
+ if (input.status === "completed" && plan.execution?.policy === "commit-per-phase") {
8223
+ const unsettled = plan.phases.filter((phase) => phase.status === "completed" && (!phase.commit?.pushedAt || !["passed", "skipped"].includes(phase.commit.ci?.status ?? "pending")));
8224
+ if (unsettled.length)
8225
+ throw new Error(`CI must settle successfully for phases: ${unsettled.map((phase) => phase.id).join(", ")}.`);
8226
+ }
8227
+ return {
8228
+ ...heartbeat(plan),
8229
+ status: input.status,
8230
+ execution: input.status === "completed" || input.status === "blocked" ? plan.execution && { ...plan.execution, active: false, heartbeatAt: new Date().toISOString() } : plan.execution
8231
+ };
8232
+ }
8233
+ if (!input.target.id)
8234
+ throw new Error("Target ID is required.");
8235
+ const phase = input.target.type === "phase" ? getPhase(plan, input.target.id) : findTaskPhase(plan, input.target.id);
8236
+ if (input.target.type === "phase") {
8237
+ if (phase.status !== input.expectedStatus)
8238
+ throw new Error(`Expected phase status ${input.expectedStatus}, found ${phase.status}.`);
8239
+ if (phase.status === "completed")
8240
+ throw new Error(`Completed phase ${phase.id} cannot transition again.`);
8241
+ assertExecutionOwner(plan, input.executionId);
8242
+ assertPhaseTransition(phase.status, input.status);
8243
+ if (input.status === "in_progress" || input.status === "completed")
8244
+ assertPhaseDependenciesSatisfied(plan, phase);
8245
+ if (input.status === "completed") {
8246
+ if (phase.tasks.some((task) => task.status !== "completed" && task.status !== "skipped"))
8247
+ throw new Error("All phase tasks must be complete or skipped.");
8248
+ if (phase.gate.status !== "passed")
8249
+ throw new Error("Phase gates must pass before completion.");
8250
+ if (plan.execution?.policy === "commit-per-phase")
8251
+ assertPhasePushEligible(plan, phase.id, input.executionId);
8252
+ if (plan.execution?.policy === "commit-per-phase" && !input.commit)
8253
+ throw new Error("Commit-per-phase execution requires commit metadata before phase completion.");
8254
+ if (plan.execution?.policy === "commit-per-phase" && (!input.commit?.pushedAt || input.commit.ci?.status !== "pending"))
8255
+ throw new Error("Commit-per-phase execution requires a pushed commit with pending CI monitoring.");
8256
+ if (plan.execution?.policy === "no-commit" && input.commit)
8257
+ throw new Error("No-commit execution cannot record a phase commit.");
8258
+ }
8259
+ const updated = {
8260
+ ...phase,
8261
+ status: input.status,
8262
+ revision: phase.revision + 1,
8263
+ commit: input.commit ?? phase.commit,
8264
+ blocker: input.blockedReason ? blocker(input) : phase.blocker
8265
+ };
8266
+ if (input.status === "blocked" && input.blockedReason)
8267
+ escalation = escalationFor(plan, updated.id, undefined, input);
8268
+ const phases = plan.phases.map((item) => {
8269
+ if (item.id === updated.id)
8270
+ return updated;
8271
+ if (input.status === "completed" && plan.execution?.policy === "commit-per-phase" && input.commit && item.status !== "completed" && item.status !== "skipped" && item.gate.status === "passed")
8272
+ return { ...item, gate: { ...item.gate, status: "stale" } };
8273
+ return item;
8274
+ });
8275
+ return { ...heartbeat(plan), phases };
8276
+ }
8277
+ const task = getTask(phase, input.target.id);
8278
+ if (task.status !== input.expectedStatus)
8279
+ throw new Error(`Expected task status ${input.expectedStatus}, found ${task.status}.`);
8280
+ assertExecutionOwner(plan, input.executionId);
8281
+ assertTaskTransition(task, input.status, input.executionId, input.actor);
8282
+ if (input.status === "in_progress" || input.status === "completed") {
8283
+ if (phase.status !== "in_progress")
8284
+ throw new Error(`Phase ${phase.id} is not in progress.`);
8285
+ assertTaskDependenciesSatisfied(plan, task);
8286
+ }
8287
+ const updatedTask = {
8288
+ ...task,
8289
+ status: input.status,
8290
+ revision: task.revision + 1,
8291
+ owner: input.status === "in_progress" ? input.actor : task.owner,
8292
+ executionId: input.status === "in_progress" ? input.executionId : task.executionId,
8293
+ blocker: input.blockedReason ? blocker(input) : task.blocker
8294
+ };
8295
+ if (input.status === "blocked" && input.blockedReason)
8296
+ escalation = escalationFor(plan, phase.id, task.id, input);
8297
+ const updatedPhase = {
8298
+ ...phase,
8299
+ revision: phase.revision + 1,
8300
+ tasks: phase.tasks.map((item) => item.id === updatedTask.id ? updatedTask : item)
8301
+ };
8302
+ return {
8303
+ ...heartbeat(plan),
8304
+ phases: plan.phases.map((item) => item.id === updatedPhase.id ? updatedPhase : item)
8305
+ };
8306
+ });
8307
+ await store.log(cwd, record.id, {
8308
+ planRevision: record.document.revision,
8309
+ kind: input.status === "blocked" ? "blocker" : "status",
8310
+ actor: input.actor,
8311
+ message: input.message,
8312
+ executionId: input.executionId,
8313
+ phaseId: input.target.type === "phase" ? input.target.id : escalation?.correlation.parentUnitId ?? escalation?.correlation.unitId,
8314
+ taskId: input.target.type === "task" ? input.target.id : undefined,
8315
+ evidence: input.evidence
8316
+ });
8317
+ return { record, escalation };
8318
+ }
8319
+ async function updateCi(store, cwd, query, input) {
8320
+ const record = await store.mutate(cwd, query, "update CI status", (plan) => {
8321
+ assertExecutionOwner(plan, input.executionId);
8322
+ if (plan.execution?.policy !== "commit-per-phase")
8323
+ throw new Error("CI status applies only to commit-per-phase execution.");
8324
+ const phase = getPhase(plan, input.phaseId);
8325
+ if (phase.status !== "completed")
8326
+ throw new Error(`Phase ${phase.id} must be completed before CI can settle.`);
8327
+ if (!phase.commit || phase.commit.sha !== input.sha)
8328
+ throw new Error(`Phase ${phase.id} is not committed at ${input.sha}.`);
8329
+ const current = phase.commit.ci?.status;
8330
+ if (current !== "pending")
8331
+ throw new Error(`CI for phase ${phase.id} is already ${current ?? "untracked"}.`);
8332
+ const timestamp = new Date().toISOString();
8333
+ const updated = {
8334
+ ...phase,
8335
+ revision: phase.revision + 1,
8336
+ commit: {
8337
+ ...phase.commit,
8338
+ ci: {
8339
+ ...phase.commit.ci,
8340
+ status: input.status,
8341
+ startedAt: phase.commit.ci?.startedAt ?? timestamp,
8342
+ completedAt: timestamp,
8343
+ detail: input.detail
8344
+ }
8345
+ }
8346
+ };
8347
+ return {
8348
+ ...heartbeat(plan),
8349
+ phases: plan.phases.map((item) => item.id === updated.id ? updated : item)
8350
+ };
8351
+ });
8352
+ await store.log(cwd, record.id, {
8353
+ planRevision: record.document.revision,
8354
+ kind: "gate",
8355
+ actor: input.actor,
8356
+ message: `CI ${input.status} for ${input.phaseId}: ${input.detail}`,
8357
+ executionId: input.executionId,
8358
+ phaseId: input.phaseId,
8359
+ evidence: [input.sha]
8360
+ });
8361
+ return record;
8362
+ }
8363
+ async function retryCi(store, cwd, query, input) {
8364
+ let previousDetail;
8365
+ const record = await store.mutate(cwd, query, "retry CI", (plan) => {
8366
+ assertExecutionOwner(plan, input.executionId);
8367
+ if (plan.execution?.policy !== "commit-per-phase")
8368
+ throw new Error("CI retry applies only to commit-per-phase execution.");
8369
+ const phase = getPhase(plan, input.phaseId);
8370
+ if (phase.status !== "completed")
8371
+ throw new Error(`Phase ${phase.id} must be completed before CI can retry.`);
8372
+ if (!phase.commit || phase.commit.sha !== input.sha)
8373
+ throw new Error(`Phase ${phase.id} is not committed at ${input.sha}.`);
8374
+ if (phase.commit.ci?.status !== "failed")
8375
+ throw new Error(`CI for phase ${phase.id} cannot retry from ${phase.commit.ci?.status ?? "untracked"}.`);
8376
+ previousDetail = phase.commit.ci.detail;
8377
+ const updated = {
8378
+ ...phase,
8379
+ revision: phase.revision + 1,
8380
+ commit: {
8381
+ ...phase.commit,
8382
+ ci: { status: "pending", startedAt: new Date().toISOString() }
8383
+ }
8384
+ };
8385
+ return {
8386
+ ...heartbeat(plan),
8387
+ phases: plan.phases.map((item) => item.id === updated.id ? updated : item)
8388
+ };
8389
+ });
8390
+ await store.log(cwd, record.id, {
8391
+ planRevision: record.document.revision,
8392
+ kind: "gate",
8393
+ actor: input.actor,
8394
+ message: `Retrying CI for ${input.phaseId}: ${input.reason}`,
8395
+ executionId: input.executionId,
8396
+ phaseId: input.phaseId,
8397
+ evidence: [input.sha],
8398
+ data: { previousStatus: "failed", previousDetail }
8399
+ });
8400
+ return record;
8401
+ }
8402
+ function assertPhasePushEligible(plan, phaseId, executionId) {
8403
+ assertExecutionOwner(plan, executionId);
8404
+ if (plan.execution?.policy !== "commit-per-phase")
8405
+ return;
8406
+ const phaseIndex = plan.phases.findIndex((phase) => phase.id === phaseId);
8407
+ if (phaseIndex < 0)
8408
+ throw new Error(`Unknown phase: ${phaseId}.`);
8409
+ const unsettled = plan.phases.slice(0, phaseIndex).filter((phase) => {
8410
+ if (phase.status === "skipped")
8411
+ return false;
8412
+ return phase.status !== "completed" || !phase.commit?.pushedAt || !["passed", "skipped"].includes(phase.commit.ci?.status ?? "pending");
8413
+ });
8414
+ if (unsettled.length)
8415
+ throw new Error(`Prior phase CI must settle successfully before another push: ${unsettled.map((phase) => phase.id).join(", ")}.`);
8416
+ }
8417
+ function assertPhaseDependenciesSatisfied(plan, phase) {
8418
+ const unsettled = phase.dependencies.map((id) => getPhase(plan, id)).filter((dependency) => !dependencySatisfied(dependency.status));
8419
+ if (unsettled.length)
8420
+ throw new Error(`Phase ${phase.id} has incomplete dependencies: ${unsettled.map((dependency) => `${dependency.id} (${dependency.status})`).join(", ")}.`);
8421
+ }
8422
+ function assertTaskDependenciesSatisfied(plan, task) {
8423
+ const tasks = plan.phases.flatMap((phase) => phase.tasks);
8424
+ const unsettled = task.dependencies.map((id) => {
8425
+ const dependency = tasks.find((candidate) => candidate.id === id);
8426
+ if (!dependency)
8427
+ throw new Error(`Unknown task dependency: ${id}.`);
8428
+ return dependency;
8429
+ }).filter((dependency) => !dependencySatisfied(dependency.status));
8430
+ if (unsettled.length)
8431
+ throw new Error(`Task ${task.id} has incomplete dependencies: ${unsettled.map((dependency) => `${dependency.id} (${dependency.status})`).join(", ")}.`);
8432
+ }
8433
+ function dependencySatisfied(status) {
8434
+ return status === "completed" || status === "skipped";
8435
+ }
8436
+ var PLAN_TRANSITIONS = {
8437
+ draft: ["ready"],
8438
+ ready: ["draft", "in_progress"],
8439
+ in_progress: ["blocked", "completed"],
8440
+ blocked: ["draft", "ready", "in_progress"],
8441
+ completed: []
8442
+ };
8443
+ var WORK_TRANSITIONS = {
8444
+ pending: ["in_progress", "skipped"],
8445
+ in_progress: ["blocked", "completed"],
8446
+ blocked: ["pending", "in_progress", "skipped"],
8447
+ completed: [],
8448
+ skipped: []
8449
+ };
8450
+ function assertPlanTransition(from, to) {
8451
+ if (from !== to && !PLAN_TRANSITIONS[from].includes(to))
8452
+ throw new Error(`Invalid plan status transition: ${from} -> ${to}.`);
8453
+ }
8454
+ function assertPhaseTransition(from, to) {
8455
+ if (from !== to && !WORK_TRANSITIONS[from].includes(to))
8456
+ throw new Error(`Invalid phase status transition: ${from} -> ${to}.`);
8457
+ }
8458
+ function assertTaskTransition(task, to, executionId, actor) {
8459
+ if (task.status !== to && !WORK_TRANSITIONS[task.status].includes(to))
8460
+ throw new Error(`Invalid task status transition: ${task.status} -> ${to}.`);
8461
+ if (task.status === "in_progress" && task.executionId && executionId !== task.executionId)
8462
+ throw new Error(`Task ${task.id} is owned by execution ${task.executionId}.`);
8463
+ if (task.status === "in_progress" && task.owner && actor && actor !== task.owner)
8464
+ throw new Error(`Task ${task.id} is owned by ${task.owner}.`);
8465
+ }
8466
+ function getPhase(plan, phaseId) {
8467
+ const phase = plan.phases.find((item) => item.id === phaseId);
8468
+ if (!phase)
8469
+ throw new Error(`Unknown phase: ${phaseId}.`);
8470
+ return phase;
8471
+ }
8472
+ function findTaskPhase(plan, taskId) {
8473
+ const phase = plan.phases.find((item) => item.tasks.some((task) => task.id === taskId));
8474
+ if (!phase)
8475
+ throw new Error(`Unknown task: ${taskId}.`);
8476
+ return phase;
8477
+ }
8478
+ function getTask(phase, taskId) {
8479
+ const task = phase.tasks.find((item) => item.id === taskId);
8480
+ if (!task)
8481
+ throw new Error(`Unknown task: ${taskId}.`);
8482
+ return task;
8483
+ }
8484
+ function assertExecutionOwner(plan, executionId) {
8485
+ if (!plan.execution?.active || !executionId || plan.execution.id !== executionId)
8486
+ throw new Error(`Execution ${executionId ?? "(missing)"} does not own this plan.`);
8487
+ }
8488
+ function blocker(input) {
8489
+ return {
8490
+ reason: input.blockedReason,
8491
+ attempts: input.attempts,
8492
+ evidence: input.evidence,
8493
+ needsUserDecision: input.needsUserDecision
8494
+ };
8495
+ }
8496
+ function escalationFor(plan, phaseId, taskId, input) {
8497
+ return {
8498
+ correlation: {
8499
+ workflow: "plan",
8500
+ executionId: input.executionId,
8501
+ unitId: taskId ?? phaseId,
8502
+ parentUnitId: taskId ? phaseId : undefined,
8503
+ metadata: { planId: plan.id }
8504
+ },
8505
+ blocker: input.blockedReason,
8506
+ attempts: input.attempts ?? [],
8507
+ evidence: input.evidence ?? [],
8508
+ needsUserDecision: input.needsUserDecision ?? false
8509
+ };
8510
+ }
8511
+ function heartbeat(plan) {
8512
+ return plan.execution?.active ? { ...plan, execution: { ...plan.execution, heartbeatAt: new Date().toISOString() } } : plan;
8513
+ }
7977
8514
  // src/setup.ts
7978
8515
  import { parseFrontmatter as parseFrontmatter3 } from "@earendil-works/pi-coding-agent";
7979
- import { readdir as readdir3, readFile as readFile13 } from "node:fs/promises";
8516
+ import { readdir as readdir3, readFile as readFile15 } from "node:fs/promises";
7980
8517
  import { homedir as homedir9 } from "node:os";
7981
- import { basename as basename7, join as join17 } from "node:path";
8518
+ import { basename as basename7, join as join18 } from "node:path";
7982
8519
  var MISE_DEPENDENCIES = [
7983
8520
  { name: "node", tool: "node", spec: "node@22", minimumVersion: "22.19.0" },
7984
8521
  { name: "zellij", tool: "zellij", spec: "zellij@latest", minimumVersion: undefined },
@@ -8017,7 +8554,7 @@ var FORGE_DEPENDENCIES = {
8017
8554
  };
8018
8555
  async function ensureMise(options = {}) {
8019
8556
  const homeDir = options.homeDir ?? homedir9();
8020
- const current = await mise.executableCheck() ?? await mise.executableCheck(join17(homeDir, ".local", "bin", "mise"));
8557
+ const current = await mise.executableCheck() ?? await mise.executableCheck(join18(homeDir, ".local", "bin", "mise"));
8021
8558
  if (current)
8022
8559
  return { executable: current, action: createSetupAction("mise", "ready", current) };
8023
8560
  reportProgress(options, "Installing mise");
@@ -8066,9 +8603,9 @@ async function ensureMiseDeps(miseExecutable, options = {}) {
8066
8603
  async function ensurePiPlugins(options = {}) {
8067
8604
  const actions = await ensurePiPackages(PI_PACKAGES, options);
8068
8605
  const agentDir = options.agentDir ?? pi.agentDir(options.homeDir);
8069
- const webSearch = await pi.configEnsure(join17(agentDir, "web-search.json"), (config) => ({ ...config, workflow: "auto-summary" }), options.dryRun);
8606
+ const webSearch = await pi.configEnsure(join18(agentDir, "web-search.json"), (config) => ({ ...config, workflow: "auto-summary" }), options.dryRun);
8070
8607
  actions.push(getConfigSetupAction("web search settings", webSearch));
8071
- const lsp = await pi.configEnsure(join17(agentDir, "pi-lsp.json"), (config) => ({
8608
+ const lsp = await pi.configEnsure(join18(agentDir, "pi-lsp.json"), (config) => ({
8072
8609
  ...config,
8073
8610
  progressive: { ...getRecord(config.progressive), enabled: true, inject: "none" }
8074
8611
  }), options.dryRun);
@@ -8083,7 +8620,7 @@ async function ensurePiAgents(options = {}) {
8083
8620
  const actions = [];
8084
8621
  for (const entry of entries) {
8085
8622
  const id = basename7(entry.name, ".md").replace(/^diffpi-/, "");
8086
- const source = await readFile13(join17(bundledAgentsDir, entry.name), "utf8");
8623
+ const source = await readFile15(join18(bundledAgentsDir, entry.name), "utf8");
8087
8624
  const content = materializeAgentModels(source, id, userConfig.config, options.availableModels);
8088
8625
  const result = await pi.agentEnsure(entry.name, content, agentDir, options.dryRun);
8089
8626
  actions.push(getConfigSetupAction(`pi agent ${id}`, result));
@@ -8092,7 +8629,7 @@ async function ensurePiAgents(options = {}) {
8092
8629
  }
8093
8630
  async function ensurePiSkills(miseExecutable, options = {}) {
8094
8631
  const agentDir = options.agentDir ?? pi.agentDir(options.homeDir);
8095
- const sharedSkillsDir = join17(options.homeDir ?? homedir9(), ".agents", "skills");
8632
+ const sharedSkillsDir = join18(options.homeDir ?? homedir9(), ".agents", "skills");
8096
8633
  const actions = [];
8097
8634
  for (const source of PI_SKILL_SOURCES) {
8098
8635
  const missing = [];
@@ -8288,7 +8825,7 @@ function reportProgress(options, message) {
8288
8825
  options.onProgress?.(message);
8289
8826
  }
8290
8827
  async function packageVersion2() {
8291
- const source = await readFile13(join17(resolveBundledAgentsDir(), "..", "package.json"), "utf8");
8828
+ const source = await readFile15(join18(resolveBundledAgentsDir(), "..", "package.json"), "utf8");
8292
8829
  let value;
8293
8830
  try {
8294
8831
  value = JSON.parse(source);
@@ -8319,7 +8856,7 @@ export {
8319
8856
  createForgeBackend,
8320
8857
  createLocalReviewBackend,
8321
8858
  createModeController,
8322
- createPlanStore,
8859
+ createPlanController,
8323
8860
  createRemoteReviewBackend,
8324
8861
  createVcsBackend,
8325
8862
  dedupeFindings,
@@ -8355,7 +8892,6 @@ export {
8355
8892
  localReviewAuthor,
8356
8893
  mcp,
8357
8894
  mise,
8358
- openFileAdjacent,
8359
8895
  openInNewTab,
8360
8896
  parseGitlabDiffRefs,
8361
8897
  parseRemote,