gsd-pi 2.11.0 → 2.12.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 (113) hide show
  1. package/dist/onboarding.js +3 -0
  2. package/dist/resources/extensions/bg-shell/index.ts +51 -7
  3. package/dist/resources/extensions/gsd/auto.ts +159 -2
  4. package/dist/resources/extensions/gsd/commands.ts +9 -3
  5. package/dist/resources/extensions/gsd/doctor.ts +60 -3
  6. package/dist/resources/extensions/gsd/guided-flow.ts +81 -9
  7. package/dist/resources/extensions/gsd/post-unit-hooks.ts +449 -0
  8. package/dist/resources/extensions/gsd/preferences.ts +192 -0
  9. package/dist/resources/extensions/gsd/prompt-loader.ts +28 -1
  10. package/dist/resources/extensions/gsd/prompts/complete-milestone.md +1 -1
  11. package/dist/resources/extensions/gsd/prompts/complete-slice.md +1 -3
  12. package/dist/resources/extensions/gsd/prompts/discuss.md +10 -8
  13. package/dist/resources/extensions/gsd/prompts/execute-task.md +4 -2
  14. package/dist/resources/extensions/gsd/prompts/guided-complete-slice.md +3 -1
  15. package/dist/resources/extensions/gsd/prompts/guided-discuss-milestone.md +3 -1
  16. package/dist/resources/extensions/gsd/prompts/guided-discuss-slice.md +3 -1
  17. package/dist/resources/extensions/gsd/prompts/guided-execute-task.md +3 -1
  18. package/dist/resources/extensions/gsd/prompts/guided-plan-milestone.md +4 -2
  19. package/dist/resources/extensions/gsd/prompts/guided-plan-slice.md +3 -1
  20. package/dist/resources/extensions/gsd/prompts/guided-research-slice.md +3 -1
  21. package/dist/resources/extensions/gsd/prompts/plan-milestone.md +9 -12
  22. package/dist/resources/extensions/gsd/prompts/plan-slice.md +1 -3
  23. package/dist/resources/extensions/gsd/prompts/queue.md +3 -1
  24. package/dist/resources/extensions/gsd/prompts/research-milestone.md +1 -1
  25. package/dist/resources/extensions/gsd/prompts/research-slice.md +1 -1
  26. package/dist/resources/extensions/gsd/templates/context.md +1 -1
  27. package/dist/resources/extensions/gsd/templates/state.md +3 -3
  28. package/dist/resources/extensions/gsd/tests/doctor.test.ts +115 -1
  29. package/dist/resources/extensions/gsd/tests/post-unit-hooks.test.ts +297 -0
  30. package/dist/resources/extensions/gsd/tests/preferences-hooks.test.ts +226 -0
  31. package/dist/resources/extensions/gsd/tests/regex-hardening.test.ts +12 -0
  32. package/dist/resources/extensions/gsd/types.ts +109 -0
  33. package/dist/resources/extensions/search-the-web/command-search-provider.ts +8 -4
  34. package/dist/resources/extensions/search-the-web/provider.ts +19 -2
  35. package/dist/resources/extensions/search-the-web/tool-fetch-page.ts +62 -0
  36. package/dist/resources/extensions/search-the-web/tool-llm-context.ts +62 -3
  37. package/dist/resources/extensions/search-the-web/tool-search.ts +62 -3
  38. package/dist/wizard.js +1 -0
  39. package/package.json +1 -1
  40. package/packages/pi-agent-core/dist/agent-loop.d.ts.map +1 -1
  41. package/packages/pi-agent-core/dist/agent-loop.js +169 -55
  42. package/packages/pi-agent-core/dist/agent-loop.js.map +1 -1
  43. package/packages/pi-agent-core/dist/agent.d.ts +13 -1
  44. package/packages/pi-agent-core/dist/agent.d.ts.map +1 -1
  45. package/packages/pi-agent-core/dist/agent.js +16 -0
  46. package/packages/pi-agent-core/dist/agent.js.map +1 -1
  47. package/packages/pi-agent-core/dist/types.d.ts +91 -1
  48. package/packages/pi-agent-core/dist/types.d.ts.map +1 -1
  49. package/packages/pi-agent-core/dist/types.js.map +1 -1
  50. package/packages/pi-agent-core/src/agent-loop.ts +273 -63
  51. package/packages/pi-agent-core/src/agent.ts +24 -0
  52. package/packages/pi-agent-core/src/types.ts +98 -0
  53. package/packages/pi-ai/dist/env-api-keys.js +1 -0
  54. package/packages/pi-ai/dist/env-api-keys.js.map +1 -1
  55. package/packages/pi-ai/dist/models.generated.d.ts +314 -0
  56. package/packages/pi-ai/dist/models.generated.d.ts.map +1 -1
  57. package/packages/pi-ai/dist/models.generated.js +236 -0
  58. package/packages/pi-ai/dist/models.generated.js.map +1 -1
  59. package/packages/pi-ai/dist/types.d.ts +1 -1
  60. package/packages/pi-ai/dist/types.d.ts.map +1 -1
  61. package/packages/pi-ai/dist/types.js.map +1 -1
  62. package/packages/pi-ai/src/env-api-keys.ts +1 -0
  63. package/packages/pi-ai/src/models.generated.ts +236 -0
  64. package/packages/pi-ai/src/types.ts +2 -1
  65. package/packages/pi-coding-agent/dist/cli/args.d.ts.map +1 -1
  66. package/packages/pi-coding-agent/dist/cli/args.js +1 -0
  67. package/packages/pi-coding-agent/dist/cli/args.js.map +1 -1
  68. package/packages/pi-coding-agent/dist/core/agent-session.d.ts +10 -0
  69. package/packages/pi-coding-agent/dist/core/agent-session.d.ts.map +1 -1
  70. package/packages/pi-coding-agent/dist/core/agent-session.js +69 -8
  71. package/packages/pi-coding-agent/dist/core/agent-session.js.map +1 -1
  72. package/packages/pi-coding-agent/dist/core/model-resolver.d.ts.map +1 -1
  73. package/packages/pi-coding-agent/dist/core/model-resolver.js +1 -0
  74. package/packages/pi-coding-agent/dist/core/model-resolver.js.map +1 -1
  75. package/packages/pi-coding-agent/src/cli/args.ts +1 -0
  76. package/packages/pi-coding-agent/src/core/agent-session.ts +76 -7
  77. package/packages/pi-coding-agent/src/core/model-resolver.ts +1 -0
  78. package/src/resources/extensions/bg-shell/index.ts +51 -7
  79. package/src/resources/extensions/gsd/auto.ts +159 -2
  80. package/src/resources/extensions/gsd/commands.ts +9 -3
  81. package/src/resources/extensions/gsd/doctor.ts +60 -3
  82. package/src/resources/extensions/gsd/guided-flow.ts +81 -9
  83. package/src/resources/extensions/gsd/post-unit-hooks.ts +449 -0
  84. package/src/resources/extensions/gsd/preferences.ts +192 -0
  85. package/src/resources/extensions/gsd/prompt-loader.ts +28 -1
  86. package/src/resources/extensions/gsd/prompts/complete-milestone.md +1 -1
  87. package/src/resources/extensions/gsd/prompts/complete-slice.md +1 -3
  88. package/src/resources/extensions/gsd/prompts/discuss.md +10 -8
  89. package/src/resources/extensions/gsd/prompts/execute-task.md +4 -2
  90. package/src/resources/extensions/gsd/prompts/guided-complete-slice.md +3 -1
  91. package/src/resources/extensions/gsd/prompts/guided-discuss-milestone.md +3 -1
  92. package/src/resources/extensions/gsd/prompts/guided-discuss-slice.md +3 -1
  93. package/src/resources/extensions/gsd/prompts/guided-execute-task.md +3 -1
  94. package/src/resources/extensions/gsd/prompts/guided-plan-milestone.md +4 -2
  95. package/src/resources/extensions/gsd/prompts/guided-plan-slice.md +3 -1
  96. package/src/resources/extensions/gsd/prompts/guided-research-slice.md +3 -1
  97. package/src/resources/extensions/gsd/prompts/plan-milestone.md +9 -12
  98. package/src/resources/extensions/gsd/prompts/plan-slice.md +1 -3
  99. package/src/resources/extensions/gsd/prompts/queue.md +3 -1
  100. package/src/resources/extensions/gsd/prompts/research-milestone.md +1 -1
  101. package/src/resources/extensions/gsd/prompts/research-slice.md +1 -1
  102. package/src/resources/extensions/gsd/templates/context.md +1 -1
  103. package/src/resources/extensions/gsd/templates/state.md +3 -3
  104. package/src/resources/extensions/gsd/tests/doctor.test.ts +115 -1
  105. package/src/resources/extensions/gsd/tests/post-unit-hooks.test.ts +297 -0
  106. package/src/resources/extensions/gsd/tests/preferences-hooks.test.ts +226 -0
  107. package/src/resources/extensions/gsd/tests/regex-hardening.test.ts +12 -0
  108. package/src/resources/extensions/gsd/types.ts +109 -0
  109. package/src/resources/extensions/search-the-web/command-search-provider.ts +8 -4
  110. package/src/resources/extensions/search-the-web/provider.ts +19 -2
  111. package/src/resources/extensions/search-the-web/tool-fetch-page.ts +62 -0
  112. package/src/resources/extensions/search-the-web/tool-llm-context.ts +62 -3
  113. package/src/resources/extensions/search-the-web/tool-search.ts +62 -3
