gsd-pi 2.18.0 → 2.19.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 (73) hide show
  1. package/dist/resources/extensions/gsd/auto-dashboard.ts +14 -2
  2. package/dist/resources/extensions/gsd/auto-prompts.ts +45 -15
  3. package/dist/resources/extensions/gsd/auto.ts +276 -19
  4. package/dist/resources/extensions/gsd/captures.ts +384 -0
  5. package/dist/resources/extensions/gsd/commands.ts +139 -3
  6. package/dist/resources/extensions/gsd/complexity-classifier.ts +322 -0
  7. package/dist/resources/extensions/gsd/dashboard-overlay.ts +10 -0
  8. package/dist/resources/extensions/gsd/metrics.ts +48 -0
  9. package/dist/resources/extensions/gsd/model-cost-table.ts +65 -0
  10. package/dist/resources/extensions/gsd/model-router.ts +256 -0
  11. package/dist/resources/extensions/gsd/post-unit-hooks.ts +2 -1
  12. package/dist/resources/extensions/gsd/preferences.ts +73 -0
  13. package/dist/resources/extensions/gsd/prompt-loader.ts +45 -9
  14. package/dist/resources/extensions/gsd/prompts/reassess-roadmap.md +6 -0
  15. package/dist/resources/extensions/gsd/prompts/replan-slice.md +8 -0
  16. package/dist/resources/extensions/gsd/prompts/triage-captures.md +62 -0
  17. package/dist/resources/extensions/gsd/tests/captures.test.ts +438 -0
  18. package/dist/resources/extensions/gsd/tests/complexity-classifier.test.ts +181 -0
  19. package/dist/resources/extensions/gsd/tests/feature-branch-lifecycle-integration.test.ts +434 -0
  20. package/dist/resources/extensions/gsd/tests/milestone-transition-worktree.test.ts +144 -0
  21. package/dist/resources/extensions/gsd/tests/model-cost-table.test.ts +69 -0
  22. package/dist/resources/extensions/gsd/tests/model-router.test.ts +167 -0
  23. package/dist/resources/extensions/gsd/tests/remote-questions.test.ts +227 -1
  24. package/dist/resources/extensions/gsd/tests/routing-history.test.ts +215 -62
  25. package/dist/resources/extensions/gsd/tests/triage-dispatch.test.ts +224 -0
  26. package/dist/resources/extensions/gsd/tests/triage-resolution.test.ts +215 -0
  27. package/dist/resources/extensions/gsd/tests/visualizer-data.test.ts +198 -0
  28. package/dist/resources/extensions/gsd/tests/visualizer-views.test.ts +255 -0
  29. package/dist/resources/extensions/gsd/triage-resolution.ts +200 -0
  30. package/dist/resources/extensions/gsd/triage-ui.ts +175 -0
  31. package/dist/resources/extensions/gsd/visualizer-data.ts +154 -0
  32. package/dist/resources/extensions/gsd/visualizer-overlay.ts +193 -0
  33. package/dist/resources/extensions/gsd/visualizer-views.ts +293 -0
  34. package/dist/resources/extensions/remote-questions/discord-adapter.ts +33 -0
  35. package/dist/resources/extensions/remote-questions/format.ts +12 -6
  36. package/dist/resources/extensions/remote-questions/manager.ts +8 -0
  37. package/package.json +1 -1
  38. package/src/resources/extensions/gsd/auto-dashboard.ts +14 -2
  39. package/src/resources/extensions/gsd/auto-prompts.ts +45 -15
  40. package/src/resources/extensions/gsd/auto.ts +276 -19
  41. package/src/resources/extensions/gsd/captures.ts +384 -0
  42. package/src/resources/extensions/gsd/commands.ts +139 -3
  43. package/src/resources/extensions/gsd/complexity-classifier.ts +322 -0
  44. package/src/resources/extensions/gsd/dashboard-overlay.ts +10 -0
  45. package/src/resources/extensions/gsd/metrics.ts +48 -0
  46. package/src/resources/extensions/gsd/model-cost-table.ts +65 -0
  47. package/src/resources/extensions/gsd/model-router.ts +256 -0
  48. package/src/resources/extensions/gsd/post-unit-hooks.ts +2 -1
  49. package/src/resources/extensions/gsd/preferences.ts +73 -0
  50. package/src/resources/extensions/gsd/prompt-loader.ts +45 -9
  51. package/src/resources/extensions/gsd/prompts/reassess-roadmap.md +6 -0
  52. package/src/resources/extensions/gsd/prompts/replan-slice.md +8 -0
  53. package/src/resources/extensions/gsd/prompts/triage-captures.md +62 -0
  54. package/src/resources/extensions/gsd/tests/captures.test.ts +438 -0
  55. package/src/resources/extensions/gsd/tests/complexity-classifier.test.ts +181 -0
  56. package/src/resources/extensions/gsd/tests/feature-branch-lifecycle-integration.test.ts +434 -0
  57. package/src/resources/extensions/gsd/tests/milestone-transition-worktree.test.ts +144 -0
  58. package/src/resources/extensions/gsd/tests/model-cost-table.test.ts +69 -0
  59. package/src/resources/extensions/gsd/tests/model-router.test.ts +167 -0
  60. package/src/resources/extensions/gsd/tests/remote-questions.test.ts +227 -1
  61. package/src/resources/extensions/gsd/tests/routing-history.test.ts +215 -62
  62. package/src/resources/extensions/gsd/tests/triage-dispatch.test.ts +224 -0
  63. package/src/resources/extensions/gsd/tests/triage-resolution.test.ts +215 -0
  64. package/src/resources/extensions/gsd/tests/visualizer-data.test.ts +198 -0
  65. package/src/resources/extensions/gsd/tests/visualizer-views.test.ts +255 -0
  66. package/src/resources/extensions/gsd/triage-resolution.ts +200 -0
  67. package/src/resources/extensions/gsd/triage-ui.ts +175 -0
  68. package/src/resources/extensions/gsd/visualizer-data.ts +154 -0
  69. package/src/resources/extensions/gsd/visualizer-overlay.ts +193 -0
  70. package/src/resources/extensions/gsd/visualizer-views.ts +293 -0
  71. package/src/resources/extensions/remote-questions/discord-adapter.ts +33 -0
  72. package/src/resources/extensions/remote-questions/format.ts +12 -6
  73. package/src/resources/extensions/remote-questions/manager.ts +8 -0
