@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/cli.js CHANGED
@@ -2331,11 +2331,7 @@ function run(command, args, options = {}) {
2331
2331
  const child = spawn(command, args, {
2332
2332
  cwd: options.cwd,
2333
2333
  env: options.env ?? process.env,
2334
- stdio: [
2335
- options.interactive ? "inherit" : options.input === undefined ? "ignore" : "pipe",
2336
- options.interactive ? "inherit" : "pipe",
2337
- "pipe"
2338
- ],
2334
+ stdio: [options.input === undefined ? "ignore" : "pipe", "pipe", "pipe"],
2339
2335
  signal: options.signal
2340
2336
  });
2341
2337
  let stdout = "";
@@ -2352,8 +2348,6 @@ function run(command, args, options = {}) {
2352
2348
  });
2353
2349
  child.stderr?.on("data", (chunk) => {
2354
2350
  const text = chunk.toString();
2355
- if (options.interactive)
2356
- process.stderr.write(text);
2357
2351
  if (unbounded)
2358
2352
  stderrChunks.push(text);
2359
2353
  else
@@ -2382,52 +2376,32 @@ function appendBounded(current, next) {
2382
2376
  return combined.length <= MAX_CAPTURED_OUTPUT_LENGTH ? combined : combined.slice(-MAX_CAPTURED_OUTPUT_LENGTH);
2383
2377
  }
2384
2378
 
2385
- // src/plan/store.ts
2386
- import { mkdir as mkdir3, readdir, readFile as readFile3, realpath as realpath3, rm as rm2 } from "node:fs/promises";
2387
- import { basename as basename3, dirname as dirname4, join as join7, resolve as resolve3, sep } from "node:path";
2388
-
2389
- // src/extensions/fsx.ts
2390
- import { mkdir, rename, rm, writeFile } from "node:fs/promises";
2391
- import { basename, dirname, join } from "node:path";
2392
- async function atomicWrite(path, content) {
2393
- const temp = join(dirname(path), `.${basename(path)}.${crypto.randomUUID()}.tmp`);
2394
- await writeFile(temp, content, { encoding: "utf8", mode: 384, flag: "wx" });
2395
- await rename(temp, path);
2396
- }
2397
- async function withDirectoryLock(lockDir, operation, options = {}) {
2398
- const waitMs = options.waitMs ?? 5000;
2399
- const pollMs = options.pollMs ?? 25;
2400
- const started = Date.now();
2401
- while (true) {
2402
- try {
2403
- await mkdir(lockDir);
2404
- break;
2405
- } catch (error) {
2406
- if (error.code !== "EEXIST")
2407
- throw error;
2408
- if (Date.now() - started >= waitMs)
2409
- throw new Error(`Timed out waiting to ${options.operation ?? "acquire lock"}: ${lockDir}`);
2410
- await sleep(pollMs);
2411
- }
2412
- }
2413
- try {
2414
- return await operation();
2415
- } finally {
2416
- await rm(lockDir, { recursive: true, force: true });
2417
- }
2418
- }
2419
- function sleep(ms) {
2420
- return new Promise((resolve) => setTimeout(resolve, ms));
2379
+ // src/extensions/subagentx.ts
2380
+ import { z } from "zod";
2381
+ var escalationSchema = z.object({
2382
+ correlation: z.object({
2383
+ workflow: z.string().min(1),
2384
+ executionId: z.string().min(1).optional(),
2385
+ unitId: z.string().min(1).optional(),
2386
+ parentUnitId: z.string().min(1).optional(),
2387
+ metadata: z.record(z.string(), z.string()).optional()
2388
+ }).strict(),
2389
+ blocker: z.string().min(1),
2390
+ attempts: z.array(z.string()),
2391
+ evidence: z.array(z.string()),
2392
+ needsUserDecision: z.boolean()
2393
+ }).strict();
2394
+ var ESCALATION_OPEN = "<diffpi-subagent-escalation>";
2395
+ var ESCALATION_CLOSE = "</diffpi-subagent-escalation>";
2396
+ function renderSubagentEscalation(value) {
2397
+ return `${ESCALATION_OPEN}${JSON.stringify(escalationSchema.parse(value))}${ESCALATION_CLOSE}`;
2421
2398
  }
2422
2399
 
2423
- // src/extensions/zodx.ts
2424
- import { z } from "zod";
2425
- var zx = {
2426
- cwd: z.string().optional(),
2427
- id: z.string().min(1).max(80).regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),
2428
- revision: z.number().int().nonnegative(),
2429
- text: z.string().trim().min(1)
2430
- };
2400
+ // src/plan/annotations.ts
2401
+ import { spawn as spawn2 } from "node:child_process";
2402
+ import { readFile as readFile3, rename, writeFile as writeFile2 } from "node:fs/promises";
2403
+ import { basename, dirname, join as join2 } from "node:path";
2404
+ import { z as z2 } from "zod";
2431
2405
 
2432
2406
  // src/log.ts
2433
2407
  import { randomUUID } from "node:crypto";
@@ -2444,167 +2418,229 @@ async function appendLogEntry(logPath, event) {
2444
2418
  return entry;
2445
2419
  }
2446
2420
 
2447
- // src/store.ts
2448
- import { createHash } from "node:crypto";
2449
- import { lstat, mkdir as mkdir2, readlink, realpath as realpath2, symlink, unlink } from "node:fs/promises";
2450
- import { homedir } from "node:os";
2451
- import { dirname as dirname2, isAbsolute as isAbsolute2, join as join3, resolve as resolve2 } from "node:path";
2452
-
2453
- // src/extensions/gitx.ts
2454
- import { realpath } from "node:fs/promises";
2455
- import { basename as basename2, isAbsolute, join as join2, resolve } from "node:path";
2456
- async function gitToplevel(cwd) {
2457
- const result = await run("git", ["-C", cwd, "rev-parse", "--show-toplevel"]);
2458
- const top = result.stdout.trim();
2459
- return result.code === 0 && top ? top : resolve(cwd);
2421
+ // src/plan/log.ts
2422
+ async function appendPlanLog(logPath, event) {
2423
+ return appendLogEntry(logPath, event);
2460
2424
  }
2461
- async function inspectGitRepository(cwd) {
2462
- const root = await gitToplevel(cwd);
2463
- const remoteResult = await run("git", ["-C", root, "remote", "get-url", "origin"]);
2464
- const remote = remoteResult.code === 0 ? remoteResult.stdout.trim() : "";
2465
- const commonResult = await run("git", ["-C", root, "rev-parse", "--git-common-dir"]);
2466
- const common = commonResult.stdout.trim();
2467
- const commonPath = commonResult.code === 0 && common ? resolve(isAbsolute(common) ? common : join2(root, common)) : root;
2468
- const commonDir = await canonicalPath(commonPath);
2469
- return {
2470
- root,
2471
- commonDir,
2472
- ...remote ? { remote } : {},
2473
- name: remote ? repositoryName(remote) : basename2(resolve(commonDir, "..")) || basename2(root),
2474
- identity: remote ? `remote:${normalizeRemote(remote)}` : `git-common-dir:${commonDir}`
2425
+
2426
+ // src/plan/lock.ts
2427
+ import { randomUUID as randomUUID2 } from "node:crypto";
2428
+ import { mkdir, readFile as readFile2, rm, writeFile } from "node:fs/promises";
2429
+ import { hostname } from "node:os";
2430
+ import { join } from "node:path";
2431
+ async function withPlanLock(planDir, operation, options = {}) {
2432
+ const lockDir = join(planDir, ".lock");
2433
+ const waitMs = options.waitMs ?? 5000;
2434
+ const pollMs = options.pollMs ?? 25;
2435
+ const owner = {
2436
+ token: randomUUID2(),
2437
+ pid: process.pid,
2438
+ hostname: hostname(),
2439
+ operation: options.operation ?? "plan mutation",
2440
+ acquiredAt: new Date().toISOString()
2475
2441
  };
2476
- }
2477
- function normalizeRemote(remote) {
2478
- return remote.trim().replace(/\.git\/?$/i, "").replace(/\/+$/, "").toLowerCase();
2479
- }
2480
- function repositoryName(remote) {
2481
- const normalized = remote.trim().replace(/\.git\/?$/i, "").replace(/\/+$/, "");
2482
- return normalized.split(/[/\\:]/).filter(Boolean).at(-1) ?? "";
2483
- }
2484
- async function canonicalPath(path) {
2442
+ const started = Date.now();
2443
+ while (true) {
2444
+ try {
2445
+ await mkdir(lockDir);
2446
+ await writeFile(join(lockDir, "owner.json"), `${JSON.stringify(owner)}
2447
+ `, { mode: 384 });
2448
+ break;
2449
+ } catch (error) {
2450
+ if (error.code !== "EEXIST")
2451
+ throw error;
2452
+ if (Date.now() - started >= waitMs) {
2453
+ let existing = "unknown owner";
2454
+ try {
2455
+ existing = (await readFile2(join(lockDir, "owner.json"), "utf8")).trim();
2456
+ } catch {}
2457
+ throw new Error(`Timed out waiting for plan lock ${lockDir}; owner: ${existing}. Remove it only after confirming the owner is stale.`);
2458
+ }
2459
+ await new Promise((resolve) => setTimeout(resolve, pollMs));
2460
+ }
2461
+ }
2485
2462
  try {
2486
- return await realpath(path);
2487
- } catch {
2488
- return resolve(path);
2463
+ return await operation();
2464
+ } finally {
2465
+ let current;
2466
+ try {
2467
+ current = JSON.parse(await readFile2(join(lockDir, "owner.json"), "utf8"));
2468
+ } catch {}
2469
+ if (current?.token === owner.token)
2470
+ await rm(lockDir, { recursive: true, force: true });
2489
2471
  }
2490
2472
  }
2491
2473
 
2492
- // src/store.ts
2493
- var STORE_LINK = ".diffpi";
2494
- var LEGACY_STORE_LINK = join3(".pi", "diffpi");
2495
- function storeGlobalRoot(homeDir = homedir()) {
2496
- return join3(homeDir, ".difflab", "diffpi", "projects");
2497
- }
2498
- async function ensureStore(cwd, homeDir = homedir()) {
2499
- const repository = await inspectGitRepository(cwd);
2500
- const root = repository.root;
2501
- const slug = projectSlug(repository);
2502
- const dest = join3(storeGlobalRoot(homeDir), slug);
2503
- const link = join3(root, STORE_LINK);
2504
- await mkdir2(dest, { recursive: true });
2505
- try {
2506
- await assertStoreLink(link, dest);
2507
- } catch (error) {
2508
- if (error.code !== "ENOENT")
2474
+ // src/plan/annotations.ts
2475
+ var commentSchema = z2.object({
2476
+ id: z2.string(),
2477
+ content: z2.string(),
2478
+ path: z2.string().optional(),
2479
+ start_line: z2.number().int().optional(),
2480
+ line: z2.number().int().optional(),
2481
+ end_line: z2.number().int().optional()
2482
+ }).loose();
2483
+ async function annotatePlan(record, runtime = {}) {
2484
+ const execute = runtime.execute ?? executeProcess;
2485
+ const result = await execute("tuicr", ["--file", record.planPath], dirname(record.planPath), true);
2486
+ const sessionSlug = [...result.stderr.matchAll(/^tuicr-session:\s*(\S+)\s*$/gm)].at(-1)?.[1];
2487
+ if (result.code !== 0)
2488
+ throw new Error(result.stderr.trim() || `tuicr exited with status ${result.code}.`);
2489
+ if (!sessionSlug)
2490
+ throw new Error("tuicr did not report a tuicr-session marker.");
2491
+ const state = await withPlanLock(record.dir, async () => {
2492
+ const previous = await readAnnotationState(record).catch((error) => {
2493
+ if (error.code === "ENOENT")
2494
+ return;
2509
2495
  throw error;
2510
- await symlink(dest, link);
2511
- }
2512
- await removeLegacyStoreLink(join3(root, LEGACY_STORE_LINK), dest);
2513
- return { slug, root, dest, link, linked: true };
2514
- }
2515
- async function plansDir(cwd, homeDir = homedir()) {
2516
- const store = await ensureStore(cwd, homeDir);
2517
- const dir = join3(store.link, "plan");
2518
- await mkdir2(dir, { recursive: true });
2519
- return dir;
2520
- }
2521
- async function assertStoreLink(path, dest) {
2522
- const entry = await lstat(path);
2523
- if (!entry.isSymbolicLink())
2524
- throw new Error(`${path} exists and is not a symlink.`);
2525
- const target = await symlinkTarget(path);
2526
- if (target !== await canonicalPath2(dest))
2527
- throw new Error(`${path} points to ${target}, not ${dest}.`);
2496
+ });
2497
+ const next = {
2498
+ schemaVersion: 1,
2499
+ sessionSlug,
2500
+ updatedAt: new Date().toISOString(),
2501
+ appliedCommentIds: previous?.appliedCommentIds ?? []
2502
+ };
2503
+ await atomicJson(annotationStatePath(record), next);
2504
+ return next;
2505
+ }, { operation: "save annotation session" });
2506
+ return { sessionSlug, code: result.code, state };
2528
2507
  }
2529
- async function removeLegacyStoreLink(path, dest) {
2530
- try {
2531
- const entry = await lstat(path);
2532
- if (!entry.isSymbolicLink())
2508
+ async function readPlanAnnotations(record, options = {}) {
2509
+ const state = await readAnnotationState(record).catch((error) => {
2510
+ if (error.code === "ENOENT")
2533
2511
  return;
2534
- const target = await symlinkTarget(path);
2535
- if (target === await canonicalPath2(dest))
2536
- await unlink(path);
2537
- } catch (error) {
2538
- if (error.code !== "ENOENT")
2539
- throw error;
2512
+ throw error;
2513
+ });
2514
+ if (!state)
2515
+ return { comments: [], pending: [] };
2516
+ const execute = options.runtime?.execute ?? executeProcess;
2517
+ const response = await execute("tuicr", ["review", "comments", "--session", state.sessionSlug], record.dir, false);
2518
+ if (response.code !== 0) {
2519
+ if (/not found|no session|deleted/i.test(response.stderr))
2520
+ return { state, comments: [], pending: [] };
2521
+ throw new Error(response.stderr.trim() || "Cannot read tuicr comments.");
2522
+ }
2523
+ let raw;
2524
+ try {
2525
+ raw = JSON.parse(response.stdout);
2526
+ } catch {
2527
+ throw new Error("tuicr returned malformed comment JSON.");
2540
2528
  }
2529
+ if (!Array.isArray(raw))
2530
+ throw new Error("tuicr comment output must be a JSON array.");
2531
+ const lines = record.source.split(`
2532
+ `);
2533
+ const applied = new Set(state.appliedCommentIds);
2534
+ const comments = raw.map((value, index) => normalizeComment(value, index, lines, applied));
2535
+ const pending = comments.filter((comment) => !comment.applied);
2536
+ return { state, comments: options.includeApplied ? comments : pending, pending };
2537
+ }
2538
+ async function acknowledgePlanAnnotations(record, commentIds, summary) {
2539
+ if (!summary.trim())
2540
+ throw new Error("Annotation acknowledgement summary is required.");
2541
+ return withPlanLock(record.dir, async () => {
2542
+ const state = await readAnnotationState(record);
2543
+ const next = {
2544
+ ...state,
2545
+ updatedAt: new Date().toISOString(),
2546
+ appliedCommentIds: [...new Set([...state.appliedCommentIds, ...commentIds])]
2547
+ };
2548
+ await atomicJson(annotationStatePath(record), next);
2549
+ await appendPlanLog(record.logPath, {
2550
+ planRevision: record.document.revision,
2551
+ kind: "annotation",
2552
+ actor: "planner",
2553
+ message: summary,
2554
+ data: { commentIds: [...commentIds], sessionSlug: state.sessionSlug }
2555
+ });
2556
+ return next;
2557
+ }, { operation: "acknowledge annotations" });
2541
2558
  }
2542
- async function symlinkTarget(path) {
2543
- const target = await readlink(path);
2544
- return canonicalPath2(isAbsolute2(target) ? target : resolve2(dirname2(path), target));
2559
+ function annotationStatePath(record) {
2560
+ return join2(record.dir, "annotations.json");
2545
2561
  }
2546
- async function canonicalPath2(path) {
2562
+ async function readAnnotationState(record) {
2563
+ const source = await readFile3(annotationStatePath(record), "utf8");
2564
+ let value;
2547
2565
  try {
2548
- return await realpath2(path);
2566
+ value = JSON.parse(source);
2549
2567
  } catch {
2550
- return resolve2(path);
2568
+ throw new Error(`Malformed annotation state: ${annotationStatePath(record)}.`);
2551
2569
  }
2570
+ if (value.schemaVersion !== 1 || typeof value.sessionSlug !== "string" || !Array.isArray(value.appliedCommentIds)) {
2571
+ throw new Error(`Malformed annotation state: ${annotationStatePath(record)}.`);
2572
+ }
2573
+ return value;
2552
2574
  }
2553
- function projectSlug(repository) {
2554
- const readable = repository.name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "unnamed";
2555
- const digest = createHash("sha256").update(repository.identity).digest("hex").slice(0, 12);
2556
- return `${readable}-${digest}`;
2575
+ function normalizeComment(value, index, lines, applied) {
2576
+ const parsed = commentSchema.safeParse(value);
2577
+ if (!parsed.success)
2578
+ throw new Error(`Malformed tuicr comment at index ${index}.`);
2579
+ const raw = parsed.data;
2580
+ const line = raw.start_line ?? raw.line;
2581
+ const endLine = raw.end_line ?? line;
2582
+ const targetPath = raw.path;
2583
+ const appliesToPlan = !targetPath || basename(targetPath) === basename("PLAN.md");
2584
+ const validAnchor = appliesToPlan && line !== undefined && line > 0 && line <= lines.length;
2585
+ return {
2586
+ id: raw.id,
2587
+ body: raw.content,
2588
+ file: targetPath,
2589
+ line,
2590
+ endLine,
2591
+ context: validAnchor ? lines.slice(line - 1, Math.min(endLine ?? line, lines.length)).join(`
2592
+ `) : undefined,
2593
+ stale: line !== undefined && !validAnchor,
2594
+ applied: applied.has(raw.id)
2595
+ };
2557
2596
  }
2558
-
2559
- // src/templates.ts
2560
- import { readFile as readFile2 } from "node:fs/promises";
2561
- import { homedir as homedir2 } from "node:os";
2562
- import { join as join5, normalize } from "node:path";
2563
-
2564
- // src/assets.ts
2565
- import { existsSync } from "node:fs";
2566
- import { dirname as dirname3, join as join4 } from "node:path";
2567
- import { fileURLToPath } from "node:url";
2568
- function resolveBundledAssetDir(name, moduleUrl = import.meta.url) {
2569
- const moduleDir = dirname3(fileURLToPath(moduleUrl));
2570
- const candidates = [join4(moduleDir, name), join4(moduleDir, "..", name), join4(moduleDir, "..", "..", name)];
2571
- return candidates.find((path) => existsSync(path)) ?? candidates[1];
2597
+ async function executeProcess(command, args, cwd, interactive) {
2598
+ if (!interactive) {
2599
+ const result = await run(command, args, { cwd, capture: "unbounded" });
2600
+ return { code: result.code, stdout: result.stdout, stderr: result.stderr };
2601
+ }
2602
+ return new Promise((resolve, reject) => {
2603
+ const child = spawn2(command, args, { cwd, stdio: ["inherit", "inherit", "pipe"] });
2604
+ let stderr = "";
2605
+ child.stderr.on("data", (chunk) => {
2606
+ const text = chunk.toString();
2607
+ stderr += text;
2608
+ process.stderr.write(text);
2609
+ });
2610
+ child.on("error", reject);
2611
+ child.on("close", (code) => resolve({ code: code ?? 1, stdout: "", stderr }));
2612
+ });
2572
2613
  }
2573
- function resolveBundledTemplatesDir(moduleUrl = import.meta.url) {
2574
- return resolveBundledAssetDir("templates", moduleUrl);
2614
+ async function atomicJson(path, value) {
2615
+ const temp = join2(dirname(path), `.${basename(path)}.${crypto.randomUUID()}.tmp`);
2616
+ await writeFile2(temp, `${JSON.stringify(value, null, 2)}
2617
+ `, { mode: 384, flag: "wx" });
2618
+ await rename(temp, path);
2575
2619
  }
2576
2620
 
2577
- // src/templates.ts
2578
- async function loadTemplate(name, options = {}) {
2579
- const relative = templateRelativePath(name);
2580
- const userPath = join5(options.homeDir ?? homedir2(), ".difflab", "diffpi", "templates", relative);
2581
- const bundledPath = join5(options.bundledDir ?? resolveBundledTemplatesDir(), relative);
2582
- const user = await readOptionalFile(userPath);
2583
- if (user !== undefined)
2584
- return { name, path: userPath, source: "user", content: user };
2585
- const bundled = await readOptionalFile(bundledPath);
2586
- if (bundled !== undefined)
2587
- return { name, path: bundledPath, source: "bundled", content: bundled };
2588
- throw new Error(`Template "${name}" was not found at ${userPath} or ${bundledPath}.`);
2589
- }
2590
- function renderTemplate(content, variables) {
2591
- return content.replace(/\{\{\s*([a-zA-Z0-9_.-]+)\s*\}\}/g, (match, key) => variables[key] ?? match);
2592
- }
2593
- function templateRelativePath(name) {
2594
- const normalized = normalize(name.replaceAll("\\", "/")).replace(/^\.\//, "");
2595
- if (!normalized || normalized.startsWith("../") || normalized.includes("/../") || normalized.startsWith("/")) {
2596
- throw new Error(`Invalid template name: ${name}`);
2597
- }
2598
- return normalized.endsWith(".md") ? normalized : `${normalized}.md`;
2621
+ // src/plan/execution.ts
2622
+ function createExecutionPacket(plan, coordinator) {
2623
+ if (!plan.execution?.active)
2624
+ throw new Error(`Plan ${plan.id} has no active execution.`);
2625
+ return {
2626
+ version: 1,
2627
+ planId: plan.id,
2628
+ executionId: plan.execution.id,
2629
+ policy: plan.execution.policy,
2630
+ cwd: plan.execution.cwd,
2631
+ branch: plan.execution.branch,
2632
+ coordinator
2633
+ };
2599
2634
  }
2600
- async function readOptionalFile(path) {
2601
- try {
2602
- return await readFile2(path, "utf8");
2603
- } catch (error) {
2604
- if (error.code === "ENOENT")
2605
- return;
2606
- throw error;
2607
- }
2635
+ function renderExecutionPrompt(packet) {
2636
+ return [
2637
+ `Execute the durable Diffpi plan using this packet: ${JSON.stringify(packet)}.`,
2638
+ "Call plan_context first and keep plan tools as the source of truth for eligibility, ownership, status, gates, and commits.",
2639
+ "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.",
2640
+ "The extension cannot launch workflow children over RPC, so the active orchestrator must invoke SubagentWorkflow itself.",
2641
+ "Persist every transition, progress event, issue, and deviation. Do not edit PLAN.md directly. Delegated implementation workers never commit or restructure the plan.",
2642
+ "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."
2643
+ ].join(" ");
2608
2644
  }
2609
2645
 
2610
2646
  // src/plan/markdown.ts
@@ -3953,7 +3989,7 @@ var handle = {
3953
3989
  };
3954
3990
 
3955
3991
  // ../../node_modules/mdast-util-to-markdown/lib/join.js
3956
- var join6 = [joinDefaults];
3992
+ var join3 = [joinDefaults];
3957
3993
  function joinDefaults(left, right, parent, state) {
3958
3994
  if (right.type === "code" && formatCodeAsIndented(right, state) && (left.type === "list" || left.type === right.type && formatCodeAsIndented(left, state))) {
3959
3995
  return false;
@@ -6499,7 +6535,7 @@ function toMarkdown(tree, options) {
6499
6535
  handle: undefined,
6500
6536
  indentLines,
6501
6537
  indexStack: [],
6502
- join: [...join6],
6538
+ join: [...join3],
6503
6539
  options: {},
6504
6540
  safe: safeBound,
6505
6541
  stack: [],
@@ -6803,7 +6839,7 @@ import { default as default2 } from "node:path";
6803
6839
  import { default as default3 } from "node:process";
6804
6840
 
6805
6841
  // ../../node_modules/vfile/lib/minurl.js
6806
- import { fileURLToPath as fileURLToPath2 } from "node:url";
6842
+ import { fileURLToPath } from "node:url";
6807
6843
 
6808
6844
  // ../../node_modules/vfile/lib/minurl.shared.js
6809
6845
  function isUrl(fileUrlOrPath) {
@@ -6889,7 +6925,7 @@ class VFile {
6889
6925
  }
6890
6926
  set path(path) {
6891
6927
  if (isUrl(path)) {
6892
- path = fileURLToPath2(path);
6928
+ path = fileURLToPath(path);
6893
6929
  }
6894
6930
  assertNonEmpty(path, "path");
6895
6931
  if (this.path !== path) {
@@ -7244,6 +7280,25 @@ function looksLikeAValue(value) {
7244
7280
  function isUint8Array2(value) {
7245
7281
  return Boolean(value && typeof value === "object" && "byteLength" in value && "byteOffset" in value);
7246
7282
  }
7283
+ // src/plan/ids.ts
7284
+ var STABLE_ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
7285
+ function isStableId(value) {
7286
+ return STABLE_ID.test(value) && value.length <= 80;
7287
+ }
7288
+ function assertStableId(value, label = "identifier") {
7289
+ if (!isStableId(value))
7290
+ throw new Error(`${label} must be a lowercase stable slug, not a path: ${value}`);
7291
+ }
7292
+ function assertUniqueIds(ids, label) {
7293
+ const seen = new Set;
7294
+ for (const id of ids) {
7295
+ assertStableId(id, label);
7296
+ if (seen.has(id))
7297
+ throw new Error(`Duplicate ${label}: ${id}.`);
7298
+ seen.add(id);
7299
+ }
7300
+ }
7301
+
7247
7302
  // src/plan/markdown.ts
7248
7303
  var PLAN_MARKER = /<!-- diffpi-plan: (\{[^\n]+\}) -->/;
7249
7304
  var PHASE_MARKER = /<!-- diffpi-phase: (\{[^\n]+\}) -->/g;
@@ -7291,6 +7346,8 @@ function validatePlanDocument(plan, options = {}) {
7291
7346
  add("design-too-long", "error", `Design is ${designWords} words; finalization allows at most 800.`);
7292
7347
  if (options.strict && plan.phases.length === 0)
7293
7348
  add("no-phases", "error", "Finalization requires at least one phase.");
7349
+ if (options.strict && (options.pendingAnnotations ?? 0) > 0)
7350
+ add("pending-annotations", "error", `${options.pendingAnnotations} annotations remain pending.`);
7294
7351
  return issues;
7295
7352
  }
7296
7353
  function renderPlanDocument(plan, previousSource) {
@@ -7632,19 +7689,6 @@ function parseCsv(value) {
7632
7689
  return [];
7633
7690
  return value.split(",").map((item) => item.trim()).filter(Boolean);
7634
7691
  }
7635
- function assertStableId(value, label) {
7636
- if (!zx.id.safeParse(value).success)
7637
- throw new Error(`${label} must be a lowercase stable slug, not a path: ${value}`);
7638
- }
7639
- function assertUniqueIds(ids, label) {
7640
- const seen = new Set;
7641
- for (const id of ids) {
7642
- assertStableId(id, label);
7643
- if (seen.has(id))
7644
- throw new Error(`Duplicate ${label}: ${id}.`);
7645
- seen.add(id);
7646
- }
7647
- }
7648
7692
  function cleanPlaceholder(value) {
7649
7693
  return value.replace(/<!--[^]*?-->/g, "").trim();
7650
7694
  }
@@ -7676,6 +7720,173 @@ function escapeRegExp(value) {
7676
7720
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
7677
7721
  }
7678
7722
 
7723
+ // src/plan/store.ts
7724
+ import { open, mkdir as mkdir3, readdir, readFile as readFile5, realpath as realpath3, rename as rename2, rm as rm2 } from "node:fs/promises";
7725
+ import { basename as basename3, dirname as dirname4, join as join8, resolve as resolve3, sep } from "node:path";
7726
+
7727
+ // src/store.ts
7728
+ import { createHash } from "node:crypto";
7729
+ import { lstat, mkdir as mkdir2, readlink, realpath as realpath2, symlink, unlink } from "node:fs/promises";
7730
+ import { homedir } from "node:os";
7731
+ import { dirname as dirname2, isAbsolute as isAbsolute2, join as join5, resolve as resolve2 } from "node:path";
7732
+
7733
+ // src/extensions/gitx.ts
7734
+ import { realpath } from "node:fs/promises";
7735
+ import { basename as basename2, isAbsolute, join as join4, resolve } from "node:path";
7736
+ async function gitToplevel(cwd) {
7737
+ const result = await run("git", ["-C", cwd, "rev-parse", "--show-toplevel"]);
7738
+ const top = result.stdout.trim();
7739
+ return result.code === 0 && top ? top : resolve(cwd);
7740
+ }
7741
+ async function inspectGitRepository(cwd) {
7742
+ const root = await gitToplevel(cwd);
7743
+ const remoteResult = await run("git", ["-C", root, "remote", "get-url", "origin"]);
7744
+ const remote = remoteResult.code === 0 ? remoteResult.stdout.trim() : "";
7745
+ const commonResult = await run("git", ["-C", root, "rev-parse", "--git-common-dir"]);
7746
+ const common = commonResult.stdout.trim();
7747
+ const commonPath = commonResult.code === 0 && common ? resolve(isAbsolute(common) ? common : join4(root, common)) : root;
7748
+ const commonDir = await canonicalPath(commonPath);
7749
+ return {
7750
+ root,
7751
+ commonDir,
7752
+ ...remote ? { remote } : {},
7753
+ name: remote ? repositoryName(remote) : basename2(resolve(commonDir, "..")) || basename2(root),
7754
+ identity: remote ? `remote:${normalizeRemote(remote)}` : `git-common-dir:${commonDir}`
7755
+ };
7756
+ }
7757
+ function normalizeRemote(remote) {
7758
+ return remote.trim().replace(/\.git\/?$/i, "").replace(/\/+$/, "").toLowerCase();
7759
+ }
7760
+ function repositoryName(remote) {
7761
+ const normalized = remote.trim().replace(/\.git\/?$/i, "").replace(/\/+$/, "");
7762
+ return normalized.split(/[/\\:]/).filter(Boolean).at(-1) ?? "";
7763
+ }
7764
+ async function canonicalPath(path) {
7765
+ try {
7766
+ return await realpath(path);
7767
+ } catch {
7768
+ return resolve(path);
7769
+ }
7770
+ }
7771
+
7772
+ // src/store.ts
7773
+ var STORE_LINK = ".diffpi";
7774
+ var LEGACY_STORE_LINK = join5(".pi", "diffpi");
7775
+ function storeGlobalRoot(homeDir = homedir()) {
7776
+ return join5(homeDir, ".difflab", "diffpi", "projects");
7777
+ }
7778
+ async function ensureStore(cwd, homeDir = homedir()) {
7779
+ const repository = await inspectGitRepository(cwd);
7780
+ const root = repository.root;
7781
+ const slug = projectSlug(repository);
7782
+ const dest = join5(storeGlobalRoot(homeDir), slug);
7783
+ const link = join5(root, STORE_LINK);
7784
+ await mkdir2(dest, { recursive: true });
7785
+ try {
7786
+ await assertStoreLink(link, dest);
7787
+ } catch (error) {
7788
+ if (error.code !== "ENOENT")
7789
+ throw error;
7790
+ await symlink(dest, link);
7791
+ }
7792
+ await removeLegacyStoreLink(join5(root, LEGACY_STORE_LINK), dest);
7793
+ return { slug, root, dest, link, linked: true };
7794
+ }
7795
+ async function plansDir(cwd, homeDir = homedir()) {
7796
+ const store = await ensureStore(cwd, homeDir);
7797
+ const dir = join5(store.link, "plan");
7798
+ await mkdir2(dir, { recursive: true });
7799
+ return dir;
7800
+ }
7801
+ async function assertStoreLink(path, dest) {
7802
+ const entry = await lstat(path);
7803
+ if (!entry.isSymbolicLink())
7804
+ throw new Error(`${path} exists and is not a symlink.`);
7805
+ const target = await symlinkTarget(path);
7806
+ if (target !== await canonicalPath2(dest))
7807
+ throw new Error(`${path} points to ${target}, not ${dest}.`);
7808
+ }
7809
+ async function removeLegacyStoreLink(path, dest) {
7810
+ try {
7811
+ const entry = await lstat(path);
7812
+ if (!entry.isSymbolicLink())
7813
+ return;
7814
+ const target = await symlinkTarget(path);
7815
+ if (target === await canonicalPath2(dest))
7816
+ await unlink(path);
7817
+ } catch (error) {
7818
+ if (error.code !== "ENOENT")
7819
+ throw error;
7820
+ }
7821
+ }
7822
+ async function symlinkTarget(path) {
7823
+ const target = await readlink(path);
7824
+ return canonicalPath2(isAbsolute2(target) ? target : resolve2(dirname2(path), target));
7825
+ }
7826
+ async function canonicalPath2(path) {
7827
+ try {
7828
+ return await realpath2(path);
7829
+ } catch {
7830
+ return resolve2(path);
7831
+ }
7832
+ }
7833
+ function projectSlug(repository) {
7834
+ const readable = repository.name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "unnamed";
7835
+ const digest = createHash("sha256").update(repository.identity).digest("hex").slice(0, 12);
7836
+ return `${readable}-${digest}`;
7837
+ }
7838
+
7839
+ // src/templates.ts
7840
+ import { readFile as readFile4 } from "node:fs/promises";
7841
+ import { homedir as homedir2 } from "node:os";
7842
+ import { join as join7, normalize } from "node:path";
7843
+
7844
+ // src/assets.ts
7845
+ import { existsSync } from "node:fs";
7846
+ import { dirname as dirname3, join as join6 } from "node:path";
7847
+ import { fileURLToPath as fileURLToPath2 } from "node:url";
7848
+ function resolveBundledAssetDir(name, moduleUrl = import.meta.url) {
7849
+ const moduleDir = dirname3(fileURLToPath2(moduleUrl));
7850
+ const candidates = [join6(moduleDir, name), join6(moduleDir, "..", name), join6(moduleDir, "..", "..", name)];
7851
+ return candidates.find((path) => existsSync(path)) ?? candidates[1];
7852
+ }
7853
+ function resolveBundledTemplatesDir(moduleUrl = import.meta.url) {
7854
+ return resolveBundledAssetDir("templates", moduleUrl);
7855
+ }
7856
+
7857
+ // src/templates.ts
7858
+ async function loadTemplate(name, options = {}) {
7859
+ const relative = templateRelativePath(name);
7860
+ const userPath = join7(options.homeDir ?? homedir2(), ".difflab", "diffpi", "templates", relative);
7861
+ const bundledPath = join7(options.bundledDir ?? resolveBundledTemplatesDir(), relative);
7862
+ const user = await readOptionalFile(userPath);
7863
+ if (user !== undefined)
7864
+ return { name, path: userPath, source: "user", content: user };
7865
+ const bundled = await readOptionalFile(bundledPath);
7866
+ if (bundled !== undefined)
7867
+ return { name, path: bundledPath, source: "bundled", content: bundled };
7868
+ throw new Error(`Template "${name}" was not found at ${userPath} or ${bundledPath}.`);
7869
+ }
7870
+ function renderTemplate(content, variables) {
7871
+ return content.replace(/\{\{\s*([a-zA-Z0-9_.-]+)\s*\}\}/g, (match, key) => variables[key] ?? match);
7872
+ }
7873
+ function templateRelativePath(name) {
7874
+ const normalized = normalize(name.replaceAll("\\", "/")).replace(/^\.\//, "");
7875
+ if (!normalized || normalized.startsWith("../") || normalized.includes("/../") || normalized.startsWith("/")) {
7876
+ throw new Error(`Invalid template name: ${name}`);
7877
+ }
7878
+ return normalized.endsWith(".md") ? normalized : `${normalized}.md`;
7879
+ }
7880
+ async function readOptionalFile(path) {
7881
+ try {
7882
+ return await readFile4(path, "utf8");
7883
+ } catch (error) {
7884
+ if (error.code === "ENOENT")
7885
+ return;
7886
+ throw error;
7887
+ }
7888
+ }
7889
+
7679
7890
  // src/plan/store.ts
7680
7891
  function planRecordName(shortSlug, date = new Date) {
7681
7892
  const slug = normalizeSlug(shortSlug);
@@ -7691,7 +7902,7 @@ async function resolvePlan(cwd, query, filters = {}, options = {}) {
7691
7902
  continue;
7692
7903
  if (normalizedQuery && entry.name !== normalizedQuery && stripDate(entry.name) !== normalizedQuery)
7693
7904
  continue;
7694
- const dir = join7(root, entry.name);
7905
+ const dir = join8(root, entry.name);
7695
7906
  await assertContained(root, dir);
7696
7907
  try {
7697
7908
  const record = await readRecord(dir);
@@ -7717,7 +7928,7 @@ function createPlanStore(options = {}) {
7717
7928
  async init(input) {
7718
7929
  const root = await plansDir(input.cwd, options.homeDir);
7719
7930
  const id = planRecordName(input.shortSlug, now());
7720
- const dir = join7(root, id);
7931
+ const dir = join8(root, id);
7721
7932
  await assertContained(root, dirname4(dir));
7722
7933
  try {
7723
7934
  await mkdir3(dir);
@@ -7737,19 +7948,25 @@ function createPlanStore(options = {}) {
7737
7948
  branch: input.branch,
7738
7949
  title: input.title?.trim() || titleFromSlug(input.shortSlug),
7739
7950
  intent: input.intent?.trim() || "<!-- Describe the intended outcome. -->",
7740
- issue_id: input.issueId?.trim() || "<!-- Add issue tracker ID. -->",
7741
- issue_url: input.issueUrl?.trim() || "<!-- Add issue tracker URL. -->",
7742
7951
  created_at: timestamp,
7743
7952
  updated_at: timestamp
7744
7953
  });
7745
- const document = parsePlanDocument(source, join7(dir, "PLAN.md"));
7746
- await atomicWrite(join7(dir, "PLAN.md"), source);
7954
+ const document = parsePlanDocument(source, join8(dir, "PLAN.md"));
7955
+ await atomicWrite(join8(dir, "PLAN.md"), source);
7956
+ await ensureImplementationFiles(dir, document, options);
7957
+ await atomicWrite(join8(dir, "logs.txt"), "");
7958
+ await appendPlanLog(join8(dir, "logs.txt"), {
7959
+ planRevision: document.revision,
7960
+ kind: "created",
7961
+ actor: "diffpi",
7962
+ message: `Created plan ${id}.`
7963
+ });
7747
7964
  return {
7748
7965
  id,
7749
7966
  dir,
7750
- planPath: join7(dir, "PLAN.md"),
7751
- logPath: join7(dir, "logs.txt"),
7752
- implementationDir: join7(dir, "implementation"),
7967
+ planPath: join8(dir, "PLAN.md"),
7968
+ logPath: join8(dir, "logs.txt"),
7969
+ implementationDir: join8(dir, "implementation"),
7753
7970
  document,
7754
7971
  source
7755
7972
  };
@@ -7769,7 +7986,7 @@ function createPlanStore(options = {}) {
7769
7986
  },
7770
7987
  async mutate(cwd, query, operation, update) {
7771
7988
  const initial = await this.read(cwd, query);
7772
- return withDirectoryLock(join7(initial.dir, ".lock"), async () => {
7989
+ return withPlanLock(initial.dir, async () => {
7773
7990
  const current = await readRecord(initial.dir);
7774
7991
  const next = await update(structuredClone(current.document));
7775
7992
  if (next.id !== current.id)
@@ -7783,41 +8000,41 @@ function createPlanStore(options = {}) {
7783
8000
  parsePlanDocument(source, current.planPath);
7784
8001
  await atomicWrite(current.planPath, source);
7785
8002
  await ensureImplementationFiles(current.dir, document, options);
7786
- return { ...current, document, source, implementationDir: join7(current.dir, "implementation") };
8003
+ return { ...current, document, source, implementationDir: join8(current.dir, "implementation") };
7787
8004
  }, { operation });
7788
8005
  },
7789
8006
  async log(cwd, query, event) {
7790
8007
  const record = await this.read(cwd, query);
7791
- return appendLogEntry(record.logPath, event);
8008
+ return withPlanLock(record.dir, () => appendPlanLog(record.logPath, event), { operation: "append log" });
7792
8009
  }
7793
8010
  };
7794
8011
  }
7795
8012
  async function readRecord(dir) {
7796
- const planPath = join7(dir, "PLAN.md");
7797
- const logPath = join7(dir, "logs.txt");
7798
- const source = await readFile3(planPath, "utf8");
8013
+ const planPath = join8(dir, "PLAN.md");
8014
+ const logPath = join8(dir, "logs.txt");
8015
+ const source = await readFile5(planPath, "utf8");
7799
8016
  const document = parsePlanDocument(source, planPath);
7800
8017
  return {
7801
8018
  id: basename3(dir),
7802
8019
  dir,
7803
8020
  planPath,
7804
8021
  logPath,
7805
- implementationDir: join7(dir, "implementation"),
8022
+ implementationDir: join8(dir, "implementation"),
7806
8023
  document,
7807
8024
  source
7808
8025
  };
7809
8026
  }
7810
8027
  async function ensureImplementationFiles(dir, document, options) {
7811
- const implementationDir = join7(dir, "implementation");
8028
+ const implementationDir = join8(dir, "implementation");
7812
8029
  await mkdir3(implementationDir, { recursive: true });
7813
8030
  const template = await loadTemplate("plan/implementation", {
7814
8031
  homeDir: options.homeDir,
7815
8032
  bundledDir: options.bundledTemplatesDir
7816
8033
  });
7817
8034
  for (const phase of document.phases) {
7818
- const path = join7(implementationDir, `phase-${phase.id}.md`);
8035
+ const path = join8(implementationDir, `phase-${phase.id}.md`);
7819
8036
  try {
7820
- await readFile3(path, "utf8");
8037
+ await readFile5(path, "utf8");
7821
8038
  } catch (error) {
7822
8039
  if (error.code !== "ENOENT")
7823
8040
  throw error;
@@ -7831,6 +8048,25 @@ async function ensureImplementationFiles(dir, document, options) {
7831
8048
  }
7832
8049
  }
7833
8050
  }
8051
+ async function atomicWrite(path, content) {
8052
+ const temp = join8(dirname4(path), `.${basename3(path)}.${process.pid}.${crypto.randomUUID()}.tmp`);
8053
+ const file = await open(temp, "wx", 384);
8054
+ try {
8055
+ await file.writeFile(content, "utf8");
8056
+ await file.sync();
8057
+ } finally {
8058
+ await file.close();
8059
+ }
8060
+ await rename2(temp, path);
8061
+ try {
8062
+ const directory = await open(dirname4(path), "r");
8063
+ try {
8064
+ await directory.sync();
8065
+ } finally {
8066
+ await directory.close();
8067
+ }
8068
+ } catch {}
8069
+ }
7834
8070
  async function assertContained(root, candidate) {
7835
8071
  const canonicalRoot = await realpath3(root);
7836
8072
  let canonicalCandidate;
@@ -7848,8 +8084,7 @@ function normalizeSlug(value) {
7848
8084
  throw new Error(`Invalid plan slug: ${value}.`);
7849
8085
  }
7850
8086
  const slug = value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
7851
- if (!zx.id.safeParse(slug).success)
7852
- throw new Error(`Invalid plan slug: ${value}.`);
8087
+ assertStableId(slug, "plan slug");
7853
8088
  return slug;
7854
8089
  }
7855
8090
  function normalizeQuery(value) {
@@ -7864,99 +8099,369 @@ function stripDate(value) {
7864
8099
  function titleFromSlug(value) {
7865
8100
  return normalizeSlug(value).replace(/^(?:[a-z]+-\d+-)/, "").split("-").map((part) => part[0].toUpperCase() + part.slice(1)).join(" ");
7866
8101
  }
7867
- // src/plan/reviews.ts
7868
- import { mkdir as mkdir4, readFile as readFile4, writeFile as writeFile2 } from "node:fs/promises";
7869
- import { dirname as dirname5, join as join8 } from "node:path";
7870
- async function createPlanReview(record, runtime = {}) {
7871
- if (await readPlanReview(record))
7872
- throw new Error(`Plan revision ${record.document.revision} already has a review.`);
7873
- const execute = runtime.execute ?? ((command, args, cwd, interactive) => run(command, args, { cwd, capture: "unbounded", interactive }));
7874
- const result = await execute("tuicr", ["--file", record.dir], record.dir, true);
7875
- const sourceId = [...result.stderr.matchAll(/^tuicr-session:\s*(\S+)\s*$/gm)].at(-1)?.[1];
7876
- if (result.code !== 0)
7877
- throw new Error(result.stderr.trim() || `tuicr exited with status ${result.code}.`);
7878
- if (!sourceId)
7879
- throw new Error("tuicr did not report a tuicr-session marker.");
7880
- const response = await execute("tuicr", ["review", "comments", "--session", sourceId], record.dir, false);
7881
- if (response.code !== 0)
7882
- throw new Error(response.stderr.trim() || "Cannot read the tuicr review.");
7883
- const review = {
7884
- path: reviewPath(record),
7885
- dump: {
7886
- schemaVersion: 1,
7887
- planId: record.id,
7888
- planRevision: record.document.revision,
7889
- source: "tuicr",
7890
- sourceId,
7891
- planSource: record.source,
7892
- content: response.stdout
7893
- }
8102
+
8103
+ // src/plan/controller.ts
8104
+ function createPlanController(options = {}) {
8105
+ const store = createPlanStore(options);
8106
+ const controller = {
8107
+ context: store.context,
8108
+ create: store.init,
8109
+ read: store.read,
8110
+ update: store.mutate,
8111
+ appendLog: store.log,
8112
+ updateStatus: (cwd, query, input) => updateStatus(store, cwd, query, input),
8113
+ updateCi: (cwd, query, input) => updateCi(store, cwd, query, input),
8114
+ retryCi: (cwd, query, input) => retryCi(store, cwd, query, input),
8115
+ assertPhasePushEligible,
8116
+ validate: validatePlanDocument,
8117
+ countDesignWords,
8118
+ annotate: annotatePlan,
8119
+ annotations: readPlanAnnotations,
8120
+ acknowledgeAnnotations: acknowledgePlanAnnotations,
8121
+ executionPacket: createExecutionPacket,
8122
+ executionPrompt: renderExecutionPrompt,
8123
+ renderEscalation: renderSubagentEscalation,
8124
+ assertStableId
7894
8125
  };
7895
- await writeReview(review);
7896
- return { code: result.code, review };
8126
+ return controller;
8127
+ }
8128
+ async function updateStatus(store, cwd, query, input) {
8129
+ let escalation;
8130
+ const record = await store.mutate(cwd, query, "update status", (plan) => {
8131
+ if (input.target.type === "plan") {
8132
+ if (plan.status !== input.expectedStatus)
8133
+ throw new Error(`Expected plan status ${input.expectedStatus}, found ${plan.status}.`);
8134
+ assertPlanTransition(plan.status, input.status);
8135
+ if (input.status === "ready") {
8136
+ const errors = validatePlanDocument(plan, { strict: true }).filter((issue) => issue.severity === "error");
8137
+ if (errors.length)
8138
+ throw new Error(`Plan cannot become ready: ${errors.map((issue) => issue.message).join(" ")}`);
8139
+ }
8140
+ if ((input.status === "blocked" || input.status === "completed") && plan.status === "in_progress")
8141
+ assertExecutionOwner(plan, input.executionId);
8142
+ if (input.status === "completed" && plan.phases.some((phase) => phase.status !== "completed" && phase.status !== "skipped"))
8143
+ throw new Error("Every phase must be complete or skipped before the plan completes.");
8144
+ if (input.status === "completed" && plan.execution?.policy === "commit-per-phase") {
8145
+ const unsettled = plan.phases.filter((phase) => phase.status === "completed" && (!phase.commit?.pushedAt || !["passed", "skipped"].includes(phase.commit.ci?.status ?? "pending")));
8146
+ if (unsettled.length)
8147
+ throw new Error(`CI must settle successfully for phases: ${unsettled.map((phase) => phase.id).join(", ")}.`);
8148
+ }
8149
+ return {
8150
+ ...heartbeat(plan),
8151
+ status: input.status,
8152
+ execution: input.status === "completed" || input.status === "blocked" ? plan.execution && { ...plan.execution, active: false, heartbeatAt: new Date().toISOString() } : plan.execution
8153
+ };
8154
+ }
8155
+ if (!input.target.id)
8156
+ throw new Error("Target ID is required.");
8157
+ const phase = input.target.type === "phase" ? getPhase(plan, input.target.id) : findTaskPhase(plan, input.target.id);
8158
+ if (input.target.type === "phase") {
8159
+ if (phase.status !== input.expectedStatus)
8160
+ throw new Error(`Expected phase status ${input.expectedStatus}, found ${phase.status}.`);
8161
+ if (phase.status === "completed")
8162
+ throw new Error(`Completed phase ${phase.id} cannot transition again.`);
8163
+ assertExecutionOwner(plan, input.executionId);
8164
+ assertPhaseTransition(phase.status, input.status);
8165
+ if (input.status === "in_progress" || input.status === "completed")
8166
+ assertPhaseDependenciesSatisfied(plan, phase);
8167
+ if (input.status === "completed") {
8168
+ if (phase.tasks.some((task) => task.status !== "completed" && task.status !== "skipped"))
8169
+ throw new Error("All phase tasks must be complete or skipped.");
8170
+ if (phase.gate.status !== "passed")
8171
+ throw new Error("Phase gates must pass before completion.");
8172
+ if (plan.execution?.policy === "commit-per-phase")
8173
+ assertPhasePushEligible(plan, phase.id, input.executionId);
8174
+ if (plan.execution?.policy === "commit-per-phase" && !input.commit)
8175
+ throw new Error("Commit-per-phase execution requires commit metadata before phase completion.");
8176
+ if (plan.execution?.policy === "commit-per-phase" && (!input.commit?.pushedAt || input.commit.ci?.status !== "pending"))
8177
+ throw new Error("Commit-per-phase execution requires a pushed commit with pending CI monitoring.");
8178
+ if (plan.execution?.policy === "no-commit" && input.commit)
8179
+ throw new Error("No-commit execution cannot record a phase commit.");
8180
+ }
8181
+ const updated = {
8182
+ ...phase,
8183
+ status: input.status,
8184
+ revision: phase.revision + 1,
8185
+ commit: input.commit ?? phase.commit,
8186
+ blocker: input.blockedReason ? blocker(input) : phase.blocker
8187
+ };
8188
+ if (input.status === "blocked" && input.blockedReason)
8189
+ escalation = escalationFor(plan, updated.id, undefined, input);
8190
+ const phases = plan.phases.map((item) => {
8191
+ if (item.id === updated.id)
8192
+ return updated;
8193
+ if (input.status === "completed" && plan.execution?.policy === "commit-per-phase" && input.commit && item.status !== "completed" && item.status !== "skipped" && item.gate.status === "passed")
8194
+ return { ...item, gate: { ...item.gate, status: "stale" } };
8195
+ return item;
8196
+ });
8197
+ return { ...heartbeat(plan), phases };
8198
+ }
8199
+ const task = getTask(phase, input.target.id);
8200
+ if (task.status !== input.expectedStatus)
8201
+ throw new Error(`Expected task status ${input.expectedStatus}, found ${task.status}.`);
8202
+ assertExecutionOwner(plan, input.executionId);
8203
+ assertTaskTransition(task, input.status, input.executionId, input.actor);
8204
+ if (input.status === "in_progress" || input.status === "completed") {
8205
+ if (phase.status !== "in_progress")
8206
+ throw new Error(`Phase ${phase.id} is not in progress.`);
8207
+ assertTaskDependenciesSatisfied(plan, task);
8208
+ }
8209
+ const updatedTask = {
8210
+ ...task,
8211
+ status: input.status,
8212
+ revision: task.revision + 1,
8213
+ owner: input.status === "in_progress" ? input.actor : task.owner,
8214
+ executionId: input.status === "in_progress" ? input.executionId : task.executionId,
8215
+ blocker: input.blockedReason ? blocker(input) : task.blocker
8216
+ };
8217
+ if (input.status === "blocked" && input.blockedReason)
8218
+ escalation = escalationFor(plan, phase.id, task.id, input);
8219
+ const updatedPhase = {
8220
+ ...phase,
8221
+ revision: phase.revision + 1,
8222
+ tasks: phase.tasks.map((item) => item.id === updatedTask.id ? updatedTask : item)
8223
+ };
8224
+ return {
8225
+ ...heartbeat(plan),
8226
+ phases: plan.phases.map((item) => item.id === updatedPhase.id ? updatedPhase : item)
8227
+ };
8228
+ });
8229
+ await store.log(cwd, record.id, {
8230
+ planRevision: record.document.revision,
8231
+ kind: input.status === "blocked" ? "blocker" : "status",
8232
+ actor: input.actor,
8233
+ message: input.message,
8234
+ executionId: input.executionId,
8235
+ phaseId: input.target.type === "phase" ? input.target.id : escalation?.correlation.parentUnitId ?? escalation?.correlation.unitId,
8236
+ taskId: input.target.type === "task" ? input.target.id : undefined,
8237
+ evidence: input.evidence
8238
+ });
8239
+ return { record, escalation };
8240
+ }
8241
+ async function updateCi(store, cwd, query, input) {
8242
+ const record = await store.mutate(cwd, query, "update CI status", (plan) => {
8243
+ assertExecutionOwner(plan, input.executionId);
8244
+ if (plan.execution?.policy !== "commit-per-phase")
8245
+ throw new Error("CI status applies only to commit-per-phase execution.");
8246
+ const phase = getPhase(plan, input.phaseId);
8247
+ if (phase.status !== "completed")
8248
+ throw new Error(`Phase ${phase.id} must be completed before CI can settle.`);
8249
+ if (!phase.commit || phase.commit.sha !== input.sha)
8250
+ throw new Error(`Phase ${phase.id} is not committed at ${input.sha}.`);
8251
+ const current = phase.commit.ci?.status;
8252
+ if (current !== "pending")
8253
+ throw new Error(`CI for phase ${phase.id} is already ${current ?? "untracked"}.`);
8254
+ const timestamp = new Date().toISOString();
8255
+ const updated = {
8256
+ ...phase,
8257
+ revision: phase.revision + 1,
8258
+ commit: {
8259
+ ...phase.commit,
8260
+ ci: {
8261
+ ...phase.commit.ci,
8262
+ status: input.status,
8263
+ startedAt: phase.commit.ci?.startedAt ?? timestamp,
8264
+ completedAt: timestamp,
8265
+ detail: input.detail
8266
+ }
8267
+ }
8268
+ };
8269
+ return {
8270
+ ...heartbeat(plan),
8271
+ phases: plan.phases.map((item) => item.id === updated.id ? updated : item)
8272
+ };
8273
+ });
8274
+ await store.log(cwd, record.id, {
8275
+ planRevision: record.document.revision,
8276
+ kind: "gate",
8277
+ actor: input.actor,
8278
+ message: `CI ${input.status} for ${input.phaseId}: ${input.detail}`,
8279
+ executionId: input.executionId,
8280
+ phaseId: input.phaseId,
8281
+ evidence: [input.sha]
8282
+ });
8283
+ return record;
8284
+ }
8285
+ async function retryCi(store, cwd, query, input) {
8286
+ let previousDetail;
8287
+ const record = await store.mutate(cwd, query, "retry CI", (plan) => {
8288
+ assertExecutionOwner(plan, input.executionId);
8289
+ if (plan.execution?.policy !== "commit-per-phase")
8290
+ throw new Error("CI retry applies only to commit-per-phase execution.");
8291
+ const phase = getPhase(plan, input.phaseId);
8292
+ if (phase.status !== "completed")
8293
+ throw new Error(`Phase ${phase.id} must be completed before CI can retry.`);
8294
+ if (!phase.commit || phase.commit.sha !== input.sha)
8295
+ throw new Error(`Phase ${phase.id} is not committed at ${input.sha}.`);
8296
+ if (phase.commit.ci?.status !== "failed")
8297
+ throw new Error(`CI for phase ${phase.id} cannot retry from ${phase.commit.ci?.status ?? "untracked"}.`);
8298
+ previousDetail = phase.commit.ci.detail;
8299
+ const updated = {
8300
+ ...phase,
8301
+ revision: phase.revision + 1,
8302
+ commit: {
8303
+ ...phase.commit,
8304
+ ci: { status: "pending", startedAt: new Date().toISOString() }
8305
+ }
8306
+ };
8307
+ return {
8308
+ ...heartbeat(plan),
8309
+ phases: plan.phases.map((item) => item.id === updated.id ? updated : item)
8310
+ };
8311
+ });
8312
+ await store.log(cwd, record.id, {
8313
+ planRevision: record.document.revision,
8314
+ kind: "gate",
8315
+ actor: input.actor,
8316
+ message: `Retrying CI for ${input.phaseId}: ${input.reason}`,
8317
+ executionId: input.executionId,
8318
+ phaseId: input.phaseId,
8319
+ evidence: [input.sha],
8320
+ data: { previousStatus: "failed", previousDetail }
8321
+ });
8322
+ return record;
7897
8323
  }
7898
- async function readPlanReview(record) {
7899
- const path = reviewPath(record);
7900
- let source;
7901
- try {
7902
- source = await readFile4(path, "utf8");
7903
- } catch (error) {
7904
- if (error.code === "ENOENT")
7905
- return;
7906
- throw error;
7907
- }
7908
- let dump;
7909
- try {
7910
- dump = JSON.parse(source);
7911
- } catch {
7912
- throw new Error(`Malformed plan review: ${path}.`);
7913
- }
7914
- if (!isPlanReviewDump(dump))
7915
- throw new Error(`Malformed plan review: ${path}.`);
7916
- return { path, dump };
7917
- }
7918
- function reviewPath(record) {
7919
- return join8(record.dir, "reviews", `${record.document.revision}.json`);
7920
- }
7921
- async function writeReview(review) {
7922
- await mkdir4(dirname5(review.path), { recursive: true });
7923
- await writeFile2(review.path, `${JSON.stringify(review.dump, null, 2)}
7924
- `, {
7925
- encoding: "utf8",
7926
- mode: 384,
7927
- flag: "wx"
8324
+ function assertPhasePushEligible(plan, phaseId, executionId) {
8325
+ assertExecutionOwner(plan, executionId);
8326
+ if (plan.execution?.policy !== "commit-per-phase")
8327
+ return;
8328
+ const phaseIndex = plan.phases.findIndex((phase) => phase.id === phaseId);
8329
+ if (phaseIndex < 0)
8330
+ throw new Error(`Unknown phase: ${phaseId}.`);
8331
+ const unsettled = plan.phases.slice(0, phaseIndex).filter((phase) => {
8332
+ if (phase.status === "skipped")
8333
+ return false;
8334
+ return phase.status !== "completed" || !phase.commit?.pushedAt || !["passed", "skipped"].includes(phase.commit.ci?.status ?? "pending");
7928
8335
  });
8336
+ if (unsettled.length)
8337
+ throw new Error(`Prior phase CI must settle successfully before another push: ${unsettled.map((phase) => phase.id).join(", ")}.`);
8338
+ }
8339
+ function assertPhaseDependenciesSatisfied(plan, phase) {
8340
+ const unsettled = phase.dependencies.map((id) => getPhase(plan, id)).filter((dependency) => !dependencySatisfied(dependency.status));
8341
+ if (unsettled.length)
8342
+ throw new Error(`Phase ${phase.id} has incomplete dependencies: ${unsettled.map((dependency) => `${dependency.id} (${dependency.status})`).join(", ")}.`);
8343
+ }
8344
+ function assertTaskDependenciesSatisfied(plan, task) {
8345
+ const tasks = plan.phases.flatMap((phase) => phase.tasks);
8346
+ const unsettled = task.dependencies.map((id) => {
8347
+ const dependency = tasks.find((candidate) => candidate.id === id);
8348
+ if (!dependency)
8349
+ throw new Error(`Unknown task dependency: ${id}.`);
8350
+ return dependency;
8351
+ }).filter((dependency) => !dependencySatisfied(dependency.status));
8352
+ if (unsettled.length)
8353
+ throw new Error(`Task ${task.id} has incomplete dependencies: ${unsettled.map((dependency) => `${dependency.id} (${dependency.status})`).join(", ")}.`);
8354
+ }
8355
+ function dependencySatisfied(status) {
8356
+ return status === "completed" || status === "skipped";
8357
+ }
8358
+ var PLAN_TRANSITIONS = {
8359
+ draft: ["ready"],
8360
+ ready: ["draft", "in_progress"],
8361
+ in_progress: ["blocked", "completed"],
8362
+ blocked: ["draft", "ready", "in_progress"],
8363
+ completed: []
8364
+ };
8365
+ var WORK_TRANSITIONS = {
8366
+ pending: ["in_progress", "skipped"],
8367
+ in_progress: ["blocked", "completed"],
8368
+ blocked: ["pending", "in_progress", "skipped"],
8369
+ completed: [],
8370
+ skipped: []
8371
+ };
8372
+ function assertPlanTransition(from, to) {
8373
+ if (from !== to && !PLAN_TRANSITIONS[from].includes(to))
8374
+ throw new Error(`Invalid plan status transition: ${from} -> ${to}.`);
8375
+ }
8376
+ function assertPhaseTransition(from, to) {
8377
+ if (from !== to && !WORK_TRANSITIONS[from].includes(to))
8378
+ throw new Error(`Invalid phase status transition: ${from} -> ${to}.`);
8379
+ }
8380
+ function assertTaskTransition(task, to, executionId, actor) {
8381
+ if (task.status !== to && !WORK_TRANSITIONS[task.status].includes(to))
8382
+ throw new Error(`Invalid task status transition: ${task.status} -> ${to}.`);
8383
+ if (task.status === "in_progress" && task.executionId && executionId !== task.executionId)
8384
+ throw new Error(`Task ${task.id} is owned by execution ${task.executionId}.`);
8385
+ if (task.status === "in_progress" && task.owner && actor && actor !== task.owner)
8386
+ throw new Error(`Task ${task.id} is owned by ${task.owner}.`);
8387
+ }
8388
+ function getPhase(plan, phaseId) {
8389
+ const phase = plan.phases.find((item) => item.id === phaseId);
8390
+ if (!phase)
8391
+ throw new Error(`Unknown phase: ${phaseId}.`);
8392
+ return phase;
8393
+ }
8394
+ function findTaskPhase(plan, taskId) {
8395
+ const phase = plan.phases.find((item) => item.tasks.some((task) => task.id === taskId));
8396
+ if (!phase)
8397
+ throw new Error(`Unknown task: ${taskId}.`);
8398
+ return phase;
8399
+ }
8400
+ function getTask(phase, taskId) {
8401
+ const task = phase.tasks.find((item) => item.id === taskId);
8402
+ if (!task)
8403
+ throw new Error(`Unknown task: ${taskId}.`);
8404
+ return task;
8405
+ }
8406
+ function assertExecutionOwner(plan, executionId) {
8407
+ if (!plan.execution?.active || !executionId || plan.execution.id !== executionId)
8408
+ throw new Error(`Execution ${executionId ?? "(missing)"} does not own this plan.`);
8409
+ }
8410
+ function blocker(input) {
8411
+ return {
8412
+ reason: input.blockedReason,
8413
+ attempts: input.attempts,
8414
+ evidence: input.evidence,
8415
+ needsUserDecision: input.needsUserDecision
8416
+ };
7929
8417
  }
7930
- function isPlanReviewDump(value) {
7931
- if (!value || typeof value !== "object")
7932
- return false;
7933
- const review = value;
7934
- return review.schemaVersion === 1 && typeof review.planId === "string" && Number.isInteger(review.planRevision) && review.source === "tuicr" && typeof review.sourceId === "string" && typeof review.planSource === "string" && typeof review.content === "string";
8418
+ function escalationFor(plan, phaseId, taskId, input) {
8419
+ return {
8420
+ correlation: {
8421
+ workflow: "plan",
8422
+ executionId: input.executionId,
8423
+ unitId: taskId ?? phaseId,
8424
+ parentUnitId: taskId ? phaseId : undefined,
8425
+ metadata: { planId: plan.id }
8426
+ },
8427
+ blocker: input.blockedReason,
8428
+ attempts: input.attempts ?? [],
8429
+ evidence: input.evidence ?? [],
8430
+ needsUserDecision: input.needsUserDecision ?? false
8431
+ };
8432
+ }
8433
+ function heartbeat(plan) {
8434
+ return plan.execution?.active ? { ...plan, execution: { ...plan.execution, heartbeatAt: new Date().toISOString() } } : plan;
7935
8435
  }
7936
-
7937
8436
  // src/cli/plan.ts
7938
- var plans = createPlanStore();
7939
- var runtime = { context: plans.context, createPlanReview };
7940
- function createPlanCliCommand(io = process, planRuntime = runtime) {
7941
- const program = new Command2().name("plan").description("Plan review commands").showHelpAfterError().exitOverride().configureOutput({
8437
+ var plans = createPlanController();
8438
+ function createPlanCliCommand(io = process, controller = plans) {
8439
+ const program = new Command2().name("plan").description("Plan annotation commands").showHelpAfterError().exitOverride().configureOutput({
7942
8440
  writeOut: (message) => io.stdout.write(message),
7943
8441
  writeErr: (message) => io.stderr.write(message)
7944
8442
  });
7945
- addReviewCommand(program, io, planRuntime);
8443
+ addAnnotationCommand(program, "annotate", io, controller);
8444
+ addAnnotationCommand(program, "annotations", io, controller);
7946
8445
  return program;
7947
8446
  }
7948
- function addReviewCommand(program, io, planRuntime) {
7949
- program.command("annotate [plan]").description("Review a plan in tuicr and save the immutable plan review").option("--cwd <path>", "Repository working directory", process.cwd()).action(async (query, options) => {
8447
+ function addAnnotationCommand(program, verb, io, controller) {
8448
+ program.command(`${verb} [plan]`).description(verb === "annotate" ? "Open a plan in tuicr for annotation" : "Print plan annotations as JSON").option("--cwd <path>", "Repository working directory", process.cwd()).action(async (query, options) => {
7950
8449
  const cwd = resolve4(options.cwd);
7951
- const record = await resolveCliPlan(cwd, query, planRuntime);
7952
- const result = await planRuntime.createPlanReview(record);
7953
- io.stdout.write(`Saved review for ${record.id} to ${result.review.path}.
8450
+ const record = await resolveCliPlan(cwd, query, controller);
8451
+ if (verb === "annotate") {
8452
+ const result = await controller.annotate(record);
8453
+ io.stdout.write(`Annotated ${record.id} in tuicr session ${result.sessionSlug}.
8454
+ `);
8455
+ return;
8456
+ }
8457
+ const annotations = await controller.annotations(record, { includeApplied: true });
8458
+ io.stdout.write(`${JSON.stringify(annotations, null, 2)}
7954
8459
  `);
7955
8460
  });
7956
8461
  }
7957
- async function resolveCliPlan(cwd, query, planRuntime) {
8462
+ async function resolveCliPlan(cwd, query, controller) {
7958
8463
  const branch = query ? undefined : (await runChecked("git", ["-C", cwd, "branch", "--show-current"])).stdout.trim();
7959
- const resolution = await planRuntime.context(cwd, query, query ? {} : { branch, statuses: ["draft", "ready", "in_progress", "blocked"] });
8464
+ const resolution = await controller.context(cwd, query, query ? {} : { branch, statuses: ["draft", "ready", "in_progress", "blocked"] });
7960
8465
  if (resolution.record)
7961
8466
  return resolution.record;
7962
8467
  if (resolution.ambiguous)