gsd-pi 2.11.0 → 2.13.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 (165) hide show
  1. package/dist/cli.js +18 -1
  2. package/dist/onboarding.js +3 -0
  3. package/dist/resource-loader.d.ts +2 -0
  4. package/dist/resource-loader.js +36 -1
  5. package/dist/resources/extensions/bg-shell/index.ts +51 -7
  6. package/dist/resources/extensions/gsd/auto-worktree.ts +509 -0
  7. package/dist/resources/extensions/gsd/auto.ts +381 -13
  8. package/dist/resources/extensions/gsd/commands.ts +9 -3
  9. package/dist/resources/extensions/gsd/doctor.ts +254 -3
  10. package/dist/resources/extensions/gsd/git-self-heal.ts +198 -0
  11. package/dist/resources/extensions/gsd/git-service.ts +11 -0
  12. package/dist/resources/extensions/gsd/guided-flow.ts +81 -9
  13. package/dist/resources/extensions/gsd/post-unit-hooks.ts +449 -0
  14. package/dist/resources/extensions/gsd/preferences.ts +209 -1
  15. package/dist/resources/extensions/gsd/prompt-loader.ts +28 -1
  16. package/dist/resources/extensions/gsd/prompts/complete-milestone.md +1 -1
  17. package/dist/resources/extensions/gsd/prompts/complete-slice.md +1 -3
  18. package/dist/resources/extensions/gsd/prompts/discuss.md +10 -8
  19. package/dist/resources/extensions/gsd/prompts/execute-task.md +4 -2
  20. package/dist/resources/extensions/gsd/prompts/guided-complete-slice.md +3 -1
  21. package/dist/resources/extensions/gsd/prompts/guided-discuss-milestone.md +3 -1
  22. package/dist/resources/extensions/gsd/prompts/guided-discuss-slice.md +3 -1
  23. package/dist/resources/extensions/gsd/prompts/guided-execute-task.md +3 -1
  24. package/dist/resources/extensions/gsd/prompts/guided-plan-milestone.md +4 -2
  25. package/dist/resources/extensions/gsd/prompts/guided-plan-slice.md +3 -1
  26. package/dist/resources/extensions/gsd/prompts/guided-research-slice.md +3 -1
  27. package/dist/resources/extensions/gsd/prompts/plan-milestone.md +9 -12
  28. package/dist/resources/extensions/gsd/prompts/plan-slice.md +1 -3
  29. package/dist/resources/extensions/gsd/prompts/queue.md +3 -1
  30. package/dist/resources/extensions/gsd/prompts/research-milestone.md +1 -1
  31. package/dist/resources/extensions/gsd/prompts/research-slice.md +1 -1
  32. package/dist/resources/extensions/gsd/prompts/system.md +32 -29
  33. package/dist/resources/extensions/gsd/templates/context.md +1 -1
  34. package/dist/resources/extensions/gsd/templates/state.md +3 -3
  35. package/dist/resources/extensions/gsd/tests/auto-worktree-merge.test.ts +282 -0
  36. package/dist/resources/extensions/gsd/tests/auto-worktree-milestone-merge.test.ts +259 -0
  37. package/dist/resources/extensions/gsd/tests/auto-worktree.test.ts +147 -0
  38. package/dist/resources/extensions/gsd/tests/doctor-git.test.ts +246 -0
  39. package/dist/resources/extensions/gsd/tests/doctor.test.ts +115 -1
  40. package/dist/resources/extensions/gsd/tests/git-self-heal.test.ts +234 -0
  41. package/dist/resources/extensions/gsd/tests/isolation-resolver.test.ts +107 -0
  42. package/dist/resources/extensions/gsd/tests/post-unit-hooks.test.ts +297 -0
  43. package/dist/resources/extensions/gsd/tests/preferences-git.test.ts +88 -0
  44. package/dist/resources/extensions/gsd/tests/preferences-hooks.test.ts +226 -0
  45. package/dist/resources/extensions/gsd/tests/regex-hardening.test.ts +12 -0
  46. package/dist/resources/extensions/gsd/tests/worktree-e2e.test.ts +315 -0
  47. package/dist/resources/extensions/gsd/types.ts +109 -0
  48. package/dist/resources/extensions/gsd/worktree-manager.ts +6 -4
  49. package/dist/resources/extensions/search-the-web/command-search-provider.ts +8 -4
  50. package/dist/resources/extensions/search-the-web/native-search.ts +15 -10
  51. package/dist/resources/extensions/search-the-web/provider.ts +19 -2
  52. package/dist/resources/extensions/search-the-web/tool-fetch-page.ts +62 -0
  53. package/dist/resources/extensions/search-the-web/tool-llm-context.ts +62 -3
  54. package/dist/resources/extensions/search-the-web/tool-search.ts +62 -3
  55. package/dist/wizard.js +1 -0
  56. package/package.json +1 -1
  57. package/packages/pi-agent-core/dist/agent-loop.d.ts.map +1 -1
  58. package/packages/pi-agent-core/dist/agent-loop.js +169 -55
  59. package/packages/pi-agent-core/dist/agent-loop.js.map +1 -1
  60. package/packages/pi-agent-core/dist/agent.d.ts +13 -1
  61. package/packages/pi-agent-core/dist/agent.d.ts.map +1 -1
  62. package/packages/pi-agent-core/dist/agent.js +16 -0
  63. package/packages/pi-agent-core/dist/agent.js.map +1 -1
  64. package/packages/pi-agent-core/dist/types.d.ts +91 -1
  65. package/packages/pi-agent-core/dist/types.d.ts.map +1 -1
  66. package/packages/pi-agent-core/dist/types.js.map +1 -1
  67. package/packages/pi-agent-core/src/agent-loop.ts +273 -63
  68. package/packages/pi-agent-core/src/agent.ts +24 -0
  69. package/packages/pi-agent-core/src/types.ts +98 -0
  70. package/packages/pi-ai/dist/env-api-keys.js +1 -0
  71. package/packages/pi-ai/dist/env-api-keys.js.map +1 -1
  72. package/packages/pi-ai/dist/models.generated.d.ts +314 -0
  73. package/packages/pi-ai/dist/models.generated.d.ts.map +1 -1
  74. package/packages/pi-ai/dist/models.generated.js +236 -0
  75. package/packages/pi-ai/dist/models.generated.js.map +1 -1
  76. package/packages/pi-ai/dist/types.d.ts +1 -1
  77. package/packages/pi-ai/dist/types.d.ts.map +1 -1
  78. package/packages/pi-ai/dist/types.js.map +1 -1
  79. package/packages/pi-ai/src/env-api-keys.ts +1 -0
  80. package/packages/pi-ai/src/models.generated.ts +236 -0
  81. package/packages/pi-ai/src/types.ts +2 -1
  82. package/packages/pi-coding-agent/dist/cli/args.d.ts.map +1 -1
  83. package/packages/pi-coding-agent/dist/cli/args.js +2 -1
  84. package/packages/pi-coding-agent/dist/cli/args.js.map +1 -1
  85. package/packages/pi-coding-agent/dist/core/agent-session.d.ts +10 -0
  86. package/packages/pi-coding-agent/dist/core/agent-session.d.ts.map +1 -1
  87. package/packages/pi-coding-agent/dist/core/agent-session.js +69 -8
  88. package/packages/pi-coding-agent/dist/core/agent-session.js.map +1 -1
  89. package/packages/pi-coding-agent/dist/core/extensions/runner.d.ts +4 -1
  90. package/packages/pi-coding-agent/dist/core/extensions/runner.d.ts.map +1 -1
  91. package/packages/pi-coding-agent/dist/core/extensions/runner.js +2 -1
  92. package/packages/pi-coding-agent/dist/core/extensions/runner.js.map +1 -1
  93. package/packages/pi-coding-agent/dist/core/extensions/types.d.ts +5 -0
  94. package/packages/pi-coding-agent/dist/core/extensions/types.d.ts.map +1 -1
  95. package/packages/pi-coding-agent/dist/core/extensions/types.js.map +1 -1
  96. package/packages/pi-coding-agent/dist/core/model-resolver.d.ts.map +1 -1
  97. package/packages/pi-coding-agent/dist/core/model-resolver.js +1 -0
  98. package/packages/pi-coding-agent/dist/core/model-resolver.js.map +1 -1
  99. package/packages/pi-coding-agent/dist/core/sdk.js +3 -3
  100. package/packages/pi-coding-agent/dist/core/sdk.js.map +1 -1
  101. package/packages/pi-coding-agent/dist/core/system-prompt.d.ts.map +1 -1
  102. package/packages/pi-coding-agent/dist/core/system-prompt.js +6 -0
  103. package/packages/pi-coding-agent/dist/core/system-prompt.js.map +1 -1
  104. package/packages/pi-coding-agent/src/cli/args.ts +2 -1
  105. package/packages/pi-coding-agent/src/core/agent-session.ts +76 -7
  106. package/packages/pi-coding-agent/src/core/extensions/runner.ts +2 -1
  107. package/packages/pi-coding-agent/src/core/extensions/types.ts +2 -0
  108. package/packages/pi-coding-agent/src/core/model-resolver.ts +1 -0
  109. package/packages/pi-coding-agent/src/core/sdk.ts +3 -3
  110. package/packages/pi-coding-agent/src/core/system-prompt.ts +9 -0
  111. package/packages/pi-tui/dist/components/editor.d.ts +11 -0
  112. package/packages/pi-tui/dist/components/editor.d.ts.map +1 -1
  113. package/packages/pi-tui/dist/components/editor.js +64 -6
  114. package/packages/pi-tui/dist/components/editor.js.map +1 -1
  115. package/packages/pi-tui/src/components/editor.ts +71 -6
  116. package/src/resources/extensions/bg-shell/index.ts +51 -7
  117. package/src/resources/extensions/gsd/auto-worktree.ts +509 -0
  118. package/src/resources/extensions/gsd/auto.ts +381 -13
  119. package/src/resources/extensions/gsd/commands.ts +9 -3
  120. package/src/resources/extensions/gsd/doctor.ts +254 -3
  121. package/src/resources/extensions/gsd/git-self-heal.ts +198 -0
  122. package/src/resources/extensions/gsd/git-service.ts +11 -0
  123. package/src/resources/extensions/gsd/guided-flow.ts +81 -9
  124. package/src/resources/extensions/gsd/post-unit-hooks.ts +449 -0
  125. package/src/resources/extensions/gsd/preferences.ts +209 -1
  126. package/src/resources/extensions/gsd/prompt-loader.ts +28 -1
  127. package/src/resources/extensions/gsd/prompts/complete-milestone.md +1 -1
  128. package/src/resources/extensions/gsd/prompts/complete-slice.md +1 -3
  129. package/src/resources/extensions/gsd/prompts/discuss.md +10 -8
  130. package/src/resources/extensions/gsd/prompts/execute-task.md +4 -2
  131. package/src/resources/extensions/gsd/prompts/guided-complete-slice.md +3 -1
  132. package/src/resources/extensions/gsd/prompts/guided-discuss-milestone.md +3 -1
  133. package/src/resources/extensions/gsd/prompts/guided-discuss-slice.md +3 -1
  134. package/src/resources/extensions/gsd/prompts/guided-execute-task.md +3 -1
  135. package/src/resources/extensions/gsd/prompts/guided-plan-milestone.md +4 -2
  136. package/src/resources/extensions/gsd/prompts/guided-plan-slice.md +3 -1
  137. package/src/resources/extensions/gsd/prompts/guided-research-slice.md +3 -1
  138. package/src/resources/extensions/gsd/prompts/plan-milestone.md +9 -12
  139. package/src/resources/extensions/gsd/prompts/plan-slice.md +1 -3
  140. package/src/resources/extensions/gsd/prompts/queue.md +3 -1
  141. package/src/resources/extensions/gsd/prompts/research-milestone.md +1 -1
  142. package/src/resources/extensions/gsd/prompts/research-slice.md +1 -1
  143. package/src/resources/extensions/gsd/prompts/system.md +32 -29
  144. package/src/resources/extensions/gsd/templates/context.md +1 -1
  145. package/src/resources/extensions/gsd/templates/state.md +3 -3
  146. package/src/resources/extensions/gsd/tests/auto-worktree-merge.test.ts +282 -0
  147. package/src/resources/extensions/gsd/tests/auto-worktree-milestone-merge.test.ts +259 -0
  148. package/src/resources/extensions/gsd/tests/auto-worktree.test.ts +147 -0
  149. package/src/resources/extensions/gsd/tests/doctor-git.test.ts +246 -0
  150. package/src/resources/extensions/gsd/tests/doctor.test.ts +115 -1
  151. package/src/resources/extensions/gsd/tests/git-self-heal.test.ts +234 -0
  152. package/src/resources/extensions/gsd/tests/isolation-resolver.test.ts +107 -0
  153. package/src/resources/extensions/gsd/tests/post-unit-hooks.test.ts +297 -0
  154. package/src/resources/extensions/gsd/tests/preferences-git.test.ts +88 -0
  155. package/src/resources/extensions/gsd/tests/preferences-hooks.test.ts +226 -0
  156. package/src/resources/extensions/gsd/tests/regex-hardening.test.ts +12 -0
  157. package/src/resources/extensions/gsd/tests/worktree-e2e.test.ts +315 -0
  158. package/src/resources/extensions/gsd/types.ts +109 -0
  159. package/src/resources/extensions/gsd/worktree-manager.ts +6 -4
  160. package/src/resources/extensions/search-the-web/command-search-provider.ts +8 -4
  161. package/src/resources/extensions/search-the-web/native-search.ts +15 -10
  162. package/src/resources/extensions/search-the-web/provider.ts +19 -2
  163. package/src/resources/extensions/search-the-web/tool-fetch-page.ts +62 -0
  164. package/src/resources/extensions/search-the-web/tool-llm-context.ts +62 -3
  165. package/src/resources/extensions/search-the-web/tool-search.ts +62 -3
