gsd-pi 2.31.2 → 2.32.0-dev.1e39869

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 (74) hide show
  1. package/dist/cli.js +5 -5
  2. package/dist/resources/extensions/gsd/auto-constants.ts +6 -0
  3. package/dist/resources/extensions/gsd/auto-dashboard.ts +20 -26
  4. package/dist/resources/extensions/gsd/auto-direct-dispatch.ts +1 -6
  5. package/dist/resources/extensions/gsd/auto-dispatch.ts +4 -8
  6. package/dist/resources/extensions/gsd/auto-post-unit.ts +27 -32
  7. package/dist/resources/extensions/gsd/auto-prompts.ts +38 -34
  8. package/dist/resources/extensions/gsd/auto-start.ts +8 -6
  9. package/dist/resources/extensions/gsd/auto.ts +54 -33
  10. package/dist/resources/extensions/gsd/commands-workflow-templates.ts +3 -5
  11. package/dist/resources/extensions/gsd/commands.ts +19 -0
  12. package/dist/resources/extensions/gsd/dashboard-overlay.ts +28 -0
  13. package/dist/resources/extensions/gsd/doctor-environment.ts +497 -0
  14. package/dist/resources/extensions/gsd/doctor-providers.ts +343 -0
  15. package/dist/resources/extensions/gsd/doctor-types.ts +14 -1
  16. package/dist/resources/extensions/gsd/doctor.ts +6 -0
  17. package/dist/resources/extensions/gsd/git-service.ts +9 -0
  18. package/dist/resources/extensions/gsd/guided-flow-queue.ts +1 -8
  19. package/dist/resources/extensions/gsd/health-widget.ts +167 -0
  20. package/dist/resources/extensions/gsd/index.ts +6 -0
  21. package/dist/resources/extensions/gsd/preferences-types.ts +8 -0
  22. package/dist/resources/extensions/gsd/preferences-validation.ts +3 -10
  23. package/dist/resources/extensions/gsd/progress-score.ts +273 -0
  24. package/dist/resources/extensions/gsd/prompts/run-uat.md +1 -42
  25. package/dist/resources/extensions/gsd/quick.ts +3 -5
  26. package/dist/resources/extensions/gsd/tests/auto-reentrancy-guard.test.ts +127 -0
  27. package/dist/resources/extensions/gsd/tests/doctor-environment.test.ts +314 -0
  28. package/dist/resources/extensions/gsd/tests/doctor-providers.test.ts +298 -0
  29. package/dist/resources/extensions/gsd/tests/export-html-enhancements.test.ts +3 -0
  30. package/dist/resources/extensions/gsd/tests/memory-leak-guards.test.ts +7 -3
  31. package/dist/resources/extensions/gsd/tests/progress-score.test.ts +206 -0
  32. package/dist/resources/extensions/gsd/tests/run-uat.test.ts +56 -7
  33. package/dist/resources/extensions/gsd/tests/visualizer-views.test.ts +12 -0
  34. package/dist/resources/extensions/gsd/visualizer-data.ts +60 -2
  35. package/dist/resources/extensions/gsd/visualizer-views.ts +54 -0
  36. package/dist/worktree-cli.d.ts +42 -6
  37. package/dist/worktree-cli.js +88 -48
  38. package/package.json +1 -1
  39. package/packages/pi-coding-agent/package.json +1 -1
  40. package/pkg/package.json +1 -1
  41. package/src/resources/extensions/gsd/auto-constants.ts +6 -0
  42. package/src/resources/extensions/gsd/auto-dashboard.ts +20 -26
  43. package/src/resources/extensions/gsd/auto-direct-dispatch.ts +1 -6
  44. package/src/resources/extensions/gsd/auto-dispatch.ts +4 -8
  45. package/src/resources/extensions/gsd/auto-post-unit.ts +27 -32
  46. package/src/resources/extensions/gsd/auto-prompts.ts +38 -34
  47. package/src/resources/extensions/gsd/auto-start.ts +8 -6
  48. package/src/resources/extensions/gsd/auto.ts +54 -33
  49. package/src/resources/extensions/gsd/commands-workflow-templates.ts +3 -5
  50. package/src/resources/extensions/gsd/commands.ts +19 -0
  51. package/src/resources/extensions/gsd/dashboard-overlay.ts +28 -0
  52. package/src/resources/extensions/gsd/doctor-environment.ts +497 -0
  53. package/src/resources/extensions/gsd/doctor-providers.ts +343 -0
  54. package/src/resources/extensions/gsd/doctor-types.ts +14 -1
  55. package/src/resources/extensions/gsd/doctor.ts +6 -0
  56. package/src/resources/extensions/gsd/git-service.ts +9 -0
  57. package/src/resources/extensions/gsd/guided-flow-queue.ts +1 -8
  58. package/src/resources/extensions/gsd/health-widget.ts +167 -0
  59. package/src/resources/extensions/gsd/index.ts +6 -0
  60. package/src/resources/extensions/gsd/preferences-types.ts +8 -0
  61. package/src/resources/extensions/gsd/preferences-validation.ts +3 -10
  62. package/src/resources/extensions/gsd/progress-score.ts +273 -0
  63. package/src/resources/extensions/gsd/prompts/run-uat.md +1 -42
  64. package/src/resources/extensions/gsd/quick.ts +3 -5
  65. package/src/resources/extensions/gsd/tests/auto-reentrancy-guard.test.ts +127 -0
  66. package/src/resources/extensions/gsd/tests/doctor-environment.test.ts +314 -0
  67. package/src/resources/extensions/gsd/tests/doctor-providers.test.ts +298 -0
  68. package/src/resources/extensions/gsd/tests/export-html-enhancements.test.ts +3 -0
  69. package/src/resources/extensions/gsd/tests/memory-leak-guards.test.ts +7 -3
  70. package/src/resources/extensions/gsd/tests/progress-score.test.ts +206 -0
  71. package/src/resources/extensions/gsd/tests/run-uat.test.ts +56 -7
  72. package/src/resources/extensions/gsd/tests/visualizer-views.test.ts +12 -0
  73. package/src/resources/extensions/gsd/visualizer-data.ts +60 -2
  74. package/src/resources/extensions/gsd/visualizer-views.ts +54 -0
