gsd-pi 2.16.0 → 2.17.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 (89) hide show
  1. package/dist/resources/extensions/gsd/auto-dashboard.ts +4 -0
  2. package/dist/resources/extensions/gsd/auto-dispatch.ts +9 -3
  3. package/dist/resources/extensions/gsd/auto-prompts.ts +71 -41
  4. package/dist/resources/extensions/gsd/auto-recovery.ts +7 -2
  5. package/dist/resources/extensions/gsd/auto.ts +54 -15
  6. package/dist/resources/extensions/gsd/commands.ts +20 -2
  7. package/dist/resources/extensions/gsd/complexity.ts +236 -0
  8. package/dist/resources/extensions/gsd/docs/preferences-reference.md +1 -0
  9. package/dist/resources/extensions/gsd/files.ts +6 -2
  10. package/dist/resources/extensions/gsd/git-service.ts +19 -8
  11. package/dist/resources/extensions/gsd/gitignore.ts +41 -2
  12. package/dist/resources/extensions/gsd/guided-flow.ts +10 -6
  13. package/dist/resources/extensions/gsd/metrics.ts +44 -0
  14. package/dist/resources/extensions/gsd/native-git-bridge.ts +5 -0
  15. package/dist/resources/extensions/gsd/native-parser-bridge.ts +5 -0
  16. package/dist/resources/extensions/gsd/preferences.ts +122 -1
  17. package/dist/resources/extensions/gsd/routing-history.ts +290 -0
  18. package/dist/resources/extensions/gsd/tests/auto-recovery.test.ts +50 -0
  19. package/dist/resources/extensions/gsd/tests/budget-prediction.test.ts +220 -0
  20. package/dist/resources/extensions/gsd/tests/complexity-routing.test.ts +294 -0
  21. package/dist/resources/extensions/gsd/tests/context-compression.test.ts +180 -0
  22. package/dist/resources/extensions/gsd/tests/git-service.test.ts +132 -0
  23. package/dist/resources/extensions/gsd/tests/preferences-git.test.ts +28 -0
  24. package/dist/resources/extensions/gsd/tests/routing-history.test.ts +87 -0
  25. package/dist/resources/extensions/gsd/tests/stop-auto-remote.test.ts +130 -0
  26. package/dist/resources/extensions/gsd/tests/token-profile.test.ts +263 -0
  27. package/dist/resources/extensions/gsd/types.ts +28 -0
  28. package/dist/resources/extensions/gsd/worktree.ts +2 -2
  29. package/package.json +1 -1
  30. package/packages/pi-ai/dist/models.generated.d.ts +493 -13
  31. package/packages/pi-ai/dist/models.generated.d.ts.map +1 -1
  32. package/packages/pi-ai/dist/models.generated.js +422 -62
  33. package/packages/pi-ai/dist/models.generated.js.map +1 -1
  34. package/packages/pi-ai/dist/providers/google-shared.d.ts +12 -0
  35. package/packages/pi-ai/dist/providers/google-shared.d.ts.map +1 -1
  36. package/packages/pi-ai/dist/providers/google-shared.js +9 -22
  37. package/packages/pi-ai/dist/providers/google-shared.js.map +1 -1
  38. package/packages/pi-ai/dist/providers/google-shared.test.d.ts +2 -0
  39. package/packages/pi-ai/dist/providers/google-shared.test.d.ts.map +1 -0
  40. package/packages/pi-ai/dist/providers/google-shared.test.js +125 -0
  41. package/packages/pi-ai/dist/providers/google-shared.test.js.map +1 -0
  42. package/packages/pi-ai/src/models.generated.ts +422 -62
  43. package/packages/pi-ai/src/providers/google-shared.test.ts +137 -0
  44. package/packages/pi-ai/src/providers/google-shared.ts +10 -19
  45. package/packages/pi-coding-agent/dist/core/tools/edit-diff.d.ts +7 -7
  46. package/packages/pi-coding-agent/dist/core/tools/edit-diff.d.ts.map +1 -1
  47. package/packages/pi-coding-agent/dist/core/tools/edit-diff.js +209 -13
  48. package/packages/pi-coding-agent/dist/core/tools/edit-diff.js.map +1 -1
  49. package/packages/pi-coding-agent/dist/core/tools/edit-diff.test.d.ts +2 -0
  50. package/packages/pi-coding-agent/dist/core/tools/edit-diff.test.d.ts.map +1 -0
  51. package/packages/pi-coding-agent/dist/core/tools/edit-diff.test.js +67 -0
  52. package/packages/pi-coding-agent/dist/core/tools/edit-diff.test.js.map +1 -0
  53. package/packages/pi-coding-agent/dist/modes/interactive/theme/theme.d.ts.map +1 -1
  54. package/packages/pi-coding-agent/dist/modes/interactive/theme/theme.js +10 -0
  55. package/packages/pi-coding-agent/dist/modes/interactive/theme/theme.js.map +1 -1
  56. package/packages/pi-coding-agent/src/core/tools/edit-diff.test.ts +85 -0
  57. package/packages/pi-coding-agent/src/core/tools/edit-diff.ts +245 -17
  58. package/packages/pi-coding-agent/src/modes/interactive/theme/theme.ts +13 -0
  59. package/pkg/dist/modes/interactive/theme/theme.d.ts.map +1 -1
  60. package/pkg/dist/modes/interactive/theme/theme.js +10 -0
  61. package/pkg/dist/modes/interactive/theme/theme.js.map +1 -1
  62. package/src/resources/extensions/gsd/auto-dashboard.ts +4 -0
  63. package/src/resources/extensions/gsd/auto-dispatch.ts +9 -3
  64. package/src/resources/extensions/gsd/auto-prompts.ts +71 -41
  65. package/src/resources/extensions/gsd/auto-recovery.ts +7 -2
  66. package/src/resources/extensions/gsd/auto.ts +54 -15
  67. package/src/resources/extensions/gsd/commands.ts +20 -2
  68. package/src/resources/extensions/gsd/complexity.ts +236 -0
  69. package/src/resources/extensions/gsd/docs/preferences-reference.md +1 -0
  70. package/src/resources/extensions/gsd/files.ts +6 -2
  71. package/src/resources/extensions/gsd/git-service.ts +19 -8
  72. package/src/resources/extensions/gsd/gitignore.ts +41 -2
  73. package/src/resources/extensions/gsd/guided-flow.ts +10 -6
  74. package/src/resources/extensions/gsd/metrics.ts +44 -0
  75. package/src/resources/extensions/gsd/native-git-bridge.ts +5 -0
  76. package/src/resources/extensions/gsd/native-parser-bridge.ts +5 -0
  77. package/src/resources/extensions/gsd/preferences.ts +122 -1
  78. package/src/resources/extensions/gsd/routing-history.ts +290 -0
  79. package/src/resources/extensions/gsd/tests/auto-recovery.test.ts +50 -0
  80. package/src/resources/extensions/gsd/tests/budget-prediction.test.ts +220 -0
  81. package/src/resources/extensions/gsd/tests/complexity-routing.test.ts +294 -0
  82. package/src/resources/extensions/gsd/tests/context-compression.test.ts +180 -0
  83. package/src/resources/extensions/gsd/tests/git-service.test.ts +132 -0
  84. package/src/resources/extensions/gsd/tests/preferences-git.test.ts +28 -0
  85. package/src/resources/extensions/gsd/tests/routing-history.test.ts +87 -0
  86. package/src/resources/extensions/gsd/tests/stop-auto-remote.test.ts +130 -0
  87. package/src/resources/extensions/gsd/tests/token-profile.test.ts +263 -0
  88. package/src/resources/extensions/gsd/types.ts +28 -0
  89. package/src/resources/extensions/gsd/worktree.ts +2 -2