@@ -48,6 +48,7 @@ const LLM_PROVIDER_IDS = [
48
48
  'xai',
49
49
  'openrouter',
50
50
  'mistral',
51
+ 'ollama-cloud',
51
52
  'custom-openai',
52
53
  ];
53
54
  /** API key prefix validation — loose checks to catch obvious mistakes */
@@ -61,6 +62,7 @@ const OTHER_PROVIDERS = [
61
62
  { value: 'xai', label: 'xAI (Grok)' },
62
63
  { value: 'openrouter', label: 'OpenRouter' },
63
64
  { value: 'mistral', label: 'Mistral' },
65
+ { value: 'ollama-cloud', label: 'Ollama Cloud' },
64
66
  { value: 'custom-openai', label: 'Custom (OpenAI-compatible)' },
65
67
  ];
66
68
  // ─── Dynamic imports ──────────────────────────────────────────────────────────
@@ -755,6 +757,7 @@ export function loadStoredEnvKeys(authStorage) {
755
757
  ['slack_bot', 'SLACK_BOT_TOKEN'],
756
758
  ['discord_bot', 'DISCORD_BOT_TOKEN'],
757
759
  ['groq', 'GROQ_API_KEY'],
760
+ ['ollama-cloud', 'OLLAMA_API_KEY'],
758
761
  ['custom-openai', 'CUSTOM_OPENAI_API_KEY'],
759
762
  ];
