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
@@ -10,7 +10,7 @@ import type { ExtensionContext, ExtensionCommandContext } from "@gsd/pi-coding-a
10
10
  import type { GSDState } from "./types.js";
11
11
  import { getCurrentBranch } from "./worktree.js";
12
12
  import { getActiveHook } from "./post-unit-hooks.js";
13
- import { getLedger, getProjectTotals, formatCost, formatTokenCount } from "./metrics.js";
13
+ import { getLedger, getProjectTotals, formatCost, formatTokenCount, formatTierSavings } from "./metrics.js";
14
14
  import {
15
15
  resolveMilestoneFile,
16
16
  resolveSliceFile,
@@ -39,6 +39,8 @@ export interface AutoDashboardData {
39
39
  projectedRemainingCost?: number;
40
40
  /** Whether token profile has been auto-downgraded due to budget prediction */
41
41
  profileDowngraded?: boolean;
42
+ /** Number of pending captures awaiting triage (0 if none or file missing) */
43
+ pendingCaptureCount: number;
42
44
  }
43
45
 
44
46
  // ─── Unit Description Helpers ─────────────────────────────────────────────────
@@ -239,6 +241,7 @@ export function updateProgressWidget(
239
241
  unitId: string,
240
242
  state: GSDState,
241
243
  accessors: WidgetStateAccessors,
244
+ tierBadge?: string,
242
245
  ): void {
243
246
  if (!ctx.hasUI) return;
244
247
 
@@ -319,7 +322,8 @@ export function updateProgressWidget(
319
322
 
320
323
  const target = task ? `${task.id}: ${task.title}` : unitId;
321
324
  const actionLeft = `${pad}${theme.fg("accent", "▸")} ${theme.fg("accent", verb)} ${theme.fg("text", target)}`;
322
- const phaseBadge = theme.fg("dim", phaseLabel);
325
+ const tierTag = tierBadge ? theme.fg("dim", `[${tierBadge}] `) : "";
326
+ const phaseBadge = `${tierTag}${theme.fg("dim", phaseLabel)}`;
323
327
  lines.push(rightAlign(actionLeft, phaseBadge, width));
324
328
  lines.push("");
325
329
 
@@ -414,6 +418,14 @@ export function updateProgressWidget(
414
418
  ? `${modelPhase}${theme.fg("dim", modelDisplay)}`
415
419
  : "";
416
420
  lines.push(rightAlign(`${pad}${sLeft}`, sRight, width));
421
+
422
+ // Dynamic routing savings summary
423
+ if (mLedger && mLedger.units.some(u => u.tier)) {
424
+ const savings = formatTierSavings(mLedger.units);
425
+ if (savings) {
426
+ lines.push(truncateToWidth(theme.fg("dim", `${pad}${savings}`), width));
427
+ }
428
+ }
417
429
  }
418
430
 
419
431
  const hintParts: string[] = [];
@@ -389,7 +389,7 @@ export async function buildResearchMilestonePrompt(mid: string, midTitle: string
389
389
  milestoneId: mid, milestoneTitle: midTitle,
390
390
  milestonePath: relMilestonePath(base, mid),
391
391
  contextPath: contextRel,
392
- outputPath: outputRelPath,
392
+ outputPath: join(base, outputRelPath),
393
393
  inlinedContext,
394
394
  ...buildSkillDiscoveryVars(),
395
395
  });
@@ -432,14 +432,14 @@ export async function buildPlanMilestonePrompt(mid: string, midTitle: string, ba
432
432
  const inlinedContext = `## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`;
433
433
 
434
434
  const outputRelPath = relMilestoneFile(base, mid, "ROADMAP");
435
- const secretsOutputPath = relMilestoneFile(base, mid, "SECRETS");
435
+ const secretsOutputPath = join(base, relMilestoneFile(base, mid, "SECRETS"));
436
436
  return loadPrompt("plan-milestone", {
437
437
  workingDirectory: base,
438
438
  milestoneId: mid, milestoneTitle: midTitle,
439
439
  milestonePath: relMilestonePath(base, mid),
440
440
  contextPath: contextRel,
441
441
  researchPath: researchRel,
442
- outputPath: outputRelPath,
442
+ outputPath: join(base, outputRelPath),
443
443
  secretsOutputPath,
444
444
  inlinedContext,
445
445
  });
@@ -484,7 +484,7 @@ export async function buildResearchSlicePrompt(
484
484
  roadmapPath: roadmapRel,
485
485
  contextPath: contextRel,
486
486
  milestoneResearchPath: milestoneResearchRel,
487
- outputPath: outputRelPath,
487
+ outputPath: join(base, outputRelPath),
488
488
  inlinedContext,
489
489
  dependencySummaries: depContent,
490
490
  ...buildSkillDiscoveryVars(),
@@ -531,7 +531,7 @@ export async function buildPlanSlicePrompt(
531
531
  slicePath: relSlicePath(base, mid, sid),
532
532
  roadmapPath: roadmapRel,
533
533
  researchPath: researchRel,
534
- outputPath: outputRelPath,
534
+ outputPath: join(base, outputRelPath),
535
535
  inlinedContext,
536
536
  dependencySummaries: depContent,
537
537
  });
@@ -598,7 +598,7 @@ export async function buildExecuteTaskPrompt(
598
598
  ...(knowledgeInlineET ? [knowledgeInlineET] : []),
599
599
  ].join("\n\n---\n\n");
600
600
 
601
- const taskSummaryPath = `${relSlicePath(base, mid, sid)}/tasks/${tid}-SUMMARY.md`;
601
+ const taskSummaryPath = join(base, `${relSlicePath(base, mid, sid)}/tasks/${tid}-SUMMARY.md`);
602
602
 
603
603
  const activeOverrides = await loadActiveOverrides(base);
604
604
  const overridesSection = formatOverridesSection(activeOverrides);
@@ -607,7 +607,7 @@ export async function buildExecuteTaskPrompt(
607
607
  overridesSection,
608
608
  workingDirectory: base,
609
609
  milestoneId: mid, sliceId: sid, sliceTitle: sTitle, taskId: tid, taskTitle: tTitle,
610
- planPath: relSliceFile(base, mid, sid, "PLAN"),
610
+ planPath: join(base, relSliceFile(base, mid, sid, "PLAN")),
611
611
  slicePath: relSlicePath(base, mid, sid),
612
612
  taskPlanPath: taskPlanRelPath,
613
613
  taskPlanInline,
@@ -665,14 +665,14 @@ export async function buildCompleteSlicePrompt(
665
665
  const inlinedContext = `## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`;
666
666
 
667
667
  const sliceRel = relSlicePath(base, mid, sid);
668
- const sliceSummaryPath = `${sliceRel}/${sid}-SUMMARY.md`;
669
- const sliceUatPath = `${sliceRel}/${sid}-UAT.md`;
668
+ const sliceSummaryPath = join(base, `${sliceRel}/${sid}-SUMMARY.md`);
669
+ const sliceUatPath = join(base, `${sliceRel}/${sid}-UAT.md`);
670
670
 
671
671
  return loadPrompt("complete-slice", {
672
672
  workingDirectory: base,
673
673
  milestoneId: mid, sliceId: sid, sliceTitle: sTitle,
674
674
  slicePath: sliceRel,
675
- roadmapPath: roadmapRel,
675
+ roadmapPath: join(base, roadmapRel),
676
676
  inlinedContext,
677
677
  sliceSummaryPath,
678
678
  sliceUatPath,
@@ -723,7 +723,7 @@ export async function buildCompleteMilestonePrompt(
723
723
 
724
724
  const inlinedContext = `## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`;
725
725
 
726
- const milestoneSummaryPath = `${relMilestonePath(base, mid)}/${mid}-SUMMARY.md`;
726
+ const milestoneSummaryPath = join(base, `${relMilestonePath(base, mid)}/${mid}-SUMMARY.md`);
727
727
 
728
728
  return loadPrompt("complete-milestone", {
729
729
  workingDirectory: base,
@@ -775,7 +775,21 @@ export async function buildReplanSlicePrompt(
775
775
 
776
776
  const inlinedContext = `## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`;
777
777
 
778
- const replanPath = `${relSlicePath(base, mid, sid)}/${sid}-REPLAN.md`;
778
+ const replanPath = join(base, `${relSlicePath(base, mid, sid)}/${sid}-REPLAN.md`);
779
+
780
+ // Build capture context for replan prompt (captures that triggered this replan)
781
+ let captureContext = "(none)";
782
+ try {
783
+ const { loadReplanCaptures } = await import("./triage-resolution.js");
784
+ const replanCaptures = loadReplanCaptures(base);
785
+ if (replanCaptures.length > 0) {
786
+ captureContext = replanCaptures.map(c =>
787
+ `- **${c.id}**: "${c.text}" — ${c.rationale ?? "no rationale"}`
788
+ ).join("\n");
789
+ }
790
+ } catch {
791
+ // Non-fatal — captures module may not be available
792
+ }
779
793
 
780
794
  return loadPrompt("replan-slice", {
781
795
  workingDirectory: base,
@@ -783,10 +797,11 @@ export async function buildReplanSlicePrompt(
783
797
  sliceId: sid,
784
798
  sliceTitle: sTitle,
785
799
  slicePath: relSlicePath(base, mid, sid),
786
- planPath: slicePlanRel,
800
+ planPath: join(base, slicePlanRel),
787
801
  blockerTaskId,
788
802
  inlinedContext,
789
803
  replanPath,
804
+ captureContext,
790
805
  });
791
806
  }
792
807
 
@@ -808,7 +823,7 @@ export async function buildRunUatPrompt(
808
823
 
809
824
  const inlinedContext = `## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`;
810
825
 
811
- const uatResultPath = relSliceFile(base, mid, sliceId, "UAT-RESULT");
826
+ const uatResultPath = join(base, relSliceFile(base, mid, sliceId, "UAT-RESULT"));
812
827
  const uatType = extractUatType(uatContent) ?? "human-experience";
813
828
 
814
829
  return loadPrompt("run-uat", {
@@ -847,7 +862,21 @@ export async function buildReassessRoadmapPrompt(
847
862
 
848
863
  const inlinedContext = `## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`;
849
864
 
850
- const assessmentPath = relSliceFile(base, mid, completedSliceId, "ASSESSMENT");
865
+ const assessmentPath = join(base, relSliceFile(base, mid, completedSliceId, "ASSESSMENT"));
866
+
867
+ // Build deferred captures context for reassess prompt
868
+ let deferredCaptures = "(none)";
869
+ try {
870
+ const { loadDeferredCaptures } = await import("./triage-resolution.js");
871
+ const deferred = loadDeferredCaptures(base);
872
+ if (deferred.length > 0) {
873
+ deferredCaptures = deferred.map(c =>
874
+ `- **${c.id}**: "${c.text}" — ${c.rationale ?? "deferred during triage"}`
875
+ ).join("\n");
876
+ }
877
+ } catch {
878
+ // Non-fatal — captures module may not be available
879
+ }
851
880
 
852
881
  return loadPrompt("reassess-roadmap", {
853
882
  workingDirectory: base,
@@ -858,6 +887,7 @@ export async function buildReassessRoadmapPrompt(
858
887
  completedSliceSummaryPath: summaryRel,
859
888
  assessmentPath,
860
889
  inlinedContext,
890
+ deferredCaptures,
861
891
  });
862
892
  }
863
893
 
@@ -19,6 +19,7 @@ import type {
19
19
  import { deriveState, invalidateStateCache } from "./state.js";
20
20
  import type { BudgetEnforcementMode, GSDState } from "./types.js";
21
21
  import { loadFile, parseRoadmap, getManifestStatus, resolveAllOverrides } from "./files.js";
22
+ import { loadPrompt } from "./prompt-loader.js";
22
23
  export { inlinePriorMilestoneSummary } from "./files.js";
23
24
  import { collectSecretsFromManifest } from "../get-secrets-from-user.js";
24
25
  import {
@@ -39,9 +40,12 @@ import {
39
40
  readUnitRuntimeRecord,
40
41
  writeUnitRuntimeRecord,
41
42
  } from "./unit-runtime.js";
42
- import { resolveAutoSupervisorConfig, resolveModelWithFallbacksForUnit, loadEffectiveGSDPreferences, resolveSkillDiscoveryMode } from "./preferences.js";
43
+ import { resolveAutoSupervisorConfig, resolveModelWithFallbacksForUnit, loadEffectiveGSDPreferences, resolveSkillDiscoveryMode, resolveDynamicRoutingConfig } from "./preferences.js";
43
44
  import { sendDesktopNotification } from "./notifications.js";
44
45
  import type { GSDPreferences } from "./preferences.js";
46
+ import { classifyUnitComplexity, tierLabel } from "./complexity-classifier.js";
47
+ import { resolveModelForComplexity } from "./model-router.js";
48
+ import { initRoutingHistory, resetRoutingHistory, recordOutcome } from "./routing-history.js";
45
49
  import {
46
50
  checkPostUnitHooks,
47
51
  getActiveHook,
@@ -129,6 +133,7 @@ import {
129
133
  deregisterSigtermHandler as _deregisterSigtermHandler,
130
134
  detectWorkingTreeActivity,
131
135
  } from "./auto-supervisor.js";
136
+ import { hasPendingCaptures, loadPendingCaptures, countPendingCaptures } from "./captures.js";
132
137
 
133
138
  // ─── State ────────────────────────────────────────────────────────────────────
134
139
 
@@ -233,6 +238,9 @@ let autoStartTime: number = 0;
233
238
  let completedUnits: { type: string; id: string; startedAt: number; finishedAt: number }[] = [];
234
239
  let currentUnit: { type: string; id: string; startedAt: number } | null = null;
235
240
 
241
+ /** Track dynamic routing decision for the current unit (for metrics) */
242
+ let currentUnitRouting: { tier: string; modelDowngraded: boolean } | null = null;
243
+
236
244
  /** Track current milestone to detect transitions */
237
245
  let currentMilestoneId: string | null = null;
238
246
  let lastBudgetAlertLevel: BudgetAlertLevel = 0;
@@ -301,6 +309,15 @@ export { type AutoDashboardData } from "./auto-dashboard.js";
301
309
  export function getAutoDashboardData(): AutoDashboardData {
302
310
  const ledger = getLedger();
303
311
  const totals = ledger ? getProjectTotals(ledger.units) : null;
312
+ // Pending capture count — lazy check, non-fatal
313
+ let pendingCaptureCount = 0;
314
+ try {
315
+ if (basePath) {
316
+ pendingCaptureCount = countPendingCaptures(basePath);
317
+ }
318
+ } catch {
319
+ // Non-fatal — captures module may not be loaded
320
+ }
304
321
  return {
305
322
  active,
306
323
  paused,
@@ -312,6 +329,7 @@ export function getAutoDashboardData(): AutoDashboardData {
312
329
  basePath,
313
330
  totalCost: totals?.cost ?? 0,
314
331
  totalTokens: totals?.tokens.total ?? 0,
332
+ pendingCaptureCount,
315
333
  };
316
334
  }
317
335
 
@@ -504,6 +522,7 @@ export async function stopAuto(ctx?: ExtensionContext, pi?: ExtensionAPI): Promi
504
522
  }
505
523
 
506
524
  resetMetrics();
525
+ resetRoutingHistory();
507
526
  resetHookState();
508
527
  if (basePath) clearPersistedHookState(basePath);
509
528
  active = false;
@@ -809,6 +828,9 @@ export async function startAuto(
809
828
  // Initialize metrics — loads existing ledger from disk
810
829
  initMetrics(base);
811
830
 
831
+ // Initialize routing history for adaptive learning
832
+ initRoutingHistory(base);
833
+
812
834
  // Snapshot installed skills so we can detect new ones after research
813
835
  if (resolveSkillDiscoveryMode() !== "off") {
814
836
  snapshotSkills();
@@ -1011,7 +1033,7 @@ export async function handleAgentEnd(
1011
1033
  const hookStartedAt = Date.now();
1012
1034
  if (currentUnit) {
1013
1035
  const modelId = ctx.model?.id ?? "unknown";
1014
- snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId);
1036
+ snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId, currentUnitRouting ?? undefined);
1015
1037
  saveActivityLog(ctx, basePath, currentUnit.type, currentUnit.id);
1016
1038
  }
1017
1039
  currentUnit = { type: hookUnit.unitType, id: hookUnit.unitId, startedAt: hookStartedAt };
@@ -1106,6 +1128,108 @@ export async function handleAgentEnd(
1106
1128
  }
1107
1129
  }
1108
1130
 
1131
+ // ── Triage check: dispatch triage unit if pending captures exist ──────────
1132
+ // Fires after hooks complete, before normal dispatch. Follows the same
1133
+ // early-dispatch-and-return pattern as hooks and fix-merge.
1134
+ // Skip for: step mode (shows wizard instead), triage units (prevent triage-on-triage),
1135
+ // hook units (hooks run before triage conceptually).
1136
+ if (
1137
+ !stepMode &&
1138
+ currentUnit &&
1139
+ !currentUnit.type.startsWith("hook/") &&
1140
+ currentUnit.type !== "triage-captures" &&
1141
+ currentUnit.type !== "quick-task"
1142
+ ) {
1143
+ try {
1144
+ if (hasPendingCaptures(basePath)) {
1145
+ const pending = loadPendingCaptures(basePath);
1146
+ if (pending.length > 0) {
1147
+ const state = await deriveState(basePath);
1148
+ const mid = state.activeMilestone?.id;
1149
+ const sid = state.activeSlice?.id;
1150
+
1151
+ if (mid && sid) {
1152
+ // Build triage prompt with current context
1153
+ let currentPlan = "";
1154
+ let roadmapContext = "";
1155
+ const planFile = resolveSliceFile(basePath, mid, sid, "PLAN");
1156
+ if (planFile) currentPlan = (await loadFile(planFile)) ?? "";
1157
+ const roadmapFile = resolveMilestoneFile(basePath, mid, "ROADMAP");
1158
+ if (roadmapFile) roadmapContext = (await loadFile(roadmapFile)) ?? "";
1159
+
1160
+ const capturesList = pending.map(c =>
1161
+ `- **${c.id}**: "${c.text}" (captured: ${c.timestamp})`
1162
+ ).join("\n");
1163
+
1164
+ const prompt = loadPrompt("triage-captures", {
1165
+ pendingCaptures: capturesList,
1166
+ currentPlan: currentPlan || "(no active slice plan)",
1167
+ roadmapContext: roadmapContext || "(no active roadmap)",
1168
+ });
1169
+
1170
+ ctx.ui.notify(
1171
+ `Triaging ${pending.length} pending capture${pending.length === 1 ? "" : "s"}...`,
1172
+ "info",
1173
+ );
1174
+
1175
+ // Close out previous unit metrics
1176
+ if (currentUnit) {
1177
+ const modelId = ctx.model?.id ?? "unknown";
1178
+ snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId);
1179
+ saveActivityLog(ctx, basePath, currentUnit.type, currentUnit.id);
1180
+ }
1181
+
1182
+ // Dispatch triage as a new unit (early-dispatch-and-return)
1183
+ const triageUnitType = "triage-captures";
1184
+ const triageUnitId = `${mid}/${sid}/triage`;
1185
+ const triageStartedAt = Date.now();
1186
+ currentUnit = { type: triageUnitType, id: triageUnitId, startedAt: triageStartedAt };
1187
+ writeUnitRuntimeRecord(basePath, triageUnitType, triageUnitId, triageStartedAt, {
1188
+ phase: "dispatched",
1189
+ wrapupWarningSent: false,
1190
+ timeoutAt: null,
1191
+ lastProgressAt: triageStartedAt,
1192
+ progressCount: 0,
1193
+ lastProgressKind: "dispatch",
1194
+ });
1195
+ updateProgressWidget(ctx, triageUnitType, triageUnitId, state);
1196
+
1197
+ const result = await cmdCtx!.newSession();
1198
+ if (result.cancelled) {
1199
+ await stopAuto(ctx, pi);
1200
+ return;
1201
+ }
1202
+ const sessionFile = ctx.sessionManager.getSessionFile();
1203
+ writeLock(basePath, triageUnitType, triageUnitId, completedUnits.length, sessionFile);
1204
+
1205
+ // Start unit timeout for triage (use same supervisor config as hooks)
1206
+ clearUnitTimeout();
1207
+ const supervisor = resolveAutoSupervisorConfig();
1208
+ const triageTimeoutMs = (supervisor.hard_timeout_minutes ?? 30) * 60 * 1000;
1209
+ unitTimeoutHandle = setTimeout(async () => {
1210
+ unitTimeoutHandle = null;
1211
+ if (!active) return;
1212
+ ctx.ui.notify(
1213
+ `Triage unit exceeded timeout. Pausing auto-mode.`,
1214
+ "warning",
1215
+ );
1216
+ await pauseAuto(ctx, pi);
1217
+ }, triageTimeoutMs);
1218
+
1219
+ if (!active) return;
1220
+ pi.sendMessage(
1221
+ { customType: "gsd-auto", content: prompt, display: verbose },
1222
+ { triggerTurn: true },
1223
+ );
1224
+ return; // handleAgentEnd will fire again when triage session completes
1225
+ }
1226
+ }
1227
+ }
1228
+ } catch {
1229
+ // Triage check failure is non-fatal — proceed to normal dispatch
1230
+ }
1231
+ }
1232
+
1109
1233
  // In step mode, pause and show a wizard instead of immediately dispatching
1110
1234
  if (stepMode) {
1111
1235
  await showStepWizard(ctx, pi);
@@ -1227,7 +1351,10 @@ function updateProgressWidget(
1227
1351
  unitId: string,
1228
1352
  state: GSDState,
1229
1353
  ): void {
1230
- _updateProgressWidget(ctx, unitType, unitId, state, widgetStateAccessors);
1354
+ const badge = currentUnitRouting?.tier
1355
+ ? ({ light: "L", standard: "S", heavy: "H" }[currentUnitRouting.tier] ?? undefined)
1356
+ : undefined;
1357
+ _updateProgressWidget(ctx, unitType, unitId, state, widgetStateAccessors, badge);
1231
1358
  }
1232
1359
 
1233
1360
  /** State accessors for the widget — closures over module globals. */
@@ -1306,12 +1433,85 @@ async function dispatchNextUnit(
1306
1433
  "info",
1307
1434
  );
1308
1435
  sendDesktopNotification("GSD", `Milestone ${currentMilestoneId} complete!`, "success", "milestone");
1436
+ // Hint: visualizer available after milestone transition
1437
+ const vizPrefs = loadEffectiveGSDPreferences()?.preferences;
1438
+ if (vizPrefs?.auto_visualize) {
1439
+ ctx.ui.notify("Run /gsd visualize to see progress overview.", "info");
1440
+ }
1309
1441
  // Reset stuck detection for new milestone
1310
1442
  unitDispatchCount.clear();
1311
1443
  unitRecoveryCount.clear();
1312
1444
  unitLifetimeDispatches.clear();
1313
- // Capture integration branch for the new milestone and update git service
1314
- captureIntegrationBranch(originalBasePath || basePath, mid, { commitDocs: loadEffectiveGSDPreferences()?.preferences?.git?.commit_docs });
1445
+ // Clear completed-units.json for the finished milestone
1446
+ try {
1447
+ const file = completedKeysPath(basePath);
1448
+ if (existsSync(file)) writeFileSync(file, JSON.stringify([]), "utf-8");
1449
+ completedKeySet.clear();
1450
+ } catch { /* non-fatal */ }
1451
+
1452
+ // ── Worktree lifecycle on milestone transition (#616) ──────────────
1453
+ // When transitioning from M_old to M_new inside a worktree, we must:
1454
+ // 1. Merge the completed milestone's worktree back to main
1455
+ // 2. Re-derive state from the project root
1456
+ // 3. Create a new worktree for the incoming milestone
1457
+ // Without this, M_new runs inside M_old's worktree on the wrong branch,
1458
+ // and artifact paths resolve against the wrong .gsd/ directory.
1459
+ if (isInAutoWorktree(basePath) && originalBasePath && shouldUseWorktreeIsolation()) {
1460
+ try {
1461
+ const roadmapPath = resolveMilestoneFile(originalBasePath, currentMilestoneId, "ROADMAP");
1462
+ if (roadmapPath) {
1463
+ const roadmapContent = readFileSync(roadmapPath, "utf-8");
1464
+ const mergeResult = mergeMilestoneToMain(originalBasePath, currentMilestoneId, roadmapContent);
1465
+ ctx.ui.notify(
1466
+ `Milestone ${currentMilestoneId} merged to main.${mergeResult.pushed ? " Pushed to remote." : ""}`,
1467
+ "info",
1468
+ );
1469
+ } else {
1470
+ // No roadmap found — teardown worktree without merge
1471
+ teardownAutoWorktree(originalBasePath, currentMilestoneId);
1472
+ ctx.ui.notify(`Exited worktree for ${currentMilestoneId} (no roadmap for merge).`, "info");
1473
+ }
1474
+ } catch (err) {
1475
+ ctx.ui.notify(
1476
+ `Milestone merge failed during transition: ${err instanceof Error ? err.message : String(err)}`,
1477
+ "warning",
1478
+ );
1479
+ // Force cwd back to project root even if merge failed
1480
+ if (originalBasePath) {
1481
+ try { process.chdir(originalBasePath); } catch { /* best-effort */ }
1482
+ }
1483
+ }
1484
+
1485
+ // Update basePath to project root (mergeMilestoneToMain already chdir'd)
1486
+ basePath = originalBasePath;
1487
+ gitService = new GitServiceImpl(basePath, loadEffectiveGSDPreferences()?.preferences?.git ?? {});
1488
+ invalidateAllCaches();
1489
+
1490
+ // Re-derive state from project root before creating new worktree
1491
+ state = await deriveState(basePath);
1492
+ mid = state.activeMilestone?.id;
1493
+ midTitle = state.activeMilestone?.title;
1494
+
1495
+ // Create new worktree for the incoming milestone
1496
+ if (mid) {
1497
+ captureIntegrationBranch(basePath, mid, { commitDocs: loadEffectiveGSDPreferences()?.preferences?.git?.commit_docs });
1498
+ try {
1499
+ const wtPath = createAutoWorktree(basePath, mid);
1500
+ basePath = wtPath;
1501
+ gitService = new GitServiceImpl(basePath, loadEffectiveGSDPreferences()?.preferences?.git ?? {});
1502
+ ctx.ui.notify(`Created auto-worktree for ${mid} at ${wtPath}`, "info");
1503
+ } catch (err) {
1504
+ ctx.ui.notify(
1505
+ `Auto-worktree creation for ${mid} failed: ${err instanceof Error ? err.message : String(err)}. Continuing in project root.`,
1506
+ "warning",
1507
+ );
1508
+ }
1509
+ }
1510
+ } else {
1511
+ // Not in worktree — just capture integration branch for the new milestone
1512
+ captureIntegrationBranch(originalBasePath || basePath, mid, { commitDocs: loadEffectiveGSDPreferences()?.preferences?.git?.commit_docs });
1513
+ }
1514
+
1315
1515
  // Prune completed milestone from queue order file
1316
1516
  const pendingIds = state.registry
1317
1517
  .filter(m => m.status !== "complete")
@@ -1327,7 +1527,7 @@ async function dispatchNextUnit(
1327
1527
  // Save final session before stopping
1328
1528
  if (currentUnit) {
1329
1529
  const modelId = ctx.model?.id ?? "unknown";
1330
- snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId);
1530
+ snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId, currentUnitRouting ?? undefined);
1331
1531
  saveActivityLog(ctx, basePath, currentUnit.type, currentUnit.id);
1332
1532
  }
1333
1533
  sendDesktopNotification("GSD", "All milestones complete!", "success", "milestone");
@@ -1355,7 +1555,7 @@ async function dispatchNextUnit(
1355
1555
  if (!mid || !midTitle) {
1356
1556
  if (currentUnit) {
1357
1557
  const modelId = ctx.model?.id ?? "unknown";
1358
- snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId);
1558
+ snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId, currentUnitRouting ?? undefined);
1359
1559
  saveActivityLog(ctx, basePath, currentUnit.type, currentUnit.id);
1360
1560
  }
1361
1561
  await stopAuto(ctx, pi);
@@ -1370,7 +1570,7 @@ async function dispatchNextUnit(
1370
1570
  if (state.phase === "complete") {
1371
1571
  if (currentUnit) {
1372
1572
  const modelId = ctx.model?.id ?? "unknown";
1373
- snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId);
1573
+ snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId, currentUnitRouting ?? undefined);
1374
1574
  saveActivityLog(ctx, basePath, currentUnit.type, currentUnit.id);
1375
1575
  }
1376
1576
  // Clear completed-units.json for the finished milestone so it doesn't grow unbounded.
@@ -1440,7 +1640,7 @@ async function dispatchNextUnit(
1440
1640
  if (state.phase === "blocked") {
1441
1641
  if (currentUnit) {
1442
1642
  const modelId = ctx.model?.id ?? "unknown";
1443
- snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId);
1643
+ snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId, currentUnitRouting ?? undefined);
1444
1644
  saveActivityLog(ctx, basePath, currentUnit.type, currentUnit.id);
1445
1645
  }
1446
1646
  await stopAuto(ctx, pi);
@@ -1548,7 +1748,7 @@ async function dispatchNextUnit(
1548
1748
  if (dispatchResult.action === "stop") {
1549
1749
  if (currentUnit) {
1550
1750
  const modelId = ctx.model?.id ?? "unknown";
1551
- snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId);
1751
+ snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId, currentUnitRouting ?? undefined);
1552
1752
  saveActivityLog(ctx, basePath, currentUnit.type, currentUnit.id);
1553
1753
  }
1554
1754
  await stopAuto(ctx, pi);
@@ -1658,7 +1858,7 @@ async function dispatchNextUnit(
1658
1858
  if (lifetimeCount > MAX_LIFETIME_DISPATCHES) {
1659
1859
  if (currentUnit) {
1660
1860
  const modelId = ctx.model?.id ?? "unknown";
1661
- snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId);
1861
+ snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId, currentUnitRouting ?? undefined);
1662
1862
  }
1663
1863
  saveActivityLog(ctx, basePath, unitType, unitId);
1664
1864
  const expected = diagnoseExpectedArtifact(unitType, unitId, basePath);
@@ -1672,7 +1872,7 @@ async function dispatchNextUnit(
1672
1872
  if (prevCount >= MAX_UNIT_DISPATCHES) {
1673
1873
  if (currentUnit) {
1674
1874
  const modelId = ctx.model?.id ?? "unknown";
1675
- snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId);
1875
+ snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId, currentUnitRouting ?? undefined);
1676
1876
  }
1677
1877
  saveActivityLog(ctx, basePath, unitType, unitId);
1678
1878
 
@@ -1830,9 +2030,19 @@ async function dispatchNextUnit(
1830
2030
  // The session still holds the previous unit's data (newSession hasn't fired yet).
1831
2031
  if (currentUnit) {
1832
2032
  const modelId = ctx.model?.id ?? "unknown";
1833
- snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId);
2033
+ snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId, currentUnitRouting ?? undefined);
1834
2034
  saveActivityLog(ctx, basePath, currentUnit.type, currentUnit.id);
1835
2035
 
2036
+ // Record routing outcome for adaptive learning
2037
+ if (currentUnitRouting) {
2038
+ const isRetry = currentUnit.type === unitType && currentUnit.id === unitId;
2039
+ recordOutcome(
2040
+ currentUnit.type,
2041
+ currentUnitRouting.tier as "light" | "standard" | "heavy",
2042
+ !isRetry, // success = not being retried
2043
+ );
2044
+ }
2045
+
1836
2046
  // Only mark the previous unit as completed if:
1837
2047
  // 1. We're not about to re-dispatch the same unit (retry scenario)
1838
2048
  // 2. The expected artifact actually exists on disk
@@ -1935,7 +2145,54 @@ async function dispatchNextUnit(
1935
2145
  const modelConfig = resolveModelWithFallbacksForUnit(unitType);
1936
2146
  if (modelConfig) {
1937
2147
  const availableModels = ctx.modelRegistry.getAvailable();
1938
- const modelsToTry = [modelConfig.primary, ...modelConfig.fallbacks];
2148
+
2149
+ // ─── Dynamic Model Routing ─────────────────────────────────────────
2150
+ // If enabled, classify unit complexity and potentially downgrade to a
2151
+ // cheaper model. The user's configured model is the ceiling.
2152
+ const routingConfig = resolveDynamicRoutingConfig();
2153
+ let effectiveModelConfig = modelConfig;
2154
+ let routingTierLabel = "";
2155
+ currentUnitRouting = null;
2156
+
2157
+ if (routingConfig.enabled) {
2158
+ // Compute budget pressure if budget ceiling is set
2159
+ let budgetPct: number | undefined;
2160
+ if (routingConfig.budget_pressure !== false) {
2161
+ const budgetCeiling = prefs?.budget_ceiling;
2162
+ if (budgetCeiling !== undefined && budgetCeiling > 0) {
2163
+ const currentLedger = getLedger();
2164
+ const totalCost = currentLedger ? getProjectTotals(currentLedger.units).cost : 0;
2165
+ budgetPct = totalCost / budgetCeiling;
2166
+ }
2167
+ }
2168
+
2169
+ // Classify complexity (hook routing controlled by config.hooks)
2170
+ const isHook = unitType.startsWith("hook/");
2171
+ const shouldClassify = !isHook || routingConfig.hooks !== false;
2172
+
2173
+ if (shouldClassify) {
2174
+ const classification = classifyUnitComplexity(unitType, unitId, basePath, budgetPct);
2175
+ const availableModelIds = availableModels.map(m => m.id);
2176
+ const routing = resolveModelForComplexity(classification, modelConfig, routingConfig, availableModelIds);
2177
+
2178
+ if (routing.wasDowngraded) {
2179
+ effectiveModelConfig = {
2180
+ primary: routing.modelId,
2181
+ fallbacks: routing.fallbacks,
2182
+ };
2183
+ if (verbose) {
2184
+ ctx.ui.notify(
2185
+ `Dynamic routing [${tierLabel(classification.tier)}]: ${routing.modelId} (${classification.reason})`,
2186
+ "info",
2187
+ );
2188
+ }
2189
+ }
2190
+ routingTierLabel = ` [${tierLabel(classification.tier)}]`;
2191
+ currentUnitRouting = { tier: classification.tier, modelDowngraded: routing.wasDowngraded };
2192
+ }
2193
+ }
2194
+
2195
+ const modelsToTry = [effectiveModelConfig.primary, ...effectiveModelConfig.fallbacks];
1939
2196
  let modelSet = false;
1940
2197
 
1941
2198
  for (const modelId of modelsToTry) {
@@ -2000,11 +2257,11 @@ async function dispatchNextUnit(
2000
2257
 
2001
2258
  const ok = await pi.setModel(model, { persist: false });
2002
2259
  if (ok) {
2003
- const fallbackNote = modelId === modelConfig.primary
2260
+ const fallbackNote = modelId === effectiveModelConfig.primary
2004
2261
  ? ""
2005
- : ` (fallback from ${modelConfig.primary})`;
2262
+ : ` (fallback from ${effectiveModelConfig.primary})`;
2006
2263
  const phase = unitPhaseLabel(unitType);
2007
- ctx.ui.notify(`Model [${phase}]: ${model.provider}/${model.id}${fallbackNote}`, "info");
2264
+ ctx.ui.notify(`Model [${phase}]${routingTierLabel}: ${model.provider}/${model.id}${fallbackNote}`, "info");
2008
2265
  modelSet = true;
2009
2266
  break;
2010
2267
  } else {
@@ -2083,7 +2340,7 @@ async function dispatchNextUnit(
2083
2340
 
2084
2341
  if (currentUnit) {
2085
2342
  const modelId = ctx.model?.id ?? "unknown";
2086
- snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId);
2343
+ snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId, currentUnitRouting ?? undefined);
2087
2344
  }
2088
2345
  saveActivityLog(ctx, basePath, unitType, unitId);
2089
2346
 
@@ -2109,7 +2366,7 @@ async function dispatchNextUnit(
2109
2366
  timeoutAt: Date.now(),
2110
2367
  });
2111
2368
  const modelId = ctx.model?.id ?? "unknown";
2112
- snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId);
2369
+ snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId, currentUnitRouting ?? undefined);
2113
2370
  }
2114
2371
  saveActivityLog(ctx, basePath, unitType, unitId);
2115
2372