@@ -0,0 +1,256 @@
1
+ // GSD Extension — Dynamic Model Router
2
+ // Maps complexity tiers to models, enforcing downgrade-only semantics.
3
+ // The user's configured model is always the ceiling.
4
+
5
+ import type { ComplexityTier, ClassificationResult } from "./complexity-classifier.js";
6
+ import { tierOrdinal } from "./complexity-classifier.js";
7
+ import type { ResolvedModelConfig } from "./preferences.js";
8
+
9
+ // ─── Types ───────────────────────────────────────────────────────────────────
10
+
11
+ export interface DynamicRoutingConfig {
12
+ enabled?: boolean;
13
+ tier_models?: {
14
+ light?: string;
15
+ standard?: string;
16
+ heavy?: string;
17
+ };
18
+ escalate_on_failure?: boolean; // default: true
19
+ budget_pressure?: boolean; // default: true
20
+ cross_provider?: boolean; // default: true
21
+ hooks?: boolean; // default: true
22
+ }
23
+
24
+ export interface RoutingDecision {
25
+ /** The model ID to use (may be downgraded from configured) */
26
+ modelId: string;
27
+ /** Fallback chain: [selected_model, ...configured_fallbacks, configured_primary] */
28
+ fallbacks: string[];
29
+ /** The complexity tier that drove this decision */
30
+ tier: ComplexityTier;
31
+ /** True if the model was downgraded from the configured primary */
32
+ wasDowngraded: boolean;
33
+ /** Human-readable reason for this decision */
34
+ reason: string;
35
+ }
36
+
37
+ // ─── Known Model Tiers ───────────────────────────────────────────────────────
38
+ // Maps known model IDs to their capability tier. Used when tier_models is not
39
+ // explicitly configured to pick the best available model for each tier.
40
+
41
+ const MODEL_CAPABILITY_TIER: Record<string, ComplexityTier> = {
42
+ // Light-tier models (cheapest)
43
+ "claude-haiku-4-5": "light",
44
+ "claude-3-5-haiku-latest": "light",
45
+ "claude-3-haiku-20240307": "light",
46
+ "gpt-4o-mini": "light",
47
+ "gemini-2.0-flash": "light",
48
+ "gemini-flash-2.0": "light",
49
+
50
+ // Standard-tier models
51
+ "claude-sonnet-4-6": "standard",
52
+ "claude-sonnet-4-5-20250514": "standard",
53
+ "claude-3-5-sonnet-latest": "standard",
54
+ "gpt-4o": "standard",
55
+ "gemini-2.5-pro": "standard",
56
+ "deepseek-chat": "standard",
57
+
58
+ // Heavy-tier models (most capable)
59
+ "claude-opus-4-6": "heavy",
60
+ "claude-3-opus-latest": "heavy",
61
+ "gpt-4-turbo": "heavy",
62
+ "o1": "heavy",
63
+ "o3": "heavy",
64
+ };
65
+
66
+ // ─── Cost Table (per 1K input tokens, approximate USD) ───────────────────────
67
+ // Used for cross-provider cost comparison when multiple providers offer
68
+ // the same capability tier.
69
+
70
+ const MODEL_COST_PER_1K_INPUT: Record<string, number> = {
71
+ "claude-haiku-4-5": 0.0008,
72
+ "claude-3-5-haiku-latest": 0.0008,
73
+ "claude-sonnet-4-6": 0.003,
74
+ "claude-sonnet-4-5-20250514": 0.003,
75
+ "claude-opus-4-6": 0.015,
76
+ "gpt-4o-mini": 0.00015,
77
+ "gpt-4o": 0.0025,
78
+ "gemini-2.0-flash": 0.0001,
79
+ "gemini-2.5-pro": 0.00125,
80
+ "deepseek-chat": 0.00014,
81
+ };
82
+
83
+ // ─── Public API ──────────────────────────────────────────────────────────────
84
+
85
+ /**
86
+ * Resolve the model to use for a given complexity tier.
87
+ *
88
+ * Downgrade-only: the returned model is always equal to or cheaper than
89
+ * the user's configured primary model. Never upgrades beyond configuration.
90
+ *
91
+ * @param classification The complexity classification result
92
+ * @param phaseConfig The user's configured model for this phase (ceiling)
93
+ * @param routingConfig Dynamic routing configuration
94
+ * @param availableModelIds List of available model IDs (from registry)
95
+ */
96
+ export function resolveModelForComplexity(
97
+ classification: ClassificationResult,
98
+ phaseConfig: ResolvedModelConfig | undefined,
99
+ routingConfig: DynamicRoutingConfig,
100
+ availableModelIds: string[],
101
+ ): RoutingDecision {
102
+ // If no phase config or routing disabled, pass through
103
+ if (!phaseConfig || !routingConfig.enabled) {
104
+ return {
105
+ modelId: phaseConfig?.primary ?? "",
106
+ fallbacks: phaseConfig?.fallbacks ?? [],
107
+ tier: classification.tier,
108
+ wasDowngraded: false,
109
+ reason: "dynamic routing disabled or no phase config",
110
+ };
111
+ }
112
+
113
+ const configuredPrimary = phaseConfig.primary;
114
+ const configuredTier = getModelTier(configuredPrimary);
115
+ const requestedTier = classification.tier;
116
+
117
+ // Downgrade-only: if requested tier >= configured tier, no change
118
+ if (tierOrdinal(requestedTier) >= tierOrdinal(configuredTier)) {
119
+ return {
120
+ modelId: configuredPrimary,
121
+ fallbacks: phaseConfig.fallbacks,
122
+ tier: requestedTier,
123
+ wasDowngraded: false,
124
+ reason: `tier ${requestedTier} >= configured ${configuredTier}`,
125
+ };
126
+ }
127
+
128
+ // Find the best model for the requested tier
129
+ const targetModelId = findModelForTier(
130
+ requestedTier,
131
+ routingConfig,
132
+ availableModelIds,
133
+ routingConfig.cross_provider !== false,
134
+ );
135
+
136
+ if (!targetModelId) {
137
+ // No suitable model found — use configured primary
138
+ return {
139
+ modelId: configuredPrimary,
140
+ fallbacks: phaseConfig.fallbacks,
141
+ tier: requestedTier,
142
+ wasDowngraded: false,
143
+ reason: `no ${requestedTier}-tier model available`,
144
+ };
145
+ }
146
+
147
+ // Build fallback chain: [downgraded_model, ...configured_fallbacks, configured_primary]
148
+ const fallbacks = [
149
+ ...phaseConfig.fallbacks.filter(f => f !== targetModelId),
150
+ configuredPrimary,
151
+ ].filter(f => f !== targetModelId);
152
+
153
+ return {
154
+ modelId: targetModelId,
155
+ fallbacks,
156
+ tier: requestedTier,
157
+ wasDowngraded: true,
158
+ reason: classification.reason,
159
+ };
160
+ }
161
+
162
+ /**
163
+ * Escalate to the next tier after a failure.
164
+ * Returns the new tier, or null if already at heavy (max).
165
+ */
166
+ export function escalateTier(currentTier: ComplexityTier): ComplexityTier | null {
167
+ switch (currentTier) {
168
+ case "light": return "standard";
169
+ case "standard": return "heavy";
170
+ case "heavy": return null;
171
+ }
172
+ }
173
+
174
+ /**
175
+ * Get the default routing config (all features enabled).
176
+ */
177
+ export function defaultRoutingConfig(): DynamicRoutingConfig {
178
+ return {
179
+ enabled: false,
180
+ escalate_on_failure: true,
181
+ budget_pressure: true,
182
+ cross_provider: true,
183
+ hooks: true,
184
+ };
185
+ }
186
+
187
+ // ─── Internal ────────────────────────────────────────────────────────────────
188
+
189
+ function getModelTier(modelId: string): ComplexityTier {
190
+ // Strip provider prefix if present
191
+ const bareId = modelId.includes("/") ? modelId.split("/").pop()! : modelId;
192
+
193
+ // Check exact match first
194
+ if (MODEL_CAPABILITY_TIER[bareId]) return MODEL_CAPABILITY_TIER[bareId];
195
+
196
+ // Check if any known model ID is a prefix/suffix match
197
+ for (const [knownId, tier] of Object.entries(MODEL_CAPABILITY_TIER)) {
198
+ if (bareId.includes(knownId) || knownId.includes(bareId)) return tier;
199
+ }
200
+
201
+ // Unknown models are assumed heavy (safest assumption)
202
+ return "heavy";
203
+ }
204
+
205
+ function findModelForTier(
206
+ tier: ComplexityTier,
207
+ config: DynamicRoutingConfig,
208
+ availableModelIds: string[],
209
+ crossProvider: boolean,
210
+ ): string | null {
211
+ // 1. Check explicit tier_models config
212
+ const explicitModel = config.tier_models?.[tier];
213
+ if (explicitModel && availableModelIds.includes(explicitModel)) {
214
+ return explicitModel;
215
+ }
216
+ // Also check with provider prefix stripped
217
+ if (explicitModel) {
218
+ const match = availableModelIds.find(id => {
219
+ const bareAvail = id.includes("/") ? id.split("/").pop()! : id;
220
+ const bareExplicit = explicitModel.includes("/") ? explicitModel.split("/").pop()! : explicitModel;
221
+ return bareAvail === bareExplicit;
222
+ });
223
+ if (match) return match;
224
+ }
225
+
226
+ // 2. Auto-detect: find the cheapest available model in the requested tier
227
+ const candidates = availableModelIds
228
+ .filter(id => {
229
+ const modelTier = getModelTier(id);
230
+ return modelTier === tier;
231
+ })
232
+ .sort((a, b) => {
233
+ if (!crossProvider) return 0;
234
+ const costA = getModelCost(a);
235
+ const costB = getModelCost(b);
236
+ return costA - costB;
237
+ });
238
+
239
+ return candidates[0] ?? null;
240
+ }
241
+
242
+ function getModelCost(modelId: string): number {
243
+ const bareId = modelId.includes("/") ? modelId.split("/").pop()! : modelId;
244
+
245
+ if (MODEL_COST_PER_1K_INPUT[bareId] !== undefined) {
246
+ return MODEL_COST_PER_1K_INPUT[bareId];
247
+ }
248
+
249
+ // Check partial matches
250
+ for (const [knownId, cost] of Object.entries(MODEL_COST_PER_1K_INPUT)) {
251
+ if (bareId.includes(knownId) || knownId.includes(bareId)) return cost;
252
+ }
253
+
254
+ // Unknown cost — assume expensive to avoid routing to unknown cheap models
255
+ return 999;
256
+ }
@@ -60,7 +60,8 @@ export function checkPostUnitHooks(
60
60
  }