@@ -3,7 +3,7 @@ import { homedir } from "node:os";
3
3
  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
- import type { PostUnitHookConfig, PreDispatchHookConfig, BudgetEnforcementMode, NotificationPreferences } from "./types.js";
6
+ import type { PostUnitHookConfig, PreDispatchHookConfig, BudgetEnforcementMode, NotificationPreferences, TokenProfile, InlineLevel, PhaseSkipPreferences } from "./types.js";
7
7
  import { VALID_BRANCH_NAME } from "./git-service.js";
8
8
 
9
9
  const GLOBAL_PREFERENCES_PATH = join(homedir(), ".gsd", "preferences.md");
@@ -36,6 +36,8 @@ const KNOWN_PREFERENCE_KEYS = new Set<string>([
36
36
  "git",
37
37
  "post_unit_hooks",
38
38
  "pre_dispatch_hooks",
39
+ "token_profile",
40
+ "phases",
39
41
  ]);
40
42
 
41
43
  export interface GSDSkillRule {
@@ -66,7 +68,9 @@ export interface GSDModelConfig {
66
68
  research?: string;
67
69
  planning?: string;
68
70
  execution?: string;
71
+ execution_simple?: string;
69
72
  completion?: string;
73
+ subagent?: string;
70
74
  }
71
75
 
72
76
  /**
@@ -77,7 +81,9 @@ export interface GSDModelConfigV2 {
77
81
  research?: string | GSDPhaseModelConfig;
78
82
  planning?: string | GSDPhaseModelConfig;
79
83
  execution?: string | GSDPhaseModelConfig;
84
+ execution_simple?: string | GSDPhaseModelConfig;
80
85
  completion?: string | GSDPhaseModelConfig;
86
+ subagent?: string | GSDPhaseModelConfig;
81
87
  }
82
88
 
83
89
  /** Normalized model selection with resolved fallbacks */
@@ -122,6 +128,8 @@ export interface GSDPreferences {
122
128
  git?: GitPreferences;
123
129
  post_unit_hooks?: PostUnitHookConfig[];
124
130
  pre_dispatch_hooks?: PreDispatchHookConfig[];
131
+ token_profile?: TokenProfile;
132
+ phases?: PhaseSkipPreferences;
125
133
  }
126
134
 
127
135
  export interface LoadedGSDPreferences {
@@ -631,11 +639,19 @@ export function resolveModelWithFallbacksForUnit(unitType: string): ResolvedMode
631
639
  case "execute-task":
632
640
  phaseConfig = m.execution;
633
641
  break;
642
+ case "execute-task-simple":
643
+ phaseConfig = m.execution_simple ?? m.execution;
644
+ break;
634
645
  case "complete-slice":
635
646
  case "run-uat":
636
647
  phaseConfig = m.completion;
637
648
  break;
638
649
  default:
650
+ // Subagent unit types (e.g., "subagent", "subagent/scout")
651
+ if (unitType === "subagent" || unitType.startsWith("subagent/")) {
652
+ phaseConfig = m.subagent;
653
+ break;
654
+ }
639
655
  return undefined;
640
656
  }
641
657
 
@@ -670,6 +686,73 @@ export function resolveAutoSupervisorConfig(): AutoSupervisorConfig {
670
686
  };
671
687
  }
