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
@@ -123,12 +123,18 @@ export async function postUnitPreVerification(pctx, opts) {
123
123
  if (report.fixesApplied.length > 0) {
124
124
  ctx.ui.notify(`Post-hook: applied ${report.fixesApplied.length} fix(es).`, "info");
125
125
  }
126
- // Proactive health tracking
127
- const summary = summarizeDoctorIssues(report.issues);
126
+ // Proactive health tracking — filter to current milestone to avoid
127
+ // cross-milestone stale errors inflating the escalation counter
128
+ const currentMilestoneId = s.currentUnit.id.split("/")[0];
129
+ const milestoneIssues = currentMilestoneId
130
+ ? report.issues.filter(i => i.unitId === currentMilestoneId ||
131
+ i.unitId.startsWith(`${currentMilestoneId}/`))
132
+ : report.issues;
133
+ const summary = summarizeDoctorIssues(milestoneIssues);
128
134
  recordHealthSnapshot(summary.errors, summary.warnings, report.fixesApplied.length);
129
135
  // Check if we should escalate to LLM-assisted heal
130
136
  if (summary.errors > 0) {
131
- const unresolvedErrors = report.issues
137
+ const unresolvedErrors = milestoneIssues
132
138
  .filter(i => i.severity === "error" && !i.fixable)
133
139
  .map(i => ({ code: i.code, message: i.message, unitId: i.unitId }));
134
140
  const escalation = checkHealEscalation(summary.errors, unresolvedErrors);
@@ -11,8 +11,15 @@ import { resolveMilestoneFile, resolveSliceFile, resolveSlicePath, resolveTasksD
11
11
  import { resolveSkillDiscoveryMode, resolveInlineLevel, loadEffectiveGSDPreferences } from "./preferences.js";
12
12
  import { join } from "node:path";
13
13
  import { existsSync } from "node:fs";
14
- import { computeBudgets, resolveExecutorContextWindow } from "./context-budget.js";
14
+ import { computeBudgets, resolveExecutorContextWindow, truncateAtSectionBoundary } from "./context-budget.js";
15
15
  import { formatDecisionsCompact, formatRequirementsCompact } from "./structured-data-formatter.js";
16
+ // ─── Preamble Cap ─────────────────────────────────────────────────────────────
17
+ const MAX_PREAMBLE_CHARS = 30_000;
18
+ function capPreamble(preamble) {
19
+ if (preamble.length <= MAX_PREAMBLE_CHARS)
20
+ return preamble;
21
+ return truncateAtSectionBoundary(preamble, MAX_PREAMBLE_CHARS).content;
22
+ }
16
23
  // ─── Executor Constraints ─────────────────────────────────────────────────────
17
24
  /**
18
25
  * Format executor context constraints for injection into the plan-slice prompt.
@@ -124,7 +131,6 @@ export async function inlineFileSmart(absPath, relPath, label, query, threshold
124
131
  return `### ${label}\nSource: \`${relPath}\`\n\n${content.trim()}`;
125
132
  }
126
133
  // For large files, truncate at section boundary
127
- const { truncateAtSectionBoundary } = await import("./context-budget.js");
128
134
  const truncated = truncateAtSectionBoundary(content, threshold).content;
129
135
  return `### ${label}\nSource: \`${relPath}\`\n\n${truncated}`;
130
136
  }
@@ -158,7 +164,6 @@ export async function inlineDependencySummaries(mid, sid, base, budgetChars) {
158
164
  }
159
165
  const result = sections.join("\n\n");
160
166
  if (budgetChars !== undefined && result.length > budgetChars) {
161
- const { truncateAtSectionBoundary } = await import("./context-budget.js");
162
167
  return truncateAtSectionBoundary(result, budgetChars).content;
163
168
  }
164
169
  return result;
@@ -525,7 +530,7 @@ export async function buildResearchMilestonePrompt(mid, midTitle, base) {
525
530
  if (knowledgeInlineRM)
526
531
  inlined.push(knowledgeInlineRM);
527
532
  inlined.push(inlineTemplate("research", "Research"));
528
- const inlinedContext = `## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`;
533
+ const inlinedContext = capPreamble(`## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`);
529
534
  const outputRelPath = relMilestoneFile(base, mid, "RESEARCH");
530
535
  return loadPrompt("research-milestone", {
531
536
  workingDirectory: base,
@@ -578,7 +583,7 @@ export async function buildPlanMilestonePrompt(mid, midTitle, base, level) {
578
583
  inlined.push(inlineTemplate("plan", "Slice Plan"));
579
584
  inlined.push(inlineTemplate("task-plan", "Task Plan"));
580
585
  }
581
- const inlinedContext = `## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`;
586
+ const inlinedContext = capPreamble(`## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`);
582
587
  const outputRelPath = relMilestoneFile(base, mid, "ROADMAP");
583
588
  const researchOutputPath = join(base, relMilestoneFile(base, mid, "RESEARCH"));
584
589
  const secretsOutputPath = join(base, relMilestoneFile(base, mid, "SECRETS"));
@@ -626,7 +631,7 @@ export async function buildResearchSlicePrompt(mid, _midTitle, sid, sTitle, base
626
631
  const overridesInline = formatOverridesSection(activeOverrides);
627
632
  if (overridesInline)
628
633
  inlined.unshift(overridesInline);
629
- const inlinedContext = `## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`;
634
+ const inlinedContext = capPreamble(`## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`);
630
635
  const outputRelPath = relSliceFile(base, mid, sid, "RESEARCH");
631
636
  return loadPrompt("research-slice", {
632
637
  workingDirectory: base,
@@ -672,7 +677,7 @@ export async function buildPlanSlicePrompt(mid, _midTitle, sid, sTitle, base, le
672
677
  const planOverridesInline = formatOverridesSection(planActiveOverrides);
673
678
  if (planOverridesInline)
674
679
  inlined.unshift(planOverridesInline);
675
- const inlinedContext = `## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`;
680
+ const inlinedContext = capPreamble(`## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`);
676
681
  // Build executor context constraints from the budget engine
677
682
  const executorContextConstraints = formatExecutorConstraints();
678
683
  const outputRelPath = relSliceFile(base, mid, sid, "PLAN");
@@ -760,7 +765,6 @@ export async function buildExecuteTaskPrompt(mid, sid, sTitle, tid, tTitle, base
760
765
  const carryForwardBudget = Math.floor(budgets.inlineContextBudgetChars * 0.4);
761
766
  let finalCarryForward = carryForwardSection;
762
767
  if (carryForwardSection.length > carryForwardBudget) {
763
- const { truncateAtSectionBoundary } = await import("./context-budget.js");
764
768
  finalCarryForward = truncateAtSectionBoundary(carryForwardSection, carryForwardBudget).content;
765
769
  }
766
770
  return loadPrompt("execute-task", {
@@ -819,7 +823,7 @@ export async function buildCompleteSlicePrompt(mid, _midTitle, sid, sTitle, base
819
823
  const completeOverridesInline = formatOverridesSection(completeActiveOverrides);
820
824
  if (completeOverridesInline)
821
825
  inlined.unshift(completeOverridesInline);
822
- const inlinedContext = `## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`;
826
+ const inlinedContext = capPreamble(`## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`);
823
827
  const sliceRel = relSlicePath(base, mid, sid);
824
828
  const sliceSummaryPath = join(base, `${sliceRel}/${sid}-SUMMARY.md`);
825
829
  const sliceUatPath = join(base, `${sliceRel}/${sid}-UAT.md`);
@@ -875,7 +879,7 @@ export async function buildCompleteMilestonePrompt(mid, midTitle, base, level) {
875
879
  if (contextInline)
876
880
  inlined.push(contextInline);
877
881
  inlined.push(inlineTemplate("milestone-summary", "Milestone Summary"));
878
- const inlinedContext = `## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`;
882
+ const inlinedContext = capPreamble(`## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`);
879
883
  const milestoneSummaryPath = join(base, `${relMilestonePath(base, mid)}/${mid}-SUMMARY.md`);
880
884
  return loadPrompt("complete-milestone", {
881
885
  workingDirectory: base,
@@ -942,7 +946,7 @@ export async function buildValidateMilestonePrompt(mid, midTitle, base, level) {
942
946
  const contextInline = await inlineFileOptional(contextPath, contextRel, "Milestone Context");
943
947
  if (contextInline)
944
948
  inlined.push(contextInline);
945
- const inlinedContext = `## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`;
949
+ const inlinedContext = capPreamble(`## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`);
946
950
  const validationOutputPath = join(base, `${relMilestonePath(base, mid)}/${mid}-VALIDATION.md`);
947
951
  const roadmapOutputPath = `${relMilestonePath(base, mid)}/${mid}-ROADMAP.md`;
948
952
  return loadPrompt("validate-milestone", {
@@ -990,7 +994,7 @@ export async function buildReplanSlicePrompt(mid, midTitle, sid, sTitle, base) {
990
994
  const replanOverridesInline = formatOverridesSection(replanActiveOverrides);
991
995
  if (replanOverridesInline)
992
996
  inlined.unshift(replanOverridesInline);
993
- const inlinedContext = `## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`;
997
+ const inlinedContext = capPreamble(`## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`);
994
998
  const replanPath = join(base, `${relSlicePath(base, mid, sid)}/${sid}-REPLAN.md`);
995
999
  // Build capture context for replan prompt (captures that triggered this replan)
996
1000
  let captureContext = "(none)";
@@ -1030,7 +1034,7 @@ export async function buildRunUatPrompt(mid, sliceId, uatPath, uatContent, base)
1030
1034
  const projectInline = await inlineProjectFromDb(base);
1031
1035
  if (projectInline)
1032
1036
  inlined.push(projectInline);
1033
- const inlinedContext = `## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`;
1037
+ const inlinedContext = capPreamble(`## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`);
1034
1038
  const uatResultPath = join(base, relSliceFile(base, mid, sliceId, "UAT-RESULT"));
1035
1039
  const uatType = extractUatType(uatContent) ?? "human-experience";
1036
1040
  return loadPrompt("run-uat", {
@@ -1066,7 +1070,7 @@ export async function buildReassessRoadmapPrompt(mid, midTitle, completedSliceId
1066
1070
  const knowledgeInlineRA = await inlineGsdRootFile(base, "knowledge.md", "Project Knowledge");
1067
1071
  if (knowledgeInlineRA)
1068
1072
  inlined.push(knowledgeInlineRA);
1069
- const inlinedContext = `## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`;
1073
+ const inlinedContext = capPreamble(`## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`);
1070
1074
  const assessmentPath = join(base, relSliceFile(base, mid, completedSliceId, "ASSESSMENT"));
1071
1075
  // Build deferred captures context for reassess prompt
1072
1076
  let deferredCaptures = "(none)";
@@ -15,10 +15,10 @@ import { safeCopy, safeCopyRecursive } from "./safe-fs.js";
15
15
  import { gsdRoot } from "./paths.js";
16
16
  import { createWorktree, removeWorktree, worktreePath, } from "./worktree-manager.js";
17
17
  import { detectWorktreeName, nudgeGitBranchCache, } from "./worktree.js";
18
- import { MergeConflictError, readIntegrationBranch } from "./git-service.js";
18
+ import { MergeConflictError, readIntegrationBranch, RUNTIME_EXCLUSION_PATHS } from "./git-service.js";
19
19
  import { parseRoadmap } from "./files.js";
20
20
  import { loadEffectiveGSDPreferences } from "./preferences.js";
21
- import { nativeGetCurrentBranch, nativeWorkingTreeStatus, nativeAddAll, nativeCommit, nativeCheckoutBranch, nativeMergeSquash, nativeConflictFiles, nativeCheckoutTheirs, nativeAddPaths, nativeRmForce, nativeBranchDelete, nativeBranchExists, } from "./native-git-bridge.js";
21
+ import { nativeGetCurrentBranch, nativeWorkingTreeStatus, nativeAddAllWithExclusions, nativeCommit, nativeCheckoutBranch, nativeMergeSquash, nativeConflictFiles, nativeCheckoutTheirs, nativeAddPaths, nativeRmForce, nativeBranchDelete, nativeBranchExists, } from "./native-git-bridge.js";
22
22
  // ─── Module State ──────────────────────────────────────────────────────────
23
23
  /** Original project root before chdir into auto-worktree. */
24
24
  let originalBase = null;
@@ -656,7 +656,7 @@ function autoCommitDirtyState(cwd) {
656
656
  const status = nativeWorkingTreeStatus(cwd);
657
657
  if (!status)
658
658
  return false;
659
- nativeAddAll(cwd);
659
+ nativeAddAllWithExclusions(cwd, RUNTIME_EXCLUSION_PATHS);
660
660
  const result = nativeCommit(cwd, "chore: auto-commit before milestone merge");
661
661
  return result !== null;
662
662
  }
@@ -3,6 +3,7 @@
3
3
  *
4
4
  * One command, one wizard. Routes to smart entry or status.
5
5
  */
6
+ import { importExtensionModule } from "@gsd/pi-coding-agent";
6
7
  import { existsSync, readFileSync, readdirSync, unlinkSync } from "node:fs";
7
8
  import { homedir } from "node:os";
8
9
  import { join } from "node:path";
@@ -529,7 +530,7 @@ export async function handleGSDCommand(args, ctx, pi) {
529
530
  return;
530
531
  }
531
532
  if (trimmed === "widget" || trimmed.startsWith("widget ")) {
532
- const { cycleWidgetMode, setWidgetMode, getWidgetMode } = await import("./auto-dashboard.js");
533
+ const { cycleWidgetMode, setWidgetMode, getWidgetMode } = await importExtensionModule(import.meta.url, "./auto-dashboard.js");
533
534
  const arg = trimmed.replace(/^widget\s*/, "").trim();
534
535
  if (arg === "full" || arg === "small" || arg === "min" || arg === "off") {
535
536
  setWidgetMode(arg);
@@ -257,10 +257,23 @@ async function markSliceDoneInRoadmap(basePath, milestoneId, sliceId, fixesAppli
257
257
  fixesApplied.push(`marked ${sliceId} done in ${roadmapPath}`);
258
258
  }
259
259
  }
260
+ async function markSliceUndoneInRoadmap(basePath, milestoneId, sliceId, fixesApplied) {
261
+ const roadmapPath = resolveMilestoneFile(basePath, milestoneId, "ROADMAP");
262
+ if (!roadmapPath)
263
+ return;
264
+ const content = await loadFile(roadmapPath);
265
+ if (!content)
266
+ return;
267
+ const updated = content.replace(new RegExp(`^(\\s*-\\s+)\\[x\\]\\s+\\*\\*${sliceId}:`, "m"), `$1[ ] **${sliceId}:`);
268
+ if (updated !== content) {
269
+ await saveFile(roadmapPath, updated);
270
+ fixesApplied.push(`unmarked ${sliceId} in ${roadmapPath} (premature completion)`);
271
+ }
272
+ }
260
273
  function matchesScope(unitId, scope) {
261
274
  if (!scope)
262
275
  return true;
263
- return unitId === scope || unitId.startsWith(`${scope}/`) || unitId.startsWith(`${scope}`);
276
+ return unitId === scope || unitId.startsWith(`${scope}/`);
264
277
  }
265
278
  function auditRequirements(content) {
266
279
  if (!content)
@@ -828,6 +841,12 @@ export async function runGSDDoctor(basePath, options) {
828
841
  file: relSliceFile(basePath, milestoneId, slice.id, "SUMMARY"),
829
842
  fixable: true,
830
843
  });
844
+ if (!allTasksDone) {
845
+ dryRunCanFix("slice_checked_missing_summary", `uncheck ${slice.id} in roadmap (tasks incomplete)`);
846
+ if (shouldFix("slice_checked_missing_summary")) {
847
+ await markSliceUndoneInRoadmap(basePath, milestoneId, slice.id, fixesApplied);
848
+ }
849
+ }
831
850
  }
832
851
  if (slice.done && !hasSliceUat) {
833
852
  issues.push({
@@ -1,9 +1,10 @@
1
+ import { importExtensionModule } from "@gsd/pi-coding-agent";
1
2
  export function registerExitCommand(pi, deps = {}) {
2
3
  pi.registerCommand("exit", {
3
4
  description: "Exit GSD gracefully",
4
5
  handler: async (_args, ctx) => {
5
6
  // Stop auto-mode first so locks and activity state are cleaned up before shutdown.
6
- const stopAuto = deps.stopAuto ?? (await import("./auto.js")).stopAuto;
7
+ const stopAuto = deps.stopAuto ?? (await importExtensionModule(import.meta.url, "./auto.js")).stopAuto;
7
8
  await stopAuto(ctx, pi, "Graceful exit");
8
9
  ctx.shutdown();
9
10
  },
@@ -692,6 +692,10 @@ export function extractUatType(content) {
692
692
  const rawValue = modeBullet.slice('UAT mode:'.length).trim().toLowerCase();
693
693
  if (rawValue.startsWith('artifact-driven'))
694
694
  return 'artifact-driven';
695
+ if (rawValue.startsWith('browser-executable'))
696
+ return 'browser-executable';
697
+ if (rawValue.startsWith('runtime-executable'))
698
+ return 'runtime-executable';
695
699
  if (rawValue.startsWith('live-runtime'))
696
700
  return 'live-runtime';
697
701
  if (rawValue.startsWith('human-experience'))
@@ -14,7 +14,7 @@ import { gsdRoot } from "./paths.js";
14
14
  import { GIT_NO_PROMPT_ENV } from "./git-constants.js";
15
15
  import { loadEffectiveGSDPreferences } from "./preferences.js";
16
16
  import { detectWorktreeName, SLICE_BRANCH_RE, } from "./worktree.js";
17
- import { nativeGetCurrentBranch, nativeDetectMainBranch, nativeBranchExists, nativeHasChanges, nativeAddAll, nativeResetPaths, nativeHasStagedChanges, nativeCommit, nativeRmCached, nativeUpdateRef, } from "./native-git-bridge.js";
17
+ import { nativeGetCurrentBranch, nativeDetectMainBranch, nativeBranchExists, nativeHasChanges, nativeAddAllWithExclusions, nativeHasStagedChanges, nativeCommit, nativeRmCached, nativeUpdateRef, } from "./native-git-bridge.js";
18
18
  import { GSDError, GSD_MERGE_CONFLICT, GSD_GIT_ERROR } from "./errors.js";
19
19
  import { getErrorMessage } from "./error-utils.js";
20
20
  export const VALID_BRANCH_NAME = /^[a-zA-Z0-9_\-\/.]+$/;
@@ -261,7 +261,9 @@ export class GitServiceImpl {
261
261
  }
262
262
  this._runtimeFilesCleanedUp = true;
263
263
  }
264
- // Stage everything, then unstage excluded paths.
264
+ // Stage everything using pathspec exclusions so excluded paths are never
265
+ // hashed by git. The old approach of `git add -A` followed by unstaging
266
+ // hangs indefinitely on repos with large untracked artifact trees (#1605).
265
267
  //
266
268
  // Exclude only RUNTIME paths from staging — not the entire .gsd/ directory.
267
269
  // When .gsd/milestones/ files are already tracked in the index (projects
@@ -271,15 +273,9 @@ export class GitServiceImpl {
271
273
  // the second half of a milestone's artifacts are never committed (#1326).
272
274
  //
273
275
  // If .gsd/ IS in .gitignore (the default for external state projects),
274
- // git add -A already skips it and the reset is a harmless no-op.
275
- nativeAddAll(this.basePath);
276
- const runtimeExclusions = [...RUNTIME_EXCLUSION_PATHS, ...extraExclusions];
277
- for (const exclusion of runtimeExclusions) {
278
- try {
279
- nativeResetPaths(this.basePath, [exclusion]);
280
- }
281
- catch { /* path not staged — ignore */ }
282
- }
276
+ // git add -A already skips it and the exclusions are harmless no-ops.
277
+ const allExclusions = [...RUNTIME_EXCLUSION_PATHS, ...extraExclusions];
278
+ nativeAddAllWithExclusions(this.basePath, allExclusions);
283
279
  }
284
280
  /** Tracks whether runtime file cleanup has run this session. */
285
281
  _runtimeFilesCleanedUp = false;
@@ -440,6 +436,21 @@ export class GitServiceImpl {
440
436
  }
441
437
  }
442
438
  }
439
+ // ─── Draft PR Creation ─────────────────────────────────────────────────────
440
+ /**
441
+ * Create a draft pull request for a completed milestone using `gh pr create`.
442
+ * Returns the PR URL on success, or null on failure.
443
+ * Non-fatal: callers should treat failure as best-effort.
444
+ */
445
+ export function createDraftPR(basePath, milestoneId, title, body) {
446
+ try {
447
+ const result = execSync(`gh pr create --draft --title ${JSON.stringify(title)} --body ${JSON.stringify(body)}`, { cwd: basePath, encoding: "utf8", timeout: 30000, env: GIT_NO_PROMPT_ENV });
448
+ return result.trim();
449
+ }
450
+ catch {
451
+ return null;
452
+ }
453
+ }
443
454
  // ─── Factory ───────────────────────────────────────────────────────────────
444
455
  /** Create a GitServiceImpl with the current effective git preferences. */
445
456
  export function createGitService(basePath) {
@@ -28,6 +28,7 @@ import { showConfirm } from "../shared/mod.js";
28
28
  import { debugLog } from "./debug-logger.js";
29
29
  import { findMilestoneIds, nextMilestoneId } from "./milestone-ids.js";
30
30
  import { parkMilestone, discardMilestone } from "./milestone-actions.js";
31
+ import { resolveModelWithFallbacksForUnit } from "./preferences-models.js";
31
32
  // ─── Re-exports (preserve public API for existing importers) ────────────────
32
33
  export { MILESTONE_ID_RE, generateMilestoneSuffix, nextMilestoneId, extractMilestoneSeq, parseMilestoneId, milestoneIdSort, maxMilestoneNum, findMilestoneIds, } from "./milestone-ids.js";
33
34
  export { showQueue, handleQueueReorder, showQueueAdd, buildExistingMilestonesContext, } from "./guided-flow-queue.js";
@@ -153,8 +154,32 @@ function parseMilestoneSequenceFromProject(content) {
153
154
  /**
154
155
  * Read GSD-WORKFLOW.md and dispatch it to the LLM with a contextual note.
155
156
  * This is the only way the wizard triggers work — everything else is the LLM's job.
157
+ *
158
+ * When a unitType is provided, resolves the user's model preference for that
159
+ * phase (e.g., models.planning → "plan-milestone") and applies it before
160
+ * dispatching. This ensures guided-flow dispatches respect the same
161
+ * per-phase model preferences that auto-mode uses.
156
162
  */
157
- function dispatchWorkflow(pi, note, customType = "gsd-run") {
163
+ async function dispatchWorkflow(pi, note, customType = "gsd-run", ctx, unitType) {
164
+ // Apply model preference for this unit type (if configured)
165
+ if (ctx && unitType) {
166
+ const modelConfig = resolveModelWithFallbacksForUnit(unitType);
167
+ if (modelConfig) {
168
+ const availableModels = ctx.modelRegistry.getAvailable();
169
+ const modelsToTry = [modelConfig.primary, ...modelConfig.fallbacks];
170
+ for (const modelId of modelsToTry) {
171
+ // Resolve model from available models (same logic as auto-model-selection)
172
+ const model = resolveAvailableModel(modelId, availableModels, ctx.model?.provider);
173
+ if (!model)
174
+ continue;
175
+ const ok = await pi.setModel(model, { persist: false });
176
+ if (ok) {
177
+ debugLog("guided-flow-model-applied", { unitType, model: `${model.provider}/${model.id}` });
178
+ break;
179
+ }
180
+ }
181
+ }
182
+ }
158
183
  const workflowPath = process.env.GSD_WORKFLOW_PATH ?? join(process.env.HOME ?? "~", ".gsd", "agent", "GSD-WORKFLOW.md");
159
184
  const workflow = readFileSync(workflowPath, "utf-8");
160
185
  pi.sendMessage({
@@ -163,6 +188,31 @@ function dispatchWorkflow(pi, note, customType = "gsd-run") {
163
188
  display: false,
164
189
  }, { triggerTurn: true });
165
190
  }
191
+ /**
192
+ * Resolve a model ID string to a model object from available models.
193
+ * Handles "provider/model" and bare ID formats.
194
+ */
195
+ function resolveAvailableModel(modelId, availableModels, currentProvider) {
196
+ const slashIdx = modelId.indexOf("/");
197
+ if (slashIdx !== -1) {
198
+ const maybeProvider = modelId.substring(0, slashIdx);
199
+ const id = modelId.substring(slashIdx + 1);
200
+ const knownProviders = new Set(availableModels.map(m => m.provider.toLowerCase()));
201
+ if (knownProviders.has(maybeProvider.toLowerCase())) {
202
+ const match = availableModels.find(m => m.provider.toLowerCase() === maybeProvider.toLowerCase()
203
+ && m.id.toLowerCase() === id.toLowerCase());
204
+ if (match)
205
+ return match;
206
+ }
207
+ // Try matching the full string as a model ID (OpenRouter-style)
208
+ const lower = modelId.toLowerCase();
209
+ return availableModels.find(m => m.id.toLowerCase() === lower
210
+ || `${m.provider}/${m.id}`.toLowerCase() === lower);
211
+ }
212
+ // Bare ID — prefer current provider, then first available
213
+ const exactProviderMatch = availableModels.find(m => m.id === modelId && m.provider === currentProvider);
214
+ return exactProviderMatch ?? availableModels.find(m => m.id === modelId);
215
+ }
166
216
  /**
167
217
  * Build the discuss-and-plan prompt for a new milestone.
168
218
  * Used by all three "new milestone" paths (first ever, no active, all complete).
@@ -244,8 +294,8 @@ export async function showHeadlessMilestoneCreation(ctx, pi, basePath, seedConte
244
294
  const prompt = buildHeadlessDiscussPrompt(nextId, seedContext, basePath);
245
295
  // Set pending auto start (auto-mode triggers on "Milestone X ready." via checkAutoStartAfterDiscuss)
246
296
  pendingAutoStart = { ctx, pi, basePath, milestoneId: nextId };
247
- // Dispatch
248
- dispatchWorkflow(pi, prompt);
297
+ // Dispatch — headless milestone creation is a planning activity
298
+ await dispatchWorkflow(pi, prompt, "gsd-run", ctx, "plan-milestone");
249
299
  }
250
300
  // ─── Discuss Flow ─────────────────────────────────────────────────────────────
251
301
  /**
@@ -381,23 +431,23 @@ export async function showDiscuss(ctx, pi, basePath) {
381
431
  ? `${basePrompt}\n\n## Prior Discussion (Draft Seed)\n\n${draftContent}`
382
432
  : basePrompt;
383
433
  pendingAutoStart = { ctx, pi, basePath, milestoneId: mid, step: false };
384
- dispatchWorkflow(pi, seed, "gsd-discuss");
434
+ await dispatchWorkflow(pi, seed, "gsd-discuss", ctx, "plan-milestone");
385
435
  }
386
436
  else if (choice === "discuss_fresh") {
387
437
  const discussMilestoneTemplates = inlineTemplate("context", "Context");
388
438
  const structuredQuestionsAvailable = pi.getActiveTools().includes("ask_user_questions") ? "true" : "false";
389
439
  pendingAutoStart = { ctx, pi, basePath, milestoneId: mid, step: false };
390
- dispatchWorkflow(pi, loadPrompt("guided-discuss-milestone", {
440
+ await dispatchWorkflow(pi, loadPrompt("guided-discuss-milestone", {
391
441
  milestoneId: mid, milestoneTitle, inlinedTemplates: discussMilestoneTemplates, structuredQuestionsAvailable,
392
442
  commitInstruction: buildDocsCommitInstruction(`docs(${mid}): milestone context from discuss`),
393
- }), "gsd-discuss");
443
+ }), "gsd-discuss", ctx, "plan-milestone");
394
444
  }
395
445
  else if (choice === "skip_milestone") {
396
446
  const milestoneIds = findMilestoneIds(basePath);
397
447
  const uniqueMilestoneIds = !!loadEffectiveGSDPreferences()?.preferences?.unique_milestone_ids;
398
448
  const nextId = nextMilestoneId(milestoneIds, uniqueMilestoneIds);
399
449
  pendingAutoStart = { ctx, pi, basePath, milestoneId: nextId, step: false };
400
- dispatchWorkflow(pi, buildDiscussPrompt(nextId, `New milestone ${nextId}.`, basePath));
450
+ await dispatchWorkflow(pi, buildDiscussPrompt(nextId, `New milestone ${nextId}.`, basePath), "gsd-run", ctx, "plan-milestone");
401
451
  }
402
452
  return;
403
453
  }
@@ -484,7 +534,7 @@ export async function showDiscuss(ctx, pi, basePath) {
484
534
  continue;
485
535
  }
486
536
  const prompt = await buildDiscussSlicePrompt(mid, chosen.id, chosen.title, basePath, { rediscuss: isRediscuss });
487
- dispatchWorkflow(pi, prompt, "gsd-discuss");
537
+ await dispatchWorkflow(pi, prompt, "gsd-discuss", ctx, "plan-slice");
488
538
  // Wait for the discuss session to finish, then loop back to the picker
489
539
  await ctx.waitForIdle();
490
540
  invalidateAllCaches();
@@ -611,7 +661,7 @@ async function handleMilestoneActions(ctx, pi, basePath, milestoneId, milestoneT
611
661
  const uniqueMilestoneIds = !!loadEffectiveGSDPreferences()?.preferences?.unique_milestone_ids;
612
662
  const nextId = nextMilestoneId(milestoneIds, uniqueMilestoneIds);
613
663
  pendingAutoStart = { ctx, pi, basePath, milestoneId: nextId, step: stepMode };
614
- dispatchWorkflow(pi, buildDiscussPrompt(nextId, `New milestone ${nextId}.`, basePath));
664
+ await dispatchWorkflow(pi, buildDiscussPrompt(nextId, `New milestone ${nextId}.`, basePath), "gsd-run", ctx, "plan-milestone");
615
665
  return true;
616
666
  }
617
667
  // "back" or null
@@ -729,7 +779,7 @@ export async function showSmartEntry(ctx, pi, basePath, options) {
729
779
  if (isFirst) {
730
780
  // First ever — skip wizard, just ask directly
731
781
  pendingAutoStart = { ctx, pi, basePath, milestoneId: nextId, step: stepMode };
732
- dispatchWorkflow(pi, buildDiscussPrompt(nextId, `New project, milestone ${nextId}. Do NOT read or explore .gsd/ — it's empty scaffolding.`, basePath));
782
+ await dispatchWorkflow(pi, buildDiscussPrompt(nextId, `New project, milestone ${nextId}. Do NOT read or explore .gsd/ — it's empty scaffolding.`, basePath), "gsd-run", ctx, "plan-milestone");
733
783
  }
734
784
  else {
735
785
  const choice = await showNextAction(ctx, {
@@ -747,7 +797,7 @@ export async function showSmartEntry(ctx, pi, basePath, options) {
747
797
  });
748
798
  if (choice === "new_milestone") {
749
799
  pendingAutoStart = { ctx, pi, basePath, milestoneId: nextId, step: stepMode };
750
- dispatchWorkflow(pi, buildDiscussPrompt(nextId, `New milestone ${nextId}.`, basePath));
800
+ await dispatchWorkflow(pi, buildDiscussPrompt(nextId, `New milestone ${nextId}.`, basePath), "gsd-run", ctx, "plan-milestone");
751
801
  }
752
802
  }
753
803
  return;
@@ -779,7 +829,7 @@ export async function showSmartEntry(ctx, pi, basePath, options) {
779
829
  const uniqueMilestoneIds = !!loadEffectiveGSDPreferences()?.preferences?.unique_milestone_ids;
780
830
  const nextId = nextMilestoneId(milestoneIds, uniqueMilestoneIds);
781
831
  pendingAutoStart = { ctx, pi, basePath, milestoneId: nextId, step: stepMode };
782
- dispatchWorkflow(pi, buildDiscussPrompt(nextId, `New milestone ${nextId}.`, basePath));
832
+ await dispatchWorkflow(pi, buildDiscussPrompt(nextId, `New milestone ${nextId}.`, basePath), "gsd-run", ctx, "plan-milestone");
783
833
  }
784
834
  else if (choice === "status") {
785
835
  const { fireStatusViaCommand } = await import("./commands.js");
@@ -825,23 +875,23 @@ export async function showSmartEntry(ctx, pi, basePath, options) {
825
875
  ? `${basePrompt}\n\n## Prior Discussion (Draft Seed)\n\n${draftContent}`
826
876
  : basePrompt;
827
877
  pendingAutoStart = { ctx, pi, basePath, milestoneId, step: stepMode };
828
- dispatchWorkflow(pi, seed, "gsd-discuss");
878
+ await dispatchWorkflow(pi, seed, "gsd-discuss", ctx, "plan-milestone");
829
879
  }
830
880
  else if (choice === "discuss_fresh") {
831
881
  const discussMilestoneTemplates = inlineTemplate("context", "Context");
832
882
  const structuredQuestionsAvailable = pi.getActiveTools().includes("ask_user_questions") ? "true" : "false";
833
883
  pendingAutoStart = { ctx, pi, basePath, milestoneId, step: stepMode };
834
- dispatchWorkflow(pi, loadPrompt("guided-discuss-milestone", {
884
+ await dispatchWorkflow(pi, loadPrompt("guided-discuss-milestone", {
835
885
  milestoneId, milestoneTitle, inlinedTemplates: discussMilestoneTemplates, structuredQuestionsAvailable,
836
886
  commitInstruction: buildDocsCommitInstruction(`docs(${milestoneId}): milestone context from discuss`),
837
- }), "gsd-discuss");
887
+ }), "gsd-discuss", ctx, "plan-milestone");
838
888
  }
839
889
  else if (choice === "skip_milestone") {
840
890
  const milestoneIds = findMilestoneIds(basePath);
841
891
  const uniqueMilestoneIds = !!loadEffectiveGSDPreferences()?.preferences?.unique_milestone_ids;
842
892
  const nextId = nextMilestoneId(milestoneIds, uniqueMilestoneIds);
843
893
  pendingAutoStart = { ctx, pi, basePath, milestoneId: nextId, step: stepMode };
844
- dispatchWorkflow(pi, buildDiscussPrompt(nextId, `New milestone ${nextId}.`, basePath));
894
+ await dispatchWorkflow(pi, buildDiscussPrompt(nextId, `New milestone ${nextId}.`, basePath), "gsd-run", ctx, "plan-milestone");
845
895
  }
846
896
  return;
847
897
  }
@@ -893,24 +943,24 @@ export async function showSmartEntry(ctx, pi, basePath, options) {
893
943
  inlineTemplate("secrets-manifest", "Secrets Manifest"),
894
944
  ].join("\n\n---\n\n");
895
945
  const secretsOutputPath = relMilestoneFile(basePath, milestoneId, "SECRETS");
896
- dispatchWorkflow(pi, loadPrompt("guided-plan-milestone", {
946
+ await dispatchWorkflow(pi, loadPrompt("guided-plan-milestone", {
897
947
  milestoneId, milestoneTitle, secretsOutputPath, inlinedTemplates: planMilestoneTemplates,
898
- }));
948
+ }), "gsd-run", ctx, "plan-milestone");
899
949
  }
900
950
  else if (choice === "discuss") {
901
951
  const discussMilestoneTemplates = inlineTemplate("context", "Context");
902
952
  const structuredQuestionsAvailable = pi.getActiveTools().includes("ask_user_questions") ? "true" : "false";
903
- dispatchWorkflow(pi, loadPrompt("guided-discuss-milestone", {
953
+ await dispatchWorkflow(pi, loadPrompt("guided-discuss-milestone", {
904
954
  milestoneId, milestoneTitle, inlinedTemplates: discussMilestoneTemplates, structuredQuestionsAvailable,
905
955
  commitInstruction: buildDocsCommitInstruction(`docs(${milestoneId}): milestone context from discuss`),
906
- }));
956
+ }), "gsd-run", ctx, "plan-milestone");
907
957
  }
908
958
  else if (choice === "skip_milestone") {
909
959
  const milestoneIds = findMilestoneIds(basePath);
910
960
  const uniqueMilestoneIds = !!loadEffectiveGSDPreferences()?.preferences?.unique_milestone_ids;
911
961
  const nextId = nextMilestoneId(milestoneIds, uniqueMilestoneIds);
912
962
  pendingAutoStart = { ctx, pi, basePath, milestoneId: nextId, step: stepMode };
913
- dispatchWorkflow(pi, buildDiscussPrompt(nextId, `New milestone ${nextId}.`, basePath));
963
+ await dispatchWorkflow(pi, buildDiscussPrompt(nextId, `New milestone ${nextId}.`, basePath), "gsd-run", ctx, "plan-milestone");
914
964
  }
915
965
  else if (choice === "discard_milestone") {
916
966
  const confirmed = await showConfirm(ctx, {
@@ -1021,18 +1071,18 @@ export async function showSmartEntry(ctx, pi, basePath, options) {
1021
1071
  inlineTemplate("plan", "Slice Plan"),
1022
1072
  inlineTemplate("task-plan", "Task Plan"),
1023
1073
  ].join("\n\n---\n\n");
1024
- dispatchWorkflow(pi, loadPrompt("guided-plan-slice", {
1074
+ await dispatchWorkflow(pi, loadPrompt("guided-plan-slice", {
1025
1075
  milestoneId, sliceId, sliceTitle, inlinedTemplates: planSliceTemplates,
1026
- }));
1076
+ }), "gsd-run", ctx, "plan-slice");
1027
1077
  }
1028
1078
  else if (choice === "discuss") {
1029
- dispatchWorkflow(pi, await buildDiscussSlicePrompt(milestoneId, sliceId, sliceTitle, basePath, { rediscuss: hasContext }));
1079
+ await dispatchWorkflow(pi, await buildDiscussSlicePrompt(milestoneId, sliceId, sliceTitle, basePath, { rediscuss: hasContext }), "gsd-run", ctx, "plan-slice");
1030
1080
  }
1031
1081
  else if (choice === "research") {
1032
1082
  const researchTemplates = inlineTemplate("research", "Research");
1033
- dispatchWorkflow(pi, loadPrompt("guided-research-slice", {
1083
+ await dispatchWorkflow(pi, loadPrompt("guided-research-slice", {
1034
1084
  milestoneId, sliceId, sliceTitle, inlinedTemplates: researchTemplates,
1035
- }));
1085
+ }), "gsd-run", ctx, "research-slice");
1036
1086
  }
1037
1087
  else if (choice === "status") {
1038
1088
  const { fireStatusViaCommand } = await import("./commands.js");
@@ -1075,9 +1125,9 @@ export async function showSmartEntry(ctx, pi, basePath, options) {
1075
1125
  inlineTemplate("slice-summary", "Slice Summary"),
1076
1126
  inlineTemplate("uat", "UAT"),
1077
1127
  ].join("\n\n---\n\n");
1078
- dispatchWorkflow(pi, loadPrompt("guided-complete-slice", {
1128
+ await dispatchWorkflow(pi, loadPrompt("guided-complete-slice", {
1079
1129
  workingDirectory: basePath, milestoneId, sliceId, sliceTitle, inlinedTemplates: completeSliceTemplates,
1080
- }));
1130
+ }), "gsd-run", ctx, "complete-slice");
1081
1131
  }
1082
1132
  else if (choice === "status") {
1083
1133
  const { fireStatusViaCommand } = await import("./commands.js");
@@ -1138,15 +1188,15 @@ export async function showSmartEntry(ctx, pi, basePath, options) {
1138
1188
  }
1139
1189
  if (choice === "execute") {
1140
1190
  if (hasInterrupted) {
1141
- dispatchWorkflow(pi, loadPrompt("guided-resume-task", {
1191
+ await dispatchWorkflow(pi, loadPrompt("guided-resume-task", {
1142
1192
  milestoneId, sliceId,
1143
- }));
1193
+ }), "gsd-run", ctx, "execute-task");
1144
1194
  }
1145
1195
  else {
1146
1196
  const executeTaskTemplates = inlineTemplate("task-summary", "Task Summary");
1147
- dispatchWorkflow(pi, loadPrompt("guided-execute-task", {
1197
+ await dispatchWorkflow(pi, loadPrompt("guided-execute-task", {
1148
1198
  milestoneId, sliceId, taskId, taskTitle, inlinedTemplates: executeTaskTemplates,
1149
- }));
1199
+ }), "gsd-run", ctx, "execute-task");
1150
1200
  }
1151
1201
  }
1152
1202
  else if (choice === "status") {
@@ -518,6 +518,43 @@ export function nativeAddAll(basePath) {
518
518
  }
519
519
  gitFileExec(basePath, ["add", "-A"]);
520
520
  }
521
+ /**
522
+ * Stage all files with pathspec exclusions (git add -A -- ':!pattern' ...).
523
+ * Excluded paths are never hashed by git, preventing hangs on large
524
+ * untracked artifact trees (57GB+, 11K+ files). See #1605.
525
+ *
526
+ * Falls back to plain `git add -A` when no exclusions are provided.
527
+ * Always uses the CLI path (not libgit2) because libgit2's add_all
528
+ * does not support pathspec exclusion syntax.
529
+ *
530
+ * When excluded paths are already covered by .gitignore, git may exit
531
+ * with code 1 and an "ignored by .gitignore" warning. This is harmless
532
+ * (the staging succeeds for all non-ignored files) and is suppressed.
533
+ */
534
+ export function nativeAddAllWithExclusions(basePath, exclusions) {
535
+ if (exclusions.length === 0) {
536
+ nativeAddAll(basePath);
537
+ return;
538
+ }
539
+ const pathspecs = exclusions.map(e => `:!${e}`);
540
+ try {
541
+ execFileSync("git", ["add", "-A", "--", ...pathspecs], {
542
+ cwd: basePath,
543
+ stdio: ["ignore", "pipe", "pipe"],
544
+ encoding: "utf-8",
545
+ env: GIT_NO_PROMPT_ENV,
546
+ });
547
+ }
548
+ catch (err) {
549
+ // git exits 1 when pathspec exclusions reference paths already covered
550
+ // by .gitignore. The staging itself succeeds — only suppress that case.
551
+ const stderr = err?.stderr ?? "";
552
+ if (stderr.includes("ignored by one of your .gitignore files")) {
553
+ return;
554
+ }
555
+ throw new GSDError(GSD_GIT_ERROR, `git add -A with exclusions failed in ${basePath}: ${getErrorMessage(err)}`);
556
+ }
557
+ }
521
558
  /**
522
559
  * Stage specific files.
523
560
  * Native: libgit2 index add.