@@ -324,6 +324,27 @@ function oneLine(text: string): string {
324
324
  return text.replace(/\s+/g, " ").trim();
325
325
  }
326
326
 
327
+ /** Build the standard inlined-context section used by all prompt builders. */
328
+ function buildInlinedContextSection(inlined: string[]): string {
329
+ return `## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`;
330
+ }
331
+
332
+ /** Build the formatted list of available GSD source files for planners to read on demand. */
333
+ function buildSourceFileList(base: string, opts?: { includeProject?: boolean }): string {
334
+ const paths: string[] = [];
335
+ if (opts?.includeProject && existsSync(resolveGsdRootFile(base, "PROJECT")))
336
+ paths.push(`- **Project**: \`${relGsdRootFile("PROJECT")}\``);
337
+ if (existsSync(resolveGsdRootFile(base, "REQUIREMENTS")))
338
+ paths.push(`- **Requirements**: \`${relGsdRootFile("REQUIREMENTS")}\``);
339
+ if (existsSync(resolveGsdRootFile(base, "DECISIONS")))
340
+ paths.push(`- **Decisions**: \`${relGsdRootFile("DECISIONS")}\``);
341
+ if (paths.length === 0) {
342
+ const types = opts?.includeProject ? "project/requirements/decisions" : "requirements/decisions";
343
+ return `_No ${types} files found._`;
344
+ }
345
+ return paths.join("\n");
346
+ }
347
+
327
348
  // ─── Section Builders ──────────────────────────────────────────────────────
328
349
 
329
350
  export function buildResumeSection(
@@ -540,8 +561,11 @@ export async function checkNeedsRunUat(
540
561
  if (resultContent) return null;
541
562
  }
542
563
 
543
- // Classify UAT type; unknown type treat as human-experience (human review)
564
+ // Classify UAT type; skip non-artifact-driven types auto-mode can only
565
+ // execute mechanical checks. Non-artifact UATs are tracked in the dashboard
566
+ // but don't block auto-mode progression.
544
567
  const uatType = extractUatType(uatContent) ?? "human-experience";
568
+ if (uatType !== "artifact-driven") return null;
545
569
 
546
570
  return { sliceId: sid, uatType };
547
571
  }
