gsd-pi 2.38.0-dev.4d4d14a → 2.38.0-dev.5492881

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 (37) hide show
  1. package/dist/resource-loader.js +34 -1
  2. package/dist/resources/extensions/gsd/auto-dispatch.js +1 -1
  3. package/dist/resources/extensions/gsd/auto-loop.js +538 -469
  4. package/dist/resources/extensions/gsd/auto-post-unit.js +9 -3
  5. package/dist/resources/extensions/gsd/auto-prompts.js +18 -14
  6. package/dist/resources/extensions/gsd/auto-worktree.js +3 -3
  7. package/dist/resources/extensions/gsd/commands.js +2 -1
  8. package/dist/resources/extensions/gsd/doctor.js +20 -1
  9. package/dist/resources/extensions/gsd/exit-command.js +2 -1
  10. package/dist/resources/extensions/gsd/files.js +4 -0
  11. package/dist/resources/extensions/gsd/git-service.js +22 -11
  12. package/dist/resources/extensions/gsd/guided-flow.js +82 -32
  13. package/dist/resources/extensions/gsd/native-git-bridge.js +37 -0
  14. package/dist/resources/extensions/gsd/prompts/run-uat.md +2 -0
  15. package/dist/resources/extensions/gsd/roadmap-mutations.js +24 -0
  16. package/dist/resources/extensions/mcp-client/index.js +14 -1
  17. package/package.json +1 -1
  18. package/packages/pi-coding-agent/dist/core/extensions/loader.d.ts.map +1 -1
  19. package/packages/pi-coding-agent/dist/core/extensions/loader.js +205 -7
  20. package/packages/pi-coding-agent/dist/core/extensions/loader.js.map +1 -1
  21. package/packages/pi-coding-agent/src/core/extensions/loader.ts +223 -7
  22. package/src/resources/extensions/gsd/auto-dispatch.ts +1 -1
  23. package/src/resources/extensions/gsd/auto-loop.ts +342 -304
  24. package/src/resources/extensions/gsd/auto-post-unit.ts +10 -3
  25. package/src/resources/extensions/gsd/auto-prompts.ts +20 -14
  26. package/src/resources/extensions/gsd/auto-worktree.ts +3 -3
  27. package/src/resources/extensions/gsd/commands.ts +2 -2
  28. package/src/resources/extensions/gsd/doctor.ts +22 -1
  29. package/src/resources/extensions/gsd/exit-command.ts +2 -2
  30. package/src/resources/extensions/gsd/files.ts +3 -1
  31. package/src/resources/extensions/gsd/git-service.ts +31 -9
  32. package/src/resources/extensions/gsd/guided-flow.ts +110 -38
  33. package/src/resources/extensions/gsd/native-git-bridge.ts +37 -0
  34. package/src/resources/extensions/gsd/prompts/run-uat.md +2 -0
  35. package/src/resources/extensions/gsd/roadmap-mutations.ts +29 -0
  36. package/src/resources/extensions/gsd/tests/auto-loop.test.ts +106 -31
  37. package/src/resources/extensions/mcp-client/index.ts +17 -1
@@ -172,13 +172,20 @@ export async function postUnitPreVerification(pctx: PostUnitContext, opts?: PreV
172
172
  ctx.ui.notify(`Post-hook: applied ${report.fixesApplied.length} fix(es).`, "info");
173
173
  }
174
174
 
175
- // Proactive health tracking
176
- const summary = summarizeDoctorIssues(report.issues);
175
+ // Proactive health tracking — filter to current milestone to avoid
176
+ // cross-milestone stale errors inflating the escalation counter
177
+ const currentMilestoneId = s.currentUnit.id.split("/")[0];
178
+ const milestoneIssues = currentMilestoneId
179
+ ? report.issues.filter(i =>
180
+ i.unitId === currentMilestoneId ||
181
+ i.unitId.startsWith(`${currentMilestoneId}/`))
182
+ : report.issues;
183
+ const summary = summarizeDoctorIssues(milestoneIssues);
177
184
  recordHealthSnapshot(summary.errors, summary.warnings, report.fixesApplied.length);
178
185
 
179
186
  // Check if we should escalate to LLM-assisted heal
