gsd-pi 2.26.0 → 2.27.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 (171) hide show
  1. package/README.md +43 -6
  2. package/dist/cli.js +4 -2
  3. package/dist/headless.d.ts +3 -0
  4. package/dist/headless.js +136 -8
  5. package/dist/help-text.js +3 -0
  6. package/dist/loader.js +33 -4
  7. package/dist/resources/extensions/bg-shell/index.ts +19 -2
  8. package/dist/resources/extensions/bg-shell/process-manager.ts +45 -0
  9. package/dist/resources/extensions/bg-shell/types.ts +21 -1
  10. package/dist/resources/extensions/gsd/auto/session.ts +224 -0
  11. package/dist/resources/extensions/gsd/auto-budget.ts +32 -0
  12. package/dist/resources/extensions/gsd/auto-dashboard.ts +63 -10
  13. package/dist/resources/extensions/gsd/auto-direct-dispatch.ts +229 -0
  14. package/dist/resources/extensions/gsd/auto-dispatch.ts +23 -10
  15. package/dist/resources/extensions/gsd/auto-model-selection.ts +179 -0
  16. package/dist/resources/extensions/gsd/auto-observability.ts +74 -0
  17. package/dist/resources/extensions/gsd/auto-prompts.ts +0 -1
  18. package/dist/resources/extensions/gsd/auto-timeout-recovery.ts +262 -0
  19. package/dist/resources/extensions/gsd/auto-tool-tracking.ts +54 -0
  20. package/dist/resources/extensions/gsd/auto-unit-closeout.ts +46 -0
  21. package/dist/resources/extensions/gsd/auto-worktree-sync.ts +207 -0
  22. package/dist/resources/extensions/gsd/auto.ts +977 -1551
  23. package/dist/resources/extensions/gsd/commands.ts +3 -3
  24. package/dist/resources/extensions/gsd/dashboard-overlay.ts +47 -72
  25. package/dist/resources/extensions/gsd/doctor-proactive.ts +9 -4
  26. package/dist/resources/extensions/gsd/export-html.ts +1001 -0
  27. package/dist/resources/extensions/gsd/export.ts +49 -1
  28. package/dist/resources/extensions/gsd/git-service.ts +6 -0
  29. package/dist/resources/extensions/gsd/gitignore.ts +4 -1
  30. package/dist/resources/extensions/gsd/guided-flow.ts +24 -5
  31. package/dist/resources/extensions/gsd/index.ts +54 -1
  32. package/dist/resources/extensions/gsd/native-git-bridge.ts +30 -2
  33. package/dist/resources/extensions/gsd/observability-validator.ts +21 -0
  34. package/dist/resources/extensions/gsd/parallel-orchestrator.ts +231 -20
  35. package/dist/resources/extensions/gsd/preferences.ts +62 -1
  36. package/dist/resources/extensions/gsd/prompts/execute-task.md +4 -3
  37. package/dist/resources/extensions/gsd/prompts/system.md +1 -1
  38. package/dist/resources/extensions/gsd/reports.ts +510 -0
  39. package/dist/resources/extensions/gsd/roadmap-slices.ts +1 -1
  40. package/dist/resources/extensions/gsd/skills/gsd-headless/SKILL.md +178 -0
  41. package/dist/resources/extensions/gsd/skills/gsd-headless/references/answer-injection.md +54 -0
  42. package/dist/resources/extensions/gsd/skills/gsd-headless/references/commands.md +59 -0
  43. package/dist/resources/extensions/gsd/skills/gsd-headless/references/multi-session.md +185 -0
  44. package/dist/resources/extensions/gsd/state.ts +30 -0
  45. package/dist/resources/extensions/gsd/templates/task-summary.md +9 -0
  46. package/dist/resources/extensions/gsd/tests/auto-dashboard.test.ts +13 -0
  47. package/dist/resources/extensions/gsd/tests/continue-here.test.ts +81 -0
  48. package/dist/resources/extensions/gsd/tests/derive-state-db.test.ts +5 -0
  49. package/dist/resources/extensions/gsd/tests/derive-state-deps.test.ts +1 -0
  50. package/dist/resources/extensions/gsd/tests/derive-state-draft.test.ts +1 -0
  51. package/dist/resources/extensions/gsd/tests/derive-state.test.ts +10 -1
  52. package/dist/resources/extensions/gsd/tests/dispatch-missing-task-plans.test.ts +132 -0
  53. package/dist/resources/extensions/gsd/tests/doctor-proactive.test.ts +14 -0
  54. package/dist/resources/extensions/gsd/tests/integration-mixed-milestones.test.ts +1 -0
  55. package/dist/resources/extensions/gsd/tests/milestone-transition-worktree.test.ts +1 -1
  56. package/dist/resources/extensions/gsd/tests/native-has-changes-cache.test.ts +61 -0
  57. package/dist/resources/extensions/gsd/tests/network-error-fallback.test.ts +51 -1
  58. package/dist/resources/extensions/gsd/tests/parallel-budget-atomicity.test.ts +331 -0
  59. package/dist/resources/extensions/gsd/tests/parallel-crash-recovery.test.ts +298 -0
  60. package/dist/resources/extensions/gsd/tests/parallel-merge.test.ts +465 -0
  61. package/dist/resources/extensions/gsd/tests/parallel-orchestration.test.ts +39 -10
  62. package/dist/resources/extensions/gsd/tests/plan-slice-prompt.test.ts +71 -0
  63. package/dist/resources/extensions/gsd/tests/replan-slice.test.ts +42 -0
  64. package/dist/resources/extensions/gsd/tests/triage-dispatch.test.ts +9 -9
  65. package/dist/resources/extensions/gsd/tests/verification-evidence.test.ts +743 -0
  66. package/dist/resources/extensions/gsd/tests/verification-gate.test.ts +965 -0
  67. package/dist/resources/extensions/gsd/tests/visualizer-data.test.ts +1 -1
  68. package/dist/resources/extensions/gsd/tests/visualizer-overlay.test.ts +44 -10
  69. package/dist/resources/extensions/gsd/tests/worktree.test.ts +3 -1
  70. package/dist/resources/extensions/gsd/types.ts +38 -0
  71. package/dist/resources/extensions/gsd/verification-evidence.ts +183 -0
  72. package/dist/resources/extensions/gsd/verification-gate.ts +567 -0
  73. package/dist/resources/extensions/gsd/visualizer-data.ts +25 -3
  74. package/dist/resources/extensions/gsd/visualizer-overlay.ts +31 -21
  75. package/dist/resources/extensions/gsd/visualizer-views.ts +15 -66
  76. package/dist/resources/extensions/search-the-web/tool-search.ts +26 -0
  77. package/dist/resources/extensions/shared/format-utils.ts +85 -0
  78. package/dist/resources/extensions/shared/tests/format-utils.test.ts +153 -0
  79. package/dist/resources/extensions/subagent/index.ts +46 -1
  80. package/dist/resources/extensions/subagent/isolation.ts +9 -6
  81. package/package.json +1 -1
  82. package/packages/pi-ai/dist/providers/openai-completions.js +7 -4
  83. package/packages/pi-ai/dist/providers/openai-completions.js.map +1 -1
  84. package/packages/pi-ai/src/providers/openai-completions.ts +7 -4
  85. package/packages/pi-coding-agent/dist/core/lsp/client.d.ts.map +1 -1
  86. package/packages/pi-coding-agent/dist/core/lsp/client.js +7 -0
  87. package/packages/pi-coding-agent/dist/core/lsp/client.js.map +1 -1
  88. package/packages/pi-coding-agent/dist/core/lsp/config.d.ts.map +1 -1
  89. package/packages/pi-coding-agent/dist/core/lsp/config.js +9 -2
  90. package/packages/pi-coding-agent/dist/core/lsp/config.js.map +1 -1
  91. package/packages/pi-coding-agent/src/core/lsp/client.ts +8 -0
  92. package/packages/pi-coding-agent/src/core/lsp/config.ts +9 -2
  93. package/packages/pi-tui/dist/components/editor.d.ts.map +1 -1
  94. package/packages/pi-tui/dist/components/editor.js +1 -1
  95. package/packages/pi-tui/dist/components/editor.js.map +1 -1
  96. package/packages/pi-tui/src/components/editor.ts +3 -1
  97. package/scripts/link-workspace-packages.cjs +22 -6
  98. package/src/resources/extensions/bg-shell/index.ts +19 -2
  99. package/src/resources/extensions/bg-shell/process-manager.ts +45 -0
  100. package/src/resources/extensions/bg-shell/types.ts +21 -1
  101. package/src/resources/extensions/gsd/auto/session.ts +224 -0
  102. package/src/resources/extensions/gsd/auto-budget.ts +32 -0
  103. package/src/resources/extensions/gsd/auto-dashboard.ts +63 -10
  104. package/src/resources/extensions/gsd/auto-direct-dispatch.ts +229 -0
  105. package/src/resources/extensions/gsd/auto-dispatch.ts +23 -10
  106. package/src/resources/extensions/gsd/auto-model-selection.ts +179 -0
  107. package/src/resources/extensions/gsd/auto-observability.ts +74 -0
  108. package/src/resources/extensions/gsd/auto-prompts.ts +0 -1
  109. package/src/resources/extensions/gsd/auto-timeout-recovery.ts +262 -0
  110. package/src/resources/extensions/gsd/auto-tool-tracking.ts +54 -0
  111. package/src/resources/extensions/gsd/auto-unit-closeout.ts +46 -0
  112. package/src/resources/extensions/gsd/auto-worktree-sync.ts +207 -0
  113. package/src/resources/extensions/gsd/auto.ts +977 -1551
  114. package/src/resources/extensions/gsd/commands.ts +3 -3
  115. package/src/resources/extensions/gsd/dashboard-overlay.ts +47 -72
  116. package/src/resources/extensions/gsd/doctor-proactive.ts +9 -4
  117. package/src/resources/extensions/gsd/export-html.ts +1001 -0
  118. package/src/resources/extensions/gsd/export.ts +49 -1
  119. package/src/resources/extensions/gsd/git-service.ts +6 -0
  120. package/src/resources/extensions/gsd/gitignore.ts +4 -1
  121. package/src/resources/extensions/gsd/guided-flow.ts +24 -5
  122. package/src/resources/extensions/gsd/index.ts +54 -1
  123. package/src/resources/extensions/gsd/native-git-bridge.ts +30 -2
  124. package/src/resources/extensions/gsd/observability-validator.ts +21 -0
  125. package/src/resources/extensions/gsd/parallel-orchestrator.ts +231 -20
  126. package/src/resources/extensions/gsd/preferences.ts +62 -1
  127. package/src/resources/extensions/gsd/prompts/execute-task.md +4 -3
  128. package/src/resources/extensions/gsd/prompts/system.md +1 -1
  129. package/src/resources/extensions/gsd/reports.ts +510 -0
  130. package/src/resources/extensions/gsd/roadmap-slices.ts +1 -1
  131. package/src/resources/extensions/gsd/skills/gsd-headless/SKILL.md +178 -0
  132. package/src/resources/extensions/gsd/skills/gsd-headless/references/answer-injection.md +54 -0
  133. package/src/resources/extensions/gsd/skills/gsd-headless/references/commands.md +59 -0
  134. package/src/resources/extensions/gsd/skills/gsd-headless/references/multi-session.md +185 -0
  135. package/src/resources/extensions/gsd/state.ts +30 -0
  136. package/src/resources/extensions/gsd/templates/task-summary.md +9 -0
  137. package/src/resources/extensions/gsd/tests/auto-dashboard.test.ts +13 -0
  138. package/src/resources/extensions/gsd/tests/continue-here.test.ts +81 -0
  139. package/src/resources/extensions/gsd/tests/derive-state-db.test.ts +5 -0
  140. package/src/resources/extensions/gsd/tests/derive-state-deps.test.ts +1 -0
  141. package/src/resources/extensions/gsd/tests/derive-state-draft.test.ts +1 -0
  142. package/src/resources/extensions/gsd/tests/derive-state.test.ts +10 -1
  143. package/src/resources/extensions/gsd/tests/dispatch-missing-task-plans.test.ts +132 -0
  144. package/src/resources/extensions/gsd/tests/doctor-proactive.test.ts +14 -0
  145. package/src/resources/extensions/gsd/tests/integration-mixed-milestones.test.ts +1 -0
  146. package/src/resources/extensions/gsd/tests/milestone-transition-worktree.test.ts +1 -1
  147. package/src/resources/extensions/gsd/tests/native-has-changes-cache.test.ts +61 -0
  148. package/src/resources/extensions/gsd/tests/network-error-fallback.test.ts +51 -1
  149. package/src/resources/extensions/gsd/tests/parallel-budget-atomicity.test.ts +331 -0
  150. package/src/resources/extensions/gsd/tests/parallel-crash-recovery.test.ts +298 -0
  151. package/src/resources/extensions/gsd/tests/parallel-merge.test.ts +465 -0
  152. package/src/resources/extensions/gsd/tests/parallel-orchestration.test.ts +39 -10
  153. package/src/resources/extensions/gsd/tests/plan-slice-prompt.test.ts +71 -0
  154. package/src/resources/extensions/gsd/tests/replan-slice.test.ts +42 -0
  155. package/src/resources/extensions/gsd/tests/triage-dispatch.test.ts +9 -9
  156. package/src/resources/extensions/gsd/tests/verification-evidence.test.ts +743 -0
  157. package/src/resources/extensions/gsd/tests/verification-gate.test.ts +965 -0
  158. package/src/resources/extensions/gsd/tests/visualizer-data.test.ts +1 -1
  159. package/src/resources/extensions/gsd/tests/visualizer-overlay.test.ts +44 -10
  160. package/src/resources/extensions/gsd/tests/worktree.test.ts +3 -1
  161. package/src/resources/extensions/gsd/types.ts +38 -0
  162. package/src/resources/extensions/gsd/verification-evidence.ts +183 -0
  163. package/src/resources/extensions/gsd/verification-gate.ts +567 -0
  164. package/src/resources/extensions/gsd/visualizer-data.ts +25 -3
  165. package/src/resources/extensions/gsd/visualizer-overlay.ts +31 -21
  166. package/src/resources/extensions/gsd/visualizer-views.ts +15 -66
  167. package/src/resources/extensions/search-the-web/tool-search.ts +26 -0
  168. package/src/resources/extensions/shared/format-utils.ts +85 -0
  169. package/src/resources/extensions/shared/tests/format-utils.test.ts +153 -0
  170. package/src/resources/extensions/subagent/index.ts +46 -1
  171. package/src/resources/extensions/subagent/isolation.ts +9 -6