672
688
 
689
+ // ─── Token Profile Resolution ─────────────────────────────────────────────
690
+
691
+ const VALID_TOKEN_PROFILES = new Set<TokenProfile>(["budget", "balanced", "quality"]);
692
+
693
+ /**
694
+ * Resolve profile defaults for a given token profile tier.
695
+ * Returns a partial GSDPreferences that is used as the base layer —
696
+ * explicit user preferences always override these defaults.
697
+ */
698
+ export function resolveProfileDefaults(profile: TokenProfile): Partial<GSDPreferences> {
699
+ switch (profile) {
700
+ case "budget":
701
+ return {
702
+ models: {
703
+ planning: "claude-sonnet-4-5-20250514",
704
+ execution: "claude-sonnet-4-5-20250514",
705
+ execution_simple: "claude-haiku-4-5-20250414",
706
+ completion: "claude-haiku-4-5-20250414",
707
+ subagent: "claude-haiku-4-5-20250414",
708
+ },
709
+ phases: {
710
+ skip_research: true,
711
+ skip_reassess: true,
712
+ skip_slice_research: true,
713
+ },
714
+ };
715
+ case "balanced":
716
+ return {
717
+ models: {
718
+ subagent: "claude-sonnet-4-5-20250514",
719
+ },
720
+ phases: {
721
+ skip_slice_research: true,
722
+ },
723
+ };
724
+ case "quality":
725
+ return {
726
+ models: {},
727
+ phases: {},
728
+ };
729
+ }
730
+ }
731
+
732
+ /**
733
+ * Resolve the effective token profile from preferences.
734
+ * Returns "balanced" when no profile is set (D046).
735
+ */
736
+ export function resolveEffectiveProfile(): TokenProfile {
737
+ const prefs = loadEffectiveGSDPreferences();
738
+ const profile = prefs?.preferences.token_profile;
739
+ if (profile && VALID_TOKEN_PROFILES.has(profile)) return profile;
740
+ return "balanced";
741
+ }
742
+
743
+ /**
744
+ * Resolve the inline level from the active token profile.
745
+ * budget → minimal, balanced → standard, quality → full.
746
+ */
747
+ export function resolveInlineLevel(): InlineLevel {
748
+ const profile = resolveEffectiveProfile();
749
+ switch (profile) {
750
+ case "budget": return "minimal";
751
+ case "balanced": return "standard";
752
+ case "quality": return "full";
753
+ }
754
+ }
755
+
673
756
  function mergePreferences(base: GSDPreferences, override: GSDPreferences): GSDPreferences {
674
757
  return {
675
758
  version: override.version ?? base.version,
@@ -697,6 +780,10 @@ function mergePreferences(base: GSDPreferences, override: GSDPreferences): GSDPr
697
780
  : undefined,
698
781
  post_unit_hooks: mergePostUnitHooks(base.post_unit_hooks, override.post_unit_hooks),
699
782
  pre_dispatch_hooks: mergePreDispatchHooks(base.pre_dispatch_hooks, override.pre_dispatch_hooks),
783
+ token_profile: override.token_profile ?? base.token_profile,
784
+ phases: (base.phases || override.phases)
785
+ ? { ...(base.phases ?? {}), ...(override.phases ?? {}) }
786
+ : undefined,
700
787
  };
701
788
  }