@@ -9,10 +9,12 @@
9
9
  import type { ExtensionAPI, ExtensionContext, ExtensionCommandContext } from "@gsd/pi-coding-agent";
10
10
  import { showNextAction } from "../shared/next-action-ui.js";
11
11
  import { loadFile, parseRoadmap } from "./files.js";
12
- import { loadPrompt } from "./prompt-loader.js";
12
+ import { loadPrompt, inlineTemplate } from "./prompt-loader.js";
13
13
  import { deriveState } from "./state.js";
14
14
  import { startAuto } from "./auto.js";
15
15
  import { readCrashLock, clearLock, formatCrashInfo } from "./crash-recovery.js";
16
+ import { listUnitRuntimeRecords, clearUnitRuntimeRecord } from "./unit-runtime.js";
17
+ import { resolveExpectedArtifactPath } from "./auto.js";
16
18
  import {
17
19
  gsdRoot, milestonesDir, resolveMilestoneFile, resolveMilestonePath,
18
20
  resolveSliceFile, resolveSlicePath, resolveGsdRootFile, relGsdRootFile,
@@ -97,11 +99,19 @@ function dispatchWorkflow(pi: ExtensionAPI, note: string, customType = "gsd-run"
97
99
  */
98
100
  function buildDiscussPrompt(nextId: string, preamble: string, _basePath: string): string {
99
101
  const milestoneRel = `.gsd/milestones/${nextId}`;
102
+ const inlinedTemplates = [
103
+ inlineTemplate("project", "Project"),
104
+ inlineTemplate("requirements", "Requirements"),
105
+ inlineTemplate("context", "Context"),
106
+ inlineTemplate("roadmap", "Roadmap"),
107
+ inlineTemplate("decisions", "Decisions"),
108
+ ].join("\n\n---\n\n");
100
109
  return loadPrompt("discuss", {
101
110
  milestoneId: nextId,
102
111
  preamble,
103
112
  contextPath: `${milestoneRel}/${nextId}-CONTEXT.md`,
104
113
  roadmapPath: `${milestoneRel}/${nextId}-ROADMAP.md`,
114
+ inlinedTemplates,
105
115
  });
106
116
  }
107
117
 
@@ -234,11 +244,13 @@ export async function showQueue(
234
244
  ].join(" ");
235
245
 
236
246
  // ── Dispatch the queue prompt ───────────────────────────────────────
247
+ const queueInlinedTemplates = inlineTemplate("context", "Context");
237
248
  const prompt = loadPrompt("queue", {
238
249
  preamble,
239
250
  nextId,
240
251
  nextIdPlus1,
241
252
  existingMilestonesContext: existingContext,
253
+ inlinedTemplates: queueInlinedTemplates,
242
254
  });
243
255
 
244
256
  pi.sendMessage(
@@ -415,6 +427,7 @@ async function buildDiscussSlicePrompt(
415
427
  const sliceDirPath = `.gsd/milestones/${mid}/slices/${sid}`;
416
428
  const sliceContextPath = `${sliceDirPath}/${sid}-CONTEXT.md`;
417
429
 
430
+ const inlinedTemplates = inlineTemplate("slice-context", "Slice Context");
418
431
  return loadPrompt("guided-discuss-slice", {
419
432
  milestoneId: mid,
420
433
  sliceId: sid,
@@ -423,6 +436,7 @@ async function buildDiscussSlicePrompt(
423
436
  sliceDirPath,
424
437
  contextPath: sliceContextPath,
425
438
  projectRoot: base,
439
+ inlinedTemplates,
426
440
  });
427
441
  }
428
442
 
@@ -506,6 +520,42 @@ export async function showDiscuss(
506
520
  /**
507
521
  * The one wizard. Reads state, shows contextual options, dispatches into the workflow doc.
508
522
  */
523
+ /**
524
+ * Self-heal: scan runtime records and clear stale ones left behind when
525
+ * auto-mode crashed mid-unit. auto.ts has its own selfHealRuntimeRecords()
526
+ * but guided-flow (manual /gsd mode) never called it — meaning stale records
527
+ * persisted until the next /gsd auto run. This ensures the wizard always
528
+ * starts from a clean state regardless of how the previous session ended.
529
+ */
530
+ function selfHealRuntimeRecords(basePath: string, ctx: ExtensionContext): { cleared: number } {
531
+ try {
532
+ const records = listUnitRuntimeRecords(basePath);
533
+ let cleared = 0;
534
+ for (const record of records) {
535
+ const { unitType, unitId, phase } = record;
536
+ // Clear records whose expected artifact already exists (completed but not cleaned up)
537
+ const artifactPath = resolveExpectedArtifactPath(unitType, unitId, basePath);
538
+ if (artifactPath && existsSync(artifactPath)) {
539
+ clearUnitRuntimeRecord(basePath, unitType, unitId);
540
+ cleared++;
541
+ continue;
542
+ }
543
+ // Clear records stuck in dispatched or timeout phase (process died mid-unit)
544
+ if (phase === "dispatched" || phase === "timeout") {
545
+ clearUnitRuntimeRecord(basePath, unitType, unitId);
546
+ cleared++;
547
+ }
548
+ }
549
+ if (cleared > 0) {
550
+ ctx.ui.notify(`Self-heal: cleared ${cleared} stale runtime record(s) from a previous session.`, "info");
551
+ }
552
+ return { cleared };
553
+ } catch {
554
+ // Non-fatal — self-heal should never block the wizard
555
+ return { cleared: 0 };
556
+ }
557
+ }
558
+
509
559
  export async function showSmartEntry(
510
560
  ctx: ExtensionCommandContext,
511
561
  pi: ExtensionAPI,
@@ -544,6 +594,9 @@ export async function showSmartEntry(
544
594
  }
545
595
  }
546
596
 
597
+ // ── Self-heal stale runtime records from crashed auto-mode sessions ──
598
+ selfHealRuntimeRecords(basePath, ctx);
599
+
547
600
  // Check for crash from previous auto-mode session
548
601
  const crashLock = readCrashLock(basePath);
549
602
  if (crashLock) {
@@ -683,8 +736,9 @@ export async function showSmartEntry(
683
736
  });
684
737
 
685
738
  if (choice === "discuss_draft") {
739
+ const discussMilestoneTemplates = inlineTemplate("context", "Context");
686
740
  const basePrompt = loadPrompt("guided-discuss-milestone", {
687
- milestoneId, milestoneTitle,
741
+ milestoneId, milestoneTitle, inlinedTemplates: discussMilestoneTemplates,
688
742
  });
689
743
  const seed = draftContent
690
744
  ? `${basePrompt}\n\n## Prior Discussion (Draft Seed)\n\n${draftContent}`
@@ -692,9 +746,10 @@ export async function showSmartEntry(
692
746
  pendingAutoStart = { ctx, pi, basePath, milestoneId, step: stepMode };
693
747
  dispatchWorkflow(pi, seed, "gsd-discuss");
694
748
  } else if (choice === "discuss_fresh") {
749
+ const discussMilestoneTemplates = inlineTemplate("context", "Context");
695
750
  pendingAutoStart = { ctx, pi, basePath, milestoneId, step: stepMode };
696
751
  dispatchWorkflow(pi, loadPrompt("guided-discuss-milestone", {
697
- milestoneId, milestoneTitle,
752
+ milestoneId, milestoneTitle, inlinedTemplates: discussMilestoneTemplates,
698
753
  }), "gsd-discuss");
699
754
  } else if (choice === "skip_milestone") {
700
755
  const milestoneIds = findMilestoneIds(basePath);
@@ -753,13 +808,20 @@ export async function showSmartEntry(
753
808
  });
754
809
 
755
810
  if (choice === "plan") {
811
+ const planMilestoneTemplates = [
812
+ inlineTemplate("roadmap", "Roadmap"),
813
+ inlineTemplate("plan", "Slice Plan"),
814
+ inlineTemplate("task-plan", "Task Plan"),
815
+ inlineTemplate("secrets-manifest", "Secrets Manifest"),
816
+ ].join("\n\n---\n\n");
756
817
  const secretsOutputPath = relMilestoneFile(basePath, milestoneId, "SECRETS");
757
818
  dispatchWorkflow(pi, loadPrompt("guided-plan-milestone", {
758
- milestoneId, milestoneTitle, secretsOutputPath,
819
+ milestoneId, milestoneTitle, secretsOutputPath, inlinedTemplates: planMilestoneTemplates,
759
820
  }));
760
821
  } else if (choice === "discuss") {
822
+ const discussMilestoneTemplates = inlineTemplate("context", "Context");
761
823
  dispatchWorkflow(pi, loadPrompt("guided-discuss-milestone", {
762
- milestoneId, milestoneTitle,
824
+ milestoneId, milestoneTitle, inlinedTemplates: discussMilestoneTemplates,
763
825
  }));
764
826
  } else if (choice === "skip_milestone") {
765
827
  const milestoneIds = findMilestoneIds(basePath);
@@ -866,14 +928,19 @@ export async function showSmartEntry(
866
928
  });
867
929
 
868
930
  if (choice === "plan") {
931
+ const planSliceTemplates = [
932
+ inlineTemplate("plan", "Slice Plan"),
933
+ inlineTemplate("task-plan", "Task Plan"),
934
+ ].join("\n\n---\n\n");
869
935
  dispatchWorkflow(pi, loadPrompt("guided-plan-slice", {
870
- milestoneId, sliceId, sliceTitle,
936
+ milestoneId, sliceId, sliceTitle, inlinedTemplates: planSliceTemplates,
871
937
  }));
872
938
  } else if (choice === "discuss") {
873
939
  dispatchWorkflow(pi, await buildDiscussSlicePrompt(milestoneId, sliceId, sliceTitle, basePath));
874
940
  } else if (choice === "research") {
941
+ const researchTemplates = inlineTemplate("research", "Research");
875
942
  dispatchWorkflow(pi, loadPrompt("guided-research-slice", {
876
- milestoneId, sliceId, sliceTitle,
943
+ milestoneId, sliceId, sliceTitle, inlinedTemplates: researchTemplates,
877
944
  }));
878
945
  } else if (choice === "status") {
879
946
  const { fireStatusViaCommand } = await import("./commands.js");
@@ -904,8 +971,12 @@ export async function showSmartEntry(
904
971
  });
905
972
 
906
973
  if (choice === "complete") {
974
+ const completeSliceTemplates = [
975
+ inlineTemplate("slice-summary", "Slice Summary"),
976
+ inlineTemplate("uat", "UAT"),
977
+ ].join("\n\n---\n\n");
907
978
  dispatchWorkflow(pi, loadPrompt("guided-complete-slice", {
908
- milestoneId, sliceId, sliceTitle,
979
+ milestoneId, sliceId, sliceTitle, inlinedTemplates: completeSliceTemplates,
909
980
  }));
910
981
  } else if (choice === "status") {
911
982
  const { fireStatusViaCommand } = await import("./commands.js");
@@ -965,8 +1036,9 @@ export async function showSmartEntry(
965
1036
  milestoneId, sliceId,
966
1037
  }));
967
1038
  } else {
1039
+ const executeTaskTemplates = inlineTemplate("task-summary", "Task Summary");
968
1040
  dispatchWorkflow(pi, loadPrompt("guided-execute-task", {
969
- milestoneId, sliceId, taskId, taskTitle,
1041
+ milestoneId, sliceId, taskId, taskTitle, inlinedTemplates: executeTaskTemplates,
970
1042
  }));
971
1043
  }
972
1044
  } else if (choice === "status") {
@@ -0,0 +1,449 @@
1
+ // GSD Extension — Hook Engine (Post-Unit, Pre-Dispatch, State Persistence)
2
+ // Manages hook queue, cycle tracking, artifact verification, pre-dispatch
3
+ // interception, and durable hook state for user-configured extensibility.
4
+ // Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
5
+
6
+ import type {
7
+ PostUnitHookConfig,
8
+ PreDispatchHookConfig,
9
+ HookExecutionState,
10
+ HookDispatchResult,
11
+ PreDispatchResult,
12
+ PersistedHookState,
13
+ HookStatusEntry,
14
+ } from "./types.js";
15
+ import { resolvePostUnitHooks, resolvePreDispatchHooks } from "./preferences.js";
16
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
17
+ import { join } from "node:path";
18
+
19
+ // ─── Hook Queue State ──────────────────────────────────────────────────────
20
+
21
+ /** Currently executing hook, or null if in normal dispatch flow. */
22
+ let activeHook: HookExecutionState | null = null;
23
+
24
+ /** Queue of hooks remaining for the current trigger unit. */
25
+ let hookQueue: Array<{
26
+ config: PostUnitHookConfig;
27
+ triggerUnitType: string;
28
+ triggerUnitId: string;
29
+ }> = [];
30
+
31
+ /** Cycle counts per hook+trigger, keyed as "hookName/triggerUnitType/triggerUnitId". */
32
+ const cycleCounts = new Map<string, number>();
33
+
34
+ /** Set when a hook completes with retry_on artifact present — signals caller to re-run trigger. */
35
+ let retryPending = false;
36
+
37
+ /** Stores the trigger unit info for pending retries so caller knows what to re-run. */
38
+ let retryTrigger: { unitType: string; unitId: string } | null = null;
39
+
40
+ // ─── Public API ────────────────────────────────────────────────────────────
41
+
42
+ /**
43
+ * Called after a unit completes. Returns the next hook unit to dispatch,
44
+ * or null if no hooks apply (normal dispatch should proceed).
45
+ *
46
+ * Call flow:
47
+ * 1. A core unit (e.g. execute-task) completes → handleAgentEnd calls this
48
+ * 2. If hooks match, returns first hook to dispatch. Caller sends the prompt.
49
+ * 3. Hook unit completes → handleAgentEnd calls this again (activeHook is set)
50
+ * 4. Checks retry_on / next hook / done → returns next action or null
51
+ */
52
+ export function checkPostUnitHooks(
53
+ completedUnitType: string,
54
+ completedUnitId: string,
55
+ basePath: string,
56
+ ): HookDispatchResult | null {
57
+ // If we just completed a hook unit, handle its result
58
+ if (activeHook) {
59
+ return handleHookCompletion(basePath);
60
+ }
61
+
62
+ // Don't trigger hooks for other hook units (prevent hook-on-hook chains)
63
+ if (completedUnitType.startsWith("hook/")) return null;
64
+
65
+ // Check if any hooks are configured for this unit type
66
+ const hooks = resolvePostUnitHooks().filter(h =>
67
+ h.after.includes(completedUnitType),
68
+ );
69
+ if (hooks.length === 0) return null;
70
+
71
+ // Build hook queue for this trigger
72
+ hookQueue = hooks.map(config => ({
73
+ config,
74
+ triggerUnitType: completedUnitType,
75
+ triggerUnitId: completedUnitId,
76
+ }));
77
+
78
+ return dequeueNextHook(basePath);
79
+ }
80
+
81
+ /**
82
+ * Returns whether a hook is currently active (for progress display).
83
+ */
84
+ export function getActiveHook(): HookExecutionState | null {
85
+ return activeHook;
86
+ }
87
+
88
+ /**
89
+ * Returns true if a retry of the trigger unit was requested by a hook.
90
+ * Caller should re-dispatch the original trigger unit, then hooks will
91
+ * fire again on its next completion.
92
+ */
93
+ export function isRetryPending(): boolean {
94
+ return retryPending;
95
+ }
96
+
97
+ /**
98
+ * Returns the trigger unit info for a pending retry, or null.
99
+ * Clears the retry state after reading.
100
+ */
101
+ export function consumeRetryTrigger(): { unitType: string; unitId: string } | null {
102
+ if (!retryPending || !retryTrigger) return null;
103
+ const trigger = { ...retryTrigger };
104
+ retryPending = false;
105
+ retryTrigger = null;
106
+ return trigger;
107
+ }
108
+
109
+ /**
110
+ * Reset all hook state. Called on auto-mode start/stop.
111
+ */
112
+ export function resetHookState(): void {
113
+ activeHook = null;
114
+ hookQueue = [];
115
+ cycleCounts.clear();
116
+ retryPending = false;
117
+ retryTrigger = null;
118
+ }
119
+
120
+ // ─── Internal ──────────────────────────────────────────────────────────────
121
+
122
+ function dequeueNextHook(basePath: string): HookDispatchResult | null {
123
+ while (hookQueue.length > 0) {
124
+ const entry = hookQueue.shift()!;
125
+ const { config, triggerUnitType, triggerUnitId } = entry;
126
+
127
+ // Check idempotency — if artifact already exists, skip this hook
128
+ if (config.artifact) {
129
+ const artifactPath = resolveHookArtifactPath(basePath, triggerUnitId, config.artifact);
130
+ if (existsSync(artifactPath)) continue;
131
+ }
132
+
133
+ // Check cycle limit
134
+ const cycleKey = `${config.name}/${triggerUnitType}/${triggerUnitId}`;
135
+ const currentCycle = (cycleCounts.get(cycleKey) ?? 0) + 1;
136
+ const maxCycles = config.max_cycles ?? 1;
137
+ if (currentCycle > maxCycles) continue;
138
+
139
+ cycleCounts.set(cycleKey, currentCycle);
140
+
141
+ activeHook = {
142
+ hookName: config.name,
143
+ triggerUnitType,
144
+ triggerUnitId,
145
+ cycle: currentCycle,
146
+ pendingRetry: false,
147
+ };
148
+
149
+ // Build the prompt with variable substitution
150
+ const [mid, sid, tid] = triggerUnitId.split("/");
151
+ const prompt = config.prompt
152
+ .replace(/\{milestoneId\}/g, mid ?? "")
153
+ .replace(/\{sliceId\}/g, sid ?? "")
154
+ .replace(/\{taskId\}/g, tid ?? "");
155
+
156
+ return {
157
+ hookName: config.name,
158
+ prompt,
159
+ model: config.model,
160
+ unitType: `hook/${config.name}`,
161
+ unitId: triggerUnitId,
162
+ };
163
+ }
164
+
165
+ // No more hooks — clear active state and return null for normal dispatch
166
+ activeHook = null;
167
+ return null;
168
+ }
169
+
170
+ function handleHookCompletion(basePath: string): HookDispatchResult | null {
171
+ const hook = activeHook!;
172
+ const hooks = resolvePostUnitHooks();
173
+ const config = hooks.find(h => h.name === hook.hookName);
174
+
175
+ // Check if retry was requested via retry_on artifact
176
+ if (config?.retry_on) {
177
+ const retryArtifactPath = resolveHookArtifactPath(basePath, hook.triggerUnitId, config.retry_on);
178
+ if (existsSync(retryArtifactPath)) {
179
+ // Check cycle limit before allowing retry
180
+ const cycleKey = `${config.name}/${hook.triggerUnitType}/${hook.triggerUnitId}`;
181
+ const currentCycle = cycleCounts.get(cycleKey) ?? 1;
182
+ const maxCycles = config.max_cycles ?? 1;
183
+
184
+ if (currentCycle < maxCycles) {
185
+ // Signal retry — caller will re-dispatch the trigger unit
186
+ activeHook = null;
187
+ hookQueue = [];
188
+ retryPending = true;
189
+ retryTrigger = { unitType: hook.triggerUnitType, unitId: hook.triggerUnitId };
190
+ return null;
191
+ }
192
+ // Max cycles reached — fall through to normal completion
193
+ }
194
+ }
195
+
196
+ // Hook completed normally — try next hook in queue
197
+ activeHook = null;
198
+ return dequeueNextHook(basePath);
199
+ }
200
+
201
+ /**
202
+ * Resolve the path where a hook artifact is expected to be written.
203
+ * Uses the trigger unit's directory context:
204
+ * - Task-level (M001/S01/T01): .gsd/M001/slices/S01/tasks/T01-{artifact}
205
+ * - Slice-level (M001/S01): .gsd/M001/slices/S01/{artifact}
206
+ * - Milestone-level (M001): .gsd/M001/{artifact}
207
+ */
208
+ export function resolveHookArtifactPath(basePath: string, unitId: string, artifactName: string): string {
209
+ const parts = unitId.split("/");
210
+ if (parts.length === 3) {
211
+ const [mid, sid, tid] = parts;
212
+ return join(basePath, ".gsd", mid, "slices", sid, "tasks", `${tid}-${artifactName}`);
213
+ }
214
+ if (parts.length === 2) {
215
+ const [mid, sid] = parts;
216
+ return join(basePath, ".gsd", mid, "slices", sid, artifactName);
217
+ }
218
+ return join(basePath, ".gsd", parts[0], artifactName);
219
+ }
220
+
221
+ // ═══════════════════════════════════════════════════════════════════════════
222
+ // Phase 2: Pre-Dispatch Hooks
223
+ // ═══════════════════════════════════════════════════════════════════════════
224
+
225
+ /**
226
+ * Run pre-dispatch hooks for a unit about to be dispatched.
227
+ * Returns a result indicating whether the unit should proceed (with optional
228
+ * prompt modifications), be skipped, or be replaced entirely.
229
+ *
230
+ * Multiple hooks can fire for the same unit type. They compose:
231
+ * - "modify" hooks stack (all prepend/append applied in order)
232
+ * - "skip" short-circuits (first matching skip wins)
233
+ * - "replace" short-circuits (first matching replace wins)
234
+ * - Skip/replace hooks take precedence over modify hooks
235
+ */
236
+ export function runPreDispatchHooks(
237
+ unitType: string,
238
+ unitId: string,
239
+ prompt: string,
240
+ basePath: string,
241
+ ): PreDispatchResult {
242
+ // Don't intercept hook units
243
+ if (unitType.startsWith("hook/")) {
244
+ return { action: "proceed", prompt, firedHooks: [] };
245
+ }
246
+
247
+ const hooks = resolvePreDispatchHooks().filter(h =>
248
+ h.before.includes(unitType),
249
+ );
250
+ if (hooks.length === 0) {
251
+ return { action: "proceed", prompt, firedHooks: [] };
252
+ }
253
+
254
+ const [mid, sid, tid] = unitId.split("/");
255
+ const substitute = (text: string): string =>
256
+ text
257
+ .replace(/\{milestoneId\}/g, mid ?? "")
258
+ .replace(/\{sliceId\}/g, sid ?? "")
259
+ .replace(/\{taskId\}/g, tid ?? "");
260
+
261
+ const firedHooks: string[] = [];
262
+ let currentPrompt = prompt;
263
+
264
+ for (const hook of hooks) {
265
+ if (hook.action === "skip") {
266
+ // Check optional skip condition
267
+ if (hook.skip_if) {
268
+ const conditionPath = resolveHookArtifactPath(basePath, unitId, hook.skip_if);
269
+ if (!existsSync(conditionPath)) continue; // Condition not met, don't skip
270
+ }
271
+ firedHooks.push(hook.name);
272
+ return { action: "skip", firedHooks };
273
+ }
274
+
275
+ if (hook.action === "replace") {
276
+ firedHooks.push(hook.name);
277
+ return {
278
+ action: "replace",
279
+ prompt: substitute(hook.prompt ?? ""),
280
+ unitType: hook.unit_type,
281
+ model: hook.model,
282
+ firedHooks,
283
+ };
284
+ }
285
+
286
+ if (hook.action === "modify") {
287
+ firedHooks.push(hook.name);
288
+ if (hook.prepend) {
289
+ currentPrompt = `${substitute(hook.prepend)}\n\n${currentPrompt}`;
290
+ }
291
+ if (hook.append) {
292
+ currentPrompt = `${currentPrompt}\n\n${substitute(hook.append)}`;
293
+ }
294
+ }
295
+ }
296
+
297
+ return {
298
+ action: "proceed",
299
+ prompt: currentPrompt,
300
+ model: hooks.find(h => h.action === "modify" && h.model)?.model,
301
+ firedHooks,
302
+ };
303
+ }
304
+
305
+ // ═══════════════════════════════════════════════════════════════════════════
306
+ // Phase 3: Hook State Persistence
307
+ // ═══════════════════════════════════════════════════════════════════════════
308
+
309
+ const HOOK_STATE_FILE = "hook-state.json";
310
+
311
+ function hookStatePath(basePath: string): string {
312
+ return join(basePath, ".gsd", HOOK_STATE_FILE);
313
+ }
314
+
315
+ /**
316
+ * Persist current hook cycle counts to disk so they survive crashes/restarts.
317
+ * Called after each hook dispatch and on auto-mode pause.
318
+ */
319
+ export function persistHookState(basePath: string): void {
320
+ const state: PersistedHookState = {
321
+ cycleCounts: Object.fromEntries(cycleCounts),
322
+ savedAt: new Date().toISOString(),
323
+ };
324
+ try {
325
+ const dir = join(basePath, ".gsd");
326
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
327
+ writeFileSync(hookStatePath(basePath), JSON.stringify(state, null, 2), "utf-8");
328
+ } catch {
329
+ // Non-fatal — state is recreatable from artifacts
330
+ }
331
+ }
332
+
333
+ /**
334
+ * Restore hook cycle counts from disk after a crash/restart.
335
+ * Called during auto-mode resume.
336
+ */
337
+ export function restoreHookState(basePath: string): void {
338
+ try {
339
+ const filePath = hookStatePath(basePath);
340
+ if (!existsSync(filePath)) return;
341
+ const raw = readFileSync(filePath, "utf-8");
342
+ const state: PersistedHookState = JSON.parse(raw);
343
+ if (state.cycleCounts && typeof state.cycleCounts === "object") {
344
+ cycleCounts.clear();
345
+ for (const [key, value] of Object.entries(state.cycleCounts)) {
346
+ if (typeof value === "number") {
347
+ cycleCounts.set(key, value);
348
+ }
349
+ }
350
+ }
351
+ } catch {
352
+ // Non-fatal — fresh state is fine
353
+ }
354
+ }
355
+
356
+ /**
357
+ * Clear persisted hook state file from disk.
358
+ * Called on clean auto-mode stop.
359
+ */
360
+ export function clearPersistedHookState(basePath: string): void {
361
+ try {
362
+ const filePath = hookStatePath(basePath);
363
+ if (existsSync(filePath)) {
364
+ writeFileSync(filePath, JSON.stringify({ cycleCounts: {}, savedAt: new Date().toISOString() }, null, 2), "utf-8");
365
+ }
366
+ } catch {
367
+ // Non-fatal
368
+ }
369
+ }
370
+
371
+ // ═══════════════════════════════════════════════════════════════════════════
372
+ // Phase 3: Hook Status Reporting
373
+ // ═══════════════════════════════════════════════════════════════════════════
374
+
375
+ /**
376
+ * Get status of all configured hooks for display by /gsd hooks.
377
+ */
378
+ export function getHookStatus(): HookStatusEntry[] {
379
+ const entries: HookStatusEntry[] = [];
380
+
381
+ // Post-unit hooks
382
+ const postHooks = resolvePostUnitHooks();
383
+ for (const hook of postHooks) {
384
+ const activeCycles: Record<string, number> = {};
385
+ for (const [key, count] of cycleCounts) {
386
+ if (key.startsWith(`${hook.name}/`)) {
387
+ activeCycles[key] = count;
388
+ }
389
+ }
390
+ entries.push({
391
+ name: hook.name,
392
+ type: "post",
393
+ enabled: hook.enabled !== false,
394
+ targets: hook.after,
395
+ activeCycles,
396
+ });
397
+ }
398
+
399
+ // Pre-dispatch hooks
400
+ const preHooks = resolvePreDispatchHooks();
401
+ for (const hook of preHooks) {
402
+ entries.push({
403
+ name: hook.name,
404
+ type: "pre",
405
+ enabled: hook.enabled !== false,
406
+ targets: hook.before,
407
+ activeCycles: {},
408
+ });
409
+ }
410
+
411
+ return entries;
412
+ }
413
+
414
+ /**
415
+ * Format hook status for terminal display.
416
+ */
417
+ export function formatHookStatus(): string {
418
+ const entries = getHookStatus();
419
+ if (entries.length === 0) {
420
+ return "No hooks configured. Add post_unit_hooks or pre_dispatch_hooks to .gsd/preferences.md";
421
+ }
422
+
423
+ const lines: string[] = ["Configured Hooks:", ""];
424
+
425
+ const postHooks = entries.filter(e => e.type === "post");
426
+ const preHooks = entries.filter(e => e.type === "pre");
427
+
428
+ if (postHooks.length > 0) {
429
+ lines.push("Post-Unit Hooks (run after unit completes):");
430
+ for (const hook of postHooks) {
431
+ const status = hook.enabled ? "enabled" : "disabled";
432
+ const cycles = Object.keys(hook.activeCycles).length;
433
+ const cycleInfo = cycles > 0 ? ` (${cycles} active cycle${cycles === 1 ? "" : "s"})` : "";
434
+ lines.push(` ${hook.name} [${status}] → after: ${hook.targets.join(", ")}${cycleInfo}`);
435
+ }
436
+ lines.push("");
437
+ }
438
+
439
+ if (preHooks.length > 0) {
440
+ lines.push("Pre-Dispatch Hooks (run before unit dispatches):");
441
+ for (const hook of preHooks) {
442
+ const status = hook.enabled ? "enabled" : "disabled";
443
+ lines.push(` ${hook.name} [${status}] → before: ${hook.targets.join(", ")}`);
444
+ }
445
+ lines.push("");
446
+ }
447
+
448
+ return lines.join("\n");
449
+ }