@@ -564,7 +588,7 @@ export async function buildResearchMilestonePrompt(mid: string, midTitle: string
564
588
  if (knowledgeInlineRM) inlined.push(knowledgeInlineRM);
565
589
  inlined.push(inlineTemplate("research", "Research"));
566
590
 
567
- const inlinedContext = `## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`;
591
+ const inlinedContext = buildInlinedContextSection(inlined);
568
592
 
569
593
  const outputRelPath = relMilestoneFile(base, mid, "RESEARCH");
570
594
  return loadPrompt("research-milestone", {
@@ -592,17 +616,7 @@ export async function buildPlanMilestonePrompt(mid: string, midTitle: string, ba
592
616
  const { inlinePriorMilestoneSummary } = await import("./files.js");
593
617
  const priorSummaryInline = await inlinePriorMilestoneSummary(mid, base);
594
618
  if (priorSummaryInline) inlined.push(priorSummaryInline);
595
- // Build source file paths for the planner to read on demand (reduces inlining)
596
- const sourcePaths: string[] = [];
597
- if (existsSync(resolveGsdRootFile(base, "PROJECT")))
598
- sourcePaths.push(`- **Project**: \`${relGsdRootFile("PROJECT")}\``);
599
- if (existsSync(resolveGsdRootFile(base, "REQUIREMENTS")))
600
- sourcePaths.push(`- **Requirements**: \`${relGsdRootFile("REQUIREMENTS")}\``);
601
- if (existsSync(resolveGsdRootFile(base, "DECISIONS")))
602
- sourcePaths.push(`- **Decisions**: \`${relGsdRootFile("DECISIONS")}\``);
603
- const sourceFilePaths = sourcePaths.length > 0
604
- ? sourcePaths.join("\n")
605
- : "_No project/requirements/decisions files found._";
619
+ const sourceFilePaths = buildSourceFileList(base, { includeProject: true });
606
620
 
607
621
  const knowledgeInlinePM = await inlineGsdRootFile(base, "knowledge.md", "Project Knowledge");
608
622
  if (knowledgeInlinePM) inlined.push(knowledgeInlinePM);
@@ -618,7 +632,7 @@ export async function buildPlanMilestonePrompt(mid: string, midTitle: string, ba
618
632
  inlined.push(inlineTemplate("task-plan", "Task Plan"));
619
633
  }
620
634
 
621
- const inlinedContext = `## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`;
635
+ const inlinedContext = buildInlinedContextSection(inlined);
622
636
 
623
637
  const outputRelPath = relMilestoneFile(base, mid, "ROADMAP");
624
638
  const secretsOutputPath = join(base, relMilestoneFile(base, mid, "SECRETS"));
@@ -667,7 +681,7 @@ export async function buildResearchSlicePrompt(
667
681
  const overridesInline = formatOverridesSection(activeOverrides);
668
682
  if (overridesInline) inlined.unshift(overridesInline);
669
683
 
670
- const inlinedContext = `## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`;
684
+ const inlinedContext = buildInlinedContextSection(inlined);
671
685
 
672
686
  const outputRelPath = relSliceFile(base, mid, sid, "RESEARCH");
673
687
  return loadPrompt("research-slice", {
@@ -697,15 +711,7 @@ export async function buildPlanSlicePrompt(
697
711
  inlined.push(await inlineFile(roadmapPath, roadmapRel, "Milestone Roadmap"));
698
712
  const researchInline = await inlineFileOptional(researchPath, researchRel, "Slice Research");
699
713
  if (researchInline) inlined.push(researchInline);
700
- // Build source file paths for the planner to read on demand (reduces inlining)
701
- const sliceSourcePaths: string[] = [];
702
- if (existsSync(resolveGsdRootFile(base, "REQUIREMENTS")))
703
- sliceSourcePaths.push(`- **Requirements**: \`${relGsdRootFile("REQUIREMENTS")}\``);
704
- if (existsSync(resolveGsdRootFile(base, "DECISIONS")))
705
- sliceSourcePaths.push(`- **Decisions**: \`${relGsdRootFile("DECISIONS")}\``);
706
- const sliceSourceFilePaths = sliceSourcePaths.length > 0
707
- ? sliceSourcePaths.join("\n")
708
- : "_No requirements/decisions files found._";
714
+ const sliceSourceFilePaths = buildSourceFileList(base);
709
715
 
710
716
  const knowledgeInlinePS = await inlineGsdRootFile(base, "knowledge.md", "Project Knowledge");
711
717
  if (knowledgeInlinePS) inlined.push(knowledgeInlinePS);
@@ -719,7 +725,7 @@ export async function buildPlanSlicePrompt(
719
725
  const planOverridesInline = formatOverridesSection(planActiveOverrides);
720
726
  if (planOverridesInline) inlined.unshift(planOverridesInline);
721
727
 
722
- const inlinedContext = `## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`;
728
+ const inlinedContext = buildInlinedContextSection(inlined);
723
729
 
724
730
  // Build executor context constraints from the budget engine
725
731
  const executorContextConstraints = formatExecutorConstraints();
@@ -894,7 +900,7 @@ export async function buildCompleteSlicePrompt(
894
900
  const completeOverridesInline = formatOverridesSection(completeActiveOverrides);
895
901
  if (completeOverridesInline) inlined.unshift(completeOverridesInline);
896
902
 
897
- const inlinedContext = `## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`;
903
+ const inlinedContext = buildInlinedContextSection(inlined);
898
904
 
899
905
  const sliceRel = relSlicePath(base, mid, sid);
900
906
  const sliceSummaryPath = join(base, `${sliceRel}/${sid}-SUMMARY.md`);
@@ -953,7 +959,7 @@ export async function buildCompleteMilestonePrompt(
953
959
  if (contextInline) inlined.push(contextInline);
954
960
  inlined.push(inlineTemplate("milestone-summary", "Milestone Summary"));
955
961
 
956
- const inlinedContext = `## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`;
962
+ const inlinedContext = buildInlinedContextSection(inlined);
957
963
 
958
964
  const milestoneSummaryPath = join(base, `${relMilestonePath(base, mid)}/${mid}-SUMMARY.md`);
959
965
 
@@ -1024,7 +1030,7 @@ export async function buildValidateMilestonePrompt(
1024
1030
  const contextInline = await inlineFileOptional(contextPath, contextRel, "Milestone Context");
1025
1031
  if (contextInline) inlined.push(contextInline);
1026
1032
 
1027
- const inlinedContext = `## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`;
1033
+ const inlinedContext = buildInlinedContextSection(inlined);
1028
1034
 
1029
1035
  const validationOutputPath = join(base, `${relMilestonePath(base, mid)}/${mid}-VALIDATION.md`);
1030
1036
  const roadmapOutputPath = `${relMilestonePath(base, mid)}/${mid}-ROADMAP.md`;
@@ -1078,7 +1084,7 @@ export async function buildReplanSlicePrompt(
1078
1084
  const replanOverridesInline = formatOverridesSection(replanActiveOverrides);
1079
1085
  if (replanOverridesInline) inlined.unshift(replanOverridesInline);
1080
1086
 
1081
- const inlinedContext = `## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`;
1087
+ const inlinedContext = buildInlinedContextSection(inlined);
1082
1088
 
1083
1089
  const replanPath = join(base, `${relSlicePath(base, mid, sid)}/${sid}-REPLAN.md`);
1084
1090
 
@@ -1111,7 +1117,7 @@ export async function buildReplanSlicePrompt(
1111
1117
  }
1112
1118
 
1113
1119
  export async function buildRunUatPrompt(
1114
- mid: string, sliceId: string, uatPath: string, uatContent: string, base: string,
1120
+ mid: string, sliceId: string, uatPath: string, base: string,
1115
1121
  ): Promise<string> {
1116
1122
  const inlined: string[] = [];
1117
1123
  inlined.push(await inlineFile(resolveSliceFile(base, mid, sliceId, "UAT"), uatPath, `${sliceId} UAT`));
@@ -1126,10 +1132,9 @@ export async function buildRunUatPrompt(
1126
1132
  const projectInline = await inlineProjectFromDb(base);
1127
1133
  if (projectInline) inlined.push(projectInline);
1128
1134
 
1129
- const inlinedContext = `## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`;
1135
+ const inlinedContext = buildInlinedContextSection(inlined);
1130
1136
 
1131
1137
  const uatResultPath = join(base, relSliceFile(base, mid, sliceId, "UAT-RESULT"));
1132
- const uatType = extractUatType(uatContent) ?? "human-experience";
1133
1138
 
1134
1139
  return loadPrompt("run-uat", {
1135
1140
  workingDirectory: base,
@@ -1137,7 +1142,6 @@ export async function buildRunUatPrompt(
1137
1142
  sliceId,
1138
1143
  uatPath,
1139
1144
  uatResultPath,
1140
- uatType,
1141
1145
  inlinedContext,
1142
1146
  });
1143
1147
  }
@@ -1165,7 +1169,7 @@ export async function buildReassessRoadmapPrompt(
1165
1169
  const knowledgeInlineRA = await inlineGsdRootFile(base, "knowledge.md", "Project Knowledge");
1166
1170
  if (knowledgeInlineRA) inlined.push(knowledgeInlineRA);
1167
1171
 
1168
- const inlinedContext = `## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`;
1172
+ const inlinedContext = buildInlinedContextSection(inlined);
1169
1173
 
1170
1174
  const assessmentPath = join(base, relSliceFile(base, mid, completedSliceId, "ASSESSMENT"));
1171
1175
 
@@ -38,7 +38,7 @@ import {
38
38
  import { selfHealRuntimeRecords } from "./auto-recovery.js";
39
39
  import { ensureGitignore, untrackRuntimeFiles } from "./gitignore.js";
40
40
  import { nativeIsRepo, nativeInit } from "./native-git-bridge.js";
41
- import { GitServiceImpl } from "./git-service.js";
41
+ import { createGitService } from "./git-service.js";
42
42
  import {
43
43
  captureIntegrationBranch,
44
44
  detectWorktreeName,
@@ -129,11 +129,13 @@ export async function bootstrapAutoSession(
129
129
  }
130
130
 
131
131
  // Initialize GitServiceImpl
132
- s.gitService = new GitServiceImpl(s.basePath, loadEffectiveGSDPreferences()?.preferences?.git ?? {});
132
+ s.gitService = createGitService(s.basePath);
133
133
 
134
- // Check for crash from previous session (use both old and new lock data)
134
+ // Check for crash from previous session (use both old and new lock data).
135
+ // Skip if the lock PID matches this process — acquireSessionLock() writes
136
+ // to the same auto.lock file before this check, so we'd always false-positive.
135
137
  const crashLock = readCrashLock(base);
136
- if (crashLock) {
138
+ if (crashLock && crashLock.pid !== process.pid) {
137
139
  // We already hold the session lock, so no concurrent session is running.
138
140
  // The crash lock is from a dead process — recover context from it.
139
141
  const recoveredMid = crashLock.unitId.split("/")[0];
@@ -330,12 +332,12 @@ export async function bootstrapAutoSession(
330
332
  if (existingWtPath) {
331
333
  const wtPath = enterAutoWorktree(base, s.currentMilestoneId);
332
334
  s.basePath = wtPath;
333
- s.gitService = new GitServiceImpl(s.basePath, loadEffectiveGSDPreferences()?.preferences?.git ?? {});
335
+ s.gitService = createGitService(s.basePath);
334
336
  ctx.ui.notify(`Entered auto-worktree at ${wtPath}`, "info");
335
337
  } else {
336
338
  const wtPath = createAutoWorktree(base, s.currentMilestoneId);
337
339
  s.basePath = wtPath;
338
- s.gitService = new GitServiceImpl(s.basePath, loadEffectiveGSDPreferences()?.preferences?.git ?? {});
340
+ s.gitService = createGitService(s.basePath);
339
341
  ctx.ui.notify(`Created auto-worktree at ${wtPath}`, "info");
340
342
  }
341
343
  registerSigtermHandler(s.originalBasePath);
@@ -118,7 +118,7 @@ import {
118
118
  parseSliceBranch,
119
119
  setActiveMilestoneId,
120
120
  } from "./worktree.js";
121
- import { GitServiceImpl, type TaskCommitContext } from "./git-service.js";
121
+ import { createGitService, type TaskCommitContext } from "./git-service.js";
122
122
  import { getPriorSliceCompletionBlocker } from "./dispatch-guard.js";
123
123
  import { formatGitError } from "./git-self-heal.js";
124
124
  import {
@@ -204,8 +204,7 @@ import type { CompletedUnit, CurrentUnit, UnitRouting, StartModel, PendingVerifi
204
204
  // ─────────────────────────────────────────────────────────────────────────────
205
205
  const s = new AutoSession();
206
206
 
207
- /** Throttle STATE.md rebuilds at most once per 30 seconds */
208
- const STATE_REBUILD_MIN_INTERVAL_MS = 30_000;
207
+ import { STATE_REBUILD_MIN_INTERVAL_MS } from "./auto-constants.js";
209
208
 
210
209
  export function shouldUseWorktreeIsolation(): boolean {
211
210
  const prefs = loadEffectiveGSDPreferences()?.preferences?.git;
@@ -462,7 +461,7 @@ export async function stopAuto(ctx?: ExtensionContext, pi?: ExtensionAPI, reason
462
461
  try { autoCommitCurrentBranch(s.basePath, "stop", s.currentMilestoneId); } catch (e) { debugLog("stop-auto-commit-failed", { error: e instanceof Error ? e.message : String(e) }); }
463
462
  teardownAutoWorktree(s.originalBasePath, s.currentMilestoneId, { preserveBranch: true });
464
463
  s.basePath = s.originalBasePath;
465
- s.gitService = new GitServiceImpl(s.basePath, loadEffectiveGSDPreferences()?.preferences?.git ?? {});
464
+ s.gitService = createGitService(s.basePath);
466
465
  ctx?.ui.notify("Exited auto-worktree (branch preserved for resume).", "info");
467
466
  } catch (err) {
468
467
  ctx?.ui.notify(
@@ -626,12 +625,12 @@ export async function startAuto(
626
625
  if (existingWtPath) {
627
626
  const wtPath = enterAutoWorktree(s.originalBasePath, s.currentMilestoneId);
628
627
  s.basePath = wtPath;
629
- s.gitService = new GitServiceImpl(s.basePath, loadEffectiveGSDPreferences()?.preferences?.git ?? {});
628
+ s.gitService = createGitService(s.basePath);
630
629
  ctx.ui.notify(`Re-entered auto-worktree at ${wtPath}`, "info");
631
630
  } else {
632
631
  const wtPath = createAutoWorktree(s.originalBasePath, s.currentMilestoneId);
633
632
  s.basePath = wtPath;
634
- s.gitService = new GitServiceImpl(s.basePath, loadEffectiveGSDPreferences()?.preferences?.git ?? {});
633
+ s.gitService = createGitService(s.basePath);
635
634
  ctx.ui.notify(`Recreated auto-worktree at ${wtPath}`, "info");
636
635
  }
637
636
  } catch (err) {
@@ -834,6 +833,9 @@ export async function handleAgentEnd(
834
833
  // permanently stalled with no unit running and no watchdog set.
835
834
  if (s.pendingAgentEndRetry) {
836
835
  s.pendingAgentEndRetry = false;
836
+ // Clear gap watchdog from the previous cycle to prevent concurrent
837
+ // dispatch when the deferred handleAgentEnd calls dispatchNextUnit (#1272).
838
+ clearDispatchGapWatchdog();
837
839
  setImmediate(() => {
838
840
  handleAgentEnd(ctx, pi).catch((err) => {
839
841
  const msg = err instanceof Error ? err.message : String(err);
@@ -976,8 +978,12 @@ async function dispatchNextUnit(
976
978
  return;
977
979
  }
978
980
 
979
- // Reentrancy guard
980
- if (s.dispatching && s.skipDepth === 0) {
981
+ // Reentrancy guard — unconditional to prevent concurrent dispatch from
982
+ // gap watchdog or pendingAgentEndRetry during skip chains (#1272).
983
+ // Previously the guard was bypassed when skipDepth > 0, but the recursive
984
+ // skip chain's inner finally block resets s.dispatching = false before the
985
+ // outer call's finally runs, opening a window for concurrent entry.
986
+ if (s.dispatching) {
981
987
  debugLog("dispatchNextUnit reentrancy guard — another dispatch in progress, bailing");
982
988
  return;
983
989
  }
@@ -1124,7 +1130,7 @@ async function dispatchNextUnit(
1124
1130
  }
1125
1131
 
1126
1132
  s.basePath = s.originalBasePath;
1127
- s.gitService = new GitServiceImpl(s.basePath, loadEffectiveGSDPreferences()?.preferences?.git ?? {});
1133
+ s.gitService = createGitService(s.basePath);
1128
1134
  invalidateAllCaches();
1129
1135
 
1130
1136
  state = await deriveState(s.basePath);
@@ -1136,7 +1142,7 @@ async function dispatchNextUnit(
1136
1142
  try {
1137
1143
  const wtPath = createAutoWorktree(s.basePath, mid);
1138
1144
  s.basePath = wtPath;
1139
- s.gitService = new GitServiceImpl(s.basePath, loadEffectiveGSDPreferences()?.preferences?.git ?? {});
1145
+ s.gitService = createGitService(s.basePath);
1140
1146
  ctx.ui.notify(`Created auto-worktree for ${mid} at ${wtPath}`, "info");
1141
1147
  } catch (err) {
1142
1148
  ctx.ui.notify(
@@ -1176,7 +1182,7 @@ async function dispatchNextUnit(
1176
1182
  const roadmapContent = readFileSync(roadmapPath, "utf-8");
1177
1183
  const mergeResult = mergeMilestoneToMain(s.originalBasePath, s.currentMilestoneId, roadmapContent);
1178
1184
  s.basePath = s.originalBasePath;
1179
- s.gitService = new GitServiceImpl(s.basePath, loadEffectiveGSDPreferences()?.preferences?.git ?? {});
1185
+ s.gitService = createGitService(s.basePath);
1180
1186
  ctx.ui.notify(
1181
1187
  `Milestone ${ s.currentMilestoneId } merged to main.${mergeResult.pushed ? " Pushed to remote." : ""}`,
1182
1188
  "info",
@@ -1201,7 +1207,7 @@ async function dispatchNextUnit(
1201
1207
  if (roadmapPath) {
1202
1208
  const roadmapContent = readFileSync(roadmapPath, "utf-8");
1203
1209
  const mergeResult = mergeMilestoneToMain(s.basePath, s.currentMilestoneId, roadmapContent);
1204
- s.gitService = new GitServiceImpl(s.basePath, loadEffectiveGSDPreferences()?.preferences?.git ?? {});
1210
+ s.gitService = createGitService(s.basePath);
1205
1211
  ctx.ui.notify(
1206
1212
  `Milestone ${ s.currentMilestoneId } merged (branch mode).${mergeResult.pushed ? " Pushed to remote." : ""}`,
1207
1213
  "info",
@@ -1279,7 +1285,7 @@ async function dispatchNextUnit(
1279
1285
  const roadmapContent = readFileSync(roadmapPath, "utf-8");
1280
1286
  const mergeResult = mergeMilestoneToMain(s.originalBasePath, s.currentMilestoneId, roadmapContent);
1281
1287
  s.basePath = s.originalBasePath;
1282
- s.gitService = new GitServiceImpl(s.basePath, loadEffectiveGSDPreferences()?.preferences?.git ?? {});
1288
+ s.gitService = createGitService(s.basePath);
1283
1289
  ctx.ui.notify(
1284
1290
  `Milestone ${ s.currentMilestoneId } merged to main.${mergeResult.pushed ? " Pushed to remote." : ""}`,
1285
1291
  "info",
@@ -1303,7 +1309,7 @@ async function dispatchNextUnit(
1303
1309
  if (roadmapPath) {
1304
1310
  const roadmapContent = readFileSync(roadmapPath, "utf-8");
1305
1311
  const mergeResult = mergeMilestoneToMain(s.basePath, s.currentMilestoneId, roadmapContent);
1306
- s.gitService = new GitServiceImpl(s.basePath, loadEffectiveGSDPreferences()?.preferences?.git ?? {});
1312
+ s.gitService = createGitService(s.basePath);
1307
1313
  ctx.ui.notify(
1308
1314
  `Milestone ${ s.currentMilestoneId } merged (branch mode).${mergeResult.pushed ? " Pushed to remote." : ""}`,
1309
1315
  "info",
@@ -1449,15 +1455,18 @@ async function dispatchNextUnit(
1449
1455
  }
1450
1456
 
1451
1457
  if (dispatchResult.action !== "dispatch") {
1452
- await new Promise(r => setImmediate(r));
1453
- await dispatchNextUnit(ctx, pi);
1458
+ // Defer re-dispatch to next microtask so s.dispatching is released first,
1459
+ // preventing reentrancy guard bypass during concurrent entry (#1272).
1460
+ setImmediate(() => dispatchNextUnit(ctx, pi).catch(err => {
1461
+ ctx.ui.notify(`Deferred dispatch failed: ${err instanceof Error ? err.message : String(err)}`, "error");
1462
+ pauseAuto(ctx, pi).catch(() => {});
1463
+ }));
1454
1464
  return;
1455
1465
  }
1456
1466
 
1457
1467
  unitType = dispatchResult.unitType;
1458
1468
  unitId = dispatchResult.unitId;
1459
1469
  prompt = dispatchResult.prompt;
1460
- let pauseAfterUatDispatch = dispatchResult.pauseAfterDispatch ?? false;
1461
1470
 
1462
1471
  // ── Pre-dispatch hooks ──
1463
1472
  const preDispatchResult = runPreDispatchHooks(unitType, unitId, prompt, s.basePath);
@@ -1469,8 +1478,10 @@ async function dispatchNextUnit(
1469
1478
  }
1470
1479
  if (preDispatchResult.action === "skip") {
1471
1480
  ctx.ui.notify(`Skipping ${unitType} ${unitId} (pre-dispatch hook).`, "info");
1472
- await new Promise(r => setImmediate(r));
1473
- await dispatchNextUnit(ctx, pi);
1481
+ setImmediate(() => dispatchNextUnit(ctx, pi).catch(err => {
1482
+ ctx.ui.notify(`Deferred dispatch failed: ${err instanceof Error ? err.message : String(err)}`, "error");
1483
+ pauseAuto(ctx, pi).catch(() => {});
1484
+ }));
1474
1485
  return;
1475
1486
  }
1476
1487
  if (preDispatchResult.action === "replace") {
@@ -1501,9 +1512,16 @@ async function dispatchNextUnit(
1501
1512
  if (idempotencyResult.reason === "completed" || idempotencyResult.reason === "fallback-persisted" || idempotencyResult.reason === "phantom-loop-cleared" || idempotencyResult.reason === "evicted") {
1502
1513
  if (!s.active) return;
1503
1514
  s.skipDepth++;
1504
- await new Promise(r => setTimeout(r, idempotencyResult.reason === "phantom-loop-cleared" ? 50 : 150));
1505
- await dispatchNextUnit(ctx, pi);
1506
- s.skipDepth = Math.max(0, s.skipDepth - 1);
1515
+ const skipDelay = idempotencyResult.reason === "phantom-loop-cleared" ? 50 : 150;
1516
+ // Defer re-dispatch so s.dispatching is released first (#1272).
1517
+ setTimeout(() => {
1518
+ dispatchNextUnit(ctx, pi).catch(err => {
1519
+ ctx.ui.notify(`Deferred skip-dispatch failed: ${err instanceof Error ? err.message : String(err)}`, "error");
1520
+ pauseAuto(ctx, pi).catch(() => {});
1521
+ }).finally(() => {
1522
+ s.skipDepth = Math.max(0, s.skipDepth - 1);
1523
+ });
1524
+ }, skipDelay);
1507
1525
  return;
1508
1526
  }
1509
1527
  } else if (idempotencyResult.action === "stop") {
@@ -1534,8 +1552,11 @@ async function dispatchNextUnit(
1534
1552
  return;
1535
1553
  }
1536
1554
  if (stuckResult.action === "recovered" && stuckResult.dispatchAgain) {
1537
- await new Promise(r => setImmediate(r));
1538
- await dispatchNextUnit(ctx, pi);
1555
+ // Defer re-dispatch so s.dispatching is released first (#1272).
1556
+ setImmediate(() => dispatchNextUnit(ctx, pi).catch(err => {
1557
+ ctx.ui.notify(`Deferred recovery-dispatch failed: ${err instanceof Error ? err.message : String(err)}`, "error");
1558
+ pauseAuto(ctx, pi).catch(() => {});
1559
+ }));
1539
1560
  return;
1540
1561
  }
1541
1562
 
@@ -1712,13 +1733,6 @@ async function dispatchNextUnit(
1712
1733
  { triggerTurn: true },
1713
1734
  );
1714
1735
 
1715
- if (pauseAfterUatDispatch) {
1716
- ctx.ui.notify(
1717
- "UAT requires human execution. Auto-mode will pause after this unit writes the result file.",
1718
- "info",
1719
- );
1720
- await pauseAuto(ctx, pi);
1721
- }
1722
1736
  } finally {
1723
1737
  s.dispatching = false;
1724
1738
  }
@@ -1788,6 +1802,15 @@ export {
1788
1802
  export function _getUnitConsecutiveSkips(): Map<string, number> { return s.unitConsecutiveSkips; }
1789
1803
  export function _resetUnitConsecutiveSkips(): void { s.unitConsecutiveSkips.clear(); }
1790
1804
 
1805
+ /**
1806
+ * Test-only: expose dispatching / skipDepth state for reentrancy guard tests.
1807
+ * Not part of the public API.
1808
+ */
1809
+ export function _getDispatching(): boolean { return s.dispatching; }
1810
+ export function _setDispatching(v: boolean): void { s.dispatching = v; }
1811
+ export function _getSkipDepth(): number { return s.skipDepth; }
1812
+ export function _setSkipDepth(v: number): void { s.skipDepth = v; }
1813
+
1791
1814
  /**
1792
1815
  * Dispatch a hook unit directly, bypassing normal pre-dispatch hooks.
1793
1816
  * Used for manual hook triggers via /gsd run-hook.
@@ -1874,8 +1897,6 @@ export async function dispatchHookUnit(
1874
1897
  ctx.ui.setStatus("gsd-auto", s.stepMode ? "next" : "auto");
1875
1898
  ctx.ui.notify(`Running post-unit hook: ${hookName}`, "info");
1876
1899
 
1877
- console.log(`[dispatchHookUnit] Sending prompt of length ${hookPrompt.length}`);
1878
- console.log(`[dispatchHookUnit] Prompt preview: ${hookPrompt.substring(0, 200)}...`);
1879
1900
  pi.sendMessage(
1880
1901
  { customType: "gsd-auto", content: hookPrompt, display: true },
1881
1902
  { triggerTurn: true },
@@ -19,8 +19,7 @@ import {
19
19
  } from "./workflow-templates.js";
20
20
  import { loadPrompt } from "./prompt-loader.js";
21
21
  import { gsdRoot } from "./paths.js";
22
- import { GitServiceImpl, runGit } from "./git-service.js";
23
- import { loadEffectiveGSDPreferences } from "./preferences.js";
22
+ import { createGitService, runGit } from "./git-service.js";
24
23
  import { isAutoActive, isAutoPaused } from "./auto.js";
25
24
 
26
25
  // ─── Helpers ─────────────────────────────────────────────────────────────────
@@ -423,9 +422,8 @@ export async function handleStart(
423
422
 
424
423
  // ─── Create git branch (unless isolation: none) ─────────────────────────
425
424
 
426
- const gitPrefs = loadEffectiveGSDPreferences()?.preferences?.git ?? {};
427
- const git = new GitServiceImpl(basePath, gitPrefs);
428
- const skipBranch = gitPrefs.isolation === "none";
425
+ const git = createGitService(basePath);
426
+ const skipBranch = git.prefs.isolation === "none";
429
427
  const slug = slugify(description || templateId);
430
428
  const branchName = `gsd/${templateId}/${slug}`;
431
429
  let branchCreated = false;
@@ -44,6 +44,8 @@ import { handleConfig } from "./commands-config.js";
44
44
  import { handleInspect } from "./commands-inspect.js";
45
45
  import { handleCleanupBranches, handleCleanupSnapshots, handleSkip, handleDryRun } from "./commands-maintenance.js";
46
46
  import { handleDoctor, handleSteer, handleCapture, handleTriage, handleKnowledge, handleRunHook, handleUpdate, handleSkillHealth } from "./commands-handlers.js";
47
+ import { computeProgressScore, formatProgressLine } from "./progress-score.js";
48
+ import { runEnvironmentChecks } from "./doctor-environment.js";
47
49
  import { handleLogs } from "./commands-logs.js";
48
50
  import { handleStart, handleTemplates, getTemplateCompletions } from "./commands-workflow-templates.js";
49
51
 
@@ -1068,6 +1070,11 @@ async function handleSetup(args: string, ctx: ExtensionCommandContext): Promise<
1068
1070
  function formatTextStatus(state: GSDState): string {
1069
1071
  const lines: string[] = ["GSD Status\n"];
1070
1072
 
1073
+ // Progress score — traffic light (#1221)
1074
+ const progressScore = computeProgressScore();
1075
+ lines.push(formatProgressLine(progressScore));
1076
+ lines.push("");
1077
+
1071
1078
  // Phase
1072
1079
  lines.push(`Phase: ${state.phase}`);
1073
1080
 
@@ -1114,5 +1121,17 @@ function formatTextStatus(state: GSDState): string {
1114
1121
  }
1115
1122
  }
1116
1123
 
1124
+ // Environment health (#1221)
1125
+ const envResults = runEnvironmentChecks(projectRoot());
1126
+ const envIssues = envResults.filter(r => r.status !== "ok");
1127
+ if (envIssues.length > 0) {
1128
+ lines.push("");
1129
+ lines.push("Environment:");
1130
+ for (const r of envIssues) {
1131
+ const icon = r.status === "error" ? "✗" : "⚠";
1132
+ lines.push(` ${icon} ${r.message}`);
1133
+ }
1134
+ }
1135
+
1117
1136
  return lines.join("\n");
1118
1137
  }
@@ -23,6 +23,8 @@ import { getActiveWorktreeName } from "./worktree-command.js";
23
23
  import { getWorkerBatches, hasActiveWorkers, type WorkerEntry } from "../subagent/worker-registry.js";
24
24
  import { formatDuration, padRight, joinColumns, centerLine, fitColumns, STATUS_GLYPH, STATUS_COLOR } from "../shared/mod.js";
25
25
  import { estimateTimeRemaining } from "./auto-dashboard.js";
26
+ import { computeProgressScore, formatProgressLine } from "./progress-score.js";
27
+ import { runEnvironmentChecks, type EnvironmentCheckResult } from "./doctor-environment.js";
26
28
 
27
29
  function unitLabel(type: string): string {
28
30
  switch (type) {
@@ -310,6 +312,15 @@ export class GSDDashboardOverlay {
310
312
  elapsedParts = th.fg("dim", `since ${this.dashData.remoteSession!.startedAt.replace("T", " ").slice(0, 19)}`);
311
313
  }
312
314
  lines.push(row(joinColumns(`${title} ${status}${worktreeTag}`, elapsedParts, contentWidth)));
315
+
316
+ // Progress score — traffic light indicator (#1221)
317
+ if (this.dashData.active || this.dashData.paused) {
318
+ const progressScore = computeProgressScore();
319
+ const progressIcon = progressScore.level === "green" ? th.fg("success", "●")
320
+ : progressScore.level === "yellow" ? th.fg("warning", "●")
321
+ : th.fg("error", "●");
322
+ lines.push(row(`${progressIcon} ${th.fg("text", progressScore.summary)}`));
323
+ }
313
324
  lines.push(blank());
314
325
 
315
326
  if (this.dashData.currentUnit) {
@@ -579,6 +590,23 @@ export class GSDDashboardOverlay {
579
590
  }
580
591
  }
581
592
 
593
+ // Environment health section (#1221) — only show issues
594
+ const envResults = runEnvironmentChecks(this.dashData.basePath || process.cwd());
595
+ const envIssues = envResults.filter(r => r.status !== "ok");
596
+ if (envIssues.length > 0) {
597
+ lines.push(blank());
598
+ lines.push(hr());
599
+ lines.push(row(th.fg("text", th.bold("Environment"))));
600
+ lines.push(blank());
601
+ for (const r of envIssues) {
602
+ const icon = r.status === "error" ? th.fg("error", "✗") : th.fg("warning", "⚠");
603
+ lines.push(row(` ${icon} ${th.fg("text", r.message)}`));
604
+ if (r.detail) {
605
+ lines.push(row(th.fg("dim", ` ${r.detail}`)));
606
+ }
607
+ }
608
+ }
609
+
582
610
  lines.push(blank());
583
611
  lines.push(hr());
584
612
  lines.push(centered(th.fg("dim", "↑↓ scroll · g/G top/end · esc close")));