@tuan_son.dinh/gsd 2.6.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 (227) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +453 -0
  3. package/dist/app-paths.d.ts +4 -0
  4. package/dist/app-paths.js +6 -0
  5. package/dist/cli.d.ts +1 -0
  6. package/dist/cli.js +269 -0
  7. package/dist/loader.d.ts +2 -0
  8. package/dist/loader.js +70 -0
  9. package/dist/logo.d.ts +16 -0
  10. package/dist/logo.js +25 -0
  11. package/dist/onboarding.d.ts +43 -0
  12. package/dist/onboarding.js +418 -0
  13. package/dist/pi-migration.d.ts +14 -0
  14. package/dist/pi-migration.js +57 -0
  15. package/dist/resource-loader.d.ts +22 -0
  16. package/dist/resource-loader.js +60 -0
  17. package/dist/tool-bootstrap.d.ts +4 -0
  18. package/dist/tool-bootstrap.js +74 -0
  19. package/dist/wizard.d.ts +7 -0
  20. package/dist/wizard.js +25 -0
  21. package/package.json +60 -0
  22. package/patches/@mariozechner+pi-coding-agent+0.57.1.patch +108 -0
  23. package/patches/@mariozechner+pi-tui+0.57.1.patch +47 -0
  24. package/pkg/dist/modes/interactive/theme/dark.json +85 -0
  25. package/pkg/dist/modes/interactive/theme/light.json +84 -0
  26. package/pkg/dist/modes/interactive/theme/theme-schema.json +335 -0
  27. package/pkg/dist/modes/interactive/theme/theme.d.ts +78 -0
  28. package/pkg/dist/modes/interactive/theme/theme.d.ts.map +1 -0
  29. package/pkg/dist/modes/interactive/theme/theme.js +949 -0
  30. package/pkg/dist/modes/interactive/theme/theme.js.map +1 -0
  31. package/pkg/package.json +8 -0
  32. package/scripts/postinstall.js +127 -0
  33. package/src/resources/GSD-WORKFLOW.md +661 -0
  34. package/src/resources/agents/researcher.md +29 -0
  35. package/src/resources/agents/scout.md +56 -0
  36. package/src/resources/agents/worker.md +31 -0
  37. package/src/resources/extensions/ask-user-questions.ts +249 -0
  38. package/src/resources/extensions/bg-shell/index.ts +2808 -0
  39. package/src/resources/extensions/browser-tools/BROWSER-TOOLS-V2-PROPOSAL.md +1277 -0
  40. package/src/resources/extensions/browser-tools/core.js +1057 -0
  41. package/src/resources/extensions/browser-tools/index.ts +4989 -0
  42. package/src/resources/extensions/browser-tools/package.json +20 -0
  43. package/src/resources/extensions/context7/index.ts +428 -0
  44. package/src/resources/extensions/context7/package.json +11 -0
  45. package/src/resources/extensions/get-secrets-from-user.ts +352 -0
  46. package/src/resources/extensions/google-search/index.ts +323 -0
  47. package/src/resources/extensions/google-search/package.json +9 -0
  48. package/src/resources/extensions/gsd/activity-log.ts +69 -0
  49. package/src/resources/extensions/gsd/auto.ts +2744 -0
  50. package/src/resources/extensions/gsd/commands.ts +313 -0
  51. package/src/resources/extensions/gsd/crash-recovery.ts +85 -0
  52. package/src/resources/extensions/gsd/dashboard-overlay.ts +521 -0
  53. package/src/resources/extensions/gsd/docs/preferences-reference.md +176 -0
  54. package/src/resources/extensions/gsd/doctor.ts +690 -0
  55. package/src/resources/extensions/gsd/files.ts +732 -0
  56. package/src/resources/extensions/gsd/git-service.ts +597 -0
  57. package/src/resources/extensions/gsd/gitignore.ts +168 -0
  58. package/src/resources/extensions/gsd/guided-flow.ts +817 -0
  59. package/src/resources/extensions/gsd/index.ts +558 -0
  60. package/src/resources/extensions/gsd/metrics.ts +374 -0
  61. package/src/resources/extensions/gsd/migrate/command.ts +218 -0
  62. package/src/resources/extensions/gsd/migrate/index.ts +42 -0
  63. package/src/resources/extensions/gsd/migrate/parser.ts +323 -0
  64. package/src/resources/extensions/gsd/migrate/parsers.ts +624 -0
  65. package/src/resources/extensions/gsd/migrate/preview.ts +48 -0
  66. package/src/resources/extensions/gsd/migrate/transformer.ts +346 -0
  67. package/src/resources/extensions/gsd/migrate/types.ts +370 -0
  68. package/src/resources/extensions/gsd/migrate/validator.ts +55 -0
  69. package/src/resources/extensions/gsd/migrate/writer.ts +539 -0
  70. package/src/resources/extensions/gsd/observability-validator.ts +408 -0
  71. package/src/resources/extensions/gsd/package.json +11 -0
  72. package/src/resources/extensions/gsd/paths.ts +308 -0
  73. package/src/resources/extensions/gsd/preferences.ts +757 -0
  74. package/src/resources/extensions/gsd/prompt-loader.ts +50 -0
  75. package/src/resources/extensions/gsd/prompts/complete-milestone.md +25 -0
  76. package/src/resources/extensions/gsd/prompts/complete-slice.md +29 -0
  77. package/src/resources/extensions/gsd/prompts/discuss.md +189 -0
  78. package/src/resources/extensions/gsd/prompts/doctor-heal.md +29 -0
  79. package/src/resources/extensions/gsd/prompts/execute-task.md +61 -0
  80. package/src/resources/extensions/gsd/prompts/guided-complete-slice.md +1 -0
  81. package/src/resources/extensions/gsd/prompts/guided-discuss-milestone.md +3 -0
  82. package/src/resources/extensions/gsd/prompts/guided-discuss-slice.md +59 -0
  83. package/src/resources/extensions/gsd/prompts/guided-execute-task.md +1 -0
  84. package/src/resources/extensions/gsd/prompts/guided-plan-milestone.md +23 -0
  85. package/src/resources/extensions/gsd/prompts/guided-plan-slice.md +1 -0
  86. package/src/resources/extensions/gsd/prompts/guided-research-slice.md +11 -0
  87. package/src/resources/extensions/gsd/prompts/guided-resume-task.md +1 -0
  88. package/src/resources/extensions/gsd/prompts/plan-milestone.md +65 -0
  89. package/src/resources/extensions/gsd/prompts/plan-slice.md +51 -0
  90. package/src/resources/extensions/gsd/prompts/queue.md +85 -0
  91. package/src/resources/extensions/gsd/prompts/reassess-roadmap.md +48 -0
  92. package/src/resources/extensions/gsd/prompts/replan-slice.md +39 -0
  93. package/src/resources/extensions/gsd/prompts/research-milestone.md +37 -0
  94. package/src/resources/extensions/gsd/prompts/research-slice.md +28 -0
  95. package/src/resources/extensions/gsd/prompts/review-migration.md +66 -0
  96. package/src/resources/extensions/gsd/prompts/run-uat.md +109 -0
  97. package/src/resources/extensions/gsd/prompts/system.md +187 -0
  98. package/src/resources/extensions/gsd/prompts/worktree-merge.md +123 -0
  99. package/src/resources/extensions/gsd/session-forensics.ts +487 -0
  100. package/src/resources/extensions/gsd/skill-discovery.ts +137 -0
  101. package/src/resources/extensions/gsd/state.ts +460 -0
  102. package/src/resources/extensions/gsd/templates/context.md +76 -0
  103. package/src/resources/extensions/gsd/templates/decisions.md +8 -0
  104. package/src/resources/extensions/gsd/templates/milestone-summary.md +73 -0
  105. package/src/resources/extensions/gsd/templates/plan.md +131 -0
  106. package/src/resources/extensions/gsd/templates/preferences.md +24 -0
  107. package/src/resources/extensions/gsd/templates/project.md +31 -0
  108. package/src/resources/extensions/gsd/templates/reassessment.md +28 -0
  109. package/src/resources/extensions/gsd/templates/requirements.md +81 -0
  110. package/src/resources/extensions/gsd/templates/research.md +46 -0
  111. package/src/resources/extensions/gsd/templates/roadmap.md +118 -0
  112. package/src/resources/extensions/gsd/templates/slice-context.md +58 -0
  113. package/src/resources/extensions/gsd/templates/slice-summary.md +99 -0
  114. package/src/resources/extensions/gsd/templates/state.md +19 -0
  115. package/src/resources/extensions/gsd/templates/task-plan.md +52 -0
  116. package/src/resources/extensions/gsd/templates/task-summary.md +57 -0
  117. package/src/resources/extensions/gsd/templates/uat.md +54 -0
  118. package/src/resources/extensions/gsd/tests/activity-log-prune.test.ts +327 -0
  119. package/src/resources/extensions/gsd/tests/auto-preflight.test.ts +56 -0
  120. package/src/resources/extensions/gsd/tests/auto-supervisor.test.mjs +53 -0
  121. package/src/resources/extensions/gsd/tests/complete-milestone.test.ts +225 -0
  122. package/src/resources/extensions/gsd/tests/cost-projection.test.ts +160 -0
  123. package/src/resources/extensions/gsd/tests/derive-state-deps.test.ts +341 -0
  124. package/src/resources/extensions/gsd/tests/derive-state.test.ts +689 -0
  125. package/src/resources/extensions/gsd/tests/discuss-prompt.test.ts +38 -0
  126. package/src/resources/extensions/gsd/tests/doctor.test.ts +505 -0
  127. package/src/resources/extensions/gsd/tests/git-service.test.ts +1313 -0
  128. package/src/resources/extensions/gsd/tests/idle-recovery.test.ts +308 -0
  129. package/src/resources/extensions/gsd/tests/metrics-io.test.ts +201 -0
  130. package/src/resources/extensions/gsd/tests/metrics.test.ts +217 -0
  131. package/src/resources/extensions/gsd/tests/migrate-command.test.ts +390 -0
  132. package/src/resources/extensions/gsd/tests/migrate-parser.test.ts +786 -0
  133. package/src/resources/extensions/gsd/tests/migrate-transformer.test.ts +657 -0
  134. package/src/resources/extensions/gsd/tests/migrate-validator-parsers.test.ts +443 -0
  135. package/src/resources/extensions/gsd/tests/migrate-writer-integration.test.ts +318 -0
  136. package/src/resources/extensions/gsd/tests/migrate-writer.test.ts +420 -0
  137. package/src/resources/extensions/gsd/tests/must-have-parser.test.ts +309 -0
  138. package/src/resources/extensions/gsd/tests/parsers.test.ts +1351 -0
  139. package/src/resources/extensions/gsd/tests/plan-milestone.test.ts +163 -0
  140. package/src/resources/extensions/gsd/tests/plan-quality-validator.test.ts +386 -0
  141. package/src/resources/extensions/gsd/tests/reassess-prompt.test.ts +171 -0
  142. package/src/resources/extensions/gsd/tests/remote-questions.test.ts +155 -0
  143. package/src/resources/extensions/gsd/tests/remote-status.test.ts +99 -0
  144. package/src/resources/extensions/gsd/tests/replan-slice.test.ts +521 -0
  145. package/src/resources/extensions/gsd/tests/requirements.test.ts +125 -0
  146. package/src/resources/extensions/gsd/tests/resolve-ts-hooks.mjs +34 -0
  147. package/src/resources/extensions/gsd/tests/resolve-ts.mjs +11 -0
  148. package/src/resources/extensions/gsd/tests/run-uat.test.ts +348 -0
  149. package/src/resources/extensions/gsd/tests/unit-runtime.test.ts +247 -0
  150. package/src/resources/extensions/gsd/tests/workflow-config.test.mjs +53 -0
  151. package/src/resources/extensions/gsd/tests/workspace-index.test.ts +94 -0
  152. package/src/resources/extensions/gsd/tests/worktree-integration.test.ts +253 -0
  153. package/src/resources/extensions/gsd/tests/worktree-manager.test.ts +160 -0
  154. package/src/resources/extensions/gsd/tests/worktree.test.ts +264 -0
  155. package/src/resources/extensions/gsd/types.ts +159 -0
  156. package/src/resources/extensions/gsd/unit-runtime.ts +184 -0
  157. package/src/resources/extensions/gsd/workspace-index.ts +203 -0
  158. package/src/resources/extensions/gsd/worktree-command.ts +845 -0
  159. package/src/resources/extensions/gsd/worktree-manager.ts +392 -0
  160. package/src/resources/extensions/gsd/worktree.ts +183 -0
  161. package/src/resources/extensions/mac-tools/index.ts +852 -0
  162. package/src/resources/extensions/mac-tools/swift-cli/Package.swift +22 -0
  163. package/src/resources/extensions/mac-tools/swift-cli/Sources/main.swift +1318 -0
  164. package/src/resources/extensions/mcporter/index.ts +429 -0
  165. package/src/resources/extensions/remote-questions/config.ts +81 -0
  166. package/src/resources/extensions/remote-questions/discord-adapter.ts +128 -0
  167. package/src/resources/extensions/remote-questions/format.ts +163 -0
  168. package/src/resources/extensions/remote-questions/manager.ts +192 -0
  169. package/src/resources/extensions/remote-questions/remote-command.ts +307 -0
  170. package/src/resources/extensions/remote-questions/slack-adapter.ts +92 -0
  171. package/src/resources/extensions/remote-questions/status.ts +31 -0
  172. package/src/resources/extensions/remote-questions/store.ts +77 -0
  173. package/src/resources/extensions/remote-questions/types.ts +75 -0
  174. package/src/resources/extensions/search-the-web/cache.ts +78 -0
  175. package/src/resources/extensions/search-the-web/command-search-provider.ts +95 -0
  176. package/src/resources/extensions/search-the-web/format.ts +258 -0
  177. package/src/resources/extensions/search-the-web/http.ts +238 -0
  178. package/src/resources/extensions/search-the-web/index.ts +65 -0
  179. package/src/resources/extensions/search-the-web/native-search.ts +157 -0
  180. package/src/resources/extensions/search-the-web/provider.ts +118 -0
  181. package/src/resources/extensions/search-the-web/tavily.ts +116 -0
  182. package/src/resources/extensions/search-the-web/tool-fetch-page.ts +519 -0
  183. package/src/resources/extensions/search-the-web/tool-llm-context.ts +561 -0
  184. package/src/resources/extensions/search-the-web/tool-search.ts +576 -0
  185. package/src/resources/extensions/search-the-web/url-utils.ts +91 -0
  186. package/src/resources/extensions/shared/confirm-ui.ts +126 -0
  187. package/src/resources/extensions/shared/interview-ui.ts +613 -0
  188. package/src/resources/extensions/shared/next-action-ui.ts +197 -0
  189. package/src/resources/extensions/shared/progress-widget.ts +282 -0
  190. package/src/resources/extensions/shared/terminal.ts +23 -0
  191. package/src/resources/extensions/shared/thinking-widget.ts +107 -0
  192. package/src/resources/extensions/shared/ui.ts +400 -0
  193. package/src/resources/extensions/shared/wizard-ui.ts +551 -0
  194. package/src/resources/extensions/slash-commands/audit.ts +88 -0
  195. package/src/resources/extensions/slash-commands/clear.ts +10 -0
  196. package/src/resources/extensions/slash-commands/create-extension.ts +297 -0
  197. package/src/resources/extensions/slash-commands/create-slash-command.ts +234 -0
  198. package/src/resources/extensions/slash-commands/index.ts +12 -0
  199. package/src/resources/extensions/subagent/agents.ts +126 -0
  200. package/src/resources/extensions/subagent/index.ts +1020 -0
  201. package/src/resources/extensions/voice/index.ts +195 -0
  202. package/src/resources/extensions/voice/speech-recognizer.swift +154 -0
  203. package/src/resources/skills/debug-like-expert/SKILL.md +231 -0
  204. package/src/resources/skills/debug-like-expert/references/debugging-mindset.md +253 -0
  205. package/src/resources/skills/debug-like-expert/references/hypothesis-testing.md +373 -0
  206. package/src/resources/skills/debug-like-expert/references/investigation-techniques.md +337 -0
  207. package/src/resources/skills/debug-like-expert/references/verification-patterns.md +425 -0
  208. package/src/resources/skills/debug-like-expert/references/when-to-research.md +361 -0
  209. package/src/resources/skills/frontend-design/SKILL.md +45 -0
  210. package/src/resources/skills/swiftui/SKILL.md +208 -0
  211. package/src/resources/skills/swiftui/references/animations.md +921 -0
  212. package/src/resources/skills/swiftui/references/architecture.md +1561 -0
  213. package/src/resources/skills/swiftui/references/layout-system.md +1186 -0
  214. package/src/resources/skills/swiftui/references/navigation.md +1492 -0
  215. package/src/resources/skills/swiftui/references/networking-async.md +214 -0
  216. package/src/resources/skills/swiftui/references/performance.md +1706 -0
  217. package/src/resources/skills/swiftui/references/platform-integration.md +204 -0
  218. package/src/resources/skills/swiftui/references/state-management.md +1443 -0
  219. package/src/resources/skills/swiftui/references/swiftdata.md +297 -0
  220. package/src/resources/skills/swiftui/references/testing-debugging.md +247 -0
  221. package/src/resources/skills/swiftui/references/uikit-appkit-interop.md +218 -0
  222. package/src/resources/skills/swiftui/workflows/add-feature.md +191 -0
  223. package/src/resources/skills/swiftui/workflows/build-new-app.md +311 -0
  224. package/src/resources/skills/swiftui/workflows/debug-swiftui.md +192 -0
  225. package/src/resources/skills/swiftui/workflows/optimize-performance.md +197 -0
  226. package/src/resources/skills/swiftui/workflows/ship-app.md +203 -0
  227. package/src/resources/skills/swiftui/workflows/write-tests.md +235 -0