760
763
  for (const [provider, envVar] of providers) {
@@ -574,6 +574,7 @@ interface StartOptions {
574
574
  type?: ProcessType;
575
575
  readyPattern?: string;
576
576
  readyPort?: number;
577
+ readyTimeout?: number;
577
578
  group?: string;
578
579
  env?: Record<string, string>;
579
580
  }
@@ -689,7 +690,7 @@ function startProcess(opts: StartOptions): BgProcess {
689
690
 
690
691
  // Port probing for server-type processes
691
692
  if (bg.readyPort) {
692
- startPortProbing(bg, bg.readyPort);
693
+ startPortProbing(bg, bg.readyPort, opts.readyTimeout);
693
694
  }
694
695
 
695
696
  // Shell sessions are ready immediately after spawn
@@ -707,9 +708,17 @@ function startProcess(opts: StartOptions): BgProcess {
707
708
 
708
709
  // ── Port Probing Loop ──────────────────────────────────────────────────────
709
710
 
710
- function startPortProbing(bg: BgProcess, port: number): void {
711
+ function startPortProbing(bg: BgProcess, port: number, customTimeout?: number): void {
712
+ const timeout = customTimeout || DEFAULT_READY_TIMEOUT;
711
713
  const interval = setInterval(async () => {
712
- if (!bg.alive || bg.status !== "starting") {
714
+ if (!bg.alive) {
715
+ clearInterval(interval);
716
+ const stderrLines = bg.output.filter(l => l.stream === "stderr").slice(-10).map(l => l.line);
717
+ const detail = `Process exited (code ${bg.exitCode}) before port ${port} opened${stderrLines.length > 0 ? ` — ${stderrLines.join("; ").slice(0, 200)}` : ""}`;
718
+ addEvent(bg, { type: "port_timeout", detail, data: { port, exitCode: bg.exitCode } });
719
+ return;
720
+ }
721
+ if (bg.status !== "starting") {
713
722
  clearInterval(interval);
714
723
  return;
715
724
  }
@@ -722,8 +731,18 @@ function startPortProbing(bg: BgProcess, port: number): void {
722
731
  }
723
732
  }, READY_POLL_INTERVAL);
724
733
 
725
- // Stop probing after timeout
726
- setTimeout(() => clearInterval(interval), DEFAULT_READY_TIMEOUT);
734
+ // Stop probing after timeout — transition to error state so the process
735
+ // doesn't stay in "starting" forever (fixes #428)
736
+ setTimeout(() => {
737
+ clearInterval(interval);
738
+ if (bg.alive && bg.status === "starting") {
739
+ const stderrLines = bg.output.filter(l => l.stream === "stderr").slice(-10).map(l => l.line);
740
+ const detail = `Port ${port} not open after ${timeout}ms${stderrLines.length > 0 ? ` — ${stderrLines.join("; ").slice(0, 200)}` : ""}`;
741
+ bg.status = "error";
742
+ addEvent(bg, { type: "port_timeout", detail, data: { port, timeout } });
743
+ pushAlert(bg, `Port ${port} readiness timeout after ${timeout / 1000}s`);
744
+ }
745
+ }, timeout);
727
746
  }
728
747
 
729
748
  // ── Process Kill ───────────────────────────────────────────────────────────
@@ -864,9 +883,19 @@ async function waitForReady(bg: BgProcess, timeout: number, signal?: AbortSignal
864
883
  return { ready: false, detail: "Cancelled" };
865
884
  }
866
885
  if (!bg.alive) {
886
+ const stderrLines = bg.output.filter(l => l.stream === "stderr").slice(-5).map(l => l.line);
887
+ const stderrContext = stderrLines.length > 0 ? `\nstderr:\n${stderrLines.join("\n").slice(0, 500)}` : "";
888
+ return {
889
+ ready: false,
890
+ detail: `Process exited before becoming ready (code ${bg.exitCode})${bg.recentErrors.length > 0 ? ` — ${bg.recentErrors.slice(-1)[0]}` : ""}${stderrContext}`,
891
+ };
892
+ }
893
+ if (bg.status === "error") {
894
+ const stderrLines = bg.output.filter(l => l.stream === "stderr").slice(-5).map(l => l.line);
895
+ const stderrContext = stderrLines.length > 0 ? `\nstderr:\n${stderrLines.join("\n").slice(0, 500)}` : "";
867
896
  return {
868
897
  ready: false,
869
- detail: `Process exited before becoming ready (code ${bg.exitCode})${bg.recentErrors.length > 0 ? ` ${bg.recentErrors.slice(-1)[0]}` : ""}`,
898
+ detail: `Process entered error state${bg.readyPort ? ` (port ${bg.readyPort} never opened)` : ""}${stderrContext}`,
870
899
  };
871
900
  }
872
901
  if (bg.status === "ready") {
@@ -887,7 +916,9 @@ async function waitForReady(bg: BgProcess, timeout: number, signal?: AbortSignal
887
916
  }
888
917
  }
889
918
 
890
- return { ready: false, detail: `Timed out after ${timeout}ms waiting for ready signal` };
919
+ const stderrLines = bg.output.filter(l => l.stream === "stderr").slice(-5).map(l => l.line);
920
+ const stderrContext = stderrLines.length > 0 ? `\nstderr:\n${stderrLines.join("\n").slice(0, 500)}` : "";
921
+ return { ready: false, detail: `Timed out after ${timeout}ms waiting for ready signal${stderrContext}` };
891
922
  }
892
923
 
893
924
  // ── Query Shell Environment ────────────────────────────────────────────────
@@ -1234,6 +1265,15 @@ export default function (pi: ExtensionAPI) {
1234
1265
  cleanupAll();
1235
1266
  });
1236
1267
 
1268
+ // Register signal handlers to clean up bg processes on unexpected exit (fixes #428)
1269
+ // This prevents orphan processes and helps the parent restore terminal state
1270
+ const signalCleanup = () => {
1271
+ cleanupAll();
1272
+ };
1273
+ process.on("SIGTERM", signalCleanup);
1274
+ process.on("SIGINT", signalCleanup);
1275
+ process.on("beforeExit", signalCleanup);
1276
+
1237
1277
  // ── Compaction Awareness: Survive Context Resets ───────────────────
1238
1278
 
1239
1279
  /** Build a compact state summary of all alive processes for context re-injection */
@@ -1424,6 +1464,9 @@ export default function (pi: ExtensionAPI) {
1424
1464
  ready_port: Type.Optional(
1425
1465
  Type.Number({ description: "Port to probe for readiness (for start). When open, process is considered ready." }),
1426
1466
  ),
1467
+ ready_timeout: Type.Optional(
1468
+ Type.Number({ description: "Max milliseconds to wait for ready_port/ready_pattern before marking as error (default: 30000)" }),
1469
+ ),
1427
1470
  group: Type.Optional(
1428
1471
  Type.String({ description: "Group name for related processes (for start, group_status)" }),
1429
1472
  ),
@@ -1449,6 +1492,7 @@ export default function (pi: ExtensionAPI) {
1449
1492
  type: params.type as ProcessType | undefined,
1450
1493
  readyPattern: params.ready_pattern,
1451
1494
  readyPort: params.ready_port,
1495
+ readyTimeout: params.ready_timeout,
1452
1496
  group: params.group,
1453
1497
  });
1454
1498
 
@@ -18,17 +18,18 @@ import type {
18
18
 
19
19
  import { deriveState, invalidateStateCache } from "./state.js";
20
20
  import type { GSDState } from "./types.js";
21
- import { loadFile, parseContinue, parsePlan, parseRoadmap, parseSummary, extractUatType, inlinePriorMilestoneSummary, getManifestStatus } from "./files.js";
21
+ import { loadFile, parseContinue, parsePlan, parseRoadmap, parseSummary, extractUatType, inlinePriorMilestoneSummary, getManifestStatus, clearParseCache } from "./files.js";
22
22
  export { inlinePriorMilestoneSummary };
23
23
  import type { UatType } from "./files.js";
24
24
  import { collectSecretsFromManifest } from "../get-secrets-from-user.js";
25
- import { loadPrompt } from "./prompt-loader.js";
25
+ import { loadPrompt, inlineTemplate } from "./prompt-loader.js";
26
26
  import {
27
27
  gsdRoot, resolveMilestoneFile, resolveSliceFile, resolveSlicePath,
28
28
  resolveMilestonePath, resolveDir, resolveTasksDir, resolveTaskFiles, resolveTaskFile,
29
29
  relMilestoneFile, relSliceFile, relTaskFile, relSlicePath, relMilestonePath,
30
30
  milestonesDir, resolveGsdRootFile, relGsdRootFile,
31
31
  buildMilestoneFileName, buildSliceFileName, buildTaskFileName,
32
+ clearPathCache,
32
33
  } from "./paths.js";
33
34
  import { saveActivityLog } from "./activity-log.js";
34
35
  import { synthesizeCrashRecovery, getDeepDiagnostic } from "./session-forensics.js";
@@ -42,6 +43,18 @@ import {
42
43
  } from "./unit-runtime.js";
43
44
  import { resolveAutoSupervisorConfig, resolveModelForUnit, resolveModelWithFallbacksForUnit, resolveSkillDiscoveryMode, loadEffectiveGSDPreferences } from "./preferences.js";
44
45
  import type { GSDPreferences } from "./preferences.js";
46
+ import {
47
+ checkPostUnitHooks,
48
+ getActiveHook,
49
+ resetHookState,
50
+ isRetryPending,
51
+ consumeRetryTrigger,
52
+ runPreDispatchHooks,
53
+ persistHookState,
54
+ restoreHookState,
55
+ clearPersistedHookState,
56
+ formatHookStatus,
57
+ } from "./post-unit-hooks.js";
45
58
  import {
46
59
  validatePlanBoundary,
47
60
  validateExecuteBoundary,
@@ -347,6 +360,8 @@ export async function stopAuto(ctx?: ExtensionContext, pi?: ExtensionAPI): Promi
347
360
  }
348
361
 
349
362
  resetMetrics();
363
+ resetHookState();
364
+ if (basePath) clearPersistedHookState(basePath);
350
365
  active = false;
351
366
  paused = false;
352
367
  stepMode = false;
@@ -564,6 +579,8 @@ export async function startAuto(
564
579
  ctx.ui.setStatus("gsd-auto", stepMode ? "next" : "auto");
565
580
  ctx.ui.setFooter(hideFooter);
566
581
  ctx.ui.notify(stepMode ? "Step-mode resumed." : "Auto-mode resumed.", "info");
582
+ // Restore hook state from disk in case session was interrupted
583
+ restoreHookState(base);
567
584
  // Rebuild disk state before resuming — user interaction during pause may have changed files
568
585
  try { await rebuildState(base); } catch { /* non-fatal */ }
569
586
  try {
@@ -575,6 +592,8 @@ export async function startAuto(
575
592
  // Self-heal: clear stale runtime records where artifacts already exist
576
593
  await selfHealRuntimeRecords(base, ctx);
577
594
  invalidateStateCache();
595
+ clearParseCache();
596
+ clearPathCache();
578
597
  await dispatchNextUnit(ctx, pi);
579
598
  return;
580
599
  }
@@ -670,6 +689,8 @@ export async function startAuto(
670
689
  unitRecoveryCount.clear();
671
690
  completedKeySet.clear();
672
691
  loadPersistedKeys(base, completedKeySet);
692
+ resetHookState();
693
+ restoreHookState(base);
673
694
  autoStartTime = Date.now();
674
695
  completedUnits = [];
675
696
  currentUnit = null;
@@ -767,6 +788,8 @@ export async function handleAgentEnd(
767
788
  // Invalidate deriveState() cache — the unit just completed and may have
768
789
  // written planning files (task summaries, roadmap checkboxes, etc.)
769
790
  invalidateStateCache();
791
+ clearParseCache();
792
+ clearPathCache();
770
793
 
771
794
  // Small delay to let files settle (git commits, file writes)
772
795
  await new Promise(r => setTimeout(r, 500));
@@ -806,6 +829,79 @@ export async function handleAgentEnd(
806
829
  }
807
830
  }
808
831
 
832
+ // ── Post-unit hooks: check if a configured hook should run before normal dispatch ──
833
+ if (currentUnit && !stepMode) {
834
+ const hookUnit = checkPostUnitHooks(currentUnit.type, currentUnit.id, basePath);
835
+ if (hookUnit) {
836
+ // Dispatch the hook unit instead of normal flow
837
+ const hookStartedAt = Date.now();
838
+ if (currentUnit) {
839
+ const modelId = ctx.model?.id ?? "unknown";
840
+ snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId);
841
+ saveActivityLog(ctx, basePath, currentUnit.type, currentUnit.id);
842
+ }
843
+ currentUnit = { type: hookUnit.unitType, id: hookUnit.unitId, startedAt: hookStartedAt };
844
+ writeUnitRuntimeRecord(basePath, hookUnit.unitType, hookUnit.unitId, hookStartedAt, {
845
+ phase: "dispatched",
846
+ wrapupWarningSent: false,
847
+ timeoutAt: null,
848
+ lastProgressAt: hookStartedAt,
849
+ progressCount: 0,
850
+ lastProgressKind: "dispatch",
851
+ });
852
+
853
+ const state = await deriveState(basePath);
854
+ updateProgressWidget(ctx, hookUnit.unitType, hookUnit.unitId, state);
855
+ const hookState = getActiveHook();
856
+ ctx.ui.notify(
857
+ `Running post-unit hook: ${hookUnit.hookName} (cycle ${hookState?.cycle ?? 1})`,
858
+ "info",
859
+ );
860
+
861
+ // Switch model if the hook specifies one
862
+ if (hookUnit.model) {
863
+ const availableModels = ctx.modelRegistry.getAvailable();
864
+ const match = availableModels.find(m =>
865
+ m.id === hookUnit.model || `${m.provider}/${m.id}` === hookUnit.model,
866
+ );
867
+ if (match) {
868
+ try {
869
+ await pi.setModel(match);
870
+ } catch { /* non-fatal — use current model */ }
871
+ }
872
+ }
873
+
874
+ const result = await cmdCtx!.newSession();
875
+ if (result.cancelled) {
876
+ resetHookState();
877
+ await stopAuto(ctx, pi);
878
+ return;
879
+ }
880
+ const sessionFile = ctx.sessionManager.getSessionFile();
881
+ writeLock(basePath, hookUnit.unitType, hookUnit.unitId, completedUnits.length, sessionFile);
882
+ // Persist hook state so cycle counts survive crashes
883
+ persistHookState(basePath);
884
+ pi.sendMessage(
885
+ { customType: "gsd-auto", content: hookUnit.prompt, display: verbose },
886
+ { triggerTurn: true },
887
+ );
888
+ return; // handleAgentEnd will fire again when hook session completes
889
+ }
890
+
891
+ // Check if a hook requested a retry of the trigger unit
892
+ if (isRetryPending()) {
893
+ const trigger = consumeRetryTrigger();
894
+ if (trigger) {
895
+ ctx.ui.notify(
896
+ `Hook requested retry of ${trigger.unitType} ${trigger.unitId}.`,
897
+ "info",
898
+ );
899
+ // Fall through to normal dispatchNextUnit — state derivation will
900
+ // re-select the same unit since it hasn't been marked complete
901
+ }
902
+ }
903
+ }
904
+
809
905
  // In step mode, pause and show a wizard instead of immediately dispatching
810
906
  if (stepMode) {
811
907
  await showStepWizard(ctx, pi);
@@ -949,6 +1045,7 @@ export function describeNextUnit(state: GSDState): { label: string; description:
949
1045
  // ─── Progress Widget ──────────────────────────────────────────────────────
950
1046
 
951
1047
  function unitVerb(unitType: string): string {
1048
+ if (unitType.startsWith("hook/")) return `hook: ${unitType.slice(5)}`;
952
1049
  switch (unitType) {
953
1050
  case "research-milestone":
954
1051
  case "research-slice": return "researching";
@@ -965,6 +1062,7 @@ function unitVerb(unitType: string): string {
965
1062
  }
966
1063
 
967
1064
  function unitPhaseLabel(unitType: string): string {
1065
+ if (unitType.startsWith("hook/")) return "HOOK";
968
1066
  switch (unitType) {
969
1067
  case "research-milestone": return "RESEARCH";
970
1068
  case "research-slice": return "RESEARCH";
@@ -981,7 +1079,14 @@ function unitPhaseLabel(unitType: string): string {
981
1079
  }
982
1080
 
983
1081
  function peekNext(unitType: string, state: GSDState): string {
1082
+ // Show active hook info in progress display
1083
+ const activeHookState = getActiveHook();
1084
+ if (activeHookState) {
1085
+ return `hook: ${activeHookState.hookName} (cycle ${activeHookState.cycle})`;
1086
+ }
1087
+
984
1088
  const sid = state.activeSlice?.id ?? "";
1089
+ if (unitType.startsWith("hook/")) return `continue ${sid}`;
985
1090
  switch (unitType) {
986
1091
  case "research-milestone": return "plan milestone roadmap";
987
1092
  case "plan-milestone": return "plan or execute first slice";
@@ -1275,6 +1380,9 @@ async function dispatchNextUnit(
1275
1380
  return;
1276
1381
  }
1277
1382
 
1383
+ // Clear stale directory listing cache so deriveState sees fresh disk state (#431)
1384
+ clearPathCache();
1385
+
1278
1386
  let state = await deriveState(basePath);
1279
1387
  let mid = state.activeMilestone?.id;
1280
1388
  let midTitle = state.activeMilestone?.title;
@@ -1338,6 +1446,8 @@ async function dispatchNextUnit(
1338
1446
  }
1339
1447
  // Re-derive state from the now-merged working tree
1340
1448
  invalidateStateCache();
1449
+ clearParseCache();
1450
+ clearPathCache();
1341
1451
  state = await deriveState(basePath);
1342
1452
  mid = state.activeMilestone?.id;
1343
1453
  midTitle = state.activeMilestone?.title;
@@ -1403,6 +1513,8 @@ async function dispatchNextUnit(
1403
1513
  );
1404
1514
  // Re-derive state from main so downstream logic sees merged state
1405
1515
  invalidateStateCache();
1516
+ clearParseCache();
1517
+ clearPathCache();
1406
1518
  state = await deriveState(basePath);
1407
1519
  mid = state.activeMilestone?.id;
1408
1520
  midTitle = state.activeMilestone?.title;
@@ -1715,6 +1827,28 @@ async function dispatchNextUnit(
1715
1827
  }
1716
1828
  }
1717
1829
 
1830
+ // ── Pre-dispatch hooks: modify, skip, or replace the unit before dispatch ──
1831
+ const preDispatchResult = runPreDispatchHooks(unitType, unitId, prompt, basePath);
1832
+ if (preDispatchResult.firedHooks.length > 0) {
1833
+ ctx.ui.notify(
1834
+ `Pre-dispatch hook${preDispatchResult.firedHooks.length > 1 ? "s" : ""}: ${preDispatchResult.firedHooks.join(", ")}`,
1835
+ "info",
1836
+ );
1837
+ }
1838
+ if (preDispatchResult.action === "skip") {
1839
+ ctx.ui.notify(`Skipping ${unitType} ${unitId} (pre-dispatch hook).`, "info");
1840
+ // Yield then re-dispatch to advance to next unit
1841
+ await new Promise(r => setImmediate(r));
1842
+ await dispatchNextUnit(ctx, pi);
1843
+ return;
1844
+ }
1845
+ if (preDispatchResult.action === "replace") {
1846
+ prompt = preDispatchResult.prompt ?? prompt;
1847
+ if (preDispatchResult.unitType) unitType = preDispatchResult.unitType;
1848
+ } else if (preDispatchResult.prompt) {
1849
+ prompt = preDispatchResult.prompt;
1850
+ }
1851
+
1718
1852
  const priorSliceBlocker = getPriorSliceCompletionBlocker(basePath, getMainBranch(basePath), unitType, unitId);
1719
1853
  if (priorSliceBlocker) {
1720
1854
  await stopAuto(ctx, pi);
@@ -2285,6 +2419,7 @@ async function buildResearchMilestonePrompt(mid: string, midTitle: string, base:
2285
2419
  if (requirementsInline) inlined.push(requirementsInline);
2286
2420
  const decisionsInline = await inlineGsdRootFile(base, "decisions.md", "Decisions");
2287
2421
  if (decisionsInline) inlined.push(decisionsInline);
2422
+ inlined.push(inlineTemplate("research", "Research"));
2288
2423
 
2289
2424
  const inlinedContext = `## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`;
2290
2425
 
@@ -2317,6 +2452,11 @@ async function buildPlanMilestonePrompt(mid: string, midTitle: string, base: str
2317
2452
  if (requirementsInline) inlined.push(requirementsInline);
2318
2453
  const decisionsInline = await inlineGsdRootFile(base, "decisions.md", "Decisions");
2319
2454
  if (decisionsInline) inlined.push(decisionsInline);
2455
+ inlined.push(inlineTemplate("roadmap", "Roadmap"));
2456
+ inlined.push(inlineTemplate("decisions", "Decisions"));
2457
+ inlined.push(inlineTemplate("plan", "Slice Plan"));
2458
+ inlined.push(inlineTemplate("task-plan", "Task Plan"));
2459
+ inlined.push(inlineTemplate("secrets-manifest", "Secrets Manifest"));
2320
2460
 
2321
2461
  const inlinedContext = `## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`;
2322
2462
 
@@ -2353,6 +2493,7 @@ async function buildResearchSlicePrompt(
2353
2493
  if (decisionsInline) inlined.push(decisionsInline);
2354
2494
  const requirementsInline = await inlineGsdRootFile(base, "requirements.md", "Requirements");
2355
2495
  if (requirementsInline) inlined.push(requirementsInline);
2496
+ inlined.push(inlineTemplate("research", "Research"));
2356
2497
 
2357
2498
  const depContent = await inlineDependencySummaries(mid, sid, base);
2358
2499
 
@@ -2388,6 +2529,8 @@ async function buildPlanSlicePrompt(
2388
2529
  if (decisionsInline) inlined.push(decisionsInline);
2389
2530
  const requirementsInline = await inlineGsdRootFile(base, "requirements.md", "Requirements");
2390
2531
  if (requirementsInline) inlined.push(requirementsInline);
2532
+ inlined.push(inlineTemplate("plan", "Slice Plan"));
2533
+ inlined.push(inlineTemplate("task-plan", "Task Plan"));
2391
2534
 
2392
2535
  const depContent = await inlineDependencySummaries(mid, sid, base);
2393
2536
 
@@ -2449,6 +2592,10 @@ async function buildExecuteTaskPrompt(
2449
2592
  );
2450
2593
 
2451
2594
  const carryForwardSection = await buildCarryForwardSection(priorSummaries, base);
2595
+ const inlinedTemplates = [
2596
+ inlineTemplate("task-summary", "Task Summary"),
2597
+ inlineTemplate("decisions", "Decisions"),
2598
+ ].join("\n\n---\n\n");
2452
2599
 
2453
2600
  const taskSummaryPath = `${relSlicePath(base, mid, sid)}/tasks/${tid}-SUMMARY.md`;
2454
2601
 
@@ -2463,6 +2610,7 @@ async function buildExecuteTaskPrompt(
2463
2610
  resumeSection,
2464
2611
  priorTaskLines: priorLines,
2465
2612
  taskSummaryPath,
2613
+ inlinedTemplates,
2466
2614
  });
2467
2615
  }
2468
2616
 
@@ -2495,6 +2643,8 @@ async function buildCompleteSlicePrompt(
2495
2643
  }
2496
2644
  }
2497
2645
  }
2646
+ inlined.push(inlineTemplate("slice-summary", "Slice Summary"));
2647
+ inlined.push(inlineTemplate("uat", "UAT"));
2498
2648
 
2499
2649
  const inlinedContext = `## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`;
2500
2650
 
@@ -2547,6 +2697,7 @@ async function buildCompleteMilestonePrompt(
2547
2697
  const contextRel = relMilestoneFile(base, mid, "CONTEXT");
2548
2698
  const contextInline = await inlineFileOptional(contextPath, contextRel, "Milestone Context");
2549
2699
  if (contextInline) inlined.push(contextInline);
2700
+ inlined.push(inlineTemplate("milestone-summary", "Milestone Summary"));
2550
2701
 
2551
2702
  const inlinedContext = `## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`;
2552
2703
 
@@ -2993,6 +3144,9 @@ async function collectObservabilityWarnings(
2993
3144
  unitType: string,
2994
3145
  unitId: string,
2995
3146
  ): Promise<import("./observability-validator.ts").ValidationIssue[]> {
3147
+ // Hook units have custom artifacts — skip standard observability checks
3148
+ if (unitType.startsWith("hook/")) return [];
3149
+
2996
3150
  const parts = unitId.split("/");
2997
3151
  const mid = parts[0];
2998
3152
  const sid = parts[1];
@@ -3404,6 +3558,9 @@ export function resolveExpectedArtifactPath(unitType: string, unitId: string, ba
3404
3558
  * skipped writing the UAT file (see #176).
3405
3559
  */
3406
3560
  export function verifyExpectedArtifact(unitType: string, unitId: string, base: string): boolean {
3561
+ // Clear stale directory listing cache so artifact checks see fresh disk state (#431)
3562
+ clearPathCache();
3563
+
3407
3564
  // fix-merge has no file artifact — verify by checking git state
3408
3565
  if (unitType === "fix-merge") {
3409
3566
  const unmerged = runGit(base, ["diff", "--name-only", "--diff-filter=U"], { allowFailure: true });
@@ -53,10 +53,10 @@ function dispatchDoctorHeal(pi: ExtensionAPI, scope: string | undefined, reportT
53
53
 
54
54
  export function registerGSDCommand(pi: ExtensionAPI): void {
55
55
  pi.registerCommand("gsd", {
56
- description: "GSD — Get Shit Done: /gsd next|auto|stop|status|queue|prefs|doctor|migrate|remote",
56
+ description: "GSD — Get Shit Done: /gsd next|auto|stop|status|queue|prefs|hooks|doctor|migrate|remote",
57
57
 
58
58
  getArgumentCompletions: (prefix: string) => {
59
- const subcommands = ["next", "auto", "stop", "status", "queue", "discuss", "prefs", "doctor", "migrate", "remote"];
59
+ const subcommands = ["next", "auto", "stop", "status", "queue", "discuss", "prefs", "hooks", "doctor", "migrate", "remote"];
60
60
  const parts = prefix.trim().split(/\s+/);
61
61
 
62
62
  if (parts.length <= 1) {
@@ -151,6 +151,12 @@ export function registerGSDCommand(pi: ExtensionAPI): void {
151
151
  return;
152
152
  }
153
153
 
154
+ if (trimmed === "hooks") {
155
+ const { formatHookStatus } = await import("./post-unit-hooks.js");
156
+ ctx.ui.notify(formatHookStatus(), "info");
157
+ return;
158
+ }
159
+
154
160
  if (trimmed === "migrate" || trimmed.startsWith("migrate ")) {
155
161
  await handleMigrate(trimmed.replace(/^migrate\s*/, "").trim(), ctx, pi);
156
162
  return;
@@ -168,7 +174,7 @@ export function registerGSDCommand(pi: ExtensionAPI): void {
168
174
  }
169
175
 
170
176
  ctx.ui.notify(
171
- `Unknown: /gsd ${trimmed}. Use /gsd, /gsd next, /gsd auto, /gsd stop, /gsd status, /gsd queue, /gsd discuss, /gsd prefs [global|project|status|wizard|setup], /gsd doctor [audit|fix|heal] [M###/S##], /gsd migrate <path>, or /gsd remote [slack|discord|status|disconnect].`,
177
+ `Unknown: /gsd ${trimmed}. Use /gsd, /gsd next, /gsd auto, /gsd stop, /gsd status, /gsd queue, /gsd discuss, /gsd prefs [global|project|status|wizard|setup], /gsd hooks, /gsd doctor [audit|fix|heal] [M###/S##], /gsd migrate <path>, or /gsd remote [slack|discord|status|disconnect].`,
172
178
  "warning",
173
179
  );
174
180
  },
@@ -22,7 +22,8 @@ export type DoctorIssueCode =
22
22
  | "task_done_must_haves_not_verified"
23
23
  | "active_requirement_missing_owner"
24
24
  | "blocked_requirement_missing_reason"
25
- | "blocker_discovered_no_replan";
25
+ | "blocker_discovered_no_replan"
26
+ | "delimiter_in_title";
26
27
 
27
28
  export interface DoctorIssue {
28
29
  severity: DoctorSeverity;
@@ -91,15 +92,43 @@ function validatePreferenceShape(preferences: GSDPreferences): string[] {
91
92
  return issues;
92
93
  }
93
94
 
95
+ /**
96
+ * Characters that are used as delimiters in GSD state management documents
97
+ * and should not appear in milestone or slice titles.
98
+ *
99
+ * - "—" (em dash, U+2014): used as a display separator in STATE.md and other docs.
100
+ * A title containing "—" makes the separator ambiguous, corrupting state display
101
+ * and confusing the LLM agent that reads and writes these files.
102
+ * - "–" (en dash, U+2013): visually similar to em dash; same ambiguity risk.
103
+ * - "/" (forward slash, U+002F): used as the path separator in unit IDs (M001/S01)
104
+ * and git branch names (gsd/M001/S01). A slash in a title can break path resolution.
105
+ */
106
+ const TITLE_DELIMITER_RE = /[\u2014\u2013\/]/; // em dash, en dash, forward slash
107
+
108
+ /**
109
+ * Check whether a milestone or slice title contains characters that conflict
110
+ * with GSD's state document delimiter conventions.
111
+ * Returns a human-readable description of the problem, or null if the title is safe.
112
+ */
113
+ export function validateTitle(title: string): string | null {
114
+ if (TITLE_DELIMITER_RE.test(title)) {
115
+ const found: string[] = [];
116
+ if (/[\u2014\u2013]/.test(title)) found.push("em/en dash (\u2014 or \u2013)");
117
+ if (/\//.test(title)) found.push("forward slash (/)");
118
+ return `title contains ${found.join(" and ")}, which conflict with GSD state document delimiters`;
119
+ }
120
+ return null;
121
+ }
122
+
94
123
  function buildStateMarkdown(state: Awaited<ReturnType<typeof deriveState>>): string {
95
124
  const lines: string[] = [];
96
125
  lines.push("# GSD State", "");
97
126
 
98
127
  const activeMilestone = state.activeMilestone
99
- ? `${state.activeMilestone.id} ${state.activeMilestone.title}`
128
+ ? `${state.activeMilestone.id}: ${state.activeMilestone.title}`
100
129
  : "None";
101
130
  const activeSlice = state.activeSlice
102
- ? `${state.activeSlice.id} ${state.activeSlice.title}`
131
+ ? `${state.activeSlice.id}: ${state.activeSlice.title}`
103
132
  : "None";
104
133
 
105
134
  lines.push(`**Active Milestone:** ${activeMilestone}`);
@@ -477,6 +506,20 @@ export async function runGSDDoctor(basePath: string, options?: { fix?: boolean;
477
506
  const milestonePath = resolveMilestonePath(basePath, milestoneId);
478
507
  if (!milestonePath) continue;
479
508
 
509
+ // Validate milestone title for delimiter characters that break state documents.
510
+ const milestoneTitleIssue = validateTitle(milestone.title);
511
+ if (milestoneTitleIssue) {
512
+ issues.push({
513
+ severity: "warning",
514
+ code: "delimiter_in_title",
515
+ scope: "milestone",
516
+ unitId: milestoneId,
517
+ message: `Milestone ${milestoneId} ${milestoneTitleIssue}. Rename the milestone to remove these characters to prevent state corruption.`,
518
+ file: relMilestoneFile(basePath, milestoneId, "ROADMAP"),
519
+ fixable: false,
520
+ });
521
+ }
522
+
480
523
  const roadmapPath = resolveMilestoneFile(basePath, milestoneId, "ROADMAP");
481
524
  const roadmapContent = roadmapPath ? await loadFile(roadmapPath) : null;
482
525
  if (!roadmapContent) continue;
@@ -486,6 +529,20 @@ export async function runGSDDoctor(basePath: string, options?: { fix?: boolean;
486
529
  const unitId = `${milestoneId}/${slice.id}`;
487
530
  if (options?.scope && !matchesScope(unitId, options.scope) && options.scope !== milestoneId) continue;
488
531
 
532
+ // Validate slice title for delimiter characters.
533
+ const sliceTitleIssue = validateTitle(slice.title);
534
+ if (sliceTitleIssue) {
535
+ issues.push({
536
+ severity: "warning",
537
+ code: "delimiter_in_title",
538
+ scope: "slice",
539
+ unitId,
540
+ message: `Slice ${unitId} ${sliceTitleIssue}. Rename the slice to remove these characters to prevent state corruption.`,
541
+ file: relMilestoneFile(basePath, milestoneId, "ROADMAP"),
542
+ fixable: false,
543
+ });
544
+ }
545
+
489
546
  const slicePath = resolveSlicePath(basePath, milestoneId, slice.id);
490
547
  if (!slicePath) continue;
491
548