702
789
 
@@ -803,6 +890,36 @@ export function validatePreferences(preferences: GSDPreferences): {
803
890
  }
804
891
  }
805
892
 
893
+ // ─── Token Profile ─────────────────────────────────────────────────
894
+ if (preferences.token_profile !== undefined) {
895
+ if (typeof preferences.token_profile === "string" && VALID_TOKEN_PROFILES.has(preferences.token_profile as TokenProfile)) {
896
+ validated.token_profile = preferences.token_profile as TokenProfile;
897
+ } else {
898
+ errors.push(`token_profile must be one of: budget, balanced, quality`);
899
+ }
900
+ }
901
+
902
+ // ─── Phase Skip Preferences ─────────────────────────────────────────
903
+ if (preferences.phases !== undefined) {
904
+ if (typeof preferences.phases === "object" && preferences.phases !== null) {
905
+ const validatedPhases: PhaseSkipPreferences = {};
906
+ const p = preferences.phases as Record<string, unknown>;
907
+ if (p.skip_research !== undefined) validatedPhases.skip_research = !!p.skip_research;
908
+ if (p.skip_reassess !== undefined) validatedPhases.skip_reassess = !!p.skip_reassess;
909
+ if (p.skip_slice_research !== undefined) validatedPhases.skip_slice_research = !!p.skip_slice_research;
910
+ // Warn on unknown phase keys
911
+ const knownPhaseKeys = new Set(["skip_research", "skip_reassess", "skip_slice_research"]);
912
+ for (const key of Object.keys(p)) {
913
+ if (!knownPhaseKeys.has(key)) {
914
+ warnings.push(`unknown phases key "${key}" — ignored`);
915
+ }
916
+ }
917
+ validated.phases = validatedPhases;
918
+ } else {
919
+ errors.push(`phases must be an object`);
920
+ }
921
+ }
922
+
806
923
  // ─── Context Pause Threshold ────────────────────────────────────────