61
61
 
62
62
  // Don't trigger hooks for other hook units (prevent hook-on-hook chains)
63
- if (completedUnitType.startsWith("hook/")) return null;
63
+ // Don't trigger hooks for triage units (prevent hook-on-triage chains)
64
+ if (completedUnitType.startsWith("hook/") || completedUnitType === "triage-captures") return null;
64
65
 
65
66
  // Check if any hooks are configured for this unit type
66
67
  const hooks = resolvePostUnitHooks().filter(h =>
@@ -4,6 +4,8 @@ import { isAbsolute, join } from "node:path";
4
4
  import { getAgentDir } from "@gsd/pi-coding-agent";
5
5
  import type { GitPreferences } from "./git-service.js";
6
6
  import type { PostUnitHookConfig, PreDispatchHookConfig, BudgetEnforcementMode, NotificationPreferences, TokenProfile, InlineLevel, PhaseSkipPreferences } from "./types.js";
7
+ import type { DynamicRoutingConfig } from "./model-router.js";
8
+ import { defaultRoutingConfig } from "./model-router.js";
7
9
  import { VALID_BRANCH_NAME } from "./git-service.js";
8
10
 
9
11
  const GLOBAL_PREFERENCES_PATH = join(homedir(), ".gsd", "preferences.md");
@@ -36,8 +38,10 @@ const KNOWN_PREFERENCE_KEYS = new Set<string>([
36
38
  "git",
37
39
  "post_unit_hooks",
38
40
  "pre_dispatch_hooks",
41
+ "dynamic_routing",
39
42
  "token_profile",
40
43
  "phases",
44
+ "auto_visualize",
41
45
  ]);
42
46
 
43
47
  export interface GSDSkillRule {
@@ -128,8 +132,10 @@ export interface GSDPreferences {
128
132
  git?: GitPreferences;
129
133
  post_unit_hooks?: PostUnitHookConfig[];
130
134
  pre_dispatch_hooks?: PreDispatchHookConfig[];
135
+ dynamic_routing?: DynamicRoutingConfig;
131
136
  token_profile?: TokenProfile;
132
137
  phases?: PhaseSkipPreferences;
138
+ auto_visualize?: boolean;
133
139
  }
134
140
 
135
141
  export interface LoadedGSDPreferences {
@@ -674,6 +680,20 @@ export function resolveModelWithFallbacksForUnit(unitType: string): ResolvedMode
674
680
  };
675
681
  }
676
682
 
683
+ /**
684
+ * Resolve the dynamic routing configuration from effective preferences.
685
+ * Returns the merged config with defaults applied.
686
+ */
687
+ export function resolveDynamicRoutingConfig(): DynamicRoutingConfig {
688
+ const prefs = loadEffectiveGSDPreferences();
689
+ const configured = prefs?.preferences.dynamic_routing;
690
+ if (!configured) return defaultRoutingConfig();
691
+ return {
692
+ ...defaultRoutingConfig(),
693
+ ...configured,
694
+ };
695
+ }
696
+
677
697
  export function resolveAutoSupervisorConfig(): AutoSupervisorConfig {
678
698
  const prefs = loadEffectiveGSDPreferences();
679
699
  const configured = prefs?.preferences.auto_supervisor ?? {};
@@ -780,6 +800,9 @@ function mergePreferences(base: GSDPreferences, override: GSDPreferences): GSDPr
780
800
  : undefined,
781
801
  post_unit_hooks: mergePostUnitHooks(base.post_unit_hooks, override.post_unit_hooks),
782
802
  pre_dispatch_hooks: mergePreDispatchHooks(base.pre_dispatch_hooks, override.pre_dispatch_hooks),
803
+ dynamic_routing: (base.dynamic_routing || override.dynamic_routing)
804
+ ? { ...(base.dynamic_routing ?? {}), ...(override.dynamic_routing ?? {}) } as DynamicRoutingConfig
805
+ : undefined,
783
806
  token_profile: override.token_profile ?? base.token_profile,
784
807
  phases: (base.phases || override.phases)
785
808
  ? { ...(base.phases ?? {}), ...(override.phases ?? {}) }
@@ -1100,6 +1123,56 @@ export function validatePreferences(preferences: GSDPreferences): {
1100
1123
  }
1101
1124
  }
1102
1125
 
1126
+ // ─── Dynamic Routing ─────────────────────────────────────────────────
1127
+ if (preferences.dynamic_routing !== undefined) {
1128
+ if (typeof preferences.dynamic_routing === "object" && preferences.dynamic_routing !== null) {
1129
+ const dr = preferences.dynamic_routing as unknown as Record<string, unknown>;
1130
+ const validDr: Partial<DynamicRoutingConfig> = {};
1131
+
1132
+ if (dr.enabled !== undefined) {
1133
+ if (typeof dr.enabled === "boolean") validDr.enabled = dr.enabled;
1134
+ else errors.push("dynamic_routing.enabled must be a boolean");
1135
+ }
1136
+ if (dr.escalate_on_failure !== undefined) {
1137
+ if (typeof dr.escalate_on_failure === "boolean") validDr.escalate_on_failure = dr.escalate_on_failure;
1138
+ else errors.push("dynamic_routing.escalate_on_failure must be a boolean");
1139
+ }
1140
+ if (dr.budget_pressure !== undefined) {
1141
+ if (typeof dr.budget_pressure === "boolean") validDr.budget_pressure = dr.budget_pressure;
1142
+ else errors.push("dynamic_routing.budget_pressure must be a boolean");
1143
+ }
1144
+ if (dr.cross_provider !== undefined) {
1145
+ if (typeof dr.cross_provider === "boolean") validDr.cross_provider = dr.cross_provider;
1146
+ else errors.push("dynamic_routing.cross_provider must be a boolean");
1147
+ }
1148
+ if (dr.hooks !== undefined) {
1149
+ if (typeof dr.hooks === "boolean") validDr.hooks = dr.hooks;
1150
+ else errors.push("dynamic_routing.hooks must be a boolean");
1151
+ }
1152
+ if (dr.tier_models !== undefined) {
1153
+ if (typeof dr.tier_models === "object" && dr.tier_models !== null) {
1154
+ const tm = dr.tier_models as Record<string, unknown>;
1155
+ const validTm: Record<string, string> = {};
1156
+ for (const tier of ["light", "standard", "heavy"]) {
1157
+ if (tm[tier] !== undefined) {
1158
+ if (typeof tm[tier] === "string") validTm[tier] = tm[tier] as string;
1159
+ else errors.push(`dynamic_routing.tier_models.${tier} must be a string`);
1160
+ }
1161
+ }
1162
+ if (Object.keys(validTm).length > 0) validDr.tier_models = validTm as DynamicRoutingConfig["tier_models"];
1163
+ } else {
1164
+ errors.push("dynamic_routing.tier_models must be an object");
1165
+ }
1166
+ }
1167
+
1168
+ if (Object.keys(validDr).length > 0) {
1169
+ validated.dynamic_routing = validDr as unknown as DynamicRoutingConfig;
1170
+ }
1171
+ } else {
1172
+ errors.push("dynamic_routing must be an object");
1173
+ }
1174
+ }
1175
+
1103
1176
  // ─── Git Preferences ───────────────────────────────────────────────────
1104
1177
  if (preferences.git && typeof preferences.git === "object") {
1105
1178
  const git: Record<string, unknown> = {};
@@ -7,15 +7,17 @@
7
7
  * Templates live at prompts/ relative to this module's directory.
8
8
  * They use {{variableName}} syntax for substitution.
9
9
  *
10
- * Templates are cached on first read per session. This prevents a running
11
- * session from being invalidated when another `gsd` launch overwrites
12
- * ~/.gsd/agent/ with newer templates via initResources(). Without caching,
13
- * the in-memory extension code (which knows variable set A) can read a
14
- * newer template from disk (which expects variable set B), causing a
15
- * "template declares {{X}} but no value was provided" crash mid-session.
10
+ * All templates are eagerly loaded into cache at module init via warmCache().
11
+ * This prevents a running session from being invalidated when another `gsd`
12
+ * launch overwrites ~/.gsd/agent/ with newer templates via initResources().
13
+ * Without eager caching, the in-memory extension code (which knows variable
14
+ * set A) can read a newer template from disk (which expects variable set B),
15
+ * causing a "template declares {{X}} but no value was provided" crash
16
+ * mid-session — especially for late-loading templates like complete-milestone
17
+ * that aren't read until the end of a long auto-mode run.
16
18
  */
17
19
 
18
- import { readFileSync } from "node:fs";
20
+ import { readFileSync, readdirSync } from "node:fs";
19
21
  import { join, dirname } from "node:path";
20
22
  import { fileURLToPath } from "node:url";
21
23
 
@@ -23,10 +25,44 @@ const __extensionDir = dirname(fileURLToPath(import.meta.url));
23
25
  const promptsDir = join(__extensionDir, "prompts");
24
26
  const templatesDir = join(__extensionDir, "templates");
25
27
 
26
- // Cache templates on first read — a running session uses the template versions
27
- // that were on disk when it first loaded them, immune to later overwrites.
28
+ // Cache all templates eagerly at module load — a running session uses the
29
+ // template versions that were on disk at startup, immune to later overwrites.
28
30
  const templateCache = new Map<string, string>();
29
31
 
32
+ /**
33
+ * Eagerly read all .md files from prompts/ and templates/ into cache.
34
+ * Called once at module init so that every template is snapshot before
35
+ * a concurrent initResources() can overwrite files on disk.
36
+ */
37
+ function warmCache(): void {
38
+ try {
39
+ for (const file of readdirSync(promptsDir)) {
40
+ if (!file.endsWith(".md")) continue;
41
+ const name = file.slice(0, -3);
42
+ if (!templateCache.has(name)) {
43
+ templateCache.set(name, readFileSync(join(promptsDir, file), "utf-8"));
44
+ }
45
+ }
46
+ } catch {
47
+ // prompts/ may not exist in test environments — lazy loading still works
48
+ }
49
+
50
+ try {
51
+ for (const file of readdirSync(templatesDir)) {
52
+ if (!file.endsWith(".md")) continue;
53
+ const cacheKey = `tpl:${file.slice(0, -3)}`;
54
+ if (!templateCache.has(cacheKey)) {
55
+ templateCache.set(cacheKey, readFileSync(join(templatesDir, file), "utf-8"));
56
+ }
57
+ }
58
+ } catch {
59
+ // templates/ may not exist in test environments — lazy loading still works
60
+ }
61
+ }
62
+
63
+ // Snapshot all templates at module load time
64
+ warmCache();
65
+
30
66
  /**
31
67
  * Load a prompt template and substitute variables.
32
68
  *
@@ -16,6 +16,12 @@ All relevant context has been preloaded below — the current roadmap, completed
16
16
 
17
17
  {{inlinedContext}}
18
18
 
19
+ ## Deferred Captures
20
+
21
+ The following user thoughts were captured during execution and deferred to future slices during triage. Consider whether any should influence the remaining roadmap:
22
+
23
+ {{deferredCaptures}}
24
+
19
25
  If a `GSD Skill Preferences` block is present in system context, use it to decide which skills to load and follow during reassessment, without relaxing required verification or artifact rules.
20
26
 
21
27
  Then assess whether the remaining roadmap still makes sense given what was just built.
@@ -12,6 +12,14 @@ All relevant context has been preloaded below — the roadmap, current slice pla
12
12
 
13
13
  {{inlinedContext}}
14
14
 
15
+ ## Capture Context
16
+
17
+ The following user-captured thoughts triggered or informed this replan:
18
+
19
+ {{captureContext}}
20
+
21
+ Consider these captures when rewriting the remaining tasks — they represent the user's real-time insights about what needs to change.
22
+
15
23
  ## Hard Constraints
16
24
 
17
25
  - **Do NOT renumber or remove completed tasks.** All `[x]` tasks and their IDs must remain exactly as they are in the plan.
@@ -0,0 +1,62 @@
1
+ You are triaging user-captured thoughts during a GSD session.
2
+
3
+ ## UNIT: Triage Captures
4
+
5
+ The user captured thoughts during execution using `/gsd capture`. Your job is to classify each capture, present your proposals, get user confirmation, and update CAPTURES.md with the final classifications.
6
+
7
+ ## Pending Captures
8
+
9
+ {{pendingCaptures}}
10
+
11
+ ## Current Slice Plan
12
+
13
+ {{currentPlan}}
14
+
15
+ ## Current Roadmap
16
+
17
+ {{roadmapContext}}
18
+
19
+ ## Classification Criteria
20
+
21
+ For each capture, classify it as one of:
22
+
23
+ - **quick-task**: Small, self-contained, no downstream impact. Can be done in minutes without modifying the plan. Examples: fix a typo, add a missing import, tweak a config value.
24
+ - **inject**: Belongs in the current slice but wasn't planned. Needs a new task added to the slice plan. Examples: add error handling to a module being built, add a missing test case for current work.
25
+ - **defer**: Belongs in a future slice or milestone. Not urgent for current work. Examples: performance optimization, feature that depends on unbuilt infrastructure, nice-to-have enhancement.
26
+ - **replan**: Changes the shape of remaining work in the current slice. Existing incomplete tasks may need rewriting. Examples: "the approach is wrong, we need to use X instead of Y", discovering a fundamental constraint.
27
+ - **note**: Informational only. No action needed right now. Good context for future reference. Examples: "remember that the API has a rate limit", observations about code quality.
28
+
29
+ ## Decision Guidelines
30
+
31
+ - Prefer **quick-task** when the work is clearly small and self-contained.
32
+ - Prefer **inject** over **replan** when only a new task is needed, not rewriting existing ones.
33
+ - Prefer **defer** over **inject** when the work doesn't belong in the current slice's scope.
34
+ - Use **replan** only when remaining incomplete tasks need to change — not just for adding work.
35
+ - Use **note** for observations that don't require action.
36
+ - When unsure between quick-task and inject, consider: will this take more than 10 minutes? If yes, inject.
37
+
38
+ ## Instructions
39
+
40
+ 1. **Classify** each pending capture using the criteria above.
41
+
42
+ 2. **Present** your classifications to the user using `ask_user_questions`. For each capture, show:
43
+ - The capture text
44
+ - Your proposed classification
45
+ - Your rationale
46
+ - If applicable, which files would be affected
47
+
48
+ For captures classified as **note** or **defer**, auto-confirm without asking — these are low-impact.
49
+ For captures classified as **quick-task**, **inject**, or **replan**, ask the user to confirm or choose a different classification.
50
+
51
+ 3. **Update** `.gsd/CAPTURES.md` — for each capture, update its section with the confirmed classification:
52
+ - Change `**Status:** pending` to `**Status:** resolved`
53
+ - Add `**Classification:** <type>`
54
+ - Add `**Resolution:** <brief description of what will happen>`
55
+ - Add `**Rationale:** <why this classification>`
56
+ - Add `**Resolved:** <current ISO timestamp>`
57
+
58
+ 4. **Summarize** what was triaged: how many captures, what classifications were assigned, and what actions are pending (e.g., "2 quick-tasks ready for execution, 1 deferred to S03").
59
+
60
+ **Important:** Do NOT execute any resolutions. Only classify and update CAPTURES.md. Resolution execution happens separately (in auto-mode dispatch or manually by the user).
61
+
62
+ When done, say: "Triage complete."