@@ -242,27 +242,40 @@ const DISPATCH_RULES: DispatchRule[] = [
242
242
  },
243
243
  },
244
244
  {
245
- name: "executing → execute-task",
246
- match: async ({ state, mid, basePath }) => {
245
+ name: "executing → execute-task (recover missing task plan → plan-slice)",
246
+ match: async ({ state, mid, midTitle, basePath }) => {
247
247
  if (state.phase !== "executing" || !state.activeTask) return null;
248
248
  const sid = state.activeSlice!.id;
249
249
  const sTitle = state.activeSlice!.title;
250
250
  const tid = state.activeTask.id;
251
- const tTitle = state.activeTask.title;
252
251
 
253
- // Guard: refuse to dispatch execute-task when the task plan file is missing.
254
- // This prevents the agent from running blind after a failed plan-slice that
255
- // wrote S{sid}-PLAN.md but omitted the individual T{tid}-PLAN.md files.
256
- // (See issue #739missing task plan caused runaway execution and EPIPE crash.)
252
+ // Guard: if the slice plan exists but the individual task plan files are
253
+ // missing, the planner created S##-PLAN.md with task entries but never
254
+ // wrote the tasks/ directory files. Dispatch plan-slice to regenerate
255
+ // them rather than hard-stopping fixes the infinite-loop described in
256
+ // issue #909.
257
257
  const taskPlanPath = resolveTaskFile(basePath, mid, sid, tid, "PLAN");
258
258
  if (!taskPlanPath || !existsSync(taskPlanPath)) {
259
259
  return {
260
- action: "stop",
261
- reason: `Task plan ${tid}-PLAN.md is missing for ${mid}/${sid}/${tid}. Re-run plan-slice to regenerate task plans, or create the file manually and resume.`,
262
- level: "error",
260
+ action: "dispatch",
261
+ unitType: "plan-slice",
262
+ unitId: `${mid}/${sid}`,
263
+ prompt: await buildPlanSlicePrompt(mid, midTitle, sid, sTitle, basePath),
263
264
  };
264
265
  }
265
266
 
267
+ return null;
268
+ },
269
+ },
270
+ {
271
+ name: "executing → execute-task",
272
+ match: async ({ state, mid, basePath }) => {
273
+ if (state.phase !== "executing" || !state.activeTask) return null;
274
+ const sid = state.activeSlice!.id;
275
+ const sTitle = state.activeSlice!.title;
276
+ const tid = state.activeTask.id;
277
+ const tTitle = state.activeTask.title;
278
+
266
279
  return {
267
280
  action: "dispatch",
268
281
  unitType: "execute-task",
@@ -0,0 +1,179 @@
1
+ /**
2
+ * Model selection and dynamic routing for auto-mode unit dispatch.
3
+ * Handles complexity-based routing, model resolution across providers,
4
+ * and fallback chains.
5
+ */
6
+
7
+ import type { ExtensionAPI, ExtensionContext } from "@gsd/pi-coding-agent";
8
+ import type { GSDPreferences } from "./preferences.js";
9
+ import { resolveModelWithFallbacksForUnit, resolveDynamicRoutingConfig } from "./preferences.js";
10
+ import { classifyUnitComplexity, tierLabel } from "./complexity-classifier.js";
11
+ import { resolveModelForComplexity } from "./model-router.js";
12
+ import { getLedger, getProjectTotals } from "./metrics.js";
13
+ import { unitPhaseLabel } from "./auto-dashboard.js";
14
+
15
+ export interface ModelSelectionResult {
16
+ /** Routing metadata for metrics recording */
17
+ routing: { tier: string; modelDowngraded: boolean } | null;
18
+ }
19
+
20
+ /**
21
+ * Select and apply the appropriate model for a unit dispatch.
22
+ * Handles: per-unit-type model preferences, dynamic complexity routing,
23
+ * provider/model resolution, fallback chains, and start-model re-application.
24
+ *
25
+ * Returns routing metadata for metrics tracking.
26
+ */
27
+ export async function selectAndApplyModel(
28
+ ctx: ExtensionContext,
29
+ pi: ExtensionAPI,
30
+ unitType: string,
31
+ unitId: string,
32
+ basePath: string,
33
+ prefs: GSDPreferences | undefined,
34
+ verbose: boolean,
35
+ autoModeStartModel: { provider: string; id: string } | null,
36
+ ): Promise<ModelSelectionResult> {
37
+ const modelConfig = resolveModelWithFallbacksForUnit(unitType);
38
+ let routing: { tier: string; modelDowngraded: boolean } | null = null;
39
+
40
+ if (modelConfig) {
41
+ const availableModels = ctx.modelRegistry.getAvailable();
42
+
43
+ // ─── Dynamic Model Routing ─────────────────────────────────────────
44
+ const routingConfig = resolveDynamicRoutingConfig();
45
+ let effectiveModelConfig = modelConfig;
46
+ let routingTierLabel = "";
47
+
48
+ if (routingConfig.enabled) {
49
+ let budgetPct: number | undefined;
50
+ if (routingConfig.budget_pressure !== false) {
51
+ const budgetCeiling = prefs?.budget_ceiling;
52
+ if (budgetCeiling !== undefined && budgetCeiling > 0) {
53
+ const currentLedger = getLedger();
54
+ const totalCost = currentLedger ? getProjectTotals(currentLedger.units).cost : 0;
55
+ budgetPct = totalCost / budgetCeiling;
56
+ }
57
+ }
58
+
59
+ const isHook = unitType.startsWith("hook/");
60
+ const shouldClassify = !isHook || routingConfig.hooks !== false;
61
+
62
+ if (shouldClassify) {
63
+ const classification = classifyUnitComplexity(unitType, unitId, basePath, budgetPct);
64
+ const availableModelIds = availableModels.map(m => m.id);
65
+ const routingResult = resolveModelForComplexity(classification, modelConfig, routingConfig, availableModelIds);
66
+
67
+ if (routingResult.wasDowngraded) {
68
+ effectiveModelConfig = {
69
+ primary: routingResult.modelId,
70
+ fallbacks: routingResult.fallbacks,
71
+ };
72
+ if (verbose) {
73
+ ctx.ui.notify(
74
+ `Dynamic routing [${tierLabel(classification.tier)}]: ${routingResult.modelId} (${classification.reason})`,
75
+ "info",
76
+ );
77
+ }
78
+ }
79
+ routingTierLabel = ` [${tierLabel(classification.tier)}]`;
80
+ routing = { tier: classification.tier, modelDowngraded: routingResult.wasDowngraded };
81
+ }
82
+ }
83
+
84
+ const modelsToTry = [effectiveModelConfig.primary, ...effectiveModelConfig.fallbacks];
85
+
86
+ for (const modelId of modelsToTry) {
87
+ const model = resolveModelId(modelId, availableModels, ctx.model?.provider);
88
+
89
+ if (!model) {
90
+ if (verbose) ctx.ui.notify(`Model ${modelId} not found, trying fallback.`, "info");
91
+ continue;
92
+ }
93
+
94
+ // Warn if the ID is ambiguous across providers
95
+ if (!modelId.includes("/")) {
96
+ const providers = availableModels.filter(m => m.id === modelId).map(m => m.provider);
97
+ if (providers.length > 1 && model.provider !== ctx.model?.provider) {
98
+ ctx.ui.notify(
99
+ `Model ID "${modelId}" exists in multiple providers (${providers.join(", ")}). ` +
100
+ `Resolved to ${model.provider}. Use "provider/model" format for explicit targeting.`,
101
+ "warning",
102
+ );
103
+ }
104
+ }
105
+
106
+ const ok = await pi.setModel(model, { persist: false });
107
+ if (ok) {
108
+ const fallbackNote = modelId === effectiveModelConfig.primary
109
+ ? ""
110
+ : ` (fallback from ${effectiveModelConfig.primary})`;
111
+ const phase = unitPhaseLabel(unitType);
112
+ ctx.ui.notify(`Model [${phase}]${routingTierLabel}: ${model.provider}/${model.id}${fallbackNote}`, "info");
113
+ break;
114
+ } else {
115
+ const nextModel = modelsToTry[modelsToTry.indexOf(modelId) + 1];
116
+ if (nextModel) {
117
+ if (verbose) ctx.ui.notify(`Failed to set model ${modelId}, trying ${nextModel}...`, "info");
118
+ } else {
119
+ ctx.ui.notify(`All preferred models unavailable for ${unitType}. Using default.`, "warning");
120
+ }
121
+ }
122
+ }
123
+ } else if (autoModeStartModel) {
124
+ // No model preference for this unit type — re-apply the model captured
125
+ // at auto-mode start to prevent bleed from shared global settings.json (#650).
126
+ const availableModels = ctx.modelRegistry.getAvailable();
127
+ const startModel = availableModels.find(
128
+ m => m.provider === autoModeStartModel.provider && m.id === autoModeStartModel.id,
129
+ );
130
+ if (startModel) {
131
+ const ok = await pi.setModel(startModel, { persist: false });
132
+ if (!ok) {
133
+ const byId = availableModels.find(m => m.id === autoModeStartModel.id);
134
+ if (byId) await pi.setModel(byId, { persist: false });
135
+ }
136
+ }
137
+ }
138
+
139
+ return { routing };
140
+ }
141
+
142
+ /**
143
+ * Resolve a model ID string to a model object from the available models list.
144
+ * Handles formats: "provider/model", "bare-id", "org/model-name" (OpenRouter).
145
+ */
146
+ function resolveModelId<T extends { id: string; provider: string }>(
147
+ modelId: string,
148
+ availableModels: T[],
149
+ currentProvider: string | undefined,
150
+ ): T | undefined {
151
+ const slashIdx = modelId.indexOf("/");
152
+
153
+ if (slashIdx !== -1) {
154
+ const maybeProvider = modelId.substring(0, slashIdx);
155
+ const id = modelId.substring(slashIdx + 1);
156
+
157
+ const knownProviders = new Set(availableModels.map(m => m.provider.toLowerCase()));
158
+ if (knownProviders.has(maybeProvider.toLowerCase())) {
159
+ const match = availableModels.find(
160
+ m => m.provider.toLowerCase() === maybeProvider.toLowerCase()
161
+ && m.id.toLowerCase() === id.toLowerCase(),
162
+ );
163
+ if (match) return match;
164
+ }
165
+
166
+ // Try matching the full string as a model ID (OpenRouter-style)
167
+ const lower = modelId.toLowerCase();
168
+ return availableModels.find(
169
+ m => m.id.toLowerCase() === lower
170
+ || `${m.provider}/${m.id}`.toLowerCase() === lower,
171
+ );
172
+ }
173
+
174
+ // Bare ID — prefer current provider, then first available
175
+ const exactProviderMatch = availableModels.find(
176
+ m => m.id === modelId && m.provider === currentProvider,
177
+ );
178
+ return exactProviderMatch ?? availableModels.find(m => m.id === modelId);
179
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Pre-dispatch observability checks for auto-mode units.
3
+ * Validates plan/summary file quality and builds repair instructions
4
+ * for the agent to fix gaps before proceeding with the unit.
5
+ */
6
+
7
+ import type { ExtensionContext } from "@gsd/pi-coding-agent";
8
+ import {
9
+ validatePlanBoundary,
10
+ validateExecuteBoundary,
11
+ validateCompleteBoundary,
12
+ formatValidationIssues,
13
+ } from "./observability-validator.js";
14
+ import type { ValidationIssue } from "./observability-validator.js";
15
+
16
+ export async function collectObservabilityWarnings(
17
+ ctx: ExtensionContext,
18
+ basePath: string,
19
+ unitType: string,
20
+ unitId: string,
21
+ ): Promise<ValidationIssue[]> {
22
+ // Hook units have custom artifacts — skip standard observability checks
23
+ if (unitType.startsWith("hook/")) return [];
24
+
25
+ const parts = unitId.split("/");
26
+ const mid = parts[0];
27
+ const sid = parts[1];
28
+ const tid = parts[2];
29
+
30
+ if (!mid || !sid) return [];
31
+
32
+ let issues = [] as Awaited<ReturnType<typeof validatePlanBoundary>>;
33
+
34
+ if (unitType === "plan-slice") {
35
+ issues = await validatePlanBoundary(basePath, mid, sid);
36
+ } else if (unitType === "execute-task" && tid) {
37
+ issues = await validateExecuteBoundary(basePath, mid, sid, tid);
38
+ } else if (unitType === "complete-slice") {
39
+ issues = await validateCompleteBoundary(basePath, mid, sid);
40
+ }
41
+
42
+ if (issues.length > 0) {
43
+ ctx.ui.notify(
44
+ `Observability check (${unitType}) found ${issues.length} warning${issues.length === 1 ? "" : "s"}:\n${formatValidationIssues(issues)}`,
45
+ "warning",
46
+ );
47
+ }
48
+
49
+ return issues;
50
+ }
51
+
52
+ export function buildObservabilityRepairBlock(issues: ValidationIssue[]): string {
53
+ if (issues.length === 0) return "";
54
+ const items = issues.map(issue => {
55
+ const fileName = issue.file.split("/").pop() || issue.file;
56
+ let line = `- **${fileName}**: ${issue.message}`;
57
+ if (issue.suggestion) line += ` → ${issue.suggestion}`;
58
+ return line;
59
+ });
60
+ return [
61
+ "",
62
+ "---",
63
+ "",
64
+ "## Pre-flight: Observability gaps to fix FIRST",
65
+ "",
66
+ "The following issues were detected in plan/summary files for this unit.",
67
+ "**Read each flagged file, apply the fix described, then proceed with the unit.**",
68
+ "",
69
+ ...items,
70
+ "",
71
+ "---",
72
+ "",
73
+ ].join("\n");
74
+ }
@@ -642,7 +642,6 @@ export async function buildPlanSlicePrompt(
642
642
  const commitInstruction = commitDocsEnabled
643
643
  ? `Commit: \`docs(${sid}): add slice plan\``
644
644
  : "Do not commit — planning docs are not tracked in git for this project.";
645
-
646
645
  return loadPrompt("plan-slice", {
647
646
  workingDirectory: base,
648
647
  milestoneId: mid, sliceId: sid, sliceTitle: sTitle,
@@ -0,0 +1,262 @@
1
+ /**
2
+ * Timeout recovery logic for auto-mode units.
3
+ * Handles idle and hard timeout recovery with escalation, steering messages,
4
+ * and blocker placeholder generation.
5
+ */
6
+
7
+ import type { ExtensionAPI, ExtensionContext } from "@gsd/pi-coding-agent";
8
+ import {
9
+ readUnitRuntimeRecord,
10
+ writeUnitRuntimeRecord,
11
+ formatExecuteTaskRecoveryStatus,
12
+ inspectExecuteTaskDurability,
13
+ } from "./unit-runtime.js";
14
+ import {
15
+ resolveExpectedArtifactPath,
16
+ diagnoseExpectedArtifact,
17
+ skipExecuteTask,
18
+ writeBlockerPlaceholder,
19
+ } from "./auto-recovery.js";
20
+ import { existsSync } from "node:fs";
21
+
22
+ export interface RecoveryContext {
23
+ basePath: string;
24
+ verbose: boolean;
25
+ currentUnitStartedAt: number;
26
+ unitRecoveryCount: Map<string, number>;
27
+ dispatchNextUnit: (ctx: ExtensionContext, pi: ExtensionAPI) => Promise<void>;
28
+ }
29
+
30
+ export async function recoverTimedOutUnit(
31
+ ctx: ExtensionContext,
32
+ pi: ExtensionAPI,
33
+ unitType: string,
34
+ unitId: string,
35
+ reason: "idle" | "hard",
36
+ rctx: RecoveryContext,
37
+ ): Promise<"recovered" | "paused"> {
38
+ const { basePath, verbose, currentUnitStartedAt, unitRecoveryCount, dispatchNextUnit } = rctx;
39
+
40
+ const runtime = readUnitRuntimeRecord(basePath, unitType, unitId);
41
+ const recoveryAttempts = runtime?.recoveryAttempts ?? 0;
42
+ const maxRecoveryAttempts = reason === "idle" ? 2 : 1;
43
+
44
+ const recoveryKey = `${unitType}/${unitId}`;
45
+ const attemptNumber = (unitRecoveryCount.get(recoveryKey) ?? 0) + 1;
46
+ unitRecoveryCount.set(recoveryKey, attemptNumber);
47
+
48
+ if (attemptNumber > 1) {
49
+ // Exponential backoff: 2^(n-1) seconds, capped at 30s
50
+ const backoffMs = Math.min(1000 * Math.pow(2, attemptNumber - 2), 30000);
51
+ ctx.ui.notify(
52
+ `Recovery attempt ${attemptNumber} for ${unitType} ${unitId}. Waiting ${backoffMs / 1000}s before retry.`,
53
+ "info",
54
+ );
55
+ await new Promise(r => setTimeout(r, backoffMs));
56
+ }
57
+
58
+ if (unitType === "execute-task") {
59
+ const status = await inspectExecuteTaskDurability(basePath, unitId);
60
+ if (!status) return "paused";
61
+
62
+ writeUnitRuntimeRecord(basePath, unitType, unitId, currentUnitStartedAt, {
63
+ recovery: status,
64
+ });
65
+
66
+ const durableComplete = status.summaryExists && status.taskChecked && status.nextActionAdvanced;
67
+ if (durableComplete) {
68
+ writeUnitRuntimeRecord(basePath, unitType, unitId, currentUnitStartedAt, {
69
+ phase: "finalized",
70
+ recovery: status,
71
+ });
72
+ ctx.ui.notify(
73
+ `${reason === "idle" ? "Idle" : "Timeout"} recovery: ${unitType} ${unitId} already completed on disk. Continuing auto-mode. (attempt ${attemptNumber})`,
74
+ "info",
75
+ );
76
+ unitRecoveryCount.delete(recoveryKey);
77
+ await dispatchNextUnit(ctx, pi);
78
+ return "recovered";
79
+ }
80
+
81
+ if (recoveryAttempts < maxRecoveryAttempts) {
82
+ const isEscalation = recoveryAttempts > 0;
83
+ writeUnitRuntimeRecord(basePath, unitType, unitId, currentUnitStartedAt, {
84
+ phase: "recovered",
85
+ recovery: status,
86
+ recoveryAttempts: recoveryAttempts + 1,
87
+ lastRecoveryReason: reason,
88
+ lastProgressAt: Date.now(),
89
+ progressCount: (runtime?.progressCount ?? 0) + 1,
90
+ lastProgressKind: reason === "idle" ? "idle-recovery-retry" : "hard-recovery-retry",
91
+ });
92
+
93
+ const steeringLines = isEscalation
94
+ ? [
95
+ `**FINAL ${reason === "idle" ? "IDLE" : "HARD TIMEOUT"} RECOVERY — last chance before this task is skipped.**`,
96
+ `You are still executing ${unitType} ${unitId}.`,
97
+ `Recovery attempt ${recoveryAttempts + 1} of ${maxRecoveryAttempts}.`,
98
+ `Current durability status: ${formatExecuteTaskRecoveryStatus(status)}.`,
99
+ "You MUST finish the durable output NOW, even if incomplete.",
100
+ "Write the task summary with whatever you have accomplished so far.",
101
+ "Mark the task [x] in the plan. Commit your work.",
102
+ "A partial summary is infinitely better than no summary.",
103
+ ]
104
+ : [
105
+ `**${reason === "idle" ? "IDLE" : "HARD TIMEOUT"} RECOVERY — do not stop.**`,
106
+ `You are still executing ${unitType} ${unitId}.`,
107
+ `Recovery attempt ${recoveryAttempts + 1} of ${maxRecoveryAttempts}.`,
108
+ `Current durability status: ${formatExecuteTaskRecoveryStatus(status)}.`,
109
+ "Do not keep exploring.",
110
+ "Immediately finish the required durable output for this unit.",
111
+ "If full completion is impossible, write the partial artifact/state needed for recovery and make the blocker explicit.",
112
+ ];
113
+
114
+ pi.sendMessage(
115
+ {
116
+ customType: "gsd-auto-timeout-recovery",
117
+ display: verbose,
118
+ content: steeringLines.join("\n"),
119
+ },
120
+ { triggerTurn: true, deliverAs: "steer" },
121
+ );
122
+ ctx.ui.notify(
123
+ `${reason === "idle" ? "Idle" : "Timeout"} recovery: steering ${unitType} ${unitId} to finish durable output (attempt ${attemptNumber}, session ${recoveryAttempts + 1}/${maxRecoveryAttempts}).`,
124
+ "warning",
125
+ );
126
+ return "recovered";
127
+ }
128
+
129
+ // Retries exhausted — write missing durable artifacts and advance.
130
+ const diagnostic = formatExecuteTaskRecoveryStatus(status);
131
+ const [mid, sid, tid] = unitId.split("/");
132
+ const skipped = mid && sid && tid
133
+ ? skipExecuteTask(basePath, mid, sid, tid, status, reason, maxRecoveryAttempts)
134
+ : false;
135
+
136
+ if (skipped) {
137
+ writeUnitRuntimeRecord(basePath, unitType, unitId, currentUnitStartedAt, {
138
+ phase: "skipped",
139
+ recovery: status,
140
+ recoveryAttempts: recoveryAttempts + 1,
141
+ lastRecoveryReason: reason,
142
+ });
143
+ ctx.ui.notify(
144
+ `${unitType} ${unitId} skipped after ${maxRecoveryAttempts} recovery attempts (${diagnostic}). Blocker artifacts written. Advancing pipeline. (attempt ${attemptNumber})`,
145
+ "warning",
146
+ );
147
+ unitRecoveryCount.delete(recoveryKey);
148
+ await dispatchNextUnit(ctx, pi);
149
+ return "recovered";
150
+ }
151
+
152
+ // Fallback: couldn't write skip artifacts — pause as before.
153
+ writeUnitRuntimeRecord(basePath, unitType, unitId, currentUnitStartedAt, {
154
+ phase: "paused",
155
+ recovery: status,
156
+ recoveryAttempts: recoveryAttempts + 1,
157
+ lastRecoveryReason: reason,
158
+ });
159
+ ctx.ui.notify(
160
+ `${reason === "idle" ? "Idle" : "Timeout"} recovery check for ${unitType} ${unitId}: ${diagnostic}`,
161
+ "warning",
162
+ );
163
+ return "paused";
164
+ }
165
+
166
+ const expected = diagnoseExpectedArtifact(unitType, unitId, basePath) ?? "required durable artifact";
167
+
168
+ // Check if the artifact already exists on disk — agent may have written it
169
+ // without signaling completion.
170
+ const artifactPath = resolveExpectedArtifactPath(unitType, unitId, basePath);
171
+ if (artifactPath && existsSync(artifactPath)) {
172
+ writeUnitRuntimeRecord(basePath, unitType, unitId, currentUnitStartedAt, {
173
+ phase: "finalized",
174
+ recoveryAttempts: recoveryAttempts + 1,
175
+ lastRecoveryReason: reason,
176
+ });
177
+ ctx.ui.notify(
178
+ `${reason === "idle" ? "Idle" : "Timeout"} recovery: ${unitType} ${unitId} artifact already exists on disk. Advancing. (attempt ${attemptNumber})`,
179
+ "info",
180
+ );
181
+ unitRecoveryCount.delete(recoveryKey);
182
+ await dispatchNextUnit(ctx, pi);
183
+ return "recovered";
184
+ }
185
+
186
+ if (recoveryAttempts < maxRecoveryAttempts) {
187
+ const isEscalation = recoveryAttempts > 0;
188
+ writeUnitRuntimeRecord(basePath, unitType, unitId, currentUnitStartedAt, {
189
+ phase: "recovered",
190
+ recoveryAttempts: recoveryAttempts + 1,
191
+ lastRecoveryReason: reason,
192
+ lastProgressAt: Date.now(),
193
+ progressCount: (runtime?.progressCount ?? 0) + 1,
194
+ lastProgressKind: reason === "idle" ? "idle-recovery-retry" : "hard-recovery-retry",
195
+ });
196
+
197
+ const steeringLines = isEscalation
198
+ ? [
199
+ `**FINAL ${reason === "idle" ? "IDLE" : "HARD TIMEOUT"} RECOVERY — last chance before skip.**`,
200
+ `You are still executing ${unitType} ${unitId}.`,
201
+ `Recovery attempt ${recoveryAttempts + 1} of ${maxRecoveryAttempts} — next failure skips this unit.`,
202
+ `Expected durable output: ${expected}.`,
203
+ "You MUST write the artifact file NOW, even if incomplete.",
204
+ "Write whatever you have — partial research, preliminary findings, best-effort analysis.",
205
+ "A partial artifact is infinitely better than no artifact.",
206
+ "If you are truly blocked, write the file with a BLOCKER section explaining why.",
207
+ ]
208
+ : [
209
+ `**${reason === "idle" ? "IDLE" : "HARD TIMEOUT"} RECOVERY — stay in auto-mode.**`,
210
+ `You are still executing ${unitType} ${unitId}.`,
211
+ `Recovery attempt ${recoveryAttempts + 1} of ${maxRecoveryAttempts}.`,
212
+ `Expected durable output: ${expected}.`,
213
+ "Stop broad exploration.",
214
+ "Write the required artifact now.",
215
+ "If blocked, write the partial artifact and explicitly record the blocker instead of going silent.",
216
+ ];
217
+
218
+ pi.sendMessage(
219
+ {
220
+ customType: "gsd-auto-timeout-recovery",
221
+ display: verbose,
222
+ content: steeringLines.join("\n"),
223
+ },
224
+ { triggerTurn: true, deliverAs: "steer" },
225
+ );
226
+ ctx.ui.notify(
227
+ `${reason === "idle" ? "Idle" : "Timeout"} recovery: steering ${unitType} ${unitId} to produce ${expected} (attempt ${attemptNumber}, session ${recoveryAttempts + 1}/${maxRecoveryAttempts}).`,
228
+ "warning",
229
+ );
230
+ return "recovered";
231
+ }
232
+
233
+ // Retries exhausted — write a blocker placeholder and advance the pipeline
234
+ // instead of silently stalling.
235
+ const placeholder = writeBlockerPlaceholder(
236
+ unitType, unitId, basePath,
237
+ `${reason} recovery exhausted ${maxRecoveryAttempts} attempts without producing the artifact.`,
238
+ );
239
+
240
+ if (placeholder) {
241
+ writeUnitRuntimeRecord(basePath, unitType, unitId, currentUnitStartedAt, {
242
+ phase: "skipped",
243
+ recoveryAttempts: recoveryAttempts + 1,
244
+ lastRecoveryReason: reason,
245
+ });
246
+ ctx.ui.notify(
247
+ `${unitType} ${unitId} skipped after ${maxRecoveryAttempts} recovery attempts. Blocker placeholder written to ${placeholder}. Advancing pipeline. (attempt ${attemptNumber})`,
248
+ "warning",
249
+ );
250
+ unitRecoveryCount.delete(recoveryKey);
251
+ await dispatchNextUnit(ctx, pi);
252
+ return "recovered";
253
+ }
254
+
255
+ // Fallback: couldn't resolve artifact path — pause as before.
256
+ writeUnitRuntimeRecord(basePath, unitType, unitId, currentUnitStartedAt, {
257
+ phase: "paused",
258
+ recoveryAttempts: recoveryAttempts + 1,
259
+ lastRecoveryReason: reason,
260
+ });
261
+ return "paused";
262
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * In-flight tool call tracking for auto-mode idle detection.
3
+ * Tracks which tool calls are currently executing so the idle watchdog
4
+ * can distinguish "waiting for tool completion" from "truly idle".
5
+ */
6
+
7
+ const inFlightTools = new Map<string, number>();
8
+
9
+ /**
10
+ * Mark a tool execution as in-flight.
11
+ * Records start time so the idle watchdog can detect tools hung longer than the idle timeout.
12
+ */
13
+ export function markToolStart(toolCallId: string, isActive: boolean): void {
14
+ if (!isActive) return;
15
+ inFlightTools.set(toolCallId, Date.now());
16
+ }
17
+
18
+ /**
19
+ * Mark a tool execution as completed.
20
+ */
21
+ export function markToolEnd(toolCallId: string): void {
22
+ inFlightTools.delete(toolCallId);
23
+ }
24
+
25
+ /**
26
+ * Returns the age (ms) of the oldest currently in-flight tool, or 0 if none.
27
+ */
28
+ export function getOldestInFlightToolAgeMs(): number {
29
+ if (inFlightTools.size === 0) return 0;
30
+ const oldestStart = Math.min(...inFlightTools.values());
31
+ return Date.now() - oldestStart;
32
+ }
33
+
34
+ /**
35
+ * Returns the number of currently in-flight tools.
36
+ */
37
+ export function getInFlightToolCount(): number {
38
+ return inFlightTools.size;
39
+ }
40
+
41
+ /**
42
+ * Returns the start timestamp of the oldest in-flight tool, or undefined if none.
43
+ */
44
+ export function getOldestInFlightToolStart(): number | undefined {
45
+ if (inFlightTools.size === 0) return undefined;
46
+ return Math.min(...inFlightTools.values());
47
+ }
48
+
49
+ /**
50
+ * Clear all in-flight tool tracking state.
51
+ */
52
+ export function clearInFlightTools(): void {
53
+ inFlightTools.clear();
54
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Unit closeout helper — consolidates the repeated pattern of
3
+ * snapshotting metrics + saving activity log + extracting memories
4
+ * that appears 6+ times in auto.ts.
5
+ */
6
+
7
+ import type { ExtensionContext } from "@gsd/pi-coding-agent";
8
+ import { snapshotUnitMetrics } from "./metrics.js";
9
+ import { saveActivityLog } from "./activity-log.js";
10
+
11
+ export interface CloseoutOptions {
12
+ promptCharCount?: number;
13
+ baselineCharCount?: number;
14
+ tier?: string;
15
+ modelDowngraded?: boolean;
16
+ continueHereFired?: boolean;
17
+ }
18
+
19
+ /**
20
+ * Snapshot metrics, save activity log, and fire-and-forget memory extraction
21
+ * for a completed unit. Returns the activity log file path (if any).
22
+ */
23
+ export async function closeoutUnit(
24
+ ctx: ExtensionContext,
25
+ basePath: string,
26
+ unitType: string,
27
+ unitId: string,
28
+ startedAt: number,
29
+ opts?: CloseoutOptions,
30
+ ): Promise<string | undefined> {
31
+ const modelId = ctx.model?.id ?? "unknown";
32
+ snapshotUnitMetrics(ctx, unitType, unitId, startedAt, modelId, opts);
33
+ const activityFile = saveActivityLog(ctx, basePath, unitType, unitId);
34
+
35
+ if (activityFile) {
36
+ try {
37
+ const { buildMemoryLLMCall, extractMemoriesFromUnit } = await import('./memory-extractor.js');
38
+ const llmCallFn = buildMemoryLLMCall(ctx);
39
+ if (llmCallFn) {
40
+ extractMemoriesFromUnit(activityFile, unitType, unitId, llmCallFn).catch(() => {});
41
+ }
42
+ } catch { /* non-fatal */ }
43
+ }
44
+
45
+ return activityFile ?? undefined;
46
+ }