807
924
  if (preferences.context_pause_threshold !== undefined) {
808
925
  const raw = preferences.context_pause_threshold;
@@ -1046,6 +1163,10 @@ export function validatePreferences(preferences: GSDPreferences): {
1046
1163
  errors.push("git.isolation must be one of: worktree, branch");
1047
1164
  }
1048
1165
  }
1166
+ if (g.commit_docs !== undefined) {
1167
+ if (typeof g.commit_docs === "boolean") git.commit_docs = g.commit_docs;
1168
+ else errors.push("git.commit_docs must be a boolean");
1169
+ }
1049
1170
  // Deprecated: merge_to_main is ignored (branchless architecture).
1050
1171
  if (g.merge_to_main !== undefined) {
1051
1172
  warnings.push("git.merge_to_main is deprecated — milestone-level merge is now always used. Remove this setting.");
@@ -0,0 +1,290 @@
1
+ // GSD Extension — Routing History (Adaptive Learning)
2
+ // Tracks success/failure per tier per unit-type pattern to improve
3
+ // classification accuracy over time.
4
+
5
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
6
+ import { join } from "node:path";
7
+ import { gsdRoot } from "./paths.js";
8
+ import type { ComplexityTier } from "./types.js";
9
+
10
+ // ─── Types ───────────────────────────────────────────────────────────────────
11
+
12
+ export interface TierOutcome {
13
+ success: number;
14
+ fail: number;
15
+ }
16
+
17
+ export interface PatternHistory {
18
+ light: TierOutcome;
19
+ standard: TierOutcome;
20
+ heavy: TierOutcome;
21
+ }
22
+
23
+ export interface RoutingHistoryData {
24
+ version: 1;
25
+ /** Keyed by pattern string, e.g. "execute-task:docs" or "complete-slice" */
26
+ patterns: Record<string, PatternHistory>;
27
+ /** User feedback entries (from /gsd:rate-unit) */
28
+ feedback: FeedbackEntry[];
29
+ /** Last updated timestamp */
30
+ updatedAt: string;
31
+ }
32
+
33
+ export interface FeedbackEntry {
34
+ unitType: string;
35
+ unitId: string;
36
+ tier: ComplexityTier;
37
+ rating: "over" | "under" | "ok";
38
+ timestamp: string;
39
+ }
40
+
41
+ // ─── Constants ───────────────────────────────────────────────────────────────
42
+
43
+ const HISTORY_FILE = "routing-history.json";
44
+ const ROLLING_WINDOW = 50; // only consider last N entries per pattern
45
+ const FAILURE_THRESHOLD = 0.20; // >20% failure rate triggers tier bump
46
+ const FEEDBACK_WEIGHT = 2; // feedback signals count 2x vs automatic
47
+
48
+ // ─── In-Memory State ─────────────────────────────────────────────────────────
49
+
50
+ let history: RoutingHistoryData | null = null;
51
+ let historyBasePath = "";
52
+
53
+ // ─── Public API ──────────────────────────────────────────────────────────────
54
+
55
+ /**
56
+ * Initialize routing history for a project.
57
+ */
58
+ export function initRoutingHistory(base: string): void {
59
+ historyBasePath = base;
60
+ history = loadHistory(base);
61
+ }
62
+
63
+ /**
64
+ * Reset routing history state.
65
+ */
66
+ export function resetRoutingHistory(): void {
67
+ history = null;
68
+ historyBasePath = "";
69
+ }
70
+
71
+ /**
72
+ * Record the outcome of a unit dispatch.
73
+ *
74
+ * @param unitType The unit type (e.g. "execute-task")
75
+ * @param tier The tier that was used
76
+ * @param success Whether the unit completed successfully
77
+ * @param tags Optional tags from task metadata (e.g. ["docs", "test"])
78
+ */
79
+ export function recordOutcome(
80
+ unitType: string,
81
+ tier: ComplexityTier,
82
+ success: boolean,
83
+ tags?: string[],
84
+ ): void {
85
+ if (!history) return;
86
+
87
+ // Record for the base unit type
88
+ const basePattern = unitType;
89
+ ensurePattern(basePattern);
90
+ const outcome = history.patterns[basePattern][tier];
91
+ if (success) outcome.success++;
92
+ else outcome.fail++;
93
+
94
+ // Record for tag-specific patterns (e.g. "execute-task:docs")
95
+ if (tags && tags.length > 0) {
96
+ for (const tag of tags) {
97
+ const tagPattern = `${unitType}:${tag}`;
98
+ ensurePattern(tagPattern);
99
+ const tagOutcome = history.patterns[tagPattern][tier];
100
+ if (success) tagOutcome.success++;
101
+ else tagOutcome.fail++;
102
+ }
103
+ }
104
+
105
+ // Apply rolling window — cap total entries per tier per pattern
106
+ for (const pattern of Object.keys(history.patterns)) {
107
+ const p = history.patterns[pattern];
108
+ for (const t of ["light", "standard", "heavy"] as const) {
109
+ const total = p[t].success + p[t].fail;
110
+ if (total > ROLLING_WINDOW) {
111
+ const scale = ROLLING_WINDOW / total;
112
+ p[t].success = Math.round(p[t].success * scale);
113
+ p[t].fail = Math.round(p[t].fail * scale);
114
+ }
115
+ }
116
+ }
117
+
118
+ history.updatedAt = new Date().toISOString();
119
+ saveHistory(historyBasePath, history);
120
+ }
121
+
122
+ /**
123
+ * Record user feedback for the last completed unit.
124
+ */
125
+ export function recordFeedback(
126
+ unitType: string,
127
+ unitId: string,
128
+ tier: ComplexityTier,
129
+ rating: "over" | "under" | "ok",
130
+ ): void {
131
+ if (!history) return;
132
+
133
+ history.feedback.push({
134
+ unitType,
135
+ unitId,
136
+ tier,
137
+ rating,
138
+ timestamp: new Date().toISOString(),
139
+ });
140
+
141
+ // Cap feedback array at 200 entries
142
+ if (history.feedback.length > 200) {
143
+ history.feedback = history.feedback.slice(-200);
144
+ }
145
+
146
+ // Apply feedback as weighted outcome
147
+ const pattern = unitType;
148
+ ensurePattern(pattern);
149
+
150
+ if (rating === "over") {
151
+ // User says this could have used a simpler model → record as success at current tier
152
+ // and also as success at one tier lower (encourages more downgrading)
153
+ const lower = tierBelow(tier);
154
+ if (lower) {
155
+ const outcomes = history.patterns[pattern][lower];
156
+ outcomes.success += FEEDBACK_WEIGHT;
157
+ }
158
+ } else if (rating === "under") {
159
+ // User says this needed a better model → record as failure at current tier
160
+ const outcomes = history.patterns[pattern][tier];
161
+ outcomes.fail += FEEDBACK_WEIGHT;
162
+ }
163
+ // "ok" = no adjustment needed
164
+
165
+ history.updatedAt = new Date().toISOString();
166
+ saveHistory(historyBasePath, history);
167
+ }
168
+
169
+ /**
170
+ * Get the recommended tier adjustment for a given pattern.
171
+ * Returns the tier to bump to if the failure rate exceeds threshold,
172
+ * or null if no adjustment is needed.
173
+ */
174
+ export function getAdaptiveTierAdjustment(
175
+ unitType: string,
176
+ currentTier: ComplexityTier,
177
+ tags?: string[],
178
+ ): ComplexityTier | null {
179
+ if (!history) return null;
180
+
181
+ // Check tag-specific patterns first (more specific)
182
+ if (tags && tags.length > 0) {
183
+ for (const tag of tags) {
184
+ const tagPattern = `${unitType}:${tag}`;
185
+ const adjustment = checkPatternFailureRate(tagPattern, currentTier);
186
+ if (adjustment) return adjustment;
187
+ }
188
+ }
189
+
190
+ // Fall back to base pattern
191
+ return checkPatternFailureRate(unitType, currentTier);
192
+ }
193
+
194
+ /**
195
+ * Clear all routing history (user-triggered reset).
196
+ */
197
+ export function clearRoutingHistory(base: string): void {
198
+ history = createEmptyHistory();
199
+ saveHistory(base, history);
200
+ }
201
+
202
+ /**
203
+ * Get current history data (for display/debugging).
204
+ */
205
+ export function getRoutingHistory(): RoutingHistoryData | null {
206
+ return history;
207
+ }
208
+
209
+ // ─── Internal ────────────────────────────────────────────────────────────────
210
+
211
+ function checkPatternFailureRate(
212
+ pattern: string,
213
+ tier: ComplexityTier,
214
+ ): ComplexityTier | null {
215
+ if (!history?.patterns[pattern]) return null;
216
+
217
+ const outcomes = history.patterns[pattern][tier];
218
+ const total = outcomes.success + outcomes.fail;
219
+ if (total < 3) return null; // Not enough data
220
+
221
+ const failureRate = outcomes.fail / total;
222
+ if (failureRate > FAILURE_THRESHOLD) {
223
+ // Bump to next tier
224
+ return tierAbove(tier);
225
+ }
226
+
227
+ return null;
228
+ }
229
+
230
+ function tierAbove(tier: ComplexityTier): ComplexityTier | null {
231
+ switch (tier) {
232
+ case "light": return "standard";
233
+ case "standard": return "heavy";
234
+ case "heavy": return null;
235
+ }
236
+ }
237
+
238
+ function tierBelow(tier: ComplexityTier): ComplexityTier | null {
239
+ switch (tier) {
240
+ case "light": return null;
241
+ case "standard": return "light";
242
+ case "heavy": return "standard";
243
+ }
244
+ }
245
+
246
+ function ensurePattern(pattern: string): void {
247
+ if (!history) return;
248
+ if (!history.patterns[pattern]) {
249
+ history.patterns[pattern] = {
250
+ light: { success: 0, fail: 0 },
251
+ standard: { success: 0, fail: 0 },
252
+ heavy: { success: 0, fail: 0 },
253
+ };
254
+ }
255
+ }
256
+
257
+ function createEmptyHistory(): RoutingHistoryData {
258
+ return {
259
+ version: 1,
260
+ patterns: {},
261
+ feedback: [],
262
+ updatedAt: new Date().toISOString(),
263
+ };
264
+ }
265
+
266
+ function historyPath(base: string): string {
267
+ return join(gsdRoot(base), HISTORY_FILE);
268
+ }
269
+
270
+ function loadHistory(base: string): RoutingHistoryData {
271
+ try {
272
+ const raw = readFileSync(historyPath(base), "utf-8");
273
+ const parsed = JSON.parse(raw);
274
+ if (parsed.version === 1 && parsed.patterns) {
275
+ return parsed as RoutingHistoryData;
276
+ }
277
+ } catch {
278
+ // File doesn't exist or is corrupt — start fresh
279
+ }
280
+ return createEmptyHistory();
281
+ }
282
+
283
+ function saveHistory(base: string, data: RoutingHistoryData): void {
284
+ try {
285
+ mkdirSync(gsdRoot(base), { recursive: true });
286
+ writeFileSync(historyPath(base), JSON.stringify(data, null, 2) + "\n", "utf-8");
287
+ } catch {
288
+ // Non-fatal — don't let history failures break auto-mode
289
+ }
290
+ }
@@ -7,6 +7,7 @@ import { randomUUID } from "node:crypto";
7
7
 
8
8
  import {
9
9
  resolveExpectedArtifactPath,
10
+ verifyExpectedArtifact,
10
11
  diagnoseExpectedArtifact,
11
12
  buildLoopRemediationSteps,
12
13
  completedKeysPath,
@@ -14,6 +15,7 @@ import {
14
15
  removePersistedKey,
15
16
  loadPersistedKeys,
16
17
  } from "../auto-recovery.ts";
18
+ import { parseRoadmap, clearParseCache } from "../files.ts";
17
19
 
18
20
  function makeTmpBase(): string {
19
21
  const base = join(tmpdir(), `gsd-test-${randomUUID()}`);
@@ -270,3 +272,51 @@ test("removePersistedKey is safe when file doesn't exist", () => {
270
272
  cleanup(base);
271
273
  }
272
274
  });
275
+
276
+ // ─── verifyExpectedArtifact: parse cache collision regression ─────────────
277
+
278
+ test("verifyExpectedArtifact detects roadmap [x] change despite parse cache", () => {
279
+ // Regression test: cacheKey collision when [ ] → [x] doesn't change
280
+ // file length or first/last 100 chars. Without the fix, parseRoadmap
281
+ // returns stale cached data with done=false even though the file has [x].
282
+ const base = makeTmpBase();
283
+ try {
284
+ // Build a roadmap long enough that the [x] change is outside the first/last 100 chars
285
+ const padding = "A".repeat(200);
286
+ const roadmapBefore = [
287
+ `# M001: Test Milestone ${padding}`,
288
+ "",
289
+ "## Slices",
290
+ "",
291
+ "- [ ] **S01: First slice** `risk:low`",
292
+ "",
293
+ `## Footer ${padding}`,
294
+ ].join("\n");
295
+ const roadmapAfter = roadmapBefore.replace("- [ ] **S01:", "- [x] **S01:");
296
+
297
+ // Verify lengths are identical (the key collision condition)
298
+ assert.equal(roadmapBefore.length, roadmapAfter.length);
299
+
300
+ // Populate parse cache with the pre-edit roadmap
301
+ const before = parseRoadmap(roadmapBefore);
302
+ const sliceBefore = before.slices.find(s => s.id === "S01");
303
+ assert.ok(sliceBefore);
304
+ assert.equal(sliceBefore!.done, false);
305
+
306
+ // Now write the post-edit roadmap to disk and create required artifacts
307
+ const roadmapPath = join(base, ".gsd", "milestones", "M001", "M001-ROADMAP.md");
308
+ writeFileSync(roadmapPath, roadmapAfter);
309
+ const summaryPath = join(base, ".gsd", "milestones", "M001", "slices", "S01", "S01-SUMMARY.md");
310
+ writeFileSync(summaryPath, "# Summary\nDone.");
311
+ const uatPath = join(base, ".gsd", "milestones", "M001", "slices", "S01", "S01-UAT.md");
312
+ writeFileSync(uatPath, "# UAT\nPassed.");
313
+
314
+ // verifyExpectedArtifact should see the [x] despite the parse cache
315
+ // having the [ ] version. The fix clears the parse cache inside verify.
316
+ const verified = verifyExpectedArtifact("complete-slice", "M001/S01", base);
317
+ assert.equal(verified, true, "verifyExpectedArtifact should return true when roadmap has [x]");
318
+ } finally {
319
+ clearParseCache();
320
+ cleanup(base);
321
+ }
322
+ });