180
187
  if (summary.errors > 0) {
181
- const unresolvedErrors = report.issues
188
+ const unresolvedErrors = milestoneIssues
182
189
  .filter(i => i.severity === "error" && !i.fixable)
183
190
  .map(i => ({ code: i.code, message: i.message, unitId: i.unitId }));
184
191
  const escalation = checkHealEscalation(summary.errors, unresolvedErrors);
@@ -20,9 +20,18 @@ import type { GSDState, InlineLevel } from "./types.js";
20
20
  import type { GSDPreferences } from "./preferences.js";
21
21
  import { join } from "node:path";
22
22
  import { existsSync } from "node:fs";
23
- import { computeBudgets, resolveExecutorContextWindow } from "./context-budget.js";
23
+ import { computeBudgets, resolveExecutorContextWindow, truncateAtSectionBoundary } from "./context-budget.js";
24
24
  import { formatDecisionsCompact, formatRequirementsCompact } from "./structured-data-formatter.js";
25
25
 
26
+ // ─── Preamble Cap ─────────────────────────────────────────────────────────────
27
+
28
+ const MAX_PREAMBLE_CHARS = 30_000;
29
+
30
+ function capPreamble(preamble: string): string {
31
+ if (preamble.length <= MAX_PREAMBLE_CHARS) return preamble;
32
+ return truncateAtSectionBoundary(preamble, MAX_PREAMBLE_CHARS).content;
33
+ }
34
+
26
35
  // ─── Executor Constraints ─────────────────────────────────────────────────────
27
36
 
28
37
  /**
@@ -157,7 +166,6 @@ export async function inlineFileSmart(
157
166
  }
158
167
 
159
168
  // For large files, truncate at section boundary
160
- const { truncateAtSectionBoundary } = await import("./context-budget.js");
161
169
  const truncated = truncateAtSectionBoundary(content, threshold).content;
162
170
  return `### ${label}\nSource: \`${relPath}\`\n\n${truncated}`;
163
171
  }
@@ -193,7 +201,6 @@ export async function inlineDependencySummaries(
193
201
 
194
202
  const result = sections.join("\n\n");
195
203
  if (budgetChars !== undefined && result.length > budgetChars) {
196
- const { truncateAtSectionBoundary } = await import("./context-budget.js");
197
204
  return truncateAtSectionBoundary(result, budgetChars).content;
198
205
  }
199
206
  return result;
@@ -611,7 +618,7 @@ export async function buildResearchMilestonePrompt(mid: string, midTitle: string
611
618
  if (knowledgeInlineRM) inlined.push(knowledgeInlineRM);
612
619
  inlined.push(inlineTemplate("research", "Research"));
613
620
 
614
- const inlinedContext = `## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`;
621
+ const inlinedContext = capPreamble(`## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`);
615
622
 
616
623
  const outputRelPath = relMilestoneFile(base, mid, "RESEARCH");
617
624
  return loadPrompt("research-milestone", {
@@ -661,7 +668,7 @@ export async function buildPlanMilestonePrompt(mid: string, midTitle: string, ba
661
668
  inlined.push(inlineTemplate("task-plan", "Task Plan"));
662
669
  }
663
670
 
664
- const inlinedContext = `## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`;
671
+ const inlinedContext = capPreamble(`## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`);
665
672
 
666
673
  const outputRelPath = relMilestoneFile(base, mid, "ROADMAP");
667
674
  const researchOutputPath = join(base, relMilestoneFile(base, mid, "RESEARCH"));
@@ -710,7 +717,7 @@ export async function buildResearchSlicePrompt(
710
717
  const overridesInline = formatOverridesSection(activeOverrides);
711
718
  if (overridesInline) inlined.unshift(overridesInline);
712
719
 
713
- const inlinedContext = `## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`;
720
+ const inlinedContext = capPreamble(`## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`);
714
721
 
715
722
  const outputRelPath = relSliceFile(base, mid, sid, "RESEARCH");
716
723
  return loadPrompt("research-slice", {
@@ -758,7 +765,7 @@ export async function buildPlanSlicePrompt(
758
765
  const planOverridesInline = formatOverridesSection(planActiveOverrides);
759
766
  if (planOverridesInline) inlined.unshift(planOverridesInline);
760
767
 
761
- const inlinedContext = `## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`;
768
+ const inlinedContext = capPreamble(`## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`);
762
769
 
763
770
  // Build executor context constraints from the budget engine
764
771
  const executorContextConstraints = formatExecutorConstraints();
@@ -881,7 +888,6 @@ export async function buildExecuteTaskPrompt(
881
888
  const carryForwardBudget = Math.floor(budgets.inlineContextBudgetChars * 0.4);
882
889
  let finalCarryForward = carryForwardSection;
883
890
  if (carryForwardSection.length > carryForwardBudget) {
884
- const { truncateAtSectionBoundary } = await import("./context-budget.js");
885
891
  finalCarryForward = truncateAtSectionBoundary(carryForwardSection, carryForwardBudget).content;
886
892
  }
887
893
 
@@ -945,7 +951,7 @@ export async function buildCompleteSlicePrompt(
945
951
  const completeOverridesInline = formatOverridesSection(completeActiveOverrides);
946
952
  if (completeOverridesInline) inlined.unshift(completeOverridesInline);
947
953
 
948
- const inlinedContext = `## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`;
954
+ const inlinedContext = capPreamble(`## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`);
949
955
 
950
956
  const sliceRel = relSlicePath(base, mid, sid);
951
957
  const sliceSummaryPath = join(base, `${sliceRel}/${sid}-SUMMARY.md`);
@@ -1004,7 +1010,7 @@ export async function buildCompleteMilestonePrompt(
1004
1010
  if (contextInline) inlined.push(contextInline);
1005
1011
  inlined.push(inlineTemplate("milestone-summary", "Milestone Summary"));
1006
1012
 
1007
- const inlinedContext = `## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`;
1013
+ const inlinedContext = capPreamble(`## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`);
1008
1014
 
1009
1015
  const milestoneSummaryPath = join(base, `${relMilestonePath(base, mid)}/${mid}-SUMMARY.md`);
1010
1016
 
@@ -1075,7 +1081,7 @@ export async function buildValidateMilestonePrompt(
1075
1081
  const contextInline = await inlineFileOptional(contextPath, contextRel, "Milestone Context");
1076
1082
  if (contextInline) inlined.push(contextInline);
1077
1083
 
1078
- const inlinedContext = `## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`;
1084
+ const inlinedContext = capPreamble(`## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`);
1079
1085
 
1080
1086
  const validationOutputPath = join(base, `${relMilestonePath(base, mid)}/${mid}-VALIDATION.md`);
1081
1087
  const roadmapOutputPath = `${relMilestonePath(base, mid)}/${mid}-ROADMAP.md`;
@@ -1129,7 +1135,7 @@ export async function buildReplanSlicePrompt(
1129
1135
  const replanOverridesInline = formatOverridesSection(replanActiveOverrides);
1130
1136
  if (replanOverridesInline) inlined.unshift(replanOverridesInline);
1131
1137
 
1132
- const inlinedContext = `## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`;
1138
+ const inlinedContext = capPreamble(`## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`);
1133
1139
 
1134
1140
  const replanPath = join(base, `${relSlicePath(base, mid, sid)}/${sid}-REPLAN.md`);
1135
1141
 
@@ -1177,7 +1183,7 @@ export async function buildRunUatPrompt(
1177
1183
  const projectInline = await inlineProjectFromDb(base);
1178
1184
  if (projectInline) inlined.push(projectInline);
1179
1185
 
1180
- const inlinedContext = `## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`;
1186
+ const inlinedContext = capPreamble(`## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`);
1181
1187
 
1182
1188
  const uatResultPath = join(base, relSliceFile(base, mid, sliceId, "UAT-RESULT"));
1183
1189
  const uatType = extractUatType(uatContent) ?? "human-experience";
@@ -1216,7 +1222,7 @@ export async function buildReassessRoadmapPrompt(
1216
1222
  const knowledgeInlineRA = await inlineGsdRootFile(base, "knowledge.md", "Project Knowledge");
1217
1223
  if (knowledgeInlineRA) inlined.push(knowledgeInlineRA);
1218
1224
 
1219
- const inlinedContext = `## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`;
1225
+ const inlinedContext = capPreamble(`## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`);
1220
1226
 
1221
1227
  const assessmentPath = join(base, relSliceFile(base, mid, completedSliceId, "ASSESSMENT"));
1222
1228
 
@@ -37,13 +37,13 @@ import {
37
37
  resolveGitHeadPath,
38
38
  nudgeGitBranchCache,
39
39
  } from "./worktree.js";
40
- import { MergeConflictError, readIntegrationBranch } from "./git-service.js";
40
+ import { MergeConflictError, readIntegrationBranch, RUNTIME_EXCLUSION_PATHS } from "./git-service.js";
41
41
  import { parseRoadmap } from "./files.js";
42
42
  import { loadEffectiveGSDPreferences } from "./preferences.js";
43
43
  import {
44
44
  nativeGetCurrentBranch,
45
45
  nativeWorkingTreeStatus,
46
- nativeAddAll,
46
+ nativeAddAllWithExclusions,
47
47
  nativeCommit,
48
48
  nativeCheckoutBranch,
49
49
  nativeMergeSquash,
@@ -768,7 +768,7 @@ function autoCommitDirtyState(cwd: string): boolean {
768
768
  try {
769
769
  const status = nativeWorkingTreeStatus(cwd);
770
770
  if (!status) return false;
771
- nativeAddAll(cwd);
771
+ nativeAddAllWithExclusions(cwd, RUNTIME_EXCLUSION_PATHS);
772
772
  const result = nativeCommit(
773
773
  cwd,
774
774
  "chore: auto-commit before milestone merge",
@@ -4,7 +4,7 @@
4
4
  * One command, one wizard. Routes to smart entry or status.
5
5
  */
6
6
 
7
- import type { ExtensionAPI, ExtensionCommandContext } from "@gsd/pi-coding-agent";
7
+ import { importExtensionModule, type ExtensionAPI, type ExtensionCommandContext } from "@gsd/pi-coding-agent";
8
8
  import type { GSDState } from "./types.js";
9
9
  import { existsSync, readFileSync, readdirSync, unlinkSync } from "node:fs";
10
10
  import { homedir } from "node:os";
@@ -585,7 +585,7 @@ export async function handleGSDCommand(
585
585
  }
586
586
 
587
587
  if (trimmed === "widget" || trimmed.startsWith("widget ")) {
588
- const { cycleWidgetMode, setWidgetMode, getWidgetMode } = await import("./auto-dashboard.js");
588
+ const { cycleWidgetMode, setWidgetMode, getWidgetMode } = await importExtensionModule<typeof import("./auto-dashboard.js")>(import.meta.url, "./auto-dashboard.js");
589
589
  const arg = trimmed.replace(/^widget\s*/, "").trim();
590
590
  if (arg === "full" || arg === "small" || arg === "min" || arg === "off") {
591
591
  setWidgetMode(arg);
@@ -280,9 +280,24 @@ async function markSliceDoneInRoadmap(basePath: string, milestoneId: string, sli
280
280
  }
281
281
  }
282
282
 
283
+ async function markSliceUndoneInRoadmap(basePath: string, milestoneId: string, sliceId: string, fixesApplied: string[]): Promise<void> {
284
+ const roadmapPath = resolveMilestoneFile(basePath, milestoneId, "ROADMAP");
285
+ if (!roadmapPath) return;
286
+ const content = await loadFile(roadmapPath);
287
+ if (!content) return;
288
+ const updated = content.replace(
289
+ new RegExp(`^(\\s*-\\s+)\\[x\\]\\s+\\*\\*${sliceId}:`, "m"),
290
+ `$1[ ] **${sliceId}:`,
291
+ );
292
+ if (updated !== content) {
293
+ await saveFile(roadmapPath, updated);
294
+ fixesApplied.push(`unmarked ${sliceId} in ${roadmapPath} (premature completion)`);
295
+ }
296
+ }
297
+
283
298
  function matchesScope(unitId: string, scope?: string): boolean {
284
299
  if (!scope) return true;
285
- return unitId === scope || unitId.startsWith(`${scope}/`) || unitId.startsWith(`${scope}`);
300
+ return unitId === scope || unitId.startsWith(`${scope}/`);
286
301
  }
287
302
 
288
303
  function auditRequirements(content: string | null): DoctorIssue[] {
@@ -863,6 +878,12 @@ export async function runGSDDoctor(basePath: string, options?: { fix?: boolean;
863
878
  file: relSliceFile(basePath, milestoneId, slice.id, "SUMMARY"),
864
879
  fixable: true,
865
880
  });
881
+ if (!allTasksDone) {
882
+ dryRunCanFix("slice_checked_missing_summary", `uncheck ${slice.id} in roadmap (tasks incomplete)`);
883
+ if (shouldFix("slice_checked_missing_summary")) {
884
+ await markSliceUndoneInRoadmap(basePath, milestoneId, slice.id, fixesApplied);
885
+ }
886
+ }
866
887
  }
867
888
 
868
889
  if (slice.done && !hasSliceUat) {
@@ -1,4 +1,4 @@
1
- import type { ExtensionAPI, ExtensionCommandContext } from "@gsd/pi-coding-agent";
1
+ import { importExtensionModule, type ExtensionAPI, type ExtensionCommandContext } from "@gsd/pi-coding-agent";
2
2
 
3
3
  type StopAutoFn = (ctx: ExtensionCommandContext, pi: ExtensionAPI, reason?: string) => Promise<void>;
4
4
 
@@ -10,7 +10,7 @@ export function registerExitCommand(
10
10
  description: "Exit GSD gracefully",
11
11
  handler: async (_args: string, ctx: ExtensionCommandContext) => {
12
12
  // Stop auto-mode first so locks and activity state are cleaned up before shutdown.
13
- const stopAuto = deps.stopAuto ?? (await import("./auto.js")).stopAuto;
13
+ const stopAuto = deps.stopAuto ?? (await importExtensionModule<typeof import("./auto.js")>(import.meta.url, "./auto.js")).stopAuto;
14
14
  await stopAuto(ctx, pi, "Graceful exit");
15
15
  ctx.shutdown();
16
16
  },
@@ -775,7 +775,7 @@ export function parseTaskPlanIO(content: string): { inputFiles: string[]; output
775
775
  * The four UAT classification types recognised by GSD auto-mode.
776
776
  * `undefined` is returned (not this union) when no type can be determined.
777
777
  */
778
- export type UatType = 'artifact-driven' | 'live-runtime' | 'human-experience' | 'mixed';
778
+ export type UatType = 'artifact-driven' | 'live-runtime' | 'human-experience' | 'mixed' | 'browser-executable' | 'runtime-executable';
779
779
 
780
780
  /**
781
781
  * Extract the UAT type from a UAT file's raw content.
@@ -799,6 +799,8 @@ export function extractUatType(content: string): UatType | undefined {
799
799
  const rawValue = modeBullet.slice('UAT mode:'.length).trim().toLowerCase();
800
800
 
801
801
  if (rawValue.startsWith('artifact-driven')) return 'artifact-driven';
802
+ if (rawValue.startsWith('browser-executable')) return 'browser-executable';
803
+ if (rawValue.startsWith('runtime-executable')) return 'runtime-executable';
802
804
  if (rawValue.startsWith('live-runtime')) return 'live-runtime';
803
805
  if (rawValue.startsWith('human-experience')) return 'human-experience';
804
806
  if (rawValue.startsWith('mixed')) return 'mixed';
@@ -24,7 +24,7 @@ import {
24
24
  nativeDetectMainBranch,
25
25
  nativeBranchExists,
26
26
  nativeHasChanges,
27
- nativeAddAll,
27
+ nativeAddAllWithExclusions,
28
28
  nativeResetPaths,
29
29
  nativeHasStagedChanges,
30
30
  nativeCommit,
@@ -385,7 +385,9 @@ export class GitServiceImpl {
385
385
  this._runtimeFilesCleanedUp = true;
386
386
  }
387
387
 
388
- // Stage everything, then unstage excluded paths.
388
+ // Stage everything using pathspec exclusions so excluded paths are never
389
+ // hashed by git. The old approach of `git add -A` followed by unstaging
390
+ // hangs indefinitely on repos with large untracked artifact trees (#1605).
389
391
  //
390
392
  // Exclude only RUNTIME paths from staging — not the entire .gsd/ directory.
391
393
  // When .gsd/milestones/ files are already tracked in the index (projects
@@ -395,13 +397,9 @@ export class GitServiceImpl {
395
397
  // the second half of a milestone's artifacts are never committed (#1326).
396
398
  //
397
399
  // If .gsd/ IS in .gitignore (the default for external state projects),
398
- // git add -A already skips it and the reset is a harmless no-op.
399
- nativeAddAll(this.basePath);
400
-
401
- const runtimeExclusions = [...RUNTIME_EXCLUSION_PATHS, ...extraExclusions];
402
- for (const exclusion of runtimeExclusions) {
403
- try { nativeResetPaths(this.basePath, [exclusion]); } catch { /* path not staged — ignore */ }
404
- }
400
+ // git add -A already skips it and the exclusions are harmless no-ops.
401
+ const allExclusions = [...RUNTIME_EXCLUSION_PATHS, ...extraExclusions];
402
+ nativeAddAllWithExclusions(this.basePath, allExclusions);
405
403
  }
406
404
 
407
405
  /** Tracks whether runtime file cleanup has run this session. */
@@ -586,6 +584,30 @@ export class GitServiceImpl {
586
584
 
587
585
  }
588
586
 
587
+ // ─── Draft PR Creation ─────────────────────────────────────────────────────
588
+
589
+ /**
590
+ * Create a draft pull request for a completed milestone using `gh pr create`.
591
+ * Returns the PR URL on success, or null on failure.
592
+ * Non-fatal: callers should treat failure as best-effort.
593
+ */
594
+ export function createDraftPR(
595
+ basePath: string,
596
+ milestoneId: string,
597
+ title: string,
598
+ body: string,
599
+ ): string | null {
600
+ try {
601
+ const result = execSync(
602
+ `gh pr create --draft --title ${JSON.stringify(title)} --body ${JSON.stringify(body)}`,
603
+ { cwd: basePath, encoding: "utf8", timeout: 30000, env: GIT_NO_PROMPT_ENV },
604
+ );
605
+ return result.trim();
606
+ } catch {
607
+ return null;
608
+ }
609
+ }
610
+
589
611
  // ─── Factory ───────────────────────────────────────────────────────────────
590
612
 
591
613
  /** Create a GitServiceImpl with the current effective git preferences. */
@@ -34,6 +34,7 @@ import { showConfirm } from "../shared/mod.js";
34
34
  import { debugLog } from "./debug-logger.js";
35
35
  import { findMilestoneIds, nextMilestoneId } from "./milestone-ids.js";
36
36
  import { parkMilestone, discardMilestone } from "./milestone-actions.js";
37
+ import { resolveModelWithFallbacksForUnit } from "./preferences-models.js";
37
38
 
38
39
  // ─── Re-exports (preserve public API for existing importers) ────────────────
39
40
  export {
@@ -190,8 +191,40 @@ type UIContext = ExtensionContext;
190
191
  /**
191
192
  * Read GSD-WORKFLOW.md and dispatch it to the LLM with a contextual note.
192
193
  * This is the only way the wizard triggers work — everything else is the LLM's job.
194
+ *
195
+ * When a unitType is provided, resolves the user's model preference for that
196
+ * phase (e.g., models.planning → "plan-milestone") and applies it before
197
+ * dispatching. This ensures guided-flow dispatches respect the same
198
+ * per-phase model preferences that auto-mode uses.
193
199
  */
194
- function dispatchWorkflow(pi: ExtensionAPI, note: string, customType = "gsd-run"): void {
200
+ async function dispatchWorkflow(
201
+ pi: ExtensionAPI,
202
+ note: string,
203
+ customType = "gsd-run",
204
+ ctx?: ExtensionContext,
205
+ unitType?: string,
206
+ ): Promise<void> {
207
+ // Apply model preference for this unit type (if configured)
208
+ if (ctx && unitType) {
209
+ const modelConfig = resolveModelWithFallbacksForUnit(unitType);
210
+ if (modelConfig) {
211
+ const availableModels = ctx.modelRegistry.getAvailable();
212
+ const modelsToTry = [modelConfig.primary, ...modelConfig.fallbacks];
213
+
214
+ for (const modelId of modelsToTry) {
215
+ // Resolve model from available models (same logic as auto-model-selection)
216
+ const model = resolveAvailableModel(modelId, availableModels, ctx.model?.provider);
217
+ if (!model) continue;
218
+
219
+ const ok = await pi.setModel(model, { persist: false });
220
+ if (ok) {
221
+ debugLog("guided-flow-model-applied", { unitType, model: `${model.provider}/${model.id}` });
222
+ break;
223
+ }
224
+ }
225
+ }
226
+ }
227
+
195
228
  const workflowPath = process.env.GSD_WORKFLOW_PATH ?? join(process.env.HOME ?? "~", ".gsd", "agent", "GSD-WORKFLOW.md");
196
229
  const workflow = readFileSync(workflowPath, "utf-8");
197
230
 
@@ -205,6 +238,45 @@ function dispatchWorkflow(pi: ExtensionAPI, note: string, customType = "gsd-run"
205
238
  );
206
239
  }
207
240
 
241
+ /**
242
+ * Resolve a model ID string to a model object from available models.
243
+ * Handles "provider/model" and bare ID formats.
244
+ */
245
+ function resolveAvailableModel<T extends { id: string; provider: string }>(
246
+ modelId: string,
247
+ availableModels: T[],
248
+ currentProvider: string | undefined,
249
+ ): T | undefined {
250
+ const slashIdx = modelId.indexOf("/");
251
+
252
+ if (slashIdx !== -1) {
253
+ const maybeProvider = modelId.substring(0, slashIdx);
254
+ const id = modelId.substring(slashIdx + 1);
255
+
256
+ const knownProviders = new Set(availableModels.map(m => m.provider.toLowerCase()));
257
+ if (knownProviders.has(maybeProvider.toLowerCase())) {
258
+ const match = availableModels.find(
259
+ m => m.provider.toLowerCase() === maybeProvider.toLowerCase()
260
+ && m.id.toLowerCase() === id.toLowerCase(),
261
+ );
262
+ if (match) return match;
263
+ }
264
+
265
+ // Try matching the full string as a model ID (OpenRouter-style)
266
+ const lower = modelId.toLowerCase();
267
+ return availableModels.find(
268
+ m => m.id.toLowerCase() === lower
269
+ || `${m.provider}/${m.id}`.toLowerCase() === lower,
270
+ );
271
+ }
272
+
273
+ // Bare ID — prefer current provider, then first available
274
+ const exactProviderMatch = availableModels.find(
275
+ m => m.id === modelId && m.provider === currentProvider,
276
+ );
277
+ return exactProviderMatch ?? availableModels.find(m => m.id === modelId);
278
+ }
279
+
208
280
  /**
209
281
  * Build the discuss-and-plan prompt for a new milestone.
210
282
  * Used by all three "new milestone" paths (first ever, no active, all complete).
@@ -301,8 +373,8 @@ export async function showHeadlessMilestoneCreation(
301
373
  // Set pending auto start (auto-mode triggers on "Milestone X ready." via checkAutoStartAfterDiscuss)
302
374
  pendingAutoStart = { ctx, pi, basePath, milestoneId: nextId };
303
375
 
304
- // Dispatch
305
- dispatchWorkflow(pi, prompt);
376
+ // Dispatch — headless milestone creation is a planning activity
377
+ await dispatchWorkflow(pi, prompt, "gsd-run", ctx, "plan-milestone");
306
378
  }
307
379
 
308
380
 
@@ -467,21 +539,21 @@ export async function showDiscuss(
467
539
  ? `${basePrompt}\n\n## Prior Discussion (Draft Seed)\n\n${draftContent}`
468
540
  : basePrompt;
469
541
  pendingAutoStart = { ctx, pi, basePath, milestoneId: mid, step: false };
470
- dispatchWorkflow(pi, seed, "gsd-discuss");
542
+ await dispatchWorkflow(pi, seed, "gsd-discuss", ctx, "plan-milestone");
471
543
  } else if (choice === "discuss_fresh") {
472
544
  const discussMilestoneTemplates = inlineTemplate("context", "Context");
473
545
  const structuredQuestionsAvailable = pi.getActiveTools().includes("ask_user_questions") ? "true" : "false";
474
546
  pendingAutoStart = { ctx, pi, basePath, milestoneId: mid, step: false };
475
- dispatchWorkflow(pi, loadPrompt("guided-discuss-milestone", {
547
+ await dispatchWorkflow(pi, loadPrompt("guided-discuss-milestone", {
476
548
  milestoneId: mid, milestoneTitle, inlinedTemplates: discussMilestoneTemplates, structuredQuestionsAvailable,
477
549
  commitInstruction: buildDocsCommitInstruction(`docs(${mid}): milestone context from discuss`),
478
- }), "gsd-discuss");
550
+ }), "gsd-discuss", ctx, "plan-milestone");
479
551
  } else if (choice === "skip_milestone") {
480
552
  const milestoneIds = findMilestoneIds(basePath);
481
553
  const uniqueMilestoneIds = !!loadEffectiveGSDPreferences()?.preferences?.unique_milestone_ids;
482
554
  const nextId = nextMilestoneId(milestoneIds, uniqueMilestoneIds);
483
555
  pendingAutoStart = { ctx, pi, basePath, milestoneId: nextId, step: false };
484
- dispatchWorkflow(pi, buildDiscussPrompt(nextId, `New milestone ${nextId}.`, basePath));
556
+ await dispatchWorkflow(pi, buildDiscussPrompt(nextId, `New milestone ${nextId}.`, basePath), "gsd-run", ctx, "plan-milestone");
485
557
  }
486
558
  return;
487
559
  }
@@ -580,7 +652,7 @@ export async function showDiscuss(
580
652
  }
581
653
 
582
654
  const prompt = await buildDiscussSlicePrompt(mid, chosen.id, chosen.title, basePath, { rediscuss: isRediscuss });
583
- dispatchWorkflow(pi, prompt, "gsd-discuss");
655
+ await dispatchWorkflow(pi, prompt, "gsd-discuss", ctx, "plan-slice");
584
656
 
585
657
  // Wait for the discuss session to finish, then loop back to the picker
586
658
  await ctx.waitForIdle();
@@ -722,10 +794,10 @@ async function handleMilestoneActions(
722
794
  const uniqueMilestoneIds = !!loadEffectiveGSDPreferences()?.preferences?.unique_milestone_ids;
723
795
  const nextId = nextMilestoneId(milestoneIds, uniqueMilestoneIds);
724
796
  pendingAutoStart = { ctx, pi, basePath, milestoneId: nextId, step: stepMode };
725
- dispatchWorkflow(pi, buildDiscussPrompt(nextId,
797
+ await dispatchWorkflow(pi, buildDiscussPrompt(nextId,
726
798
  `New milestone ${nextId}.`,
727
799
  basePath
728
- ));
800
+ ), "gsd-run", ctx, "plan-milestone");
729
801
  return true;
730
802
  }
731
803
 
@@ -866,10 +938,10 @@ export async function showSmartEntry(
866
938
  if (isFirst) {
867
939
  // First ever — skip wizard, just ask directly
868
940
  pendingAutoStart = { ctx, pi, basePath, milestoneId: nextId, step: stepMode };
869
- dispatchWorkflow(pi, buildDiscussPrompt(nextId,
941
+ await dispatchWorkflow(pi, buildDiscussPrompt(nextId,
870
942
  `New project, milestone ${nextId}. Do NOT read or explore .gsd/ — it's empty scaffolding.`,
871
943
  basePath
872
- ));
944
+ ), "gsd-run", ctx, "plan-milestone");
873
945
  } else {
874
946
  const choice = await showNextAction(ctx, {
875
947
  title: "GSD — Get Shit Done",
@@ -887,10 +959,10 @@ export async function showSmartEntry(
887
959
 
888
960
  if (choice === "new_milestone") {
889
961
  pendingAutoStart = { ctx, pi, basePath, milestoneId: nextId, step: stepMode };
890
- dispatchWorkflow(pi, buildDiscussPrompt(nextId,
962
+ await dispatchWorkflow(pi, buildDiscussPrompt(nextId,
891
963
  `New milestone ${nextId}.`,
892
964
  basePath
893
- ));
965
+ ), "gsd-run", ctx, "plan-milestone");
894
966
  }
895
967
  }
896
968
  return;
@@ -926,10 +998,10 @@ export async function showSmartEntry(
926
998
  const nextId = nextMilestoneId(milestoneIds, uniqueMilestoneIds);
927
999
 
928
1000
  pendingAutoStart = { ctx, pi, basePath, milestoneId: nextId, step: stepMode };
929
- dispatchWorkflow(pi, buildDiscussPrompt(nextId,
1001
+ await dispatchWorkflow(pi, buildDiscussPrompt(nextId,
930
1002
  `New milestone ${nextId}.`,
931
1003
  basePath
932
- ));
1004
+ ), "gsd-run", ctx, "plan-milestone");
933
1005
  } else if (choice === "status") {
934
1006
  const { fireStatusViaCommand } = await import("./commands.js");
935
1007
  await fireStatusViaCommand(ctx);
@@ -977,24 +1049,24 @@ export async function showSmartEntry(
977
1049
  ? `${basePrompt}\n\n## Prior Discussion (Draft Seed)\n\n${draftContent}`
978
1050
  : basePrompt;
979
1051
  pendingAutoStart = { ctx, pi, basePath, milestoneId, step: stepMode };
980
- dispatchWorkflow(pi, seed, "gsd-discuss");
1052
+ await dispatchWorkflow(pi, seed, "gsd-discuss", ctx, "plan-milestone");
981
1053
  } else if (choice === "discuss_fresh") {
982
1054
  const discussMilestoneTemplates = inlineTemplate("context", "Context");
983
1055
  const structuredQuestionsAvailable = pi.getActiveTools().includes("ask_user_questions") ? "true" : "false";
984
1056
  pendingAutoStart = { ctx, pi, basePath, milestoneId, step: stepMode };
985
- dispatchWorkflow(pi, loadPrompt("guided-discuss-milestone", {
1057
+ await dispatchWorkflow(pi, loadPrompt("guided-discuss-milestone", {
986
1058
  milestoneId, milestoneTitle, inlinedTemplates: discussMilestoneTemplates, structuredQuestionsAvailable,
987
1059
  commitInstruction: buildDocsCommitInstruction(`docs(${milestoneId}): milestone context from discuss`),
988
- }), "gsd-discuss");
1060
+ }), "gsd-discuss", ctx, "plan-milestone");
989
1061
  } else if (choice === "skip_milestone") {
990
1062
  const milestoneIds = findMilestoneIds(basePath);
991
1063
  const uniqueMilestoneIds = !!loadEffectiveGSDPreferences()?.preferences?.unique_milestone_ids;
992
1064
  const nextId = nextMilestoneId(milestoneIds, uniqueMilestoneIds);
993
1065
  pendingAutoStart = { ctx, pi, basePath, milestoneId: nextId, step: stepMode };
994
- dispatchWorkflow(pi, buildDiscussPrompt(nextId,
1066
+ await dispatchWorkflow(pi, buildDiscussPrompt(nextId,
995
1067
  `New milestone ${nextId}.`,
996
1068
  basePath
997
- ));
1069
+ ), "gsd-run", ctx, "plan-milestone");
998
1070
  }
999
1071
  return;
1000
1072
  }
@@ -1051,25 +1123,25 @@ export async function showSmartEntry(
1051
1123
  inlineTemplate("secrets-manifest", "Secrets Manifest"),
1052
1124
  ].join("\n\n---\n\n");
1053
1125
  const secretsOutputPath = relMilestoneFile(basePath, milestoneId, "SECRETS");
1054
- dispatchWorkflow(pi, loadPrompt("guided-plan-milestone", {
1126
+ await dispatchWorkflow(pi, loadPrompt("guided-plan-milestone", {
1055
1127
  milestoneId, milestoneTitle, secretsOutputPath, inlinedTemplates: planMilestoneTemplates,
1056
- }));
1128
+ }), "gsd-run", ctx, "plan-milestone");
1057
1129
  } else if (choice === "discuss") {
1058
1130
  const discussMilestoneTemplates = inlineTemplate("context", "Context");
1059
1131
  const structuredQuestionsAvailable = pi.getActiveTools().includes("ask_user_questions") ? "true" : "false";
1060
- dispatchWorkflow(pi, loadPrompt("guided-discuss-milestone", {
1132
+ await dispatchWorkflow(pi, loadPrompt("guided-discuss-milestone", {
1061
1133
  milestoneId, milestoneTitle, inlinedTemplates: discussMilestoneTemplates, structuredQuestionsAvailable,
1062
1134
  commitInstruction: buildDocsCommitInstruction(`docs(${milestoneId}): milestone context from discuss`),
1063
- }));
1135
+ }), "gsd-run", ctx, "plan-milestone");
1064
1136
  } else if (choice === "skip_milestone") {
1065
1137
  const milestoneIds = findMilestoneIds(basePath);
1066
1138
  const uniqueMilestoneIds = !!loadEffectiveGSDPreferences()?.preferences?.unique_milestone_ids;
1067
1139
  const nextId = nextMilestoneId(milestoneIds, uniqueMilestoneIds);
1068
1140
  pendingAutoStart = { ctx, pi, basePath, milestoneId: nextId, step: stepMode };
1069
- dispatchWorkflow(pi, buildDiscussPrompt(nextId,
1141
+ await dispatchWorkflow(pi, buildDiscussPrompt(nextId,
1070
1142
  `New milestone ${nextId}.`,
1071
1143
  basePath
1072
- ));
1144
+ ), "gsd-run", ctx, "plan-milestone");
1073
1145
  } else if (choice === "discard_milestone") {
1074
1146
  const confirmed = await showConfirm(ctx, {
1075
1147
  title: "Discard milestone?",
@@ -1181,16 +1253,16 @@ export async function showSmartEntry(
1181
1253
  inlineTemplate("plan", "Slice Plan"),
1182
1254
  inlineTemplate("task-plan", "Task Plan"),
1183
1255
  ].join("\n\n---\n\n");
1184
- dispatchWorkflow(pi, loadPrompt("guided-plan-slice", {
1256
+ await dispatchWorkflow(pi, loadPrompt("guided-plan-slice", {
1185
1257
  milestoneId, sliceId, sliceTitle, inlinedTemplates: planSliceTemplates,
1186
- }));
1258
+ }), "gsd-run", ctx, "plan-slice");
1187
1259
  } else if (choice === "discuss") {
1188
- dispatchWorkflow(pi, await buildDiscussSlicePrompt(milestoneId, sliceId, sliceTitle, basePath, { rediscuss: hasContext }));
1260
+ await dispatchWorkflow(pi, await buildDiscussSlicePrompt(milestoneId, sliceId, sliceTitle, basePath, { rediscuss: hasContext }), "gsd-run", ctx, "plan-slice");
1189
1261
  } else if (choice === "research") {
1190
1262
  const researchTemplates = inlineTemplate("research", "Research");
1191
- dispatchWorkflow(pi, loadPrompt("guided-research-slice", {
1263
+ await dispatchWorkflow(pi, loadPrompt("guided-research-slice", {
1192
1264
  milestoneId, sliceId, sliceTitle, inlinedTemplates: researchTemplates,
1193
- }));
1265
+ }), "gsd-run", ctx, "research-slice");
1194
1266
  } else if (choice === "status") {
1195
1267
  const { fireStatusViaCommand } = await import("./commands.js");
1196
1268
  await fireStatusViaCommand(ctx);
@@ -1232,9 +1304,9 @@ export async function showSmartEntry(
1232
1304
  inlineTemplate("slice-summary", "Slice Summary"),
1233
1305
  inlineTemplate("uat", "UAT"),
1234
1306
  ].join("\n\n---\n\n");
1235
- dispatchWorkflow(pi, loadPrompt("guided-complete-slice", {
1307
+ await dispatchWorkflow(pi, loadPrompt("guided-complete-slice", {
1236
1308
  workingDirectory: basePath, milestoneId, sliceId, sliceTitle, inlinedTemplates: completeSliceTemplates,
1237
- }));
1309
+ }), "gsd-run", ctx, "complete-slice");
1238
1310
  } else if (choice === "status") {
1239
1311
  const { fireStatusViaCommand } = await import("./commands.js");
1240
1312
  await fireStatusViaCommand(ctx);
@@ -1297,14 +1369,14 @@ export async function showSmartEntry(
1297
1369
 
1298
1370
  if (choice === "execute") {
1299
1371
  if (hasInterrupted) {
1300
- dispatchWorkflow(pi, loadPrompt("guided-resume-task", {
1372
+ await dispatchWorkflow(pi, loadPrompt("guided-resume-task", {
1301
1373
  milestoneId, sliceId,
1302
- }));
1374
+ }), "gsd-run", ctx, "execute-task");
1303
1375
  } else {
1304
1376
  const executeTaskTemplates = inlineTemplate("task-summary", "Task Summary");
1305
- dispatchWorkflow(pi, loadPrompt("guided-execute-task", {
1377
+ await dispatchWorkflow(pi, loadPrompt("guided-execute-task", {
1306
1378
  milestoneId, sliceId, taskId, taskTitle, inlinedTemplates: executeTaskTemplates,
1307
- }));
1379
+ }), "gsd-run", ctx, "execute-task");
1308
1380
  }
1309
1381
  } else if (choice === "status") {
1310
1382
  const { fireStatusViaCommand } = await import("./commands.js");