@@ -0,0 +1,2744 @@
1
+ /**
2
+ * GSD Auto Mode — Fresh Session Per Unit
3
+ *
4
+ * State machine driven by .gsd/ files on disk. Each "unit" of work
5
+ * (plan slice, execute task, complete slice) gets a fresh session via
6
+ * the stashed ctx.newSession() pattern.
7
+ *
8
+ * The extension reads disk state after each agent_end, determines the
9
+ * next unit type, creates a fresh session, and injects a focused prompt
10
+ * telling the LLM which files to read and what to do.
11
+ */
12
+
13
+ import type {
14
+ ExtensionAPI,
15
+ ExtensionContext,
16
+ ExtensionCommandContext,
17
+ } from "@mariozechner/pi-coding-agent";
18
+
19
+ import { deriveState } from "./state.js";
20
+ import type { GSDState } from "./types.js";
21
+ import { loadFile, parseContinue, parsePlan, parseRoadmap, parseSummary, extractUatType, inlinePriorMilestoneSummary } from "./files.js";
22
+ export { inlinePriorMilestoneSummary };
23
+ import type { UatType } from "./files.js";
24
+ import { loadPrompt } from "./prompt-loader.js";
25
+ import {
26
+ gsdRoot, resolveMilestoneFile, resolveSliceFile, resolveSlicePath,
27
+ resolveMilestonePath, resolveDir, resolveTasksDir, resolveTaskFiles, resolveTaskFile,
28
+ relMilestoneFile, relSliceFile, relTaskFile, relSlicePath, relMilestonePath,
29
+ milestonesDir, resolveGsdRootFile, relGsdRootFile,
30
+ buildMilestoneFileName, buildSliceFileName, buildTaskFileName,
31
+ } from "./paths.js";
32
+ import { saveActivityLog } from "./activity-log.js";
33
+ import { synthesizeCrashRecovery, getDeepDiagnostic } from "./session-forensics.js";
34
+ import { writeLock, clearLock, readCrashLock, formatCrashInfo } from "./crash-recovery.js";
35
+ import {
36
+ clearUnitRuntimeRecord,
37
+ formatExecuteTaskRecoveryStatus,
38
+ inspectExecuteTaskDurability,
39
+ readUnitRuntimeRecord,
40
+ writeUnitRuntimeRecord,
41
+ } from "./unit-runtime.js";
42
+ import { resolveAutoSupervisorConfig, resolveModelForUnit, resolveSkillDiscoveryMode, loadEffectiveGSDPreferences, resolveWorkflowConfig } from "./preferences.js";
43
+ import type { GSDPreferences } from "./preferences.js";
44
+ import {
45
+ validatePlanBoundary,
46
+ validateExecuteBoundary,
47
+ validateCompleteBoundary,
48
+ formatValidationIssues,
49
+ } from "./observability-validator.js";
50
+ import { ensureGitignore } from "./gitignore.js";
51
+ import { runGSDDoctor, rebuildState } from "./doctor.js";
52
+ import { snapshotSkills, clearSkillSnapshot } from "./skill-discovery.js";
53
+ import {
54
+ initMetrics, resetMetrics, snapshotUnitMetrics, getLedger,
55
+ getProjectTotals, formatCost, formatTokenCount,
56
+ } from "./metrics.js";
57
+ import { join } from "node:path";
58
+ import { readdirSync, readFileSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
59
+ import { execSync, execFileSync } from "node:child_process";
60
+ import {
61
+ autoCommitCurrentBranch,
62
+ ensureSliceBranch,
63
+ getCurrentBranch,
64
+ getMainBranch,
65
+ parseSliceBranch,
66
+ switchToMain,
67
+ mergeSliceToMain,
68
+ } from "./worktree.ts";
69
+ import { GitServiceImpl } from "./git-service.ts";
70
+ import type { GitPreferences } from "./git-service.ts";
71
+ import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
72
+ import { makeUI, GLYPH, INDENT } from "../shared/ui.js";
73
+ import { showNextAction } from "../shared/next-action-ui.js";
74
+
75
+ // ─── Disk-backed completed-unit helpers ───────────────────────────────────────
76
+
77
+ /** Path to the persisted completed-unit keys file. */
78
+ function completedKeysPath(base: string): string {
79
+ return join(base, ".gsd", "completed-units.json");
80
+ }
81
+
82
+ /** Write a completed unit key to disk (read-modify-write append to set). */
83
+ function persistCompletedKey(base: string, key: string): void {
84
+ const file = completedKeysPath(base);
85
+ let keys: string[] = [];
86
+ try {
87
+ if (existsSync(file)) {
88
+ keys = JSON.parse(readFileSync(file, "utf-8"));
89
+ }
90
+ } catch { /* corrupt file — start fresh */ }
91
+ if (!keys.includes(key)) {
92
+ keys.push(key);
93
+ writeFileSync(file, JSON.stringify(keys), "utf-8");
94
+ }
95
+ }
96
+
97
+ /** Remove a stale completed unit key from disk. */
98
+ function removePersistedKey(base: string, key: string): void {
99
+ const file = completedKeysPath(base);
100
+ try {
101
+ if (existsSync(file)) {
102
+ let keys: string[] = JSON.parse(readFileSync(file, "utf-8"));
103
+ keys = keys.filter(k => k !== key);
104
+ writeFileSync(file, JSON.stringify(keys), "utf-8");
105
+ }
106
+ } catch { /* non-fatal */ }
107
+ }
108
+
109
+ /** Load all completed unit keys from disk into the in-memory set. */
110
+ function loadPersistedKeys(base: string, target: Set<string>): void {
111
+ const file = completedKeysPath(base);
112
+ try {
113
+ if (existsSync(file)) {
114
+ const keys: string[] = JSON.parse(readFileSync(file, "utf-8"));
115
+ for (const k of keys) target.add(k);
116
+ }
117
+ } catch { /* non-fatal */ }
118
+ }
119
+
120
+ // ─── State ────────────────────────────────────────────────────────────────────
121
+
122
+ let active = false;
123
+ let paused = false;
124
+ let stepMode = false;
125
+ let verbose = false;
126
+ let cmdCtx: ExtensionCommandContext | null = null;
127
+ let basePath = "";
128
+ let gitService: GitServiceImpl | null = null;
129
+
130
+ /** Track total dispatches per unit to detect stuck loops (catches A→B→A→B patterns) */
131
+ const unitDispatchCount = new Map<string, number>();
132
+ const MAX_UNIT_DISPATCHES = 3;
133
+
134
+ /** Tracks recovery attempt count per unit for backoff and diagnostics. */
135
+ const unitRecoveryCount = new Map<string, number>();
136
+
137
+ /** Persisted completed-unit keys — survives restarts. Loaded from .gsd/completed-units.json. */
138
+ const completedKeySet = new Set<string>();
139
+
140
+ /** Crash recovery prompt — set by startAuto, consumed by first dispatchNextUnit */
141
+ let pendingCrashRecovery: string | null = null;
142
+
143
+ /** Dashboard tracking */
144
+ let autoStartTime: number = 0;
145
+ let completedUnits: { type: string; id: string; startedAt: number; finishedAt: number }[] = [];
146
+ let currentUnit: { type: string; id: string; startedAt: number } | null = null;
147
+
148
+ /** Track current milestone to detect transitions */
149
+ let currentMilestoneId: string | null = null;
150
+
151
+ /** Model the user had selected before auto-mode started */
152
+ let originalModelId: string | null = null;
153
+
154
+ /** Progress-aware timeout supervision */
155
+ let unitTimeoutHandle: ReturnType<typeof setTimeout> | null = null;
156
+ let wrapupWarningHandle: ReturnType<typeof setTimeout> | null = null;
157
+ let idleWatchdogHandle: ReturnType<typeof setInterval> | null = null;
158
+
159
+ /** Format token counts for compact display */
160
+ function formatWidgetTokens(count: number): string {
161
+ if (count < 1000) return count.toString();
162
+ if (count < 10000) return `${(count / 1000).toFixed(1)}k`;
163
+ if (count < 1000000) return `${Math.round(count / 1000)}k`;
164
+ if (count < 10000000) return `${(count / 1000000).toFixed(1)}M`;
165
+ return `${Math.round(count / 1000000)}M`;
166
+ }
167
+
168
+ /**
169
+ * Footer factory that renders zero lines — hides the built-in footer entirely.
170
+ * All footer info (pwd, branch, tokens, cost, model) is shown inside the
171
+ * progress widget instead, so there's no gap or redundancy.
172
+ */
173
+ const hideFooter = () => ({
174
+ render(_width: number): string[] { return []; },
175
+ invalidate() {},
176
+ dispose() {},
177
+ });
178
+
179
+ /** Dashboard data for the overlay */
180
+ export interface AutoDashboardData {
181
+ active: boolean;
182
+ paused: boolean;
183
+ stepMode: boolean;
184
+ startTime: number;
185
+ elapsed: number;
186
+ currentUnit: { type: string; id: string; startedAt: number } | null;
187
+ completedUnits: { type: string; id: string; startedAt: number; finishedAt: number }[];
188
+ basePath: string;
189
+ /** Running cost and token totals from metrics ledger */
190
+ totalCost: number;
191
+ totalTokens: number;
192
+ }
193
+
194
+ export function getAutoDashboardData(): AutoDashboardData {
195
+ const ledger = getLedger();
196
+ const totals = ledger ? getProjectTotals(ledger.units) : null;
197
+ return {
198
+ active,
199
+ paused,
200
+ stepMode,
201
+ startTime: autoStartTime,
202
+ elapsed: (active || paused) ? Date.now() - autoStartTime : 0,
203
+ currentUnit: currentUnit ? { ...currentUnit } : null,
204
+ completedUnits: [...completedUnits],
205
+ basePath,
206
+ totalCost: totals?.cost ?? 0,
207
+ totalTokens: totals?.tokens.total ?? 0,
208
+ };
209
+ }
210
+
211
+ // ─── Public API ───────────────────────────────────────────────────────────────
212
+
213
+ export function isAutoActive(): boolean {
214
+ return active;
215
+ }
216
+
217
+ export function isAutoPaused(): boolean {
218
+ return paused;
219
+ }
220
+
221
+ export function isStepMode(): boolean {
222
+ return stepMode;
223
+ }
224
+
225
+ function clearUnitTimeout(): void {
226
+ if (unitTimeoutHandle) {
227
+ clearTimeout(unitTimeoutHandle);
228
+ unitTimeoutHandle = null;
229
+ }
230
+ if (wrapupWarningHandle) {
231
+ clearTimeout(wrapupWarningHandle);
232
+ wrapupWarningHandle = null;
233
+ }
234
+ if (idleWatchdogHandle) {
235
+ clearInterval(idleWatchdogHandle);
236
+ idleWatchdogHandle = null;
237
+ }
238
+ }
239
+
240
+ export async function stopAuto(ctx?: ExtensionContext, pi?: ExtensionAPI): Promise<void> {
241
+ if (!active && !paused) return;
242
+ clearUnitTimeout();
243
+ if (basePath) clearLock(basePath);
244
+ clearSkillSnapshot();
245
+
246
+ // Show final cost summary before resetting
247
+ const ledger = getLedger();
248
+ if (ledger && ledger.units.length > 0) {
249
+ const totals = getProjectTotals(ledger.units);
250
+ ctx?.ui.notify(
251
+ `Auto-mode stopped. Session: ${formatCost(totals.cost)} · ${formatTokenCount(totals.tokens.total)} tokens · ${ledger.units.length} units`,
252
+ "info",
253
+ );
254
+ } else {
255
+ ctx?.ui.notify("Auto-mode stopped.", "info");
256
+ }
257
+
258
+ resetMetrics();
259
+ active = false;
260
+ paused = false;
261
+ stepMode = false;
262
+ unitDispatchCount.clear();
263
+ unitRecoveryCount.clear();
264
+ currentUnit = null;
265
+ currentMilestoneId = null;
266
+ cachedSliceProgress = null;
267
+ pendingCrashRecovery = null;
268
+ ctx?.ui.setStatus("gsd-auto", undefined);
269
+ ctx?.ui.setWidget("gsd-progress", undefined);
270
+ ctx?.ui.setFooter(undefined);
271
+
272
+ // Restore the user's original model
273
+ if (pi && ctx && originalModelId) {
274
+ const original = ctx.modelRegistry.find("anthropic", originalModelId);
275
+ if (original) await pi.setModel(original);
276
+ originalModelId = null;
277
+ }
278
+
279
+ cmdCtx = null;
280
+ }
281
+
282
+ /**
283
+ * Pause auto-mode without destroying state. Context is preserved.
284
+ * The user can interact with the agent, then `/gsd auto` resumes
285
+ * from disk state. Called when the user presses Escape during auto-mode.
286
+ */
287
+ export async function pauseAuto(ctx?: ExtensionContext, _pi?: ExtensionAPI): Promise<void> {
288
+ if (!active) return;
289
+ clearUnitTimeout();
290
+ if (basePath) clearLock(basePath);
291
+ active = false;
292
+ paused = true;
293
+ // Preserve: unitDispatchCount, currentUnit, basePath, verbose, cmdCtx,
294
+ // completedUnits, autoStartTime, currentMilestoneId, originalModelId
295
+ // — all needed for resume and dashboard display
296
+ ctx?.ui.setStatus("gsd-auto", "paused");
297
+ ctx?.ui.setWidget("gsd-progress", undefined);
298
+ ctx?.ui.setFooter(undefined);
299
+ const resumeCmd = stepMode ? "/gsd next" : "/gsd auto";
300
+ ctx?.ui.notify(
301
+ `${stepMode ? "Step" : "Auto"}-mode paused (Escape). Type to interact, or ${resumeCmd} to resume.`,
302
+ "info",
303
+ );
304
+ }
305
+
306
+ /**
307
+ * Self-heal: scan runtime records in .gsd/ and clear any where the expected
308
+ * artifact already exists on disk. This repairs incomplete closeouts from
309
+ * prior crashes — preventing spurious re-dispatch of already-completed units.
310
+ */
311
+ async function selfHealRuntimeRecords(base: string, ctx: ExtensionContext): Promise<void> {
312
+ try {
313
+ const { listUnitRuntimeRecords } = await import("./unit-runtime.js");
314
+ const records = listUnitRuntimeRecords(base);
315
+ let healed = 0;
316
+ for (const record of records) {
317
+ const { unitType, unitId } = record;
318
+ const artifactPath = resolveExpectedArtifactPath(unitType, unitId, base);
319
+ if (artifactPath && existsSync(artifactPath)) {
320
+ // Artifact exists — unit completed but closeout didn't finish.
321
+ clearUnitRuntimeRecord(base, unitType, unitId);
322
+ healed++;
323
+ }
324
+ }
325
+ if (healed > 0) {
326
+ ctx.ui.notify(`Self-heal: cleared ${healed} stale runtime record(s) with completed artifacts.`, "info");
327
+ }
328
+ } catch {
329
+ // Non-fatal — self-heal should never block auto-mode start
330
+ }
331
+ }
332
+
333
+ export async function startAuto(
334
+ ctx: ExtensionCommandContext,
335
+ pi: ExtensionAPI,
336
+ base: string,
337
+ verboseMode: boolean,
338
+ options?: { step?: boolean },
339
+ ): Promise<void> {
340
+ const requestedStepMode = options?.step ?? false;
341
+
342
+ // If resuming from paused state, just re-activate and dispatch next unit.
343
+ // The conversation is still intact — no need to reinitialize everything.
344
+ if (paused) {
345
+ paused = false;
346
+ active = true;
347
+ verbose = verboseMode;
348
+ // Allow switching between step/auto on resume
349
+ stepMode = requestedStepMode;
350
+ cmdCtx = ctx;
351
+ basePath = base;
352
+ unitDispatchCount.clear();
353
+ // Re-initialize metrics in case ledger was lost during pause
354
+ if (!getLedger()) initMetrics(base);
355
+ ctx.ui.setStatus("gsd-auto", stepMode ? "next" : "auto");
356
+ ctx.ui.setFooter(hideFooter);
357
+ ctx.ui.notify(stepMode ? "Step-mode resumed." : "Auto-mode resumed.", "info");
358
+ // Rebuild disk state before resuming — user interaction during pause may have changed files
359
+ try { await rebuildState(base); } catch { /* non-fatal */ }
360
+ try {
361
+ const report = await runGSDDoctor(base, { fix: true });
362
+ if (report.fixesApplied.length > 0) {
363
+ ctx.ui.notify(`Resume: applied ${report.fixesApplied.length} fix(es) to state.`, "info");
364
+ }
365
+ } catch { /* non-fatal */ }
366
+ // Self-heal: clear stale runtime records where artifacts already exist
367
+ await selfHealRuntimeRecords(base, ctx);
368
+ await dispatchNextUnit(ctx, pi);
369
+ return;
370
+ }
371
+
372
+ // Ensure git repo exists — GSD needs it for branch-per-slice
373
+ try {
374
+ execSync("git rev-parse --git-dir", { cwd: base, stdio: "pipe" });
375
+ } catch {
376
+ const mainBranch = loadEffectiveGSDPreferences()?.preferences?.git?.main_branch || "main";
377
+ execFileSync("git", ["init", "-b", mainBranch], { cwd: base, stdio: "pipe" });
378
+ }
379
+
380
+ // Ensure .gitignore has baseline patterns
381
+ ensureGitignore(base);
382
+
383
+ // Bootstrap .gsd/ if it doesn't exist
384
+ const gsdDir = join(base, ".gsd");
385
+ if (!existsSync(gsdDir)) {
386
+ mkdirSync(join(gsdDir, "milestones"), { recursive: true });
387
+ try {
388
+ execSync("git add -A .gsd .gitignore && git commit -m 'chore: init gsd'", {
389
+ cwd: base, stdio: "pipe",
390
+ });
391
+ } catch { /* nothing to commit */ }
392
+ }
393
+
394
+ // Initialize GitServiceImpl — basePath is set and git repo confirmed
395
+ gitService = new GitServiceImpl(basePath, loadEffectiveGSDPreferences()?.preferences?.git ?? {});
396
+
397
+ // Check for crash from previous session
398
+ const crashLock = readCrashLock(base);
399
+ if (crashLock) {
400
+ // Synthesize a rich recovery briefing from the surviving pi session file
401
+ // (pi writes entries incrementally, so it contains every tool call up to the crash)
402
+ const activityDir = join(gsdRoot(base), "activity");
403
+ const recovery = synthesizeCrashRecovery(
404
+ base, crashLock.unitType, crashLock.unitId,
405
+ crashLock.sessionFile, activityDir,
406
+ );
407
+ if (recovery && recovery.trace.toolCallCount > 0) {
408
+ pendingCrashRecovery = recovery.prompt;
409
+ ctx.ui.notify(
410
+ `${formatCrashInfo(crashLock)}\nRecovered ${recovery.trace.toolCallCount} tool calls from crashed session. Resuming with full context.`,
411
+ "warning",
412
+ );
413
+ } else {
414
+ ctx.ui.notify(
415
+ `${formatCrashInfo(crashLock)}\nNo session data recovered. Resuming from disk state.`,
416
+ "warning",
417
+ );
418
+ }
419
+ clearLock(base);
420
+ }
421
+
422
+ const state = await deriveState(base);
423
+
424
+ // No active work at all — start a new milestone via the discuss flow.
425
+ if (!state.activeMilestone || state.phase === "complete") {
426
+ const { showSmartEntry } = await import("./guided-flow.js");
427
+ await showSmartEntry(ctx, pi, base, { step: requestedStepMode });
428
+ return;
429
+ }
430
+
431
+ // Active milestone exists but has no roadmap — check if context exists.
432
+ // If context was pre-written (multi-milestone planning), auto-mode can
433
+ // research and plan it. If no context either, need user discussion.
434
+ if (state.phase === "pre-planning") {
435
+ const contextFile = resolveMilestoneFile(base, state.activeMilestone.id, "CONTEXT");
436
+ const hasContext = !!(contextFile && await loadFile(contextFile));
437
+ if (!hasContext) {
438
+ const { showSmartEntry } = await import("./guided-flow.js");
439
+ await showSmartEntry(ctx, pi, base, { step: requestedStepMode });
440
+ return;
441
+ }
442
+ // Has context, no roadmap — auto-mode will research + plan it
443
+ }
444
+
445
+ active = true;
446
+ stepMode = requestedStepMode;
447
+ verbose = verboseMode;
448
+ cmdCtx = ctx;
449
+ basePath = base;
450
+ unitDispatchCount.clear();
451
+ unitRecoveryCount.clear();
452
+ completedKeySet.clear();
453
+ loadPersistedKeys(base, completedKeySet);
454
+ autoStartTime = Date.now();
455
+ completedUnits = [];
456
+ currentUnit = null;
457
+ currentMilestoneId = state.activeMilestone?.id ?? null;
458
+ originalModelId = ctx.model?.id ?? null;
459
+
460
+ // Initialize metrics — loads existing ledger from disk
461
+ initMetrics(base);
462
+
463
+ // Snapshot installed skills so we can detect new ones after research
464
+ if (resolveSkillDiscoveryMode() !== "off") {
465
+ snapshotSkills();
466
+ }
467
+
468
+ ctx.ui.setStatus("gsd-auto", stepMode ? "next" : "auto");
469
+ ctx.ui.setFooter(hideFooter);
470
+ const modeLabel = stepMode ? "Step-mode" : "Auto-mode";
471
+ const pendingCount = state.registry.filter(m => m.status !== 'complete').length;
472
+ const scopeMsg = pendingCount > 1
473
+ ? `Will loop through ${pendingCount} milestones.`
474
+ : "Will loop until milestone complete.";
475
+ ctx.ui.notify(`${modeLabel} started. ${scopeMsg}`, "info");
476
+
477
+ // Self-heal: clear stale runtime records where artifacts already exist
478
+ await selfHealRuntimeRecords(base, ctx);
479
+
480
+ // Dispatch the first unit
481
+ await dispatchNextUnit(ctx, pi);
482
+ }
483
+
484
+ // ─── Agent End Handler ────────────────────────────────────────────────────────
485
+
486
+ export async function handleAgentEnd(
487
+ ctx: ExtensionContext,
488
+ pi: ExtensionAPI,
489
+ ): Promise<void> {
490
+ if (!active || !cmdCtx) return;
491
+
492
+ // Unit completed — clear its timeout
493
+ clearUnitTimeout();
494
+
495
+ // Small delay to let files settle (git commits, file writes)
496
+ await new Promise(r => setTimeout(r, 500));
497
+
498
+ // Auto-commit any dirty files the LLM left behind on the current branch.
499
+ if (currentUnit) {
500
+ try {
501
+ const commitMsg = autoCommitCurrentBranch(basePath, currentUnit.type, currentUnit.id);
502
+ if (commitMsg) {
503
+ ctx.ui.notify(`Auto-committed uncommitted changes.`, "info");
504
+ }
505
+ } catch {
506
+ // Non-fatal
507
+ }
508
+
509
+ // Post-hook: fix mechanical bookkeeping the LLM may have skipped.
510
+ // 1. Doctor handles: checkbox marking, stub summaries/UATs.
511
+ // 2. STATE.md is always rebuilt from disk state (purely derived, no LLM needed).
512
+ // This is more reliable than prompt instructions for mechanical tasks.
513
+ // Scope to slice level (M001/S01) so doctor checks all tasks within the slice.
514
+ try {
515
+ const scopeParts = currentUnit.id.split("/").slice(0, 2);
516
+ const doctorScope = scopeParts.join("/");
517
+ const report = await runGSDDoctor(basePath, { fix: true, scope: doctorScope });
518
+ if (report.fixesApplied.length > 0) {
519
+ ctx.ui.notify(`Post-hook: applied ${report.fixesApplied.length} fix(es).`, "info");
520
+ }
521
+ } catch {
522
+ // Non-fatal — doctor failure should never block dispatch
523
+ }
524
+ try {
525
+ await rebuildState(basePath);
526
+ autoCommitCurrentBranch(basePath, currentUnit.type, currentUnit.id);
527
+ } catch {
528
+ // Non-fatal
529
+ }
530
+ }
531
+
532
+ // In step mode, pause and show a wizard instead of immediately dispatching
533
+ if (stepMode) {
534
+ await showStepWizard(ctx, pi);
535
+ return;
536
+ }
537
+
538
+ await dispatchNextUnit(ctx, pi);
539
+ }
540
+
541
+ // ─── Step Mode Wizard ─────────────────────────────────────────────────────
542
+
543
+ /**
544
+ * Show the step-mode wizard after a unit completes.
545
+ * Derives the next unit from disk state and presents it to the user.
546
+ * If the user confirms, dispatches the next unit. If not, pauses.
547
+ */
548
+ async function showStepWizard(
549
+ ctx: ExtensionContext,
550
+ pi: ExtensionAPI,
551
+ ): Promise<void> {
552
+ if (!cmdCtx) return;
553
+
554
+ const state = await deriveState(basePath);
555
+ const mid = state.activeMilestone?.id;
556
+
557
+ // Build summary of what just completed
558
+ const justFinished = currentUnit
559
+ ? `${unitVerb(currentUnit.type)} ${currentUnit.id}`
560
+ : "previous unit";
561
+
562
+ // If no active milestone or everything is complete, stop
563
+ if (!mid || state.phase === "complete") {
564
+ await stopAuto(ctx, pi);
565
+ return;
566
+ }
567
+
568
+ // Peek at what's next by examining state
569
+ const nextDesc = describeNextUnit(state);
570
+
571
+ const choice = await showNextAction(cmdCtx, {
572
+ title: `GSD — ${justFinished} complete`,
573
+ summary: [
574
+ `${mid}: ${state.activeMilestone?.title ?? mid}`,
575
+ ...(state.activeSlice ? [`${state.activeSlice.id}: ${state.activeSlice.title}`] : []),
576
+ ],
577
+ actions: [
578
+ {
579
+ id: "continue",
580
+ label: nextDesc.label,
581
+ description: nextDesc.description,
582
+ recommended: true,
583
+ },
584
+ {
585
+ id: "auto",
586
+ label: "Switch to auto",
587
+ description: "Continue without pausing between steps.",
588
+ },
589
+ {
590
+ id: "status",
591
+ label: "View status",
592
+ description: "Open the dashboard.",
593
+ },
594
+ ],
595
+ notYetMessage: "Run /gsd next when ready to continue.",
596
+ });
597
+
598
+ if (choice === "continue") {
599
+ await dispatchNextUnit(ctx, pi);
600
+ } else if (choice === "auto") {
601
+ stepMode = false;
602
+ ctx.ui.setStatus("gsd-auto", "auto");
603
+ ctx.ui.notify("Switched to auto-mode.", "info");
604
+ await dispatchNextUnit(ctx, pi);
605
+ } else if (choice === "status") {
606
+ // Show status then re-show the wizard
607
+ const { fireStatusViaCommand } = await import("./commands.js");
608
+ await fireStatusViaCommand(ctx as ExtensionCommandContext);
609
+ await showStepWizard(ctx, pi);
610
+ } else {
611
+ // "not_yet" — pause
612
+ await pauseAuto(ctx, pi);
613
+ }
614
+ }
615
+
616
+ /**
617
+ * Describe what the next unit will be, based on current state.
618
+ */
619
+ function describeNextUnit(state: GSDState): { label: string; description: string } {
620
+ const sid = state.activeSlice?.id;
621
+ const sTitle = state.activeSlice?.title;
622
+ const tid = state.activeTask?.id;
623
+ const tTitle = state.activeTask?.title;
624
+
625
+ switch (state.phase) {
626
+ case "pre-planning":
627
+ return { label: "Research & plan milestone", description: "Scout the landscape and create the roadmap." };
628
+ case "planning":
629
+ return { label: `Plan ${sid}: ${sTitle}`, description: "Research and decompose into tasks." };
630
+ case "executing":
631
+ return { label: `Execute ${tid}: ${tTitle}`, description: "Run the next task in a fresh session." };
632
+ case "summarizing":
633
+ return { label: `Complete ${sid}: ${sTitle}`, description: "Write summary, UAT, and merge to main." };
634
+ case "replanning-slice":
635
+ return { label: `Replan ${sid}: ${sTitle}`, description: "Blocker found — replan the slice." };
636
+ case "completing-milestone":
637
+ return { label: "Complete milestone", description: "Write milestone summary." };
638
+ default:
639
+ return { label: "Continue", description: "Execute the next step." };
640
+ }
641
+ }
642
+
643
+ // ─── Progress Widget ──────────────────────────────────────────────────────
644
+
645
+ function unitVerb(unitType: string): string {
646
+ switch (unitType) {
647
+ case "research-milestone":
648
+ case "research-slice": return "researching";
649
+ case "plan-milestone":
650
+ case "plan-slice": return "planning";
651
+ case "execute-task": return "executing";
652
+ case "complete-slice": return "completing";
653
+ case "replan-slice": return "replanning";
654
+ case "reassess-roadmap": return "reassessing";
655
+ case "run-uat": return "running UAT";
656
+ default: return unitType;
657
+ }
658
+ }
659
+
660
+ function unitPhaseLabel(unitType: string): string {
661
+ switch (unitType) {
662
+ case "research-milestone": return "RESEARCH";
663
+ case "research-slice": return "RESEARCH";
664
+ case "plan-milestone": return "PLAN";
665
+ case "plan-slice": return "PLAN";
666
+ case "execute-task": return "EXECUTE";
667
+ case "complete-slice": return "COMPLETE";
668
+ case "replan-slice": return "REPLAN";
669
+ case "reassess-roadmap": return "REASSESS";
670
+ case "run-uat": return "UAT";
671
+ default: return unitType.toUpperCase();
672
+ }
673
+ }
674
+
675
+ function peekNext(unitType: string, state: GSDState): string {
676
+ const sid = state.activeSlice?.id ?? "";
677
+ switch (unitType) {
678
+ case "research-milestone": return "plan milestone roadmap";
679
+ case "plan-milestone": return "plan or execute first slice";
680
+ case "research-slice": return `plan ${sid}`;
681
+ case "plan-slice": return "execute first task";
682
+ case "execute-task": return `continue ${sid}`;
683
+ case "complete-slice": return "reassess roadmap";
684
+ case "replan-slice": return `re-execute ${sid}`;
685
+ case "reassess-roadmap": return "advance to next slice";
686
+ case "run-uat": return "reassess roadmap";
687
+ default: return "";
688
+ }
689
+ }
690
+
691
+
692
+
693
+ /** Right-align helper: build a line with left content and right content. */
694
+ function rightAlign(left: string, right: string, width: number): string {
695
+ const leftVis = visibleWidth(left);
696
+ const rightVis = visibleWidth(right);
697
+ const gap = Math.max(1, width - leftVis - rightVis);
698
+ return truncateToWidth(left + " ".repeat(gap) + right, width);
699
+ }
700
+
701
+ function updateProgressWidget(
702
+ ctx: ExtensionContext,
703
+ unitType: string,
704
+ unitId: string,
705
+ state: GSDState,
706
+ ): void {
707
+ if (!ctx.hasUI) return;
708
+
709
+ const verb = unitVerb(unitType);
710
+ const phaseLabel = unitPhaseLabel(unitType);
711
+ const mid = state.activeMilestone;
712
+ const slice = state.activeSlice;
713
+ const task = state.activeTask;
714
+ const next = peekNext(unitType, state);
715
+
716
+ // Cache git branch at widget creation time (not per render)
717
+ let cachedBranch: string | null = null;
718
+ try { cachedBranch = getCurrentBranch(basePath); } catch { /* not in git repo */ }
719
+
720
+ // Cache pwd with ~ substitution
721
+ let widgetPwd = process.cwd();
722
+ const widgetHome = process.env.HOME || process.env.USERPROFILE;
723
+ if (widgetHome && widgetPwd.startsWith(widgetHome)) {
724
+ widgetPwd = `~${widgetPwd.slice(widgetHome.length)}`;
725
+ }
726
+ if (cachedBranch) widgetPwd = `${widgetPwd} (${cachedBranch})`;
727
+
728
+ ctx.ui.setWidget("gsd-progress", (tui, theme) => {
729
+ let pulseBright = true;
730
+ let cachedLines: string[] | undefined;
731
+ let cachedWidth: number | undefined;
732
+
733
+ const pulseTimer = setInterval(() => {
734
+ pulseBright = !pulseBright;
735
+ cachedLines = undefined;
736
+ tui.requestRender();
737
+ }, 800);
738
+
739
+ return {
740
+ render(width: number): string[] {
741
+ if (cachedLines && cachedWidth === width) return cachedLines;
742
+
743
+ const ui = makeUI(theme, width);
744
+ const lines: string[] = [];
745
+ const pad = INDENT.base;
746
+
747
+ // ── Line 1: Top bar ───────────────────────────────────────────────
748
+ lines.push(...ui.bar());
749
+
750
+ const dot = pulseBright
751
+ ? theme.fg("accent", GLYPH.statusActive)
752
+ : theme.fg("dim", GLYPH.statusPending);
753
+ const elapsed = formatAutoElapsed();
754
+ const modeTag = stepMode ? "NEXT" : "AUTO";
755
+ const headerLeft = `${pad}${dot} ${theme.fg("accent", theme.bold("GSD"))} ${theme.fg("success", modeTag)}`;
756
+ const headerRight = elapsed ? theme.fg("dim", elapsed) : "";
757
+ lines.push(rightAlign(headerLeft, headerRight, width));
758
+
759
+ lines.push("");
760
+
761
+ if (mid) {
762
+ lines.push(truncateToWidth(`${pad}${theme.fg("dim", mid.title)}`, width));
763
+ }
764
+
765
+ if (slice && unitType !== "research-milestone" && unitType !== "plan-milestone") {
766
+ lines.push(truncateToWidth(
767
+ `${pad}${theme.fg("text", theme.bold(`${slice.id}: ${slice.title}`))}`,
768
+ width,
769
+ ));
770
+ }
771
+
772
+ lines.push("");
773
+
774
+ const target = task ? `${task.id}: ${task.title}` : unitId;
775
+ const actionLeft = `${pad}${theme.fg("accent", "▸")} ${theme.fg("accent", verb)} ${theme.fg("text", target)}`;
776
+ const phaseBadge = theme.fg("dim", phaseLabel);
777
+ lines.push(rightAlign(actionLeft, phaseBadge, width));
778
+ lines.push("");
779
+
780
+ if (mid) {
781
+ const roadmapSlices = getRoadmapSlicesSync();
782
+ if (roadmapSlices) {
783
+ const { done, total, activeSliceTasks } = roadmapSlices;
784
+ const barWidth = Math.max(8, Math.min(24, Math.floor(width * 0.3)));
785
+ const pct = total > 0 ? done / total : 0;
786
+ const filled = Math.round(pct * barWidth);
787
+ const bar = theme.fg("success", "█".repeat(filled))
788
+ + theme.fg("dim", "░".repeat(barWidth - filled));
789
+
790
+ let meta = theme.fg("dim", `${done}/${total} slices`);
791
+
792
+ if (activeSliceTasks && activeSliceTasks.total > 0) {
793
+ meta += theme.fg("dim", ` · task ${activeSliceTasks.done + 1}/${activeSliceTasks.total}`);
794
+ }
795
+
796
+ lines.push(truncateToWidth(`${pad}${bar} ${meta}`, width));
797
+ }
798
+ }
799
+
800
+ lines.push("");
801
+
802
+ if (next) {
803
+ lines.push(truncateToWidth(
804
+ `${pad}${theme.fg("dim", "→")} ${theme.fg("dim", `then ${next}`)}`,
805
+ width,
806
+ ));
807
+ }
808
+
809
+ // ── Footer info (pwd, tokens, cost, context, model) ──────────────
810
+ lines.push("");
811
+ lines.push(truncateToWidth(theme.fg("dim", `${pad}${widgetPwd}`), width, theme.fg("dim", "…")));
812
+
813
+ // Token stats from current unit session + cumulative cost from metrics
814
+ {
815
+ let totalInput = 0, totalOutput = 0;
816
+ let totalCacheRead = 0, totalCacheWrite = 0;
817
+ if (cmdCtx) {
818
+ for (const entry of cmdCtx.sessionManager.getEntries()) {
819
+ if (entry.type === "message" && (entry as any).message?.role === "assistant") {
820
+ const u = (entry as any).message.usage;
821
+ if (u) {
822
+ totalInput += u.input || 0;
823
+ totalOutput += u.output || 0;
824
+ totalCacheRead += u.cacheRead || 0;
825
+ totalCacheWrite += u.cacheWrite || 0;
826
+ }
827
+ }
828
+ }
829
+ }
830
+ const mLedger = getLedger();
831
+ const autoTotals = mLedger ? getProjectTotals(mLedger.units) : null;
832
+ const cumulativeCost = autoTotals?.cost ?? 0;
833
+
834
+ const cxUsage = cmdCtx?.getContextUsage?.();
835
+ const cxWindow = cxUsage?.contextWindow ?? cmdCtx?.model?.contextWindow ?? 0;
836
+ const cxPctVal = cxUsage?.percent ?? 0;
837
+ const cxPct = cxUsage?.percent !== null ? cxPctVal.toFixed(1) : "?";
838
+
839
+ const sp: string[] = [];
840
+ if (totalInput) sp.push(`↑${formatWidgetTokens(totalInput)}`);
841
+ if (totalOutput) sp.push(`↓${formatWidgetTokens(totalOutput)}`);
842
+ if (totalCacheRead) sp.push(`R${formatWidgetTokens(totalCacheRead)}`);
843
+ if (totalCacheWrite) sp.push(`W${formatWidgetTokens(totalCacheWrite)}`);
844
+ if (cumulativeCost) sp.push(`$${cumulativeCost.toFixed(3)}`);
845
+
846
+ const cxDisplay = cxPct === "?"
847
+ ? `?/${formatWidgetTokens(cxWindow)}`
848
+ : `${cxPct}%/${formatWidgetTokens(cxWindow)}`;
849
+ if (cxPctVal > 90) {
850
+ sp.push(theme.fg("error", cxDisplay));
851
+ } else if (cxPctVal > 70) {
852
+ sp.push(theme.fg("warning", cxDisplay));
853
+ } else {
854
+ sp.push(cxDisplay);
855
+ }
856
+
857
+ const sLeft = sp.map(p => p.includes("\x1b[") ? p : theme.fg("dim", p))
858
+ .join(theme.fg("dim", " "));
859
+
860
+ const modelId = cmdCtx?.model?.id ?? "";
861
+ const sRight = modelId ? theme.fg("dim", modelId) : "";
862
+ lines.push(rightAlign(`${pad}${sLeft}`, sRight, width));
863
+ }
864
+
865
+ const hintParts: string[] = [];
866
+ hintParts.push("esc pause");
867
+ hintParts.push("Ctrl+Alt+G dashboard");
868
+ lines.push(...ui.hints(hintParts));
869
+
870
+ lines.push(...ui.bar());
871
+
872
+ cachedLines = lines;
873
+ cachedWidth = width;
874
+ return lines;
875
+ },
876
+ invalidate() {
877
+ cachedLines = undefined;
878
+ cachedWidth = undefined;
879
+ },
880
+ dispose() {
881
+ clearInterval(pulseTimer);
882
+ },
883
+ };
884
+ });
885
+ }
886
+
887
+ /** Format elapsed time since auto-mode started */
888
+ function formatAutoElapsed(): string {
889
+ if (!autoStartTime) return "";
890
+ const ms = Date.now() - autoStartTime;
891
+ const s = Math.floor(ms / 1000);
892
+ if (s < 60) return `${s}s`;
893
+ const m = Math.floor(s / 60);
894
+ const rs = s % 60;
895
+ if (m < 60) return `${m}m${rs > 0 ? ` ${rs}s` : ""}`;
896
+ const h = Math.floor(m / 60);
897
+ const rm = m % 60;
898
+ return `${h}h ${rm}m`;
899
+ }
900
+
901
+ /** Cached slice progress for the widget — avoid async in render */
902
+ let cachedSliceProgress: {
903
+ done: number;
904
+ total: number;
905
+ milestoneId: string;
906
+ /** Real task progress for the active slice, if its plan file exists */
907
+ activeSliceTasks: { done: number; total: number } | null;
908
+ } | null = null;
909
+
910
+ function updateSliceProgressCache(base: string, mid: string, activeSid?: string): void {
911
+ try {
912
+ const roadmapFile = resolveMilestoneFile(base, mid, "ROADMAP");
913
+ if (!roadmapFile) return;
914
+ const content = readFileSync(roadmapFile, "utf-8");
915
+ const roadmap = parseRoadmap(content);
916
+
917
+ let activeSliceTasks: { done: number; total: number } | null = null;
918
+ if (activeSid) {
919
+ try {
920
+ const planFile = resolveSliceFile(base, mid, activeSid, "PLAN");
921
+ if (planFile && existsSync(planFile)) {
922
+ const planContent = readFileSync(planFile, "utf-8");
923
+ const plan = parsePlan(planContent);
924
+ activeSliceTasks = {
925
+ done: plan.tasks.filter(t => t.done).length,
926
+ total: plan.tasks.length,
927
+ };
928
+ }
929
+ } catch {
930
+ // Non-fatal — just omit task count
931
+ }
932
+ }
933
+
934
+ cachedSliceProgress = {
935
+ done: roadmap.slices.filter(s => s.done).length,
936
+ total: roadmap.slices.length,
937
+ milestoneId: mid,
938
+ activeSliceTasks,
939
+ };
940
+ } catch {
941
+ // Non-fatal — widget just won't show progress bar
942
+ }
943
+ }
944
+
945
+ function getRoadmapSlicesSync(): { done: number; total: number; activeSliceTasks: { done: number; total: number } | null } | null {
946
+ return cachedSliceProgress;
947
+ }
948
+
949
+ // ─── Core Loop ────────────────────────────────────────────────────────────────
950
+
951
+ async function dispatchNextUnit(
952
+ ctx: ExtensionContext,
953
+ pi: ExtensionAPI,
954
+ ): Promise<void> {
955
+ if (!active || !cmdCtx) {
956
+ if (active && !cmdCtx) {
957
+ ctx.ui.notify("Auto-mode dispatch failed: no command context. Run /gsd auto to restart.", "error");
958
+ }
959
+ return;
960
+ }
961
+
962
+ let state = await deriveState(basePath);
963
+ let mid = state.activeMilestone?.id;
964
+ let midTitle = state.activeMilestone?.title;
965
+
966
+ // Detect milestone transition
967
+ if (mid && currentMilestoneId && mid !== currentMilestoneId) {
968
+ ctx.ui.notify(
969
+ `Milestone ${currentMilestoneId} complete. Advancing to ${mid}: ${midTitle}.`,
970
+ "info",
971
+ );
972
+ // Reset stuck detection for new milestone
973
+ unitDispatchCount.clear();
974
+ unitRecoveryCount.clear();
975
+ }
976
+ if (mid) currentMilestoneId = mid;
977
+
978
+ if (!mid) {
979
+ // Save final session before stopping
980
+ if (currentUnit) {
981
+ const modelId = ctx.model?.id ?? "unknown";
982
+ snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId);
983
+ saveActivityLog(ctx, basePath, currentUnit.type, currentUnit.id);
984
+ }
985
+ await stopAuto(ctx, pi);
986
+ return;
987
+ }
988
+
989
+ // ── General merge guard: merge completed slice branches before advancing ──
990
+ // If we're on a gsd/MID/SID branch and that slice is done (roadmap [x]),
991
+ // merge to main before dispatching the next unit. This handles:
992
+ // - Normal complete-slice → merge → reassess flow
993
+ // - LLM writes summary during task execution, skipping complete-slice
994
+ // - Doctor post-hook marks everything done, skipping complete-slice
995
+ // - complete-milestone runs on a slice branch (last slice bypass)
996
+ {
997
+ const currentBranch = getCurrentBranch(basePath);
998
+ const parsedBranch = parseSliceBranch(currentBranch);
999
+ if (parsedBranch) {
1000
+ const branchMid = parsedBranch.milestoneId;
1001
+ const branchSid = parsedBranch.sliceId;
1002
+ // Check if this slice is marked done in the roadmap
1003
+ const roadmapFile = resolveMilestoneFile(basePath, branchMid, "ROADMAP");
1004
+ const roadmapContent = roadmapFile ? await loadFile(roadmapFile) : null;
1005
+ if (roadmapContent) {
1006
+ const roadmap = parseRoadmap(roadmapContent);
1007
+ const sliceEntry = roadmap.slices.find(s => s.id === branchSid);
1008
+ if (sliceEntry?.done) {
1009
+ try {
1010
+ const sliceTitleForMerge = sliceEntry.title || branchSid;
1011
+ switchToMain(basePath);
1012
+ const mergeResult = mergeSliceToMain(
1013
+ basePath, branchMid, branchSid, sliceTitleForMerge,
1014
+ );
1015
+ const targetBranch = getMainBranch(basePath);
1016
+ ctx.ui.notify(
1017
+ `Merged ${mergeResult.branch} → ${targetBranch}.`,
1018
+ "info",
1019
+ );
1020
+ // Re-derive state from main so downstream logic sees merged state
1021
+ state = await deriveState(basePath);
1022
+ mid = state.activeMilestone?.id;
1023
+ midTitle = state.activeMilestone?.title;
1024
+ } catch (error) {
1025
+ const message = error instanceof Error ? error.message : String(error);
1026
+ ctx.ui.notify(
1027
+ `Slice merge failed: ${message}`,
1028
+ "error",
1029
+ );
1030
+ // Re-derive state so dispatch can figure out what to do
1031
+ state = await deriveState(basePath);
1032
+ mid = state.activeMilestone?.id;
1033
+ midTitle = state.activeMilestone?.title;
1034
+ }
1035
+ }
1036
+ }
1037
+ }
1038
+ }
1039
+
1040
+ // Determine next unit
1041
+ let unitType: string;
1042
+ let unitId: string;
1043
+ let prompt: string;
1044
+
1045
+ if (state.phase === "complete") {
1046
+ if (currentUnit) {
1047
+ const modelId = ctx.model?.id ?? "unknown";
1048
+ snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId);
1049
+ saveActivityLog(ctx, basePath, currentUnit.type, currentUnit.id);
1050
+ }
1051
+ // Clear completed-units.json for the finished milestone so it doesn't grow unbounded.
1052
+ try {
1053
+ const file = completedKeysPath(basePath);
1054
+ if (existsSync(file)) writeFileSync(file, JSON.stringify([]), "utf-8");
1055
+ completedKeySet.clear();
1056
+ } catch { /* non-fatal */ }
1057
+ await stopAuto(ctx, pi);
1058
+ return;
1059
+ }
1060
+
1061
+ if (state.phase === "blocked") {
1062
+ if (currentUnit) {
1063
+ const modelId = ctx.model?.id ?? "unknown";
1064
+ snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId);
1065
+ saveActivityLog(ctx, basePath, currentUnit.type, currentUnit.id);
1066
+ }
1067
+ await stopAuto(ctx, pi);
1068
+ ctx.ui.notify(`Blocked: ${state.blockers.join(", ")}. Fix and run /gsd auto.`, "warning");
1069
+ return;
1070
+ }
1071
+
1072
+ // ── UAT Dispatch: run-uat fires after complete-slice merge, before reassessment ──
1073
+ // Ensures the UAT file and slice summary are both on main when UAT runs.
1074
+ const prefs = loadEffectiveGSDPreferences()?.preferences;
1075
+
1076
+ // Budget ceiling guard — pause before starting next unit if ceiling is hit
1077
+ const budgetCeiling = prefs?.budget_ceiling;
1078
+ if (budgetCeiling !== undefined) {
1079
+ const currentLedger = getLedger();
1080
+ const totalCost = currentLedger ? getProjectTotals(currentLedger.units).cost : 0;
1081
+ if (totalCost >= budgetCeiling) {
1082
+ ctx.ui.notify(
1083
+ `Budget ceiling ${formatCost(budgetCeiling)} reached (spent ${formatCost(totalCost)}). Pausing auto-mode — /gsd auto to continue.`,
1084
+ "warning",
1085
+ );
1086
+ await pauseAuto(ctx, pi);
1087
+ return;
1088
+ }
1089
+ }
1090
+
1091
+ const needsRunUat = await checkNeedsRunUat(basePath, mid, state, prefs);
1092
+ // Flag: for human/mixed UAT, pause auto-mode after the prompt is sent so the user
1093
+ // can perform the UAT manually. On next resume, result file will exist → skip.
1094
+ let pauseAfterUatDispatch = false;
1095
+
1096
+ // ── Phase-first dispatch: complete-slice MUST run before reassessment ──
1097
+ // If the current phase is "summarizing", complete-slice is responsible for
1098
+ // mergeSliceToMain. Reassessment must wait until the merge is done.
1099
+ if (state.phase === "summarizing") {
1100
+ const sid = state.activeSlice!.id;
1101
+ const sTitle = state.activeSlice!.title;
1102
+ unitType = "complete-slice";
1103
+ unitId = `${mid}/${sid}`;
1104
+ prompt = await buildCompleteSlicePrompt(mid, midTitle!, sid, sTitle, basePath);
1105
+ } else {
1106
+ // ── Adaptive Replanning: check if last completed slice needs reassessment ──
1107
+ // Computed here (after summarizing guard) so complete-slice always runs first.
1108
+ const wfReassess = resolveWorkflowConfig();
1109
+ const needsReassess = wfReassess.skip_reassessment ? null : await checkNeedsReassessment(basePath, mid, state);
1110
+ if (needsRunUat) {
1111
+ const { sliceId, uatType } = needsRunUat;
1112
+ unitType = "run-uat";
1113
+ unitId = `${mid}/${sliceId}`;
1114
+ const uatFile = resolveSliceFile(basePath, mid, sliceId, "UAT")!;
1115
+ const uatContent = await loadFile(uatFile);
1116
+ prompt = await buildRunUatPrompt(
1117
+ mid, sliceId, relSliceFile(basePath, mid, sliceId, "UAT"), uatContent ?? "", basePath,
1118
+ );
1119
+ // For non-artifact-driven UAT types, pause after the prompt is dispatched.
1120
+ // The agent receives the prompt, writes S0x-UAT-RESULT.md surfacing the UAT,
1121
+ // then auto-mode pauses for human execution. On resume, result file exists → skip.
1122
+ if (uatType !== "artifact-driven") {
1123
+ pauseAfterUatDispatch = true;
1124
+ }
1125
+ } else if (needsReassess) {
1126
+ unitType = "reassess-roadmap";
1127
+ unitId = `${mid}/${needsReassess.sliceId}`;
1128
+ prompt = await buildReassessRoadmapPrompt(mid, midTitle!, needsReassess.sliceId, basePath);
1129
+ } else if (state.phase === "pre-planning") {
1130
+ // Need roadmap — check if context exists
1131
+ const contextFile = resolveMilestoneFile(basePath, mid, "CONTEXT");
1132
+ const hasContext = !!(contextFile && await loadFile(contextFile));
1133
+
1134
+ if (!hasContext) {
1135
+ await stopAuto(ctx, pi);
1136
+ ctx.ui.notify("No context or roadmap yet. Run /gsd to discuss first.", "warning");
1137
+ return;
1138
+ }
1139
+
1140
+ // Research before roadmap if no research exists (unless skipped by workflow config)
1141
+ const researchFile = resolveMilestoneFile(basePath, mid, "RESEARCH");
1142
+ const hasResearch = !!(researchFile && await loadFile(researchFile));
1143
+ const wfConfig = resolveWorkflowConfig();
1144
+
1145
+ if (!hasResearch && !wfConfig.skip_milestone_research) {
1146
+ unitType = "research-milestone";
1147
+ unitId = mid;
1148
+ prompt = await buildResearchMilestonePrompt(mid, midTitle!, basePath);
1149
+ } else {
1150
+ unitType = "plan-milestone";
1151
+ unitId = mid;
1152
+ prompt = await buildPlanMilestonePrompt(mid, midTitle!, basePath);
1153
+ }
1154
+
1155
+ } else if (state.phase === "planning") {
1156
+ // Slice needs planning — but research first if no research exists (unless skipped)
1157
+ const sid = state.activeSlice!.id;
1158
+ const sTitle = state.activeSlice!.title;
1159
+ const researchFile = resolveSliceFile(basePath, mid, sid, "RESEARCH");
1160
+ const hasResearch = !!(researchFile && await loadFile(researchFile));
1161
+ const wfConfig = resolveWorkflowConfig();
1162
+
1163
+ if (!hasResearch && !wfConfig.skip_slice_research) {
1164
+ unitType = "research-slice";
1165
+ unitId = `${mid}/${sid}`;
1166
+ prompt = await buildResearchSlicePrompt(mid, midTitle!, sid, sTitle, basePath);
1167
+ } else {
1168
+ unitType = "plan-slice";
1169
+ unitId = `${mid}/${sid}`;
1170
+ prompt = await buildPlanSlicePrompt(mid, midTitle!, sid, sTitle, basePath);
1171
+ }
1172
+
1173
+ } else if (state.phase === "replanning-slice") {
1174
+ // Blocker discovered — replan the slice before continuing
1175
+ const sid = state.activeSlice!.id;
1176
+ const sTitle = state.activeSlice!.title;
1177
+ unitType = "replan-slice";
1178
+ unitId = `${mid}/${sid}`;
1179
+ prompt = await buildReplanSlicePrompt(mid, midTitle!, sid, sTitle, basePath);
1180
+
1181
+ } else if (state.phase === "executing" && state.activeTask) {
1182
+ // Execute next task
1183
+ const sid = state.activeSlice!.id;
1184
+ const sTitle = state.activeSlice!.title;
1185
+ const tid = state.activeTask.id;
1186
+ const tTitle = state.activeTask.title;
1187
+ unitType = "execute-task";
1188
+ unitId = `${mid}/${sid}/${tid}`;
1189
+ prompt = await buildExecuteTaskPrompt(mid, sid, sTitle, tid, tTitle, basePath);
1190
+
1191
+ } else if (state.phase === "completing-milestone") {
1192
+ // All slices done — complete the milestone
1193
+ unitType = "complete-milestone";
1194
+ unitId = mid;
1195
+ prompt = await buildCompleteMilestonePrompt(mid, midTitle!, basePath);
1196
+
1197
+ } else {
1198
+ if (currentUnit) {
1199
+ const modelId = ctx.model?.id ?? "unknown";
1200
+ snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId);
1201
+ saveActivityLog(ctx, basePath, currentUnit.type, currentUnit.id);
1202
+ }
1203
+ await stopAuto(ctx, pi);
1204
+ ctx.ui.notify(`Unexpected phase: ${state.phase}. Stopping auto-mode.`, "warning");
1205
+ return;
1206
+ }
1207
+ }
1208
+
1209
+ await emitObservabilityWarnings(ctx, unitType, unitId);
1210
+
1211
+ // Idempotency: skip units already completed in a prior session.
1212
+ const idempotencyKey = `${unitType}/${unitId}`;
1213
+ if (completedKeySet.has(idempotencyKey)) {
1214
+ // Cross-validate: does the expected artifact actually exist?
1215
+ const artifactExists = verifyExpectedArtifact(unitType, unitId, basePath);
1216
+ if (artifactExists) {
1217
+ ctx.ui.notify(
1218
+ `Skipping ${unitType} ${unitId} — already completed in a prior session. Advancing.`,
1219
+ "info",
1220
+ );
1221
+ // Yield to the event loop before re-dispatching to avoid tight recursion
1222
+ // when many units are already completed (e.g., after crash recovery).
1223
+ await new Promise(r => setImmediate(r));
1224
+ await dispatchNextUnit(ctx, pi);
1225
+ return;
1226
+ } else {
1227
+ // Stale completion record — artifact missing. Remove and re-run.
1228
+ completedKeySet.delete(idempotencyKey);
1229
+ removePersistedKey(basePath, idempotencyKey);
1230
+ ctx.ui.notify(
1231
+ `Re-running ${unitType} ${unitId} — marked complete but expected artifact missing.`,
1232
+ "warning",
1233
+ );
1234
+ }
1235
+ }
1236
+
1237
+ // Stuck detection — tracks total dispatches per unit (not just consecutive repeats).
1238
+ // Pattern A→B→A→B would reset retryCount every time; this map catches it.
1239
+ const dispatchKey = `${unitType}/${unitId}`;
1240
+ const prevCount = unitDispatchCount.get(dispatchKey) ?? 0;
1241
+ if (prevCount >= MAX_UNIT_DISPATCHES) {
1242
+ if (currentUnit) {
1243
+ const modelId = ctx.model?.id ?? "unknown";
1244
+ snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId);
1245
+ }
1246
+ saveActivityLog(ctx, basePath, unitType, unitId);
1247
+
1248
+ const expected = diagnoseExpectedArtifact(unitType, unitId, basePath);
1249
+ await stopAuto(ctx, pi);
1250
+ ctx.ui.notify(
1251
+ `Loop detected: ${unitType} ${unitId} dispatched ${prevCount + 1} times total. Expected artifact not found.${expected ? `\n Expected: ${expected}` : ""}\n Check branch state and .gsd/ artifacts.`,
1252
+ "error",
1253
+ );
1254
+ return;
1255
+ }
1256
+ unitDispatchCount.set(dispatchKey, prevCount + 1);
1257
+ if (prevCount > 0) {
1258
+ ctx.ui.notify(
1259
+ `${unitType} ${unitId} didn't produce expected artifact. Retrying (${prevCount + 1}/${MAX_UNIT_DISPATCHES}).`,
1260
+ "warning",
1261
+ );
1262
+ }
1263
+ // Snapshot metrics + activity log for the PREVIOUS unit before we reassign.
1264
+ // The session still holds the previous unit's data (newSession hasn't fired yet).
1265
+ if (currentUnit) {
1266
+ const modelId = ctx.model?.id ?? "unknown";
1267
+ snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId);
1268
+ saveActivityLog(ctx, basePath, currentUnit.type, currentUnit.id);
1269
+
1270
+ // Only mark the previous unit as completed if:
1271
+ // 1. We're not about to re-dispatch the same unit (retry scenario)
1272
+ // 2. The expected artifact actually exists on disk
1273
+ const closeoutKey = `${currentUnit.type}/${currentUnit.id}`;
1274
+ const incomingKey = `${unitType}/${unitId}`;
1275
+ const artifactVerified = verifyExpectedArtifact(currentUnit.type, currentUnit.id, basePath);
1276
+ if (closeoutKey !== incomingKey && artifactVerified) {
1277
+ persistCompletedKey(basePath, closeoutKey);
1278
+ completedKeySet.add(closeoutKey);
1279
+
1280
+ completedUnits.push({
1281
+ type: currentUnit.type,
1282
+ id: currentUnit.id,
1283
+ startedAt: currentUnit.startedAt,
1284
+ finishedAt: Date.now(),
1285
+ });
1286
+ clearUnitRuntimeRecord(basePath, currentUnit.type, currentUnit.id);
1287
+ unitDispatchCount.delete(`${currentUnit.type}/${currentUnit.id}`);
1288
+ unitRecoveryCount.delete(`${currentUnit.type}/${currentUnit.id}`);
1289
+ }
1290
+ }
1291
+ currentUnit = { type: unitType, id: unitId, startedAt: Date.now() };
1292
+ writeUnitRuntimeRecord(basePath, unitType, unitId, currentUnit.startedAt, {
1293
+ phase: "dispatched",
1294
+ wrapupWarningSent: false,
1295
+ timeoutAt: null,
1296
+ lastProgressAt: currentUnit.startedAt,
1297
+ progressCount: 0,
1298
+ lastProgressKind: "dispatch",
1299
+ });
1300
+
1301
+ // Status bar + progress widget
1302
+ ctx.ui.setStatus("gsd-auto", "auto");
1303
+ if (mid) updateSliceProgressCache(basePath, mid, state.activeSlice?.id);
1304
+ updateProgressWidget(ctx, unitType, unitId, state);
1305
+
1306
+ // Ensure preconditions — create directories, branches, etc.
1307
+ // so the LLM doesn't have to get these right
1308
+ ensurePreconditions(unitType, unitId, basePath, state);
1309
+
1310
+ // Fresh session
1311
+ const result = await cmdCtx!.newSession();
1312
+ if (result.cancelled) {
1313
+ await stopAuto(ctx, pi);
1314
+ ctx.ui.notify("New session cancelled — auto-mode stopped.", "warning");
1315
+ return;
1316
+ }
1317
+
1318
+ // NOTE: Slice merge happens AFTER the complete-slice unit finishes,
1319
+ // not here at dispatch time. See the merge logic at the top of
1320
+ // dispatchNextUnit where we check if the previous unit was complete-slice.
1321
+
1322
+ // Write lock AFTER newSession so we capture the session file path.
1323
+ // Pi appends entries incrementally via appendFileSync, so on crash the
1324
+ // session file survives with every tool call up to the crash point.
1325
+ const sessionFile = ctx.sessionManager.getSessionFile();
1326
+ writeLock(basePath, unitType, unitId, completedUnits.length, sessionFile);
1327
+
1328
+ // On crash recovery, prepend the full recovery briefing
1329
+ // On retry (stuck detection), prepend deep diagnostic from last attempt
1330
+ let finalPrompt = prompt;
1331
+ if (pendingCrashRecovery) {
1332
+ finalPrompt = `${pendingCrashRecovery}\n\n---\n\n${finalPrompt}`;
1333
+ pendingCrashRecovery = null;
1334
+ } else if ((unitDispatchCount.get(`${unitType}/${unitId}`) ?? 0) > 1) {
1335
+ const diagnostic = getDeepDiagnostic(basePath);
1336
+ if (diagnostic) {
1337
+ finalPrompt = `**RETRY — your previous attempt did not produce the required artifact.**\n\nDiagnostic from previous attempt:\n${diagnostic}\n\nFix whatever went wrong and make sure you write the required file this time.\n\n---\n\n${finalPrompt}`;
1338
+ }
1339
+ }
1340
+
1341
+ // Switch model if preferences specify one for this unit type
1342
+ const preferredModelId = resolveModelForUnit(unitType);
1343
+ if (preferredModelId) {
1344
+ // Try to find the model across all providers
1345
+ const allModels = ctx.modelRegistry.getAll();
1346
+ const model = allModels.find(m => m.id === preferredModelId);
1347
+ if (model) {
1348
+ const ok = await pi.setModel(model, { persist: false });
1349
+ if (ok) {
1350
+ ctx.ui.notify(`Model: ${preferredModelId}`, "info");
1351
+ }
1352
+ }
1353
+ }
1354
+
1355
+ // Start progress-aware supervision: a soft warning, an idle watchdog, and
1356
+ // a larger hard ceiling. Productive long-running tasks may continue past the
1357
+ // soft timeout; only idle/stalled tasks pause early.
1358
+ clearUnitTimeout();
1359
+ const supervisor = resolveAutoSupervisorConfig();
1360
+ const softTimeoutMs = supervisor.soft_timeout_minutes * 60 * 1000;
1361
+ const idleTimeoutMs = supervisor.idle_timeout_minutes * 60 * 1000;
1362
+ const hardTimeoutMs = supervisor.hard_timeout_minutes * 60 * 1000;
1363
+
1364
+ wrapupWarningHandle = setTimeout(() => {
1365
+ wrapupWarningHandle = null;
1366
+ if (!active || !currentUnit) return;
1367
+ writeUnitRuntimeRecord(basePath, unitType, unitId, currentUnit.startedAt, {
1368
+ phase: "wrapup-warning-sent",
1369
+ wrapupWarningSent: true,
1370
+ });
1371
+ pi.sendMessage(
1372
+ {
1373
+ customType: "gsd-auto-wrapup",
1374
+ display: verbose,
1375
+ content: [
1376
+ "**TIME BUDGET WARNING — keep going only if progress is real.**",
1377
+ "This unit crossed the soft time budget.",
1378
+ "If you are making progress, continue. If not, switch to wrap-up mode now:",
1379
+ "1. rerun the minimal required verification",
1380
+ "2. write or update the required durable artifacts",
1381
+ "3. mark task or slice state on disk correctly",
1382
+ "4. leave precise resume notes if anything remains unfinished",
1383
+ ].join("\n"),
1384
+ },
1385
+ { triggerTurn: true },
1386
+ );
1387
+ }, softTimeoutMs);
1388
+
1389
+ idleWatchdogHandle = setInterval(async () => {
1390
+ if (!active || !currentUnit) return;
1391
+ const runtime = readUnitRuntimeRecord(basePath, unitType, unitId);
1392
+ if (!runtime) return;
1393
+ if (Date.now() - runtime.lastProgressAt < idleTimeoutMs) return;
1394
+
1395
+ // Before triggering recovery, check if the agent is actually producing
1396
+ // work on disk. `git status --porcelain` is cheap and catches any
1397
+ // staged/unstaged/untracked changes the agent made since lastProgressAt.
1398
+ if (detectWorkingTreeActivity(basePath)) {
1399
+ writeUnitRuntimeRecord(basePath, unitType, unitId, currentUnit.startedAt, {
1400
+ lastProgressAt: Date.now(),
1401
+ lastProgressKind: "filesystem-activity",
1402
+ });
1403
+ return;
1404
+ }
1405
+
1406
+ if (currentUnit) {
1407
+ const modelId = ctx.model?.id ?? "unknown";
1408
+ snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId);
1409
+ }
1410
+ saveActivityLog(ctx, basePath, unitType, unitId);
1411
+
1412
+ const recovery = await recoverTimedOutUnit(ctx, pi, unitType, unitId, "idle");
1413
+ if (recovery === "recovered") return;
1414
+
1415
+ writeUnitRuntimeRecord(basePath, unitType, unitId, currentUnit.startedAt, {
1416
+ phase: "paused",
1417
+ });
1418
+ ctx.ui.notify(
1419
+ `Unit ${unitType} ${unitId} made no meaningful progress for ${supervisor.idle_timeout_minutes}min. Pausing auto-mode.`,
1420
+ "warning",
1421
+ );
1422
+ await pauseAuto(ctx, pi);
1423
+ }, 15000);
1424
+
1425
+ unitTimeoutHandle = setTimeout(async () => {
1426
+ unitTimeoutHandle = null;
1427
+ if (!active) return;
1428
+ if (currentUnit) {
1429
+ writeUnitRuntimeRecord(basePath, unitType, unitId, currentUnit.startedAt, {
1430
+ phase: "timeout",
1431
+ timeoutAt: Date.now(),
1432
+ });
1433
+ const modelId = ctx.model?.id ?? "unknown";
1434
+ snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId);
1435
+ }
1436
+ saveActivityLog(ctx, basePath, unitType, unitId);
1437
+
1438
+ const recovery = await recoverTimedOutUnit(ctx, pi, unitType, unitId, "hard");
1439
+ if (recovery === "recovered") return;
1440
+
1441
+ ctx.ui.notify(
1442
+ `Unit ${unitType} ${unitId} exceeded ${supervisor.hard_timeout_minutes}min hard timeout. Pausing auto-mode.`,
1443
+ "warning",
1444
+ );
1445
+ await pauseAuto(ctx, pi);
1446
+ }, hardTimeoutMs);
1447
+
1448
+ // Inject prompt — verify auto-mode still active (guards against race with timeout/pause)
1449
+ if (!active) return;
1450
+ pi.sendMessage(
1451
+ { customType: "gsd-auto", content: finalPrompt, display: verbose },
1452
+ { triggerTurn: true },
1453
+ );
1454
+
1455
+ // For non-artifact-driven UAT types, pause auto-mode after sending the prompt.
1456
+ // The agent will write the UAT result file surfacing it for human review,
1457
+ // then on resume the result file exists and run-uat is skipped automatically.
1458
+ if (pauseAfterUatDispatch) {
1459
+ ctx.ui.notify(
1460
+ "UAT requires human execution. Auto-mode will pause after this unit writes the result file.",
1461
+ "info",
1462
+ );
1463
+ await pauseAuto(ctx, pi);
1464
+ }
1465
+ }
1466
+
1467
+ // ─── Skill Discovery ──────────────────────────────────────────────────────────
1468
+
1469
+ /**
1470
+ * Build the skill discovery template variables for research prompts.
1471
+ * Returns { skillDiscoveryMode, skillDiscoveryInstructions } for template substitution.
1472
+ */
1473
+ function buildSkillDiscoveryVars(): { skillDiscoveryMode: string; skillDiscoveryInstructions: string } {
1474
+ const mode = resolveSkillDiscoveryMode();
1475
+
1476
+ if (mode === "off") {
1477
+ return {
1478
+ skillDiscoveryMode: "off",
1479
+ skillDiscoveryInstructions: " Skill discovery is disabled. Skip this step.",
1480
+ };
1481
+ }
1482
+
1483
+ const autoInstall = mode === "auto";
1484
+ const instructions = `
1485
+ Identify the key technologies, frameworks, and services this work depends on (e.g. Stripe, Clerk, Supabase, JUCE, SwiftUI).
1486
+ For each, check if a professional agent skill already exists:
1487
+ - First check \`<available_skills>\` in your system prompt — a skill may already be installed.
1488
+ - For technologies without an installed skill, run: \`npx skills find "<technology>"\`
1489
+ - Only consider skills that are **directly relevant** to core technologies — not tangentially related.
1490
+ - Evaluate results by install count and relevance to the actual work.${autoInstall
1491
+ ? `
1492
+ - Install relevant skills: \`npx skills add <owner/repo@skill> -g -y\`
1493
+ - Record installed skills in the "Skills Discovered" section of your research output.
1494
+ - Installed skills will automatically appear in subsequent units' system prompts — no manual steps needed.`
1495
+ : `
1496
+ - Note promising skills in your research output with their install commands, but do NOT install them.
1497
+ - The user will decide which to install.`
1498
+ }`;
1499
+
1500
+ return {
1501
+ skillDiscoveryMode: mode,
1502
+ skillDiscoveryInstructions: instructions,
1503
+ };
1504
+ }
1505
+
1506
+ // ─── Inline Helpers ───────────────────────────────────────────────────────────
1507
+
1508
+ /**
1509
+ * Load a file and format it for inlining into a prompt.
1510
+ * Returns the content wrapped with a source path header, or a fallback
1511
+ * message if the file doesn't exist. This eliminates tool calls — the LLM
1512
+ * gets the content directly instead of "Read this file:".
1513
+ */
1514
+ async function inlineFile(
1515
+ absPath: string | null, relPath: string, label: string,
1516
+ ): Promise<string> {
1517
+ const content = absPath ? await loadFile(absPath) : null;
1518
+ if (!content) {
1519
+ return `### ${label}\nSource: \`${relPath}\`\n\n_(not found — file does not exist yet)_`;
1520
+ }
1521
+ return `### ${label}\nSource: \`${relPath}\`\n\n${content.trim()}`;
1522
+ }
1523
+
1524
+ /**
1525
+ * Load a file for inlining, returning null if it doesn't exist.
1526
+ * Use when the file is optional and should be omitted entirely if absent.
1527
+ */
1528
+ async function inlineFileOptional(
1529
+ absPath: string | null, relPath: string, label: string,
1530
+ ): Promise<string | null> {
1531
+ const content = absPath ? await loadFile(absPath) : null;
1532
+ if (!content) return null;
1533
+ return `### ${label}\nSource: \`${relPath}\`\n\n${content.trim()}`;
1534
+ }
1535
+
1536
+ /**
1537
+ * Load and inline dependency slice summaries (full content, not just paths).
1538
+ */
1539
+ async function inlineDependencySummaries(
1540
+ mid: string, sid: string, base: string,
1541
+ ): Promise<string> {
1542
+ const roadmapFile = resolveMilestoneFile(base, mid, "ROADMAP");
1543
+ const roadmapContent = roadmapFile ? await loadFile(roadmapFile) : null;
1544
+ if (!roadmapContent) return "- (no dependencies)";
1545
+
1546
+ const roadmap = parseRoadmap(roadmapContent);
1547
+ const sliceEntry = roadmap.slices.find(s => s.id === sid);
1548
+ if (!sliceEntry || sliceEntry.depends.length === 0) return "- (no dependencies)";
1549
+
1550
+ const sections: string[] = [];
1551
+ for (const dep of sliceEntry.depends) {
1552
+ const summaryFile = resolveSliceFile(base, mid, dep, "SUMMARY");
1553
+ const summaryContent = summaryFile ? await loadFile(summaryFile) : null;
1554
+ const relPath = relSliceFile(base, mid, dep, "SUMMARY");
1555
+ if (summaryContent) {
1556
+ sections.push(`#### ${dep} Summary\nSource: \`${relPath}\`\n\n${summaryContent.trim()}`);
1557
+ } else {
1558
+ sections.push(`- \`${relPath}\` _(not found)_`);
1559
+ }
1560
+ }
1561
+ return sections.join("\n\n");
1562
+ }
1563
+
1564
+ /**
1565
+ * Load a well-known .gsd/ root file for optional inlining.
1566
+ * Handles the existsSync check internally.
1567
+ */
1568
+ async function inlineGsdRootFile(
1569
+ base: string, filename: string, label: string,
1570
+ ): Promise<string | null> {
1571
+ const key = filename.replace(/\.md$/i, "").toUpperCase() as "PROJECT" | "DECISIONS" | "QUEUE" | "STATE" | "REQUIREMENTS";
1572
+ const absPath = resolveGsdRootFile(base, key);
1573
+ if (!existsSync(absPath)) return null;
1574
+ return inlineFileOptional(absPath, relGsdRootFile(key), label);
1575
+ }
1576
+
1577
+ // ─── Prompt Builders ──────────────────────────────────────────────────────────
1578
+
1579
+ async function buildResearchMilestonePrompt(mid: string, midTitle: string, base: string): Promise<string> {
1580
+ const contextPath = resolveMilestoneFile(base, mid, "CONTEXT");
1581
+ const contextRel = relMilestoneFile(base, mid, "CONTEXT");
1582
+
1583
+ const inlined: string[] = [];
1584
+ inlined.push(await inlineFile(contextPath, contextRel, "Milestone Context"));
1585
+ const projectInline = await inlineGsdRootFile(base, "project.md", "Project");
1586
+ if (projectInline) inlined.push(projectInline);
1587
+ const requirementsInline = await inlineGsdRootFile(base, "requirements.md", "Requirements");
1588
+ if (requirementsInline) inlined.push(requirementsInline);
1589
+ const decisionsInline = await inlineGsdRootFile(base, "decisions.md", "Decisions");
1590
+ if (decisionsInline) inlined.push(decisionsInline);
1591
+
1592
+ const inlinedContext = `## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`;
1593
+
1594
+ const outputRelPath = relMilestoneFile(base, mid, "RESEARCH");
1595
+ const outputAbsPath = resolveMilestoneFile(base, mid, "RESEARCH") ?? join(base, outputRelPath);
1596
+ return loadPrompt("research-milestone", {
1597
+ milestoneId: mid, milestoneTitle: midTitle,
1598
+ milestonePath: relMilestonePath(base, mid),
1599
+ contextPath: contextRel,
1600
+ outputPath: outputRelPath,
1601
+ outputAbsPath,
1602
+ inlinedContext,
1603
+ ...buildSkillDiscoveryVars(),
1604
+ });
1605
+ }
1606
+
1607
+ async function buildPlanMilestonePrompt(mid: string, midTitle: string, base: string): Promise<string> {
1608
+ const contextPath = resolveMilestoneFile(base, mid, "CONTEXT");
1609
+ const contextRel = relMilestoneFile(base, mid, "CONTEXT");
1610
+ const researchPath = resolveMilestoneFile(base, mid, "RESEARCH");
1611
+ const researchRel = relMilestoneFile(base, mid, "RESEARCH");
1612
+
1613
+ const inlined: string[] = [];
1614
+ inlined.push(await inlineFile(contextPath, contextRel, "Milestone Context"));
1615
+ const researchInline = await inlineFileOptional(researchPath, researchRel, "Milestone Research");
1616
+ if (researchInline) inlined.push(researchInline);
1617
+ const priorSummaryInline = await inlinePriorMilestoneSummary(mid, base);
1618
+ if (priorSummaryInline) inlined.push(priorSummaryInline);
1619
+ const projectInline = await inlineGsdRootFile(base, "project.md", "Project");
1620
+ if (projectInline) inlined.push(projectInline);
1621
+ const requirementsInline = await inlineGsdRootFile(base, "requirements.md", "Requirements");
1622
+ if (requirementsInline) inlined.push(requirementsInline);
1623
+ const decisionsInline = await inlineGsdRootFile(base, "decisions.md", "Decisions");
1624
+ if (decisionsInline) inlined.push(decisionsInline);
1625
+
1626
+ const inlinedContext = `## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`;
1627
+
1628
+ const outputRelPath = relMilestoneFile(base, mid, "ROADMAP");
1629
+ const outputAbsPath = resolveMilestoneFile(base, mid, "ROADMAP") ?? join(base, outputRelPath);
1630
+
1631
+ const wf = resolveWorkflowConfig();
1632
+ const skipObservabilityNote = wf.skip_observability
1633
+ ? "\n\n> **Note:** Observability planning is disabled in preferences. Skip observability/diagnostics sections in slice plans."
1634
+ : "";
1635
+
1636
+ return loadPrompt("plan-milestone", {
1637
+ milestoneId: mid, milestoneTitle: midTitle,
1638
+ milestonePath: relMilestonePath(base, mid),
1639
+ contextPath: contextRel,
1640
+ researchPath: researchRel,
1641
+ outputPath: outputRelPath,
1642
+ outputAbsPath,
1643
+ inlinedContext,
1644
+ skipObservabilityNote,
1645
+ });
1646
+ }
1647
+
1648
+ async function buildResearchSlicePrompt(
1649
+ mid: string, _midTitle: string, sid: string, sTitle: string, base: string,
1650
+ ): Promise<string> {
1651
+ const roadmapPath = resolveMilestoneFile(base, mid, "ROADMAP");
1652
+ const roadmapRel = relMilestoneFile(base, mid, "ROADMAP");
1653
+ const contextPath = resolveMilestoneFile(base, mid, "CONTEXT");
1654
+ const contextRel = relMilestoneFile(base, mid, "CONTEXT");
1655
+ const milestoneResearchPath = resolveMilestoneFile(base, mid, "RESEARCH");
1656
+ const milestoneResearchRel = relMilestoneFile(base, mid, "RESEARCH");
1657
+
1658
+ const inlined: string[] = [];
1659
+ inlined.push(await inlineFile(roadmapPath, roadmapRel, "Milestone Roadmap"));
1660
+ const contextInline = await inlineFileOptional(contextPath, contextRel, "Milestone Context");
1661
+ if (contextInline) inlined.push(contextInline);
1662
+ const researchInline = await inlineFileOptional(milestoneResearchPath, milestoneResearchRel, "Milestone Research");
1663
+ if (researchInline) inlined.push(researchInline);
1664
+ const decisionsInline = await inlineGsdRootFile(base, "decisions.md", "Decisions");
1665
+ if (decisionsInline) inlined.push(decisionsInline);
1666
+ const requirementsInline = await inlineGsdRootFile(base, "requirements.md", "Requirements");
1667
+ if (requirementsInline) inlined.push(requirementsInline);
1668
+
1669
+ const depContent = await inlineDependencySummaries(mid, sid, base);
1670
+
1671
+ const inlinedContext = `## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`;
1672
+
1673
+ const outputRelPath = relSliceFile(base, mid, sid, "RESEARCH");
1674
+ const outputAbsPath = resolveSliceFile(base, mid, sid, "RESEARCH") ?? join(base, outputRelPath);
1675
+ return loadPrompt("research-slice", {
1676
+ milestoneId: mid, sliceId: sid, sliceTitle: sTitle,
1677
+ slicePath: relSlicePath(base, mid, sid),
1678
+ roadmapPath: roadmapRel,
1679
+ contextPath: contextRel,
1680
+ milestoneResearchPath: milestoneResearchRel,
1681
+ outputPath: outputRelPath,
1682
+ outputAbsPath,
1683
+ inlinedContext,
1684
+ dependencySummaries: depContent,
1685
+ ...buildSkillDiscoveryVars(),
1686
+ });
1687
+ }
1688
+
1689
+ async function buildPlanSlicePrompt(
1690
+ mid: string, _midTitle: string, sid: string, sTitle: string, base: string,
1691
+ ): Promise<string> {
1692
+ const roadmapPath = resolveMilestoneFile(base, mid, "ROADMAP");
1693
+ const roadmapRel = relMilestoneFile(base, mid, "ROADMAP");
1694
+ const researchPath = resolveSliceFile(base, mid, sid, "RESEARCH");
1695
+ const researchRel = relSliceFile(base, mid, sid, "RESEARCH");
1696
+
1697
+ const inlined: string[] = [];
1698
+ inlined.push(await inlineFile(roadmapPath, roadmapRel, "Milestone Roadmap"));
1699
+ const researchInline = await inlineFileOptional(researchPath, researchRel, "Slice Research");
1700
+ if (researchInline) inlined.push(researchInline);
1701
+ const decisionsInline = await inlineGsdRootFile(base, "decisions.md", "Decisions");
1702
+ if (decisionsInline) inlined.push(decisionsInline);
1703
+ const requirementsInline = await inlineGsdRootFile(base, "requirements.md", "Requirements");
1704
+ if (requirementsInline) inlined.push(requirementsInline);
1705
+
1706
+ const depContent = await inlineDependencySummaries(mid, sid, base);
1707
+
1708
+ const inlinedContext = `## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`;
1709
+
1710
+ const outputRelPath = relSliceFile(base, mid, sid, "PLAN");
1711
+ const outputAbsPath = resolveSliceFile(base, mid, sid, "PLAN") ?? join(base, outputRelPath);
1712
+ const sliceAbsPath = resolveSlicePath(base, mid, sid) ?? join(base, relSlicePath(base, mid, sid));
1713
+
1714
+ const wf = resolveWorkflowConfig();
1715
+
1716
+ const observabilityStep = wf.skip_observability ? "" : `4. Plan observability and diagnostics explicitly:
1717
+ - For non-trivial backend, integration, async, stateful, or UI slices, include an \`Observability / Diagnostics\` section in the slice plan.
1718
+ - Define how a future agent will inspect state, detect failure, and localize the problem.
1719
+ - Prefer structured logs/events, stable error codes/types, status surfaces, and persisted failure state over ad hoc debug text.
1720
+ - Include at least one verification check for a diagnostic or failure-path signal when relevant.`;
1721
+
1722
+ const selfAuditStep = wf.skip_plan_self_audit ? "" : `12. **Self-audit the plan before continuing.** Walk through each check — if any fail, fix the plan files before moving on:
1723
+ - **Completion semantics:** If every task were completed exactly as written, the slice goal/demo should actually be true at the claimed proof level. Do not allow a task plan that only scaffolds toward a future working state.
1724
+ - **Requirement coverage:** Every must-have in the slice maps to at least one task. No must-have is orphaned.
1725
+ - **Task completeness:** Every task has steps, must-haves, verification, observability impact, inputs, and expected output — none are blank or vague.
1726
+ - **Dependency correctness:** Task ordering is consistent. No task references work from a later task.
1727
+ - **Key links planned:** For every pair of artifacts that must connect (component → API, API → database, form → handler), there is an explicit step that wires them — not just "create X" and "create Y" in separate tasks with no connection step.
1728
+ - **Scope sanity:** Target 2–5 steps and 3–8 files per task. 6–8 steps or 8–10 files is a warning — consider splitting. 10+ steps or 12+ files — must split. Each task must be completable in a single fresh context window.
1729
+ - **Context compliance:** If context/research artifacts or \`.gsd/DECISIONS.md\` exist, the plan honors locked decisions and doesn't include deferred or out-of-scope items.
1730
+ - **Requirement coverage:** If \`REQUIREMENTS.md\` exists, every Active requirement this slice owns (per the roadmap) maps to at least one task with verification that proves the requirement is met. No owned requirement is left without a task. No task claims to satisfy a requirement that is Deferred or Out of Scope.
1731
+ - **Proof honesty:** The \`Proof Level\` and \`Integration Closure\` sections match what this slice will actually prove, and they do not imply live end-to-end completion if only fixture or contract proof is planned.
1732
+ - **Feature completeness:** Every task produces real, user-facing progress — not just internal scaffolding. If the slice has a UI surface, at least one task builds the real UI (not a placeholder). If the slice has an API, at least one task connects it to a real data source (not hardcoded returns). If every task were completed and you showed the result to a non-technical stakeholder, they should see real product progress, not developer artifacts.`;
1733
+
1734
+ return loadPrompt("plan-slice", {
1735
+ milestoneId: mid, sliceId: sid, sliceTitle: sTitle,
1736
+ slicePath: relSlicePath(base, mid, sid),
1737
+ sliceAbsPath,
1738
+ roadmapPath: roadmapRel,
1739
+ researchPath: researchRel,
1740
+ outputPath: outputRelPath,
1741
+ outputAbsPath,
1742
+ inlinedContext,
1743
+ dependencySummaries: depContent,
1744
+ observabilityStep,
1745
+ selfAuditStep,
1746
+ });
1747
+ }
1748
+
1749
+ async function buildExecuteTaskPrompt(
1750
+ mid: string, sid: string, sTitle: string,
1751
+ tid: string, tTitle: string, base: string,
1752
+ ): Promise<string> {
1753
+
1754
+ const priorSummaries = await getPriorTaskSummaryPaths(mid, sid, tid, base);
1755
+ const priorLines = priorSummaries.length > 0
1756
+ ? priorSummaries.map(p => `- \`${p}\``).join("\n")
1757
+ : "- (no prior tasks)";
1758
+
1759
+ const taskPlanPath = resolveTaskFile(base, mid, sid, tid, "PLAN");
1760
+ const taskPlanContent = taskPlanPath ? await loadFile(taskPlanPath) : null;
1761
+ const taskPlanRelPath = relTaskFile(base, mid, sid, tid, "PLAN");
1762
+ const taskPlanInline = taskPlanContent
1763
+ ? [
1764
+ "## Inlined Task Plan (authoritative local execution contract)",
1765
+ `Source: \`${taskPlanRelPath}\``,
1766
+ "",
1767
+ taskPlanContent.trim(),
1768
+ ].join("\n")
1769
+ : [
1770
+ "## Inlined Task Plan (authoritative local execution contract)",
1771
+ `Task plan not found at dispatch time. Read \`${taskPlanRelPath}\` before executing.`,
1772
+ ].join("\n");
1773
+
1774
+ const slicePlanPath = resolveSliceFile(base, mid, sid, "PLAN");
1775
+ const slicePlanContent = slicePlanPath ? await loadFile(slicePlanPath) : null;
1776
+ const slicePlanExcerpt = extractSliceExecutionExcerpt(slicePlanContent, relSliceFile(base, mid, sid, "PLAN"));
1777
+
1778
+ // Check for continue file (new naming or legacy)
1779
+ const continueFile = resolveSliceFile(base, mid, sid, "CONTINUE");
1780
+ const legacyContinueDir = resolveSlicePath(base, mid, sid);
1781
+ const legacyContinuePath = legacyContinueDir ? join(legacyContinueDir, "continue.md") : null;
1782
+ const continueContent = continueFile ? await loadFile(continueFile) : null;
1783
+ const legacyContinueContent = !continueContent && legacyContinuePath ? await loadFile(legacyContinuePath) : null;
1784
+ const continueRelPath = relSliceFile(base, mid, sid, "CONTINUE");
1785
+ const resumeSection = buildResumeSection(
1786
+ continueContent,
1787
+ legacyContinueContent,
1788
+ continueRelPath,
1789
+ legacyContinuePath ? `${relSlicePath(base, mid, sid)}/continue.md` : null,
1790
+ );
1791
+
1792
+ const carryForwardSection = await buildCarryForwardSection(priorSummaries, base);
1793
+
1794
+ const sliceDirAbs = resolveSlicePath(base, mid, sid) ?? join(base, relSlicePath(base, mid, sid));
1795
+ const taskSummaryAbsPath = join(sliceDirAbs, "tasks", `${tid}-SUMMARY.md`);
1796
+
1797
+ return loadPrompt("execute-task", {
1798
+ milestoneId: mid, sliceId: sid, sliceTitle: sTitle, taskId: tid, taskTitle: tTitle,
1799
+ planPath: relSliceFile(base, mid, sid, "PLAN"),
1800
+ slicePath: relSlicePath(base, mid, sid),
1801
+ taskPlanPath: taskPlanRelPath,
1802
+ taskPlanInline,
1803
+ slicePlanExcerpt,
1804
+ carryForwardSection,
1805
+ resumeSection,
1806
+ priorTaskLines: priorLines,
1807
+ taskSummaryAbsPath,
1808
+ });
1809
+ }
1810
+
1811
+ async function buildCompleteSlicePrompt(
1812
+ mid: string, _midTitle: string, sid: string, sTitle: string, base: string,
1813
+ ): Promise<string> {
1814
+
1815
+ const roadmapPath = resolveMilestoneFile(base, mid, "ROADMAP");
1816
+ const roadmapRel = relMilestoneFile(base, mid, "ROADMAP");
1817
+ const slicePlanPath = resolveSliceFile(base, mid, sid, "PLAN");
1818
+ const slicePlanRel = relSliceFile(base, mid, sid, "PLAN");
1819
+
1820
+ const inlined: string[] = [];
1821
+ inlined.push(await inlineFile(roadmapPath, roadmapRel, "Milestone Roadmap"));
1822
+ inlined.push(await inlineFile(slicePlanPath, slicePlanRel, "Slice Plan"));
1823
+ const requirementsInline = await inlineGsdRootFile(base, "requirements.md", "Requirements");
1824
+ if (requirementsInline) inlined.push(requirementsInline);
1825
+
1826
+ // Inline all task summaries for this slice
1827
+ const tDir = resolveTasksDir(base, mid, sid);
1828
+ if (tDir) {
1829
+ const summaryFiles = resolveTaskFiles(tDir, "SUMMARY").sort();
1830
+ for (const file of summaryFiles) {
1831
+ const absPath = join(tDir, file);
1832
+ const content = await loadFile(absPath);
1833
+ const sRel = relSlicePath(base, mid, sid);
1834
+ const relPath = `${sRel}/tasks/${file}`;
1835
+ if (content) {
1836
+ inlined.push(`### Task Summary: ${file.replace(/-SUMMARY\.md$/i, "")}\nSource: \`${relPath}\`\n\n${content.trim()}`);
1837
+ }
1838
+ }
1839
+ }
1840
+
1841
+ const inlinedContext = `## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`;
1842
+
1843
+ const sliceDirAbs = resolveSlicePath(base, mid, sid) ?? join(base, relSlicePath(base, mid, sid));
1844
+ const sliceSummaryAbsPath = join(sliceDirAbs, `${sid}-SUMMARY.md`);
1845
+ const sliceUatAbsPath = join(sliceDirAbs, `${sid}-UAT.md`);
1846
+
1847
+ return loadPrompt("complete-slice", {
1848
+ milestoneId: mid, sliceId: sid, sliceTitle: sTitle,
1849
+ slicePath: relSlicePath(base, mid, sid),
1850
+ roadmapPath: roadmapRel,
1851
+ inlinedContext,
1852
+ sliceSummaryAbsPath,
1853
+ sliceUatAbsPath,
1854
+ });
1855
+ }
1856
+
1857
+ async function buildCompleteMilestonePrompt(
1858
+ mid: string, midTitle: string, base: string,
1859
+ ): Promise<string> {
1860
+ const roadmapPath = resolveMilestoneFile(base, mid, "ROADMAP");
1861
+ const roadmapRel = relMilestoneFile(base, mid, "ROADMAP");
1862
+
1863
+ const inlined: string[] = [];
1864
+ inlined.push(await inlineFile(roadmapPath, roadmapRel, "Milestone Roadmap"));
1865
+
1866
+ // Inline all slice summaries
1867
+ const roadmapContent = roadmapPath ? await loadFile(roadmapPath) : null;
1868
+ if (roadmapContent) {
1869
+ const roadmap = parseRoadmap(roadmapContent);
1870
+ for (const slice of roadmap.slices) {
1871
+ const summaryPath = resolveSliceFile(base, mid, slice.id, "SUMMARY");
1872
+ const summaryRel = relSliceFile(base, mid, slice.id, "SUMMARY");
1873
+ inlined.push(await inlineFile(summaryPath, summaryRel, `${slice.id} Summary`));
1874
+ }
1875
+ }
1876
+
1877
+ // Inline root GSD files
1878
+ const requirementsInline = await inlineGsdRootFile(base, "requirements.md", "Requirements");
1879
+ if (requirementsInline) inlined.push(requirementsInline);
1880
+ const decisionsInline = await inlineGsdRootFile(base, "decisions.md", "Decisions");
1881
+ if (decisionsInline) inlined.push(decisionsInline);
1882
+ const projectInline = await inlineGsdRootFile(base, "project.md", "Project");
1883
+ if (projectInline) inlined.push(projectInline);
1884
+ // Inline milestone context file (milestone-level, not GSD root)
1885
+ const contextPath = resolveMilestoneFile(base, mid, "CONTEXT");
1886
+ const contextRel = relMilestoneFile(base, mid, "CONTEXT");
1887
+ const contextInline = await inlineFileOptional(contextPath, contextRel, "Milestone Context");
1888
+ if (contextInline) inlined.push(contextInline);
1889
+
1890
+ const inlinedContext = `## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`;
1891
+
1892
+ const milestoneDirAbs = resolveMilestonePath(base, mid) ?? join(base, relMilestonePath(base, mid));
1893
+ const milestoneSummaryAbsPath = join(milestoneDirAbs, `${mid}-SUMMARY.md`);
1894
+
1895
+ return loadPrompt("complete-milestone", {
1896
+ milestoneId: mid,
1897
+ milestoneTitle: midTitle,
1898
+ roadmapPath: roadmapRel,
1899
+ inlinedContext,
1900
+ milestoneSummaryAbsPath,
1901
+ });
1902
+ }
1903
+
1904
+ // ─── Replan Slice Prompt ───────────────────────────────────────────────────────
1905
+
1906
+ async function buildReplanSlicePrompt(
1907
+ mid: string, midTitle: string, sid: string, sTitle: string, base: string,
1908
+ ): Promise<string> {
1909
+ const roadmapPath = resolveMilestoneFile(base, mid, "ROADMAP");
1910
+ const roadmapRel = relMilestoneFile(base, mid, "ROADMAP");
1911
+ const slicePlanPath = resolveSliceFile(base, mid, sid, "PLAN");
1912
+ const slicePlanRel = relSliceFile(base, mid, sid, "PLAN");
1913
+
1914
+ const inlined: string[] = [];
1915
+ inlined.push(await inlineFile(roadmapPath, roadmapRel, "Milestone Roadmap"));
1916
+ inlined.push(await inlineFile(slicePlanPath, slicePlanRel, "Current Slice Plan"));
1917
+
1918
+ // Find the blocker task summary — the completed task with blocker_discovered: true
1919
+ let blockerTaskId = "";
1920
+ const tDir = resolveTasksDir(base, mid, sid);
1921
+ if (tDir) {
1922
+ const summaryFiles = resolveTaskFiles(tDir, "SUMMARY").sort();
1923
+ for (const file of summaryFiles) {
1924
+ const absPath = join(tDir, file);
1925
+ const content = await loadFile(absPath);
1926
+ if (!content) continue;
1927
+ const summary = parseSummary(content);
1928
+ const sRel = relSlicePath(base, mid, sid);
1929
+ const relPath = `${sRel}/tasks/${file}`;
1930
+ if (summary.frontmatter.blocker_discovered) {
1931
+ blockerTaskId = summary.frontmatter.id || file.replace(/-SUMMARY\.md$/i, "");
1932
+ inlined.push(`### Blocker Task Summary: ${blockerTaskId}\nSource: \`${relPath}\`\n\n${content.trim()}`);
1933
+ }
1934
+ }
1935
+ }
1936
+
1937
+ // Inline decisions
1938
+ const decisionsInline = await inlineGsdRootFile(base, "decisions.md", "Decisions");
1939
+ if (decisionsInline) inlined.push(decisionsInline);
1940
+
1941
+ const inlinedContext = `## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`;
1942
+
1943
+ const sliceDirAbs = resolveSlicePath(base, mid, sid) ?? join(base, relSlicePath(base, mid, sid));
1944
+ const replanAbsPath = join(sliceDirAbs, `${sid}-REPLAN.md`);
1945
+
1946
+ return loadPrompt("replan-slice", {
1947
+ milestoneId: mid,
1948
+ sliceId: sid,
1949
+ sliceTitle: sTitle,
1950
+ slicePath: relSlicePath(base, mid, sid),
1951
+ planPath: slicePlanRel,
1952
+ blockerTaskId,
1953
+ inlinedContext,
1954
+ replanAbsPath,
1955
+ });
1956
+ }
1957
+
1958
+ // ─── Adaptive Replanning ──────────────────────────────────────────────────────
1959
+
1960
+ /**
1961
+ * Check if the most recently completed slice needs reassessment.
1962
+ * Returns { sliceId } if reassessment is needed, null otherwise.
1963
+ *
1964
+ * Skips reassessment when:
1965
+ * - No roadmap exists yet
1966
+ * - No slices are completed
1967
+ * - The last completed slice already has an assessment file
1968
+ * - All slices are complete (milestone done — no point reassessing)
1969
+ */
1970
+ async function checkNeedsReassessment(
1971
+ base: string, mid: string, state: GSDState,
1972
+ ): Promise<{ sliceId: string } | null> {
1973
+ const roadmapFile = resolveMilestoneFile(base, mid, "ROADMAP");
1974
+ const roadmapContent = roadmapFile ? await loadFile(roadmapFile) : null;
1975
+ if (!roadmapContent) return null;
1976
+
1977
+ const roadmap = parseRoadmap(roadmapContent);
1978
+ const completedSlices = roadmap.slices.filter(s => s.done);
1979
+ const incompleteSlices = roadmap.slices.filter(s => !s.done);
1980
+
1981
+ // No completed slices or all slices done — skip
1982
+ if (completedSlices.length === 0 || incompleteSlices.length === 0) return null;
1983
+
1984
+ // Check the last completed slice
1985
+ const lastCompleted = completedSlices[completedSlices.length - 1];
1986
+ const assessmentFile = resolveSliceFile(base, mid, lastCompleted.id, "ASSESSMENT");
1987
+ const hasAssessment = !!(assessmentFile && await loadFile(assessmentFile));
1988
+
1989
+ if (hasAssessment) return null;
1990
+
1991
+ // Also need a summary to reassess against
1992
+ const summaryFile = resolveSliceFile(base, mid, lastCompleted.id, "SUMMARY");
1993
+ const hasSummary = !!(summaryFile && await loadFile(summaryFile));
1994
+
1995
+ if (!hasSummary) return null;
1996
+
1997
+ return { sliceId: lastCompleted.id };
1998
+ }
1999
+
2000
+ /**
2001
+ * Check if the most recently completed slice needs a UAT run.
2002
+ * Returns { sliceId, uatType } if UAT should be dispatched, null otherwise.
2003
+ *
2004
+ * Skips when:
2005
+ * - No roadmap or no completed slices
2006
+ * - All slices are done (milestone complete path — reassessment handles it)
2007
+ * - uat_dispatch preference is not enabled
2008
+ * - No UAT file exists for the slice
2009
+ * - UAT result file already exists (idempotent — already ran)
2010
+ */
2011
+ async function checkNeedsRunUat(
2012
+ base: string, mid: string, state: GSDState, prefs: GSDPreferences | undefined,
2013
+ ): Promise<{ sliceId: string; uatType: UatType } | null> {
2014
+ const roadmapFile = resolveMilestoneFile(base, mid, "ROADMAP");
2015
+ const roadmapContent = roadmapFile ? await loadFile(roadmapFile) : null;
2016
+ if (!roadmapContent) return null;
2017
+
2018
+ const roadmap = parseRoadmap(roadmapContent);
2019
+ const completedSlices = roadmap.slices.filter(s => s.done);
2020
+ const incompleteSlices = roadmap.slices.filter(s => !s.done);
2021
+
2022
+ // No completed slices — nothing to UAT yet
2023
+ if (completedSlices.length === 0) return null;
2024
+
2025
+ // All slices done — milestone complete path, skip (reassessment handles)
2026
+ if (incompleteSlices.length === 0) return null;
2027
+
2028
+ // uat_dispatch must be opted in
2029
+ if (!prefs?.uat_dispatch) return null;
2030
+
2031
+ // Take the last completed slice
2032
+ const lastCompleted = completedSlices[completedSlices.length - 1];
2033
+ const sid = lastCompleted.id;
2034
+
2035
+ // UAT file must exist
2036
+ const uatFile = resolveSliceFile(base, mid, sid, "UAT");
2037
+ if (!uatFile) return null;
2038
+ const uatContent = await loadFile(uatFile);
2039
+ if (!uatContent) return null;
2040
+
2041
+ // If UAT result already exists, skip (idempotent)
2042
+ const uatResultFile = resolveSliceFile(base, mid, sid, "UAT-RESULT");
2043
+ if (uatResultFile) {
2044
+ const hasResult = !!(await loadFile(uatResultFile));
2045
+ if (hasResult) return null;
2046
+ }
2047
+
2048
+ // Classify UAT type; unknown type → treat as human-experience (human review)
2049
+ const uatType = extractUatType(uatContent) ?? "human-experience";
2050
+
2051
+ return { sliceId: sid, uatType };
2052
+ }
2053
+
2054
+ async function buildRunUatPrompt(
2055
+ mid: string, sliceId: string, uatPath: string, uatContent: string, base: string,
2056
+ ): Promise<string> {
2057
+ const inlined: string[] = [];
2058
+ inlined.push(await inlineFile(resolveSliceFile(base, mid, sliceId, "UAT"), uatPath, `${sliceId} UAT`));
2059
+
2060
+ const summaryPath = resolveSliceFile(base, mid, sliceId, "SUMMARY");
2061
+ const summaryRel = relSliceFile(base, mid, sliceId, "SUMMARY");
2062
+ if (summaryPath) {
2063
+ const summaryInline = await inlineFileOptional(summaryPath, summaryRel, `${sliceId} Summary`);
2064
+ if (summaryInline) inlined.push(summaryInline);
2065
+ }
2066
+
2067
+ const projectInline = await inlineGsdRootFile(base, "project.md", "Project");
2068
+ if (projectInline) inlined.push(projectInline);
2069
+
2070
+ const inlinedContext = `## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`;
2071
+
2072
+ const sliceDirAbs = resolveSlicePath(base, mid, sliceId) ?? join(base, relSlicePath(base, mid, sliceId));
2073
+ const uatResultAbsPath = join(sliceDirAbs, `${sliceId}-UAT-RESULT.md`);
2074
+ const uatResultPath = relSliceFile(base, mid, sliceId, "UAT-RESULT");
2075
+ const uatType = extractUatType(uatContent) ?? "human-experience";
2076
+
2077
+ return loadPrompt("run-uat", {
2078
+ milestoneId: mid,
2079
+ sliceId,
2080
+ uatPath,
2081
+ uatResultAbsPath,
2082
+ uatResultPath,
2083
+ uatType,
2084
+ inlinedContext,
2085
+ });
2086
+ }
2087
+
2088
+ async function buildReassessRoadmapPrompt(
2089
+ mid: string, midTitle: string, completedSliceId: string, base: string,
2090
+ ): Promise<string> {
2091
+ const roadmapPath = resolveMilestoneFile(base, mid, "ROADMAP");
2092
+ const roadmapRel = relMilestoneFile(base, mid, "ROADMAP");
2093
+ const summaryPath = resolveSliceFile(base, mid, completedSliceId, "SUMMARY");
2094
+ const summaryRel = relSliceFile(base, mid, completedSliceId, "SUMMARY");
2095
+
2096
+ const inlined: string[] = [];
2097
+ inlined.push(await inlineFile(roadmapPath, roadmapRel, "Current Roadmap"));
2098
+ inlined.push(await inlineFile(summaryPath, summaryRel, `${completedSliceId} Summary`));
2099
+ const projectInline = await inlineGsdRootFile(base, "project.md", "Project");
2100
+ if (projectInline) inlined.push(projectInline);
2101
+ const requirementsInline = await inlineGsdRootFile(base, "requirements.md", "Requirements");
2102
+ if (requirementsInline) inlined.push(requirementsInline);
2103
+ const decisionsInline = await inlineGsdRootFile(base, "decisions.md", "Decisions");
2104
+ if (decisionsInline) inlined.push(decisionsInline);
2105
+
2106
+ const inlinedContext = `## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`;
2107
+
2108
+ const assessmentRel = relSliceFile(base, mid, completedSliceId, "ASSESSMENT");
2109
+ const sliceDirAbs = resolveSlicePath(base, mid, completedSliceId) ?? join(base, relSlicePath(base, mid, completedSliceId));
2110
+ const assessmentAbsPath = join(sliceDirAbs, `${completedSliceId}-ASSESSMENT.md`);
2111
+
2112
+ return loadPrompt("reassess-roadmap", {
2113
+ milestoneId: mid,
2114
+ milestoneTitle: midTitle,
2115
+ completedSliceId,
2116
+ roadmapPath: roadmapRel,
2117
+ completedSliceSummaryPath: summaryRel,
2118
+ assessmentPath: assessmentRel,
2119
+ assessmentAbsPath,
2120
+ inlinedContext,
2121
+ });
2122
+ }
2123
+
2124
+ function extractSliceExecutionExcerpt(content: string | null, relPath: string): string {
2125
+ if (!content) {
2126
+ return [
2127
+ "## Slice Plan Excerpt",
2128
+ `Slice plan not found at dispatch time. Read \`${relPath}\` before running slice-level verification.`,
2129
+ ].join("\n");
2130
+ }
2131
+
2132
+ const lines = content.split("\n");
2133
+ const goalLine = lines.find(l => l.startsWith("**Goal:**"))?.trim();
2134
+ const demoLine = lines.find(l => l.startsWith("**Demo:**"))?.trim();
2135
+
2136
+ const verification = extractMarkdownSection(content, "Verification");
2137
+ const observability = extractMarkdownSection(content, "Observability / Diagnostics");
2138
+
2139
+ const parts = ["## Slice Plan Excerpt", `Source: \`${relPath}\``];
2140
+ if (goalLine) parts.push(goalLine);
2141
+ if (demoLine) parts.push(demoLine);
2142
+ if (verification) {
2143
+ parts.push("", "### Slice Verification", verification.trim());
2144
+ }
2145
+ if (observability) {
2146
+ parts.push("", "### Slice Observability / Diagnostics", observability.trim());
2147
+ }
2148
+
2149
+ return parts.join("\n");
2150
+ }
2151
+
2152
+ function extractMarkdownSection(content: string, heading: string): string | null {
2153
+ const match = new RegExp(`^## ${escapeRegExp(heading)}\\s*$`, "m").exec(content);
2154
+ if (!match) return null;
2155
+
2156
+ const start = match.index + match[0].length;
2157
+ const rest = content.slice(start);
2158
+ const nextHeading = rest.match(/^##\s+/m);
2159
+ const end = nextHeading?.index ?? rest.length;
2160
+ return rest.slice(0, end).trim();
2161
+ }
2162
+
2163
+ function escapeRegExp(value: string): string {
2164
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
2165
+ }
2166
+
2167
+ function buildResumeSection(
2168
+ continueContent: string | null,
2169
+ legacyContinueContent: string | null,
2170
+ continueRelPath: string,
2171
+ legacyContinueRelPath: string | null,
2172
+ ): string {
2173
+ const resolvedContent = continueContent ?? legacyContinueContent;
2174
+ const resolvedRelPath = continueContent ? continueRelPath : legacyContinueRelPath;
2175
+
2176
+ if (!resolvedContent || !resolvedRelPath) {
2177
+ return ["## Resume State", "- No continue file present. Start from the top of the task plan."].join("\n");
2178
+ }
2179
+
2180
+ const cont = parseContinue(resolvedContent);
2181
+ const lines = [
2182
+ "## Resume State",
2183
+ `Source: \`${resolvedRelPath}\``,
2184
+ `- Status: ${cont.frontmatter.status || "in_progress"}`,
2185
+ ];
2186
+
2187
+ if (cont.frontmatter.step && cont.frontmatter.totalSteps) {
2188
+ lines.push(`- Progress: step ${cont.frontmatter.step} of ${cont.frontmatter.totalSteps}`);
2189
+ }
2190
+ if (cont.completedWork) lines.push(`- Completed: ${oneLine(cont.completedWork)}`);
2191
+ if (cont.remainingWork) lines.push(`- Remaining: ${oneLine(cont.remainingWork)}`);
2192
+ if (cont.decisions) lines.push(`- Decisions: ${oneLine(cont.decisions)}`);
2193
+ if (cont.nextAction) lines.push(`- Next action: ${oneLine(cont.nextAction)}`);
2194
+
2195
+ return lines.join("\n");
2196
+ }
2197
+
2198
+ async function buildCarryForwardSection(priorSummaryPaths: string[], base: string): Promise<string> {
2199
+ if (priorSummaryPaths.length === 0) {
2200
+ return ["## Carry-Forward Context", "- No prior task summaries in this slice."].join("\n");
2201
+ }
2202
+
2203
+ const items = await Promise.all(priorSummaryPaths.map(async (relPath) => {
2204
+ const absPath = join(base, relPath);
2205
+ const content = await loadFile(absPath);
2206
+ if (!content) return `- \`${relPath}\``;
2207
+
2208
+ const summary = parseSummary(content);
2209
+ const provided = summary.frontmatter.provides.slice(0, 2).join("; ");
2210
+ const decisions = summary.frontmatter.key_decisions.slice(0, 2).join("; ");
2211
+ const patterns = summary.frontmatter.patterns_established.slice(0, 2).join("; ");
2212
+ const diagnostics = extractMarkdownSection(content, "Diagnostics");
2213
+
2214
+ const parts = [summary.title || relPath];
2215
+ if (summary.oneLiner) parts.push(summary.oneLiner);
2216
+ if (provided) parts.push(`provides: ${provided}`);
2217
+ if (decisions) parts.push(`decisions: ${decisions}`);
2218
+ if (patterns) parts.push(`patterns: ${patterns}`);
2219
+ if (diagnostics) parts.push(`diagnostics: ${oneLine(diagnostics)}`);
2220
+
2221
+ return `- \`${relPath}\` — ${parts.join(" | ")}`;
2222
+ }));
2223
+
2224
+ return ["## Carry-Forward Context", ...items].join("\n");
2225
+ }
2226
+
2227
+ function oneLine(text: string): string {
2228
+ return text.replace(/\s+/g, " ").trim();
2229
+ }
2230
+
2231
+ async function getPriorTaskSummaryPaths(
2232
+ mid: string, sid: string, currentTid: string, base: string,
2233
+ ): Promise<string[]> {
2234
+ const tDir = resolveTasksDir(base, mid, sid);
2235
+ if (!tDir) return [];
2236
+
2237
+ const summaryFiles = resolveTaskFiles(tDir, "SUMMARY");
2238
+ const currentNum = parseInt(currentTid.replace(/^T/, ""), 10);
2239
+ const sRel = relSlicePath(base, mid, sid);
2240
+
2241
+ return summaryFiles
2242
+ .filter(f => {
2243
+ const num = parseInt(f.replace(/^T/, ""), 10);
2244
+ return num < currentNum;
2245
+ })
2246
+ .map(f => `${sRel}/tasks/${f}`);
2247
+ }
2248
+
2249
+ // ─── Preconditions ────────────────────────────────────────────────────────────
2250
+
2251
+ /**
2252
+ * Ensure directories, branches, and other prerequisites exist before
2253
+ * dispatching a unit. The LLM should never need to mkdir or git checkout.
2254
+ */
2255
+ function ensurePreconditions(
2256
+ unitType: string, unitId: string, base: string, state: GSDState,
2257
+ ): void {
2258
+ const parts = unitId.split("/");
2259
+ const mid = parts[0]!;
2260
+
2261
+ // Always ensure milestone dir exists
2262
+ const mDir = resolveMilestonePath(base, mid);
2263
+ if (!mDir) {
2264
+ const newDir = join(milestonesDir(base), mid);
2265
+ mkdirSync(join(newDir, "slices"), { recursive: true });
2266
+ }
2267
+
2268
+ // For slice-level units, ensure slice dir exists
2269
+ if (parts.length >= 2) {
2270
+ const sid = parts[1]!;
2271
+
2272
+ // Re-resolve milestone path after potential creation
2273
+ const mDirResolved = resolveMilestonePath(base, mid);
2274
+ if (mDirResolved) {
2275
+ const slicesDir = join(mDirResolved, "slices");
2276
+ const sDir = resolveDir(slicesDir, sid);
2277
+ if (!sDir) {
2278
+ // Create slice dir with bare ID
2279
+ const newSliceDir = join(slicesDir, sid);
2280
+ mkdirSync(join(newSliceDir, "tasks"), { recursive: true });
2281
+ } else {
2282
+ // Ensure tasks/ subdir exists
2283
+ const tasksDir = join(slicesDir, sDir, "tasks");
2284
+ if (!existsSync(tasksDir)) {
2285
+ mkdirSync(tasksDir, { recursive: true });
2286
+ }
2287
+ }
2288
+ }
2289
+ }
2290
+
2291
+ if (["research-slice", "plan-slice", "execute-task", "complete-slice", "replan-slice"].includes(unitType) && parts.length >= 2) {
2292
+ const sid = parts[1]!;
2293
+ ensureSliceBranch(base, mid, sid);
2294
+ }
2295
+ }
2296
+
2297
+ // ─── Diagnostics ──────────────────────────────────────────────────────────────
2298
+
2299
+ async function emitObservabilityWarnings(
2300
+ ctx: ExtensionContext,
2301
+ unitType: string,
2302
+ unitId: string,
2303
+ ): Promise<void> {
2304
+ const wfConfig = resolveWorkflowConfig();
2305
+ if (wfConfig.skip_observability) return;
2306
+
2307
+ const parts = unitId.split("/");
2308
+ const mid = parts[0];
2309
+ const sid = parts[1];
2310
+ const tid = parts[2];
2311
+
2312
+ if (!mid || !sid) return;
2313
+
2314
+ let issues = [] as Awaited<ReturnType<typeof validatePlanBoundary>>;
2315
+
2316
+ if (unitType === "plan-slice") {
2317
+ issues = await validatePlanBoundary(basePath, mid, sid);
2318
+ } else if (unitType === "execute-task" && tid) {
2319
+ issues = await validateExecuteBoundary(basePath, mid, sid, tid);
2320
+ } else if (unitType === "complete-slice") {
2321
+ issues = await validateCompleteBoundary(basePath, mid, sid);
2322
+ }
2323
+
2324
+ if (issues.length === 0) return;
2325
+
2326
+ ctx.ui.notify(
2327
+ `Observability check (${unitType}) found ${issues.length} warning${issues.length === 1 ? "" : "s"}:\n${formatValidationIssues(issues)}`,
2328
+ "warning",
2329
+ );
2330
+ }
2331
+
2332
+ async function recoverTimedOutUnit(
2333
+ ctx: ExtensionContext,
2334
+ pi: ExtensionAPI,
2335
+ unitType: string,
2336
+ unitId: string,
2337
+ reason: "idle" | "hard",
2338
+ ): Promise<"recovered" | "paused"> {
2339
+ if (!currentUnit) return "paused";
2340
+
2341
+ const runtime = readUnitRuntimeRecord(basePath, unitType, unitId);
2342
+ const recoveryAttempts = runtime?.recoveryAttempts ?? 0;
2343
+ const maxRecoveryAttempts = reason === "idle" ? 2 : 1;
2344
+
2345
+ const recoveryKey = `${unitType}/${unitId}`;
2346
+ const attemptNumber = (unitRecoveryCount.get(recoveryKey) ?? 0) + 1;
2347
+ unitRecoveryCount.set(recoveryKey, attemptNumber);
2348
+
2349
+ if (attemptNumber > 1) {
2350
+ // Exponential backoff: 2^(n-1) seconds, capped at 30s
2351
+ const backoffMs = Math.min(1000 * Math.pow(2, attemptNumber - 2), 30000);
2352
+ ctx.ui.notify(
2353
+ `Recovery attempt ${attemptNumber} for ${unitType} ${unitId}. Waiting ${backoffMs / 1000}s before retry.`,
2354
+ "info",
2355
+ );
2356
+ await new Promise(r => setTimeout(r, backoffMs));
2357
+ }
2358
+
2359
+ if (unitType === "execute-task") {
2360
+ const status = await inspectExecuteTaskDurability(basePath, unitId);
2361
+ if (!status) return "paused";
2362
+
2363
+ writeUnitRuntimeRecord(basePath, unitType, unitId, currentUnit.startedAt, {
2364
+ recovery: status,
2365
+ });
2366
+
2367
+ const durableComplete = status.summaryExists && status.taskChecked && status.nextActionAdvanced;
2368
+ if (durableComplete) {
2369
+ writeUnitRuntimeRecord(basePath, unitType, unitId, currentUnit.startedAt, {
2370
+ phase: "finalized",
2371
+ recovery: status,
2372
+ });
2373
+ ctx.ui.notify(
2374
+ `${reason === "idle" ? "Idle" : "Timeout"} recovery: ${unitType} ${unitId} already completed on disk. Continuing auto-mode. (attempt ${attemptNumber})`,
2375
+ "info",
2376
+ );
2377
+ unitRecoveryCount.delete(recoveryKey);
2378
+ await dispatchNextUnit(ctx, pi);
2379
+ return "recovered";
2380
+ }
2381
+
2382
+ if (recoveryAttempts < maxRecoveryAttempts) {
2383
+ const isEscalation = recoveryAttempts > 0;
2384
+ writeUnitRuntimeRecord(basePath, unitType, unitId, currentUnit.startedAt, {
2385
+ phase: "recovered",
2386
+ recovery: status,
2387
+ recoveryAttempts: recoveryAttempts + 1,
2388
+ lastRecoveryReason: reason,
2389
+ lastProgressAt: Date.now(),
2390
+ progressCount: (runtime?.progressCount ?? 0) + 1,
2391
+ lastProgressKind: reason === "idle" ? "idle-recovery-retry" : "hard-recovery-retry",
2392
+ });
2393
+
2394
+ const steeringLines = isEscalation
2395
+ ? [
2396
+ `**FINAL ${reason === "idle" ? "IDLE" : "HARD TIMEOUT"} RECOVERY — last chance before this task is skipped.**`,
2397
+ `You are still executing ${unitType} ${unitId}.`,
2398
+ `Recovery attempt ${recoveryAttempts + 1} of ${maxRecoveryAttempts}.`,
2399
+ `Current durability status: ${formatExecuteTaskRecoveryStatus(status)}.`,
2400
+ "You MUST finish the durable output NOW, even if incomplete.",
2401
+ "Write the task summary with whatever you have accomplished so far.",
2402
+ "Mark the task [x] in the plan. Commit your work.",
2403
+ "A partial summary is infinitely better than no summary.",
2404
+ ]
2405
+ : [
2406
+ `**${reason === "idle" ? "IDLE" : "HARD TIMEOUT"} RECOVERY — do not stop.**`,
2407
+ `You are still executing ${unitType} ${unitId}.`,
2408
+ `Recovery attempt ${recoveryAttempts + 1} of ${maxRecoveryAttempts}.`,
2409
+ `Current durability status: ${formatExecuteTaskRecoveryStatus(status)}.`,
2410
+ "Do not keep exploring.",
2411
+ "Immediately finish the required durable output for this unit.",
2412
+ "If full completion is impossible, write the partial artifact/state needed for recovery and make the blocker explicit.",
2413
+ ];
2414
+
2415
+ pi.sendMessage(
2416
+ {
2417
+ customType: "gsd-auto-timeout-recovery",
2418
+ display: verbose,
2419
+ content: steeringLines.join("\n"),
2420
+ },
2421
+ { triggerTurn: true, deliverAs: "steer" },
2422
+ );
2423
+ ctx.ui.notify(
2424
+ `${reason === "idle" ? "Idle" : "Timeout"} recovery: steering ${unitType} ${unitId} to finish durable output (attempt ${attemptNumber}, session ${recoveryAttempts + 1}/${maxRecoveryAttempts}).`,
2425
+ "warning",
2426
+ );
2427
+ return "recovered";
2428
+ }
2429
+
2430
+ // Retries exhausted — write missing durable artifacts and advance.
2431
+ const diagnostic = formatExecuteTaskRecoveryStatus(status);
2432
+ const [mid, sid, tid] = unitId.split("/");
2433
+ const skipped = mid && sid && tid
2434
+ ? skipExecuteTask(basePath, mid, sid, tid, status, reason, maxRecoveryAttempts)
2435
+ : false;
2436
+
2437
+ if (skipped) {
2438
+ writeUnitRuntimeRecord(basePath, unitType, unitId, currentUnit.startedAt, {
2439
+ phase: "skipped",
2440
+ recovery: status,
2441
+ recoveryAttempts: recoveryAttempts + 1,
2442
+ lastRecoveryReason: reason,
2443
+ });
2444
+ ctx.ui.notify(
2445
+ `${unitType} ${unitId} skipped after ${maxRecoveryAttempts} recovery attempts (${diagnostic}). Blocker artifacts written. Advancing pipeline. (attempt ${attemptNumber})`,
2446
+ "warning",
2447
+ );
2448
+ unitRecoveryCount.delete(recoveryKey);
2449
+ await dispatchNextUnit(ctx, pi);
2450
+ return "recovered";
2451
+ }
2452
+
2453
+ // Fallback: couldn't write skip artifacts — pause as before.
2454
+ writeUnitRuntimeRecord(basePath, unitType, unitId, currentUnit.startedAt, {
2455
+ phase: "paused",
2456
+ recovery: status,
2457
+ recoveryAttempts: recoveryAttempts + 1,
2458
+ lastRecoveryReason: reason,
2459
+ });
2460
+ ctx.ui.notify(
2461
+ `${reason === "idle" ? "Idle" : "Timeout"} recovery check for ${unitType} ${unitId}: ${diagnostic}`,
2462
+ "warning",
2463
+ );
2464
+ return "paused";
2465
+ }
2466
+
2467
+ const expected = diagnoseExpectedArtifact(unitType, unitId, basePath) ?? "required durable artifact";
2468
+
2469
+ // Check if the artifact already exists on disk — agent may have written it
2470
+ // without signaling completion.
2471
+ const artifactPath = resolveExpectedArtifactPath(unitType, unitId, basePath);
2472
+ if (artifactPath && existsSync(artifactPath)) {
2473
+ writeUnitRuntimeRecord(basePath, unitType, unitId, currentUnit.startedAt, {
2474
+ phase: "finalized",
2475
+ recoveryAttempts: recoveryAttempts + 1,
2476
+ lastRecoveryReason: reason,
2477
+ });
2478
+ ctx.ui.notify(
2479
+ `${reason === "idle" ? "Idle" : "Timeout"} recovery: ${unitType} ${unitId} artifact already exists on disk. Advancing. (attempt ${attemptNumber})`,
2480
+ "info",
2481
+ );
2482
+ unitRecoveryCount.delete(recoveryKey);
2483
+ await dispatchNextUnit(ctx, pi);
2484
+ return "recovered";
2485
+ }
2486
+
2487
+ if (recoveryAttempts < maxRecoveryAttempts) {
2488
+ const isEscalation = recoveryAttempts > 0;
2489
+ writeUnitRuntimeRecord(basePath, unitType, unitId, currentUnit.startedAt, {
2490
+ phase: "recovered",
2491
+ recoveryAttempts: recoveryAttempts + 1,
2492
+ lastRecoveryReason: reason,
2493
+ lastProgressAt: Date.now(),
2494
+ progressCount: (runtime?.progressCount ?? 0) + 1,
2495
+ lastProgressKind: reason === "idle" ? "idle-recovery-retry" : "hard-recovery-retry",
2496
+ });
2497
+
2498
+ const steeringLines = isEscalation
2499
+ ? [
2500
+ `**FINAL ${reason === "idle" ? "IDLE" : "HARD TIMEOUT"} RECOVERY — last chance before skip.**`,
2501
+ `You are still executing ${unitType} ${unitId}.`,
2502
+ `Recovery attempt ${recoveryAttempts + 1} of ${maxRecoveryAttempts} — next failure skips this unit.`,
2503
+ `Expected durable output: ${expected}.`,
2504
+ "You MUST write the artifact file NOW, even if incomplete.",
2505
+ "Write whatever you have — partial research, preliminary findings, best-effort analysis.",
2506
+ "A partial artifact is infinitely better than no artifact.",
2507
+ "If you are truly blocked, write the file with a BLOCKER section explaining why.",
2508
+ ]
2509
+ : [
2510
+ `**${reason === "idle" ? "IDLE" : "HARD TIMEOUT"} RECOVERY — stay in auto-mode.**`,
2511
+ `You are still executing ${unitType} ${unitId}.`,
2512
+ `Recovery attempt ${recoveryAttempts + 1} of ${maxRecoveryAttempts}.`,
2513
+ `Expected durable output: ${expected}.`,
2514
+ "Stop broad exploration.",
2515
+ "Write the required artifact now.",
2516
+ "If blocked, write the partial artifact and explicitly record the blocker instead of going silent.",
2517
+ ];
2518
+
2519
+ pi.sendMessage(
2520
+ {
2521
+ customType: "gsd-auto-timeout-recovery",
2522
+ display: verbose,
2523
+ content: steeringLines.join("\n"),
2524
+ },
2525
+ { triggerTurn: true, deliverAs: "steer" },
2526
+ );
2527
+ ctx.ui.notify(
2528
+ `${reason === "idle" ? "Idle" : "Timeout"} recovery: steering ${unitType} ${unitId} to produce ${expected} (attempt ${attemptNumber}, session ${recoveryAttempts + 1}/${maxRecoveryAttempts}).`,
2529
+ "warning",
2530
+ );
2531
+ return "recovered";
2532
+ }
2533
+
2534
+ // Retries exhausted — write a blocker placeholder and advance the pipeline
2535
+ // instead of silently stalling.
2536
+ const placeholder = writeBlockerPlaceholder(
2537
+ unitType, unitId, basePath,
2538
+ `${reason} recovery exhausted ${maxRecoveryAttempts} attempts without producing the artifact.`,
2539
+ );
2540
+
2541
+ if (placeholder) {
2542
+ writeUnitRuntimeRecord(basePath, unitType, unitId, currentUnit.startedAt, {
2543
+ phase: "skipped",
2544
+ recoveryAttempts: recoveryAttempts + 1,
2545
+ lastRecoveryReason: reason,
2546
+ });
2547
+ ctx.ui.notify(
2548
+ `${unitType} ${unitId} skipped after ${maxRecoveryAttempts} recovery attempts. Blocker placeholder written to ${placeholder}. Advancing pipeline. (attempt ${attemptNumber})`,
2549
+ "warning",
2550
+ );
2551
+ unitRecoveryCount.delete(recoveryKey);
2552
+ await dispatchNextUnit(ctx, pi);
2553
+ return "recovered";
2554
+ }
2555
+
2556
+ // Fallback: couldn't resolve artifact path — pause as before.
2557
+ writeUnitRuntimeRecord(basePath, unitType, unitId, currentUnit.startedAt, {
2558
+ phase: "paused",
2559
+ recoveryAttempts: recoveryAttempts + 1,
2560
+ lastRecoveryReason: reason,
2561
+ });
2562
+ return "paused";
2563
+ }
2564
+
2565
+ /**
2566
+ * Write skip artifacts for a stuck execute-task: a blocker task summary and
2567
+ * the [x] checkbox in the slice plan. Returns true if artifacts were written.
2568
+ */
2569
+ export function skipExecuteTask(
2570
+ base: string, mid: string, sid: string, tid: string,
2571
+ status: { summaryExists: boolean; taskChecked: boolean },
2572
+ reason: string, maxAttempts: number,
2573
+ ): boolean {
2574
+ // Write a blocker task summary if missing.
2575
+ if (!status.summaryExists) {
2576
+ const tasksDir = resolveTasksDir(base, mid, sid);
2577
+ const sDir = resolveSlicePath(base, mid, sid);
2578
+ const targetDir = tasksDir ?? (sDir ? join(sDir, "tasks") : null);
2579
+ if (!targetDir) return false;
2580
+ if (!existsSync(targetDir)) mkdirSync(targetDir, { recursive: true });
2581
+ const summaryPath = join(targetDir, buildTaskFileName(tid, "SUMMARY"));
2582
+ const content = [
2583
+ `# BLOCKER — task skipped by auto-mode recovery`,
2584
+ ``,
2585
+ `Task \`${tid}\` in slice \`${sid}\` (milestone \`${mid}\`) failed to complete after ${reason} recovery exhausted ${maxAttempts} attempts.`,
2586
+ ``,
2587
+ `This placeholder was written by auto-mode so the pipeline can advance.`,
2588
+ `Review this task manually and replace this file with a real summary.`,
2589
+ ].join("\n");
2590
+ writeFileSync(summaryPath, content, "utf-8");
2591
+ }
2592
+
2593
+ // Mark [x] in the slice plan if not already checked.
2594
+ if (!status.taskChecked) {
2595
+ const planAbs = resolveSliceFile(base, mid, sid, "PLAN");
2596
+ if (planAbs && existsSync(planAbs)) {
2597
+ const planContent = readFileSync(planAbs, "utf-8");
2598
+ const escapedTid = tid.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
2599
+ const re = new RegExp(`^(- \\[) \\] (\\*\\*${escapedTid}:)`, "m");
2600
+ if (re.test(planContent)) {
2601
+ writeFileSync(planAbs, planContent.replace(re, "$1x] $2"), "utf-8");
2602
+ }
2603
+ }
2604
+ }
2605
+
2606
+ return true;
2607
+ }
2608
+
2609
+ /**
2610
+ * Detect whether the agent is producing work on disk by checking git for
2611
+ * any working-tree changes (staged, unstaged, or untracked). Returns true
2612
+ * if there are uncommitted changes — meaning the agent is actively working,
2613
+ * even though it hasn't signaled progress through runtime records.
2614
+ */
2615
+ function detectWorkingTreeActivity(cwd: string): boolean {
2616
+ try {
2617
+ const out = execSync("git status --porcelain", {
2618
+ cwd,
2619
+ stdio: ["pipe", "pipe", "pipe"],
2620
+ timeout: 5000,
2621
+ });
2622
+ return out.toString().trim().length > 0;
2623
+ } catch {
2624
+ return false;
2625
+ }
2626
+ }
2627
+
2628
+ /**
2629
+ * Resolve the expected artifact for a non-execute-task unit to an absolute path.
2630
+ * Returns null for unit types that don't produce a single file (execute-task,
2631
+ * complete-slice, replan-slice).
2632
+ */
2633
+ export function resolveExpectedArtifactPath(unitType: string, unitId: string, base: string): string | null {
2634
+ const parts = unitId.split("/");
2635
+ const mid = parts[0]!;
2636
+ const sid = parts[1];
2637
+ switch (unitType) {
2638
+ case "research-milestone": {
2639
+ const dir = resolveMilestonePath(base, mid);
2640
+ return dir ? join(dir, buildMilestoneFileName(mid, "RESEARCH")) : null;
2641
+ }
2642
+ case "plan-milestone": {
2643
+ const dir = resolveMilestonePath(base, mid);
2644
+ return dir ? join(dir, buildMilestoneFileName(mid, "ROADMAP")) : null;
2645
+ }
2646
+ case "research-slice": {
2647
+ const dir = resolveSlicePath(base, mid, sid!);
2648
+ return dir ? join(dir, buildSliceFileName(sid!, "RESEARCH")) : null;
2649
+ }
2650
+ case "plan-slice": {
2651
+ const dir = resolveSlicePath(base, mid, sid!);
2652
+ return dir ? join(dir, buildSliceFileName(sid!, "PLAN")) : null;
2653
+ }
2654
+ case "reassess-roadmap": {
2655
+ const dir = resolveSlicePath(base, mid, sid!);
2656
+ return dir ? join(dir, buildSliceFileName(sid!, "ASSESSMENT")) : null;
2657
+ }
2658
+ case "run-uat": {
2659
+ const dir = resolveSlicePath(base, mid, sid!);
2660
+ return dir ? join(dir, buildSliceFileName(sid!, "UAT-RESULT")) : null;
2661
+ }
2662
+ case "execute-task": {
2663
+ const tid = parts[2];
2664
+ const dir = resolveSlicePath(base, mid, sid!);
2665
+ return dir && tid ? join(dir, "tasks", buildTaskFileName(tid, "SUMMARY")) : null;
2666
+ }
2667
+ case "complete-slice": {
2668
+ const dir = resolveSlicePath(base, mid, sid!);
2669
+ return dir ? join(dir, buildSliceFileName(sid!, "SUMMARY")) : null;
2670
+ }
2671
+ case "complete-milestone": {
2672
+ const dir = resolveMilestonePath(base, mid);
2673
+ return dir ? join(dir, buildMilestoneFileName(mid, "SUMMARY")) : null;
2674
+ }
2675
+ default:
2676
+ return null;
2677
+ }
2678
+ }
2679
+
2680
+ /**
2681
+ * Check whether the expected artifact for a unit exists on disk.
2682
+ * Returns true if the artifact file exists, or if the unit type has no
2683
+ * single verifiable artifact (e.g., replan-slice).
2684
+ */
2685
+ function verifyExpectedArtifact(unitType: string, unitId: string, base: string): boolean {
2686
+ const absPath = resolveExpectedArtifactPath(unitType, unitId, base);
2687
+ if (!absPath) return true;
2688
+ return existsSync(absPath);
2689
+ }
2690
+
2691
+ /**
2692
+ * Write a placeholder artifact so the pipeline can advance past a stuck unit.
2693
+ * Returns the relative path written, or null if the path couldn't be resolved.
2694
+ */
2695
+ export function writeBlockerPlaceholder(unitType: string, unitId: string, base: string, reason: string): string | null {
2696
+ const absPath = resolveExpectedArtifactPath(unitType, unitId, base);
2697
+ if (!absPath) return null;
2698
+ const dir = absPath.substring(0, absPath.lastIndexOf("/"));
2699
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
2700
+ const content = [
2701
+ `# BLOCKER — auto-mode recovery failed`,
2702
+ ``,
2703
+ `Unit \`${unitType}\` for \`${unitId}\` failed to produce this artifact after idle recovery exhausted all retries.`,
2704
+ ``,
2705
+ `**Reason**: ${reason}`,
2706
+ ``,
2707
+ `This placeholder was written by auto-mode so the pipeline can advance.`,
2708
+ `Review and replace this file before relying on downstream artifacts.`,
2709
+ ].join("\n");
2710
+ writeFileSync(absPath, content, "utf-8");
2711
+ return diagnoseExpectedArtifact(unitType, unitId, base);
2712
+ }
2713
+
2714
+ function diagnoseExpectedArtifact(unitType: string, unitId: string, base: string): string | null {
2715
+ const parts = unitId.split("/");
2716
+ const mid = parts[0];
2717
+ const sid = parts[1];
2718
+ switch (unitType) {
2719
+ case "research-milestone":
2720
+ return `${relMilestoneFile(base, mid!, "RESEARCH")} (milestone research)`;
2721
+ case "plan-milestone":
2722
+ return `${relMilestoneFile(base, mid!, "ROADMAP")} (milestone roadmap)`;
2723
+ case "research-slice":
2724
+ return `${relSliceFile(base, mid!, sid!, "RESEARCH")} (slice research)`;
2725
+ case "plan-slice":
2726
+ return `${relSliceFile(base, mid!, sid!, "PLAN")} (slice plan)`;
2727
+ case "execute-task": {
2728
+ const tid = parts[2];
2729
+ return `Task ${tid} marked [x] in ${relSliceFile(base, mid!, sid!, "PLAN")} + summary written`;
2730
+ }
2731
+ case "complete-slice":
2732
+ return `Slice ${sid} marked [x] in ${relMilestoneFile(base, mid!, "ROADMAP")} + summary written`;
2733
+ case "replan-slice":
2734
+ return `${relSliceFile(base, mid!, sid!, "REPLAN")} + updated ${relSliceFile(base, mid!, sid!, "PLAN")}`;
2735
+ case "reassess-roadmap":
2736
+ return `${relSliceFile(base, mid!, sid!, "ASSESSMENT")} (roadmap reassessment)`;
2737
+ case "run-uat":
2738
+ return `${relSliceFile(base, mid!, sid!, "UAT-RESULT")} (UAT result)`;
2739
+ case "complete-milestone":
2740
+ return `${relMilestoneFile(base, mid!, "SUMMARY")} (milestone summary)`;
2741
+ default:
2742
+ return null;
2743
+ }
2744
+ }