@opengsd/gsd-pi 1.2.0-dev.d6c5343c → 1.2.0-dev.e8563f58

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 (132) hide show
  1. package/dist/mcp-server.js +2 -1
  2. package/dist/resources/.managed-resources-content-hash +1 -1
  3. package/dist/resources/extensions/gsd/auto/orchestrator.js +28 -10
  4. package/dist/resources/extensions/gsd/auto-model-selection.js +11 -7
  5. package/dist/resources/extensions/gsd/auto.js +7 -0
  6. package/dist/resources/extensions/gsd/blocked-models.js +28 -0
  7. package/dist/resources/extensions/gsd/bootstrap/agent-end-recovery.js +26 -6
  8. package/dist/resources/extensions/gsd/bootstrap/exec-tools.js +2 -2
  9. package/dist/resources/extensions/gsd/closeout-wizard.js +92 -0
  10. package/dist/resources/extensions/gsd/commands-handlers.js +46 -3
  11. package/dist/resources/extensions/gsd/consent-question.js +16 -0
  12. package/dist/resources/extensions/gsd/doctor-git-checks.js +2 -18
  13. package/dist/resources/extensions/gsd/gsd-command-home.js +22 -12
  14. package/dist/resources/extensions/gsd/gsd-db.js +2 -1
  15. package/dist/resources/extensions/gsd/milestone-closeout.js +73 -2
  16. package/dist/resources/extensions/gsd/tools/workflow-tool-executors.js +67 -2
  17. package/dist/resources/extensions/shared/gsd-browser-cli.js +21 -2
  18. package/dist/resources/shared/gsd-browser-path-sync.js +214 -0
  19. package/dist/resources/shared/package-manager-detection.js +1 -1
  20. package/dist/tsconfig.extensions.tsbuildinfo +1 -1
  21. package/dist/update-check.d.ts +2 -0
  22. package/dist/update-check.js +24 -1
  23. package/dist/update-cmd.js +20 -3
  24. package/dist/web/standalone/.next/BUILD_ID +1 -1
  25. package/dist/web/standalone/.next/app-path-routes-manifest.json +8 -8
  26. package/dist/web/standalone/.next/build-manifest.json +2 -2
  27. package/dist/web/standalone/.next/prerender-manifest.json +3 -3
  28. package/dist/web/standalone/.next/server/app/_global-error.html +1 -1
  29. package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
  30. package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  31. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  32. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  33. package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  34. package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  35. package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  36. package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
  37. package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
  38. package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  39. package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  40. package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  41. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  42. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  43. package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  44. package/dist/web/standalone/.next/server/app/index.html +1 -1
  45. package/dist/web/standalone/.next/server/app/index.rsc +1 -1
  46. package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
  47. package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
  48. package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  49. package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
  50. package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  51. package/dist/web/standalone/.next/server/app-paths-manifest.json +8 -8
  52. package/dist/web/standalone/.next/server/chunks/8357.js +2 -2
  53. package/dist/web/standalone/.next/server/middleware-build-manifest.js +1 -1
  54. package/dist/web/standalone/.next/server/pages/404.html +1 -1
  55. package/dist/web/standalone/.next/server/pages/500.html +1 -1
  56. package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
  57. package/dist/web/standalone/node_modules/node-pty/build/Makefile +1 -1
  58. package/package.json +1 -1
  59. package/packages/cloud-mcp-gateway/package.json +2 -2
  60. package/packages/contracts/package.json +1 -1
  61. package/packages/daemon/package.json +4 -4
  62. package/packages/gsd-agent-core/package.json +5 -5
  63. package/packages/gsd-agent-modes/package.json +7 -7
  64. package/packages/mcp-server/dist/moonshot-tool-schema.d.ts +29 -0
  65. package/packages/mcp-server/dist/moonshot-tool-schema.d.ts.map +1 -0
  66. package/packages/mcp-server/dist/moonshot-tool-schema.js +50 -0
  67. package/packages/mcp-server/dist/moonshot-tool-schema.js.map +1 -0
  68. package/packages/mcp-server/dist/server.d.ts.map +1 -1
  69. package/packages/mcp-server/dist/server.js +4 -0
  70. package/packages/mcp-server/dist/server.js.map +1 -1
  71. package/packages/mcp-server/dist/workflow-tools.d.ts +18 -18
  72. package/packages/mcp-server/dist/workflow-tools.d.ts.map +1 -1
  73. package/packages/mcp-server/dist/workflow-tools.js +99 -38
  74. package/packages/mcp-server/dist/workflow-tools.js.map +1 -1
  75. package/packages/mcp-server/package.json +5 -4
  76. package/packages/native/package.json +1 -1
  77. package/packages/pi-agent-core/package.json +1 -1
  78. package/packages/pi-ai/dist/index.d.ts +2 -0
  79. package/packages/pi-ai/dist/index.d.ts.map +1 -1
  80. package/packages/pi-ai/dist/index.js +2 -0
  81. package/packages/pi-ai/dist/index.js.map +1 -1
  82. package/packages/pi-ai/dist/providers/anthropic.d.ts.map +1 -1
  83. package/packages/pi-ai/dist/providers/anthropic.js +12 -7
  84. package/packages/pi-ai/dist/providers/anthropic.js.map +1 -1
  85. package/packages/pi-ai/dist/providers/google-shared.d.ts +5 -0
  86. package/packages/pi-ai/dist/providers/google-shared.d.ts.map +1 -1
  87. package/packages/pi-ai/dist/providers/google-shared.js +12 -3
  88. package/packages/pi-ai/dist/providers/google-shared.js.map +1 -1
  89. package/packages/pi-ai/dist/providers/openai-completions.d.ts.map +1 -1
  90. package/packages/pi-ai/dist/providers/openai-completions.js +7 -3
  91. package/packages/pi-ai/dist/providers/openai-completions.js.map +1 -1
  92. package/packages/pi-ai/dist/utils/moonshot-tool-schema.d.ts +9 -0
  93. package/packages/pi-ai/dist/utils/moonshot-tool-schema.d.ts.map +1 -0
  94. package/packages/pi-ai/dist/utils/moonshot-tool-schema.js +34 -0
  95. package/packages/pi-ai/dist/utils/moonshot-tool-schema.js.map +1 -0
  96. package/packages/pi-ai/dist/utils/oauth/github-copilot.d.ts.map +1 -1
  97. package/packages/pi-ai/dist/utils/oauth/github-copilot.js +6 -2
  98. package/packages/pi-ai/dist/utils/oauth/github-copilot.js.map +1 -1
  99. package/packages/pi-ai/package.json +1 -1
  100. package/packages/pi-coding-agent/package.json +7 -7
  101. package/packages/pi-tui/package.json +2 -2
  102. package/packages/rpc-client/package.json +2 -2
  103. package/pkg/package.json +1 -1
  104. package/src/resources/extensions/browser-tools/tests/gsd-browser-launch-config.test.mjs +11 -0
  105. package/src/resources/extensions/gsd/auto/orchestrator.ts +28 -10
  106. package/src/resources/extensions/gsd/auto-model-selection.ts +16 -7
  107. package/src/resources/extensions/gsd/auto.ts +8 -0
  108. package/src/resources/extensions/gsd/blocked-models.ts +49 -0
  109. package/src/resources/extensions/gsd/bootstrap/agent-end-recovery.ts +34 -5
  110. package/src/resources/extensions/gsd/bootstrap/exec-tools.ts +2 -2
  111. package/src/resources/extensions/gsd/closeout-wizard.ts +102 -0
  112. package/src/resources/extensions/gsd/commands-handlers.ts +46 -3
  113. package/src/resources/extensions/gsd/consent-question.ts +15 -0
  114. package/src/resources/extensions/gsd/doctor-git-checks.ts +2 -19
  115. package/src/resources/extensions/gsd/gsd-command-home.ts +13 -3
  116. package/src/resources/extensions/gsd/gsd-db.ts +4 -3
  117. package/src/resources/extensions/gsd/milestone-closeout.ts +97 -2
  118. package/src/resources/extensions/gsd/tests/auto-model-selection.test.ts +69 -0
  119. package/src/resources/extensions/gsd/tests/auto-orchestrator.test.ts +97 -0
  120. package/src/resources/extensions/gsd/tests/blocked-models.test.ts +19 -0
  121. package/src/resources/extensions/gsd/tests/consent-question.test.ts +15 -0
  122. package/src/resources/extensions/gsd/tests/doctor-git-checks-terminal.test.ts +73 -0
  123. package/src/resources/extensions/gsd/tests/gsd-command-home.test.ts +120 -0
  124. package/src/resources/extensions/gsd/tests/milestone-closeout.test.ts +95 -4
  125. package/src/resources/extensions/gsd/tests/parsers-legacy-importers.test.ts +0 -1
  126. package/src/resources/extensions/gsd/tests/workflow-tool-executors.test.ts +273 -38
  127. package/src/resources/extensions/gsd/tools/workflow-tool-executors.ts +81 -2
  128. package/src/resources/extensions/shared/gsd-browser-cli.ts +23 -2
  129. package/src/resources/shared/gsd-browser-path-sync.ts +273 -0
  130. package/src/resources/shared/package-manager-detection.ts +1 -1
  131. /package/dist/web/standalone/.next/static/{jmTLg6xZmAuq_LIqKOxrH → LDHRKiRBIVZmiuMjrL1Vy}/_buildManifest.js +0 -0
  132. /package/dist/web/standalone/.next/static/{jmTLg6xZmAuq_LIqKOxrH → LDHRKiRBIVZmiuMjrL1Vy}/_ssgManifest.js +0 -0
@@ -9,7 +9,7 @@ import type { ExtensionAPI, ExtensionCommandContext } from "@gsd/pi-coding-agent
9
9
  import { existsSync, readFileSync, mkdirSync } from "node:fs";
10
10
  import { execFileSync } from "node:child_process";
11
11
  import { createRequire } from "node:module";
12
- import { join, resolve as resolvePath, sep } from "node:path";
12
+ import { join, resolve as resolvePath, sep, win32 as pathWin32 } from "node:path";
13
13
  import { homedir } from "node:os";
14
14
  import { deriveState } from "./state.js";
15
15
  import { gsdRoot } from "./paths.js";
@@ -29,6 +29,7 @@ import { getAutoWorktreePath } from "./auto-worktree.js";
29
29
  import { currentDirectoryRoot, projectRoot } from "./commands/context.js";
30
30
  import { loadPrompt } from "./prompt-loader.js";
31
31
  import { buildClaudeRuntimeFloorAdvisory } from "../../shared/claude-runtime-floor.js";
32
+ import { reconcileGsdBrowserPathAfterInstall } from "../../shared/gsd-browser-path-sync.js";
32
33
  import { isPnpmInstall } from "../../shared/package-manager-detection.js";
33
34
  import {
34
35
  buildDoctorHealIssuePayload,
@@ -65,9 +66,33 @@ function isBunInstall(argv1: string | undefined = process.argv[1]): boolean {
65
66
  function resolveInstallCommand(pkg: string): string {
66
67
  if (isBunInstall()) return `bun add -g ${pkg}`;
67
68
  if (isPnpmInstall()) return `pnpm add -g ${pkg}`;
69
+ const npmPrefix = resolveWindowsNpmGlobalPrefix();
70
+ if (npmPrefix) return `npm --prefix ${quoteWindowsArg(npmPrefix)} install -g ${pkg}`;
68
71
  return `npm install -g ${pkg}`;
69
72
  }
70
73
 
74
+ function resolveWindowsNpmGlobalPrefix(
75
+ argv1: string | undefined = process.argv[1],
76
+ platform: NodeJS.Platform = process.platform,
77
+ ): string | null {
78
+ if (platform !== "win32" || !argv1) return null;
79
+ const normalized = pathWin32.normalize(argv1);
80
+ const marker = `${pathWin32.sep}node_modules${pathWin32.sep}`;
81
+ const index = normalized.toLowerCase().lastIndexOf(marker);
82
+ if (index <= 0) return null;
83
+ const prefix = normalized.slice(0, index);
84
+ // Verify this is a real npm global prefix: such a directory always contains
85
+ // npm's own bin shim (`npm.cmd`) as a sibling of `node_modules/`. Local
86
+ // project `node_modules/`, npx caches, and other non-global layouts do not,
87
+ // so without this check `--prefix` would target the wrong directory.
88
+ if (!existsSync(pathWin32.join(prefix, "npm.cmd"))) return null;
89
+ return prefix;
90
+ }
91
+
92
+ function quoteWindowsArg(value: string): string {
93
+ return `"${value.replace(/"/g, '\\"')}"`;
94
+ }
95
+
71
96
  function notifyClaudeRuntimeFloorAdvisory(ctx: ExtensionCommandContext): void {
72
97
  let advisory: string | null = null;
73
98
  try {
@@ -576,12 +601,30 @@ export async function handleUpdate(ctx: ExtensionCommandContext, args = ""): Pro
576
601
  execSync(installCmd, {
577
602
  stdio: ["ignore", "pipe", "ignore"],
578
603
  });
604
+ let reconcile: ReturnType<typeof reconcileGsdBrowserPathAfterInstall> | null = null;
605
+ if (browserUpdate) {
606
+ try {
607
+ reconcile = reconcileGsdBrowserPathAfterInstall({
608
+ latestVersion: latest,
609
+ compareSemver: compareSemverLocal,
610
+ resolvePathVersion: resolveGsdBrowserPathVersionForCommand,
611
+ });
612
+ } catch {
613
+ // Reconciliation is best-effort: the install above already succeeded,
614
+ // so a reconcile failure must not flip the result to "Update failed".
615
+ reconcile = null;
616
+ }
617
+ }
579
618
  const newPathVersion = browserUpdate ? resolveGsdBrowserPathVersionForCommand() : null;
580
- const pathReady = !browserUpdate || (!!newPathVersion && compareSemverLocal(newPathVersion, latest) >= 0);
619
+ const pathNote = browserUpdate && !(newPathVersion && compareSemverLocal(newPathVersion, latest) >= 0)
620
+ ? (reconcile?.message
621
+ ?? "Ensure the npm global bin directory is on your PATH so MCP automation uses the updated binary.")
622
+ : "";
581
623
  ctx.ui.notify(
582
624
  browserUpdate
583
625
  ? `Updated gsd-browser to v${latest}. Restart your GSD session to use the new browser automation version.` +
584
- (pathReady ? "" : "\nNote: Ensure the npm global bin directory is on your PATH so MCP automation uses the updated binary.")
626
+ (reconcile?.action === "synced" && reconcile.message ? `\n${reconcile.message}` : "") +
627
+ (pathNote ? `\nNote: ${pathNote}` : "")
585
628
  : `Updated to v${latest}. Restart your GSD session to use the new version.`,
586
629
  "info",
587
630
  );
@@ -107,6 +107,20 @@ export function hasResearchDecisionQuestion(text: string): boolean {
107
107
  return hasQuestionMatching(text, [RESEARCH_DECISION_QUESTION_RE]);
108
108
  }
109
109
 
110
+ /**
111
+ * Detect a plain-text "Next steps:" menu — numbered options with an "Other"
112
+ * choice — emitted as prose instead of a structured ask_user_questions call.
113
+ * Without this, auto-mode treats the menu as informational and loops on its
114
+ * own turn until tokens are exhausted (#454).
115
+ */
116
+ export function hasPlainTextNextStepsMenu(lines: string[]): boolean {
117
+ const nextStepsIndex = lines.findIndex((line) => /^next steps\s*:?$/i.test(line));
118
+ if (nextStepsIndex < 0) return false;
119
+ const menuLines = lines.slice(nextStepsIndex + 1);
120
+ const numberedOptions = menuLines.filter((line) => /^\d+[.)]\s+\S/.test(line));
121
+ return numberedOptions.length >= 2 && numberedOptions.some((line) => /\bother\b/i.test(line));
122
+ }
123
+
110
124
  // ── Message text extraction (moved from user-input-boundary) ────────────────
111
125
 
112
126
  function extractMessageText(msg: unknown, includeThinking: boolean): string {
@@ -347,6 +361,7 @@ export function isAwaitingUserInput(messages: unknown[] | undefined): boolean {
347
361
  if (!text) return false;
348
362
  const lines = text.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
349
363
  if (lines.some((line) => line.endsWith("?"))) return true;
364
+ if (hasPlainTextNextStepsMenu(lines)) return true;
350
365
  return hasApprovalQuestion(text);
351
366
  }
352
367
 
@@ -5,10 +5,9 @@ import { dirname, join } from "node:path";
5
5
 
6
6
  import type { DoctorIssue, DoctorIssueCode } from "./doctor-types.js";
7
7
  import { loadFile } from "./files.js";
8
- import { parseRoadmap as parseLegacyRoadmap } from "./parsers-legacy.js";
9
- import { isDbAvailable, getMilestone } from "./gsd-db.js";
10
8
  import { resolveMilestoneFile } from "./paths.js";
11
- import { deriveState, isMilestoneComplete } from "./state.js";
9
+ import { isCompletedMilestoneTerminal } from "./milestone-closeout.js";
10
+ import { deriveState } from "./state.js";
12
11
  import { allWorktreesDirs, createWorktree, listWorktrees, resolveGitDir } from "./worktree-manager.js";
13
12
  import { abortAndReset } from "./git-self-heal.js";
14
13
  import { RUNTIME_EXCLUSION_PATHS, resolveMilestoneIntegrationBranch, writeIntegrationBranch } from "./git-service.js";
@@ -146,22 +145,6 @@ function getSnapshotDiffCheckFailure(basePath: string): string | null {
146
145
  return failures.length > 0 ? failures.join("\n") : null;
147
146
  }
148
147
 
149
- async function isCompletedMilestoneTerminal(basePath: string, milestoneId: string): Promise<boolean> {
150
- const summaryPath = resolveMilestoneFile(basePath, milestoneId, "SUMMARY");
151
- if (!summaryPath) return false;
152
-
153
- if (isDbAvailable()) {
154
- const milestone = getMilestone(milestoneId);
155
- return !!milestone && milestone.status === "complete";
156
- }
157
-
158
- const roadmapPath = resolveMilestoneFile(basePath, milestoneId, "ROADMAP");
159
- const roadmapContent = roadmapPath ? await loadFile(roadmapPath) : null;
160
- if (!roadmapContent) return false;
161
- const roadmap = parseLegacyRoadmap(roadmapContent);
162
- return isMilestoneComplete(roadmap);
163
- }
164
-
165
148
  export async function checkGitHealth(
166
149
  basePath: string,
167
150
  issues: DoctorIssue[],
@@ -66,7 +66,7 @@ function disabled(description: string, reason: string): string {
66
66
 
67
67
  export function buildGsdHomeModel(
68
68
  state: GSDState,
69
- closeout?: Pick<CloseoutContext, "strandedQuick" | "unmergedMilestones">,
69
+ closeout?: Pick<CloseoutContext, "strandedQuick" | "unmergedMilestones" | "idleResidueHint">,
70
70
  ): GsdHomeModel {
71
71
  const blocked = isBlocked(state);
72
72
  const complete = state.phase === "complete";
@@ -74,10 +74,14 @@ export function buildGsdHomeModel(
74
74
  const workLabel = activeWorkLabel(state);
75
75
  const strandedQuick = closeout?.strandedQuick ?? null;
76
76
  const unmergedMilestone = closeout?.unmergedMilestones?.[0];
77
+ const idleResidueHint = closeout?.idleResidueHint ?? null;
78
+ const hasIdleResidue = Boolean(idleResidueHint);
77
79
  const nextReason = complete
78
80
  ? "all milestones are complete"
79
81
  : blocked
80
82
  ? "the active milestone is blocked"
83
+ : hasIdleResidue
84
+ ? "milestone git residue needs recovery"
81
85
  : !hasActiveWork
82
86
  ? "there is no active milestone"
83
87
  : "";
@@ -91,6 +95,8 @@ export function buildGsdHomeModel(
91
95
  ? "finish_milestone"
92
96
  : blocked
93
97
  ? "fix_recover"
98
+ : hasIdleResidue
99
+ ? "fix_recover"
94
100
  : canAdvance
95
101
  ? "continue_step"
96
102
  : complete && unmappedActive > 0
@@ -107,6 +113,8 @@ export function buildGsdHomeModel(
107
113
  ? [`Quick task Q${strandedQuick.taskNum} finished on ${strandedQuick.quickBranch} but is not merged to ${strandedQuick.originalBranch}.`]
108
114
  : unmergedMilestone
109
115
  ? [`${unmergedMilestone.milestoneId} is complete but not merged into ${unmergedMilestone.integrationBranch}.`]
116
+ : idleResidueHint
117
+ ? [idleResidueHint.message]
110
118
  : completionSummary;
111
119
 
112
120
  return {
@@ -181,10 +189,12 @@ export function buildGsdHomeModel(
181
189
  label: "Fix or recover",
182
190
  description: blocked
183
191
  ? "Review the blocker and recovery commands for the active milestone."
192
+ : hasIdleResidue
193
+ ? "Review stranded milestone worktrees/branches and run the suggested recovery command."
184
194
  : disabled("This becomes active when closeout, validation, or state recovery is needed.", "no blocker is active"),
185
- enabled: blocked,
195
+ enabled: blocked || hasIdleResidue,
186
196
  recommended: recommended === "fix_recover",
187
- disabledReason: blocked ? undefined : "no blocker is active",
197
+ disabledReason: blocked || hasIdleResidue ? undefined : "no blocker is active",
188
198
  },
189
199
  {
190
200
  id: "start_configure",
@@ -246,9 +246,9 @@ export function insertMilestone(m: {
246
246
  status?: string;
247
247
  depends_on?: string[];
248
248
  planning?: Partial<MilestonePlanningRecord>;
249
- }): void {
249
+ }): boolean {
250
250
  if (!getDbOrNull()!) throw new GSDError(GSD_STALE_STATE, "gsd-db: No database open");
251
- getDbOrNull()!.prepare(
251
+ const result = getDbOrNull()!.prepare(
252
252
  `INSERT OR IGNORE INTO milestones (
253
253
  id, title, status, depends_on, created_at,
254
254
  vision, success_criteria, key_risks, proof_strategy,
@@ -279,7 +279,8 @@ export function insertMilestone(m: {
279
279
  ":definition_of_done": JSON.stringify(m.planning?.definitionOfDone ?? []),
280
280
  ":requirement_coverage": m.planning?.requirementCoverage ?? "",
281
281
  ":boundary_map_markdown": m.planning?.boundaryMapMarkdown ?? "",
282
- });
282
+ }) as { changes?: number };
283
+ return (result.changes ?? 0) > 0;
283
284
  }
284
285
 
285
286
  export function upsertMilestonePlanning(milestoneId: string, planning: Partial<MilestonePlanningRecord> & { title?: string; status?: string; depends_on?: string[] }): void {
@@ -5,10 +5,20 @@
5
5
  // - postUnit: git commit, artifact verify, DB settle, then GitHub finalize
6
6
  // - recovery: DB repair from artifacts, then GitHub finalize
7
7
 
8
+ import { existsSync } from "node:fs";
9
+
8
10
  import { loadFile } from "./files.js";
9
11
  import { resolveMilestoneFile } from "./paths.js";
10
- import { getMilestone, getClosedSliceIds, isDbAvailable } from "./gsd-db.js";
12
+ import {
13
+ getMilestone,
14
+ getClosedSliceIds,
15
+ getLatestAssessmentByScope,
16
+ getMilestoneSlices,
17
+ isDbAvailable,
18
+ } from "./gsd-db.js";
11
19
  import { isClosedStatus } from "./status-guards.js";
20
+ import { resolveExpectedArtifactPath } from "./auto-artifact-paths.js";
21
+ import { handleCompleteMilestone } from "./tools/complete-milestone.js";
12
22
  import { runSafely } from "./auto-utils.js";
13
23
  import { extractVerdict, isAcceptableUatVerdict } from "./verdict-parser.js";
14
24
  import { uatSignoffBlockerGuidance } from "./guidance.js";
@@ -28,6 +38,76 @@ import {
28
38
  const COMPLETE_MILESTONE_DB_SETTLE_MS = 1500;
29
39
  const COMPLETE_MILESTONE_DB_SETTLE_POLL_MS = 100;
30
40
 
41
+ /**
42
+ * True when a milestone is terminal for git cleanup (orphaned worktrees, stale branches).
43
+ * DB-authoritative (ADR-017): closed status, or validation-pass with all slices closed.
44
+ * When the DB is unavailable we cannot make this decision and conservatively
45
+ * return false so callers leave the worktree/branch alone instead of cleaning
46
+ * up based on parsed projections.
47
+ */
48
+ export async function isCompletedMilestoneTerminal(
49
+ _basePath: string,
50
+ milestoneId: string,
51
+ ): Promise<boolean> {
52
+ if (!isDbAvailable()) return false;
53
+
54
+ const milestone = getMilestone(milestoneId);
55
+ if (!milestone) return false;
56
+
57
+ if (isClosedStatus(milestone.status)) {
58
+ return true;
59
+ }
60
+
61
+ const validation = getLatestAssessmentByScope(milestoneId, "milestone-validation");
62
+ if (validation?.status !== "pass") {
63
+ return false;
64
+ }
65
+
66
+ const slices = getMilestoneSlices(milestoneId);
67
+ if (slices.length === 0) return false;
68
+ return slices.every((slice) => isClosedStatus(slice.status));
69
+ }
70
+
71
+ /** Write a missing milestone SUMMARY projection when canonical DB closeout already settled. */
72
+ export async function repairMissingMilestoneSummaryProjection(
73
+ basePath: string,
74
+ milestoneId: string,
75
+ ): Promise<{ ok: true } | { ok: false; error: string }> {
76
+ const milestone = getMilestone(milestoneId);
77
+ if (!milestone) {
78
+ return { ok: false, error: `milestone not found: ${milestoneId}` };
79
+ }
80
+
81
+ const artifactBasePath = resolveCanonicalMilestoneRoot(basePath, milestoneId);
82
+ const summaryPath = resolveExpectedArtifactPath("complete-milestone", milestoneId, artifactBasePath);
83
+ if (summaryPath && existsSync(summaryPath)) {
84
+ return { ok: true };
85
+ }
86
+
87
+ const result = await handleCompleteMilestone(
88
+ {
89
+ milestoneId,
90
+ title: milestone.title,
91
+ oneLiner: "Canonical closeout completed; summary projection repaired automatically.",
92
+ narrative:
93
+ "The workflow database recorded this milestone as complete, but the milestone SUMMARY artifact was missing on disk. " +
94
+ "Dispatch policy repaired the projection so closeout proof and cleanup can proceed.",
95
+ verificationPassed: true,
96
+ triggerReason: "closeout-projection-repair",
97
+ },
98
+ basePath,
99
+ );
100
+
101
+ if ("error" in result) {
102
+ return { ok: false, error: result.error };
103
+ }
104
+ const writtenSummaryPath = result.summaryPath;
105
+ if (result.stale || !writtenSummaryPath || !existsSync(writtenSummaryPath)) {
106
+ return { ok: false, error: "milestone SUMMARY projection write failed" };
107
+ }
108
+ return { ok: true };
109
+ }
110
+
31
111
  /**
32
112
  * True when the milestone is closed in the DB and the completion summary artifact exists.
33
113
  * Polls briefly so post-unit verification can observe the tool's DB write.
@@ -78,7 +158,22 @@ export async function evaluateCompleteMilestoneDispatch(
78
158
  if (isDbAvailable()) {
79
159
  const milestone = getMilestone(mid);
80
160
  if (milestone && isClosedStatus(milestone.status)) {
81
- return { action: "skip" };
161
+ const artifactBasePath = resolveCanonicalMilestoneRoot(basePath, mid);
162
+ const summaryPath = resolveExpectedArtifactPath("complete-milestone", mid, artifactBasePath);
163
+ const summaryMissing = !summaryPath || !existsSync(summaryPath);
164
+ if (summaryMissing) {
165
+ const repair = await repairMissingMilestoneSummaryProjection(basePath, mid);
166
+ if (!repair.ok) {
167
+ logWarning(
168
+ "dispatch",
169
+ `Milestone ${mid} is closed in DB but SUMMARY repair failed: ${repair.error}. Dispatching complete-milestone to retry.`,
170
+ );
171
+ } else {
172
+ return { action: "skip" };
173
+ }
174
+ } else {
175
+ return { action: "skip" };
176
+ }
82
177
  }
83
178
  }
84
179
 
@@ -8,6 +8,7 @@ import { fileURLToPath } from "node:url";
8
8
  const __dirname = dirname(fileURLToPath(import.meta.url));
9
9
 
10
10
  import { ModelPolicyDispatchBlockedError, resolvePreferredModelConfig, resolveModelId, selectAndApplyModel, floorThinkingLevelForUnit } from "../auto-model-selection.js";
11
+ import { blockModelUntil, clearTemporaryModelBlocksForTest } from "../blocked-models.ts";
11
12
 
12
13
  function makeTempDir(prefix: string): string {
13
14
  return mkdtempSync(join(tmpdir(), prefix));
@@ -223,6 +224,74 @@ test("selectAndApplyModel honors explicit phase models without downgrading (#361
223
224
  }
224
225
  });
225
226
 
227
+ test("selectAndApplyModel skips a rate-limited primary until its reset time", async () => {
228
+ const originalCwd = process.cwd();
229
+ const originalGsdHome = process.env.GSD_HOME;
230
+ const tempProject = makeTempDir("gsd-rate-limit-fallback-project-");
231
+ const tempGsdHome = makeTempDir("gsd-rate-limit-fallback-home-");
232
+ const setModelCalls: string[] = [];
233
+
234
+ try {
235
+ clearTemporaryModelBlocksForTest();
236
+ mkdirSync(join(tempProject, ".gsd"), { recursive: true });
237
+ writeFileSync(
238
+ join(tempProject, ".gsd", "PREFERENCES.md"),
239
+ [
240
+ "---",
241
+ "models:",
242
+ " execution:",
243
+ " model: gpt-5.5",
244
+ " provider: openai-codex",
245
+ " fallbacks:",
246
+ " - anthropic/claude-sonnet-4-6",
247
+ "---",
248
+ ].join("\n"),
249
+ "utf-8",
250
+ );
251
+ process.env.GSD_HOME = tempGsdHome;
252
+ process.chdir(tempProject);
253
+
254
+ const availableModels = [
255
+ { id: "gpt-5.5", provider: "openai-codex", api: "responses" },
256
+ { id: "claude-sonnet-4-6", provider: "anthropic", api: "anthropic-messages" },
257
+ ];
258
+ const ctx = {
259
+ modelRegistry: { getAvailable: () => availableModels },
260
+ sessionManager: { getSessionId: () => "test-session" },
261
+ ui: { notify: () => {} },
262
+ model: { provider: "openai-codex", id: "gpt-5.5", api: "responses" },
263
+ } as any;
264
+ const pi = {
265
+ setModel: async (model: { provider: string; id: string }) => {
266
+ setModelCalls.push(`${model.provider}/${model.id}`);
267
+ return true;
268
+ },
269
+ emitBeforeModelSelect: async () => undefined,
270
+ getActiveTools: () => [],
271
+ emitAdjustToolSet: async () => undefined,
272
+ setActiveTools: () => {},
273
+ } as any;
274
+
275
+ blockModelUntil(tempProject, "openai-codex", "gpt-5.5", Date.now() + 60_000, "session limit");
276
+ await selectAndApplyModel(ctx, pi, "execute-task", "M001/S01/T01", tempProject, undefined, false, { provider: "openai-codex", id: "gpt-5.5" }, undefined, true);
277
+
278
+ blockModelUntil(tempProject, "openai-codex", "gpt-5.5", Date.now() - 1, "expired");
279
+ await selectAndApplyModel(ctx, pi, "execute-task", "M001/S01/T02", tempProject, undefined, false, { provider: "openai-codex", id: "gpt-5.5" }, undefined, true);
280
+
281
+ assert.deepEqual(setModelCalls, [
282
+ "anthropic/claude-sonnet-4-6",
283
+ "openai-codex/gpt-5.5",
284
+ ]);
285
+ } finally {
286
+ clearTemporaryModelBlocksForTest();
287
+ process.chdir(originalCwd);
288
+ if (originalGsdHome === undefined) delete process.env.GSD_HOME;
289
+ else process.env.GSD_HOME = originalGsdHome;
290
+ rmSync(tempProject, { recursive: true, force: true });
291
+ rmSync(tempGsdHome, { recursive: true, force: true });
292
+ }
293
+ });
294
+
226
295
  test("selectAndApplyModel lets explicit unit models bypass stale cross-provider lock (#116)", async () => {
227
296
  const originalCwd = process.cwd();
228
297
  const originalGsdHome = process.env.GSD_HOME;
@@ -1223,6 +1223,103 @@ test("decideOrchestratorDispatch forwards constructor session when advance input
1223
1223
  }
1224
1224
  });
1225
1225
 
1226
+ test("decideOrchestratorDispatch evaluates deep pre-planning rules without an active milestone", async (t) => {
1227
+ const base = mkdtempSync(join(tmpdir(), "gsd-orchestrator-no-active-"));
1228
+ t.after(() => {
1229
+ resetRegistry();
1230
+ rmSync(base, { recursive: true, force: true });
1231
+ });
1232
+ resetRegistry();
1233
+ mkdirSync(join(base, ".gsd"), { recursive: true });
1234
+ writeFileSync(
1235
+ join(base, ".gsd", "PREFERENCES.md"),
1236
+ [
1237
+ "---",
1238
+ "planning_depth: deep",
1239
+ "workflow_prefs_captured: true",
1240
+ "---",
1241
+ "",
1242
+ ].join("\n"),
1243
+ );
1244
+
1245
+ const stateSnapshot: GSDState = {
1246
+ ...makeState(),
1247
+ activeMilestone: null,
1248
+ phase: "pre-planning",
1249
+ nextAction: "All remaining milestones are parked (M027). Run /gsd unpark M027 or create a new milestone.",
1250
+ registry: [{ id: "M027", title: "Parked", status: "parked" }],
1251
+ };
1252
+ const ctx = { model: {}, modelRegistry: { getAll: () => [] } } as never;
1253
+ const pi = { getActiveTools: () => [] } as never;
1254
+ const session = {
1255
+ basePath: base,
1256
+ originalBasePath: base,
1257
+ currentMilestoneId: "M027",
1258
+ } as never;
1259
+
1260
+ const result = await decideOrchestratorDispatch(ctx, pi, base, session, { stateSnapshot });
1261
+
1262
+ assert.ok(result && "unitType" in result, `expected project-level dispatch, got ${JSON.stringify(result)}`);
1263
+ assert.equal(result.unitType, "discuss-project");
1264
+ assert.equal(result.unitId, "PROJECT");
1265
+ });
1266
+
1267
+ test("decideOrchestratorDispatch does not replay milestone-scoped verification retry when no milestone is active", async (t) => {
1268
+ const base = mkdtempSync(join(tmpdir(), "gsd-orchestrator-no-active-retry-"));
1269
+ t.after(() => {
1270
+ resetRegistry();
1271
+ rmSync(base, { recursive: true, force: true });
1272
+ });
1273
+ resetRegistry();
1274
+ mkdirSync(join(base, ".gsd"), { recursive: true });
1275
+ writeFileSync(
1276
+ join(base, ".gsd", "PREFERENCES.md"),
1277
+ [
1278
+ "---",
1279
+ "planning_depth: deep",
1280
+ "workflow_prefs_captured: true",
1281
+ "---",
1282
+ "",
1283
+ ].join("\n"),
1284
+ );
1285
+
1286
+ const stateSnapshot: GSDState = {
1287
+ ...makeState(),
1288
+ activeMilestone: null,
1289
+ phase: "pre-planning",
1290
+ nextAction: "All remaining milestones are parked (M027). Run /gsd unpark M027 or create a new milestone.",
1291
+ registry: [{ id: "M027", title: "Parked", status: "parked" }],
1292
+ };
1293
+ const ctx = { model: {}, modelRegistry: { getAll: () => [] } } as never;
1294
+ const pi = { getActiveTools: () => [] } as never;
1295
+ const stalePendingRetry = {
1296
+ unitType: "execute-task",
1297
+ unitId: "M027.S1.T1",
1298
+ prompt: "stale retry prompt",
1299
+ pauseAfterUatDispatch: false,
1300
+ state: stateSnapshot,
1301
+ mid: "M027",
1302
+ midTitle: "Parked",
1303
+ };
1304
+ const session = {
1305
+ basePath: base,
1306
+ originalBasePath: base,
1307
+ currentMilestoneId: "M027",
1308
+ pendingVerificationRetryDispatch: stalePendingRetry,
1309
+ } as never;
1310
+
1311
+ const result = await decideOrchestratorDispatch(ctx, pi, base, session, { stateSnapshot });
1312
+
1313
+ assert.ok(result && "unitType" in result, `expected project-level dispatch, got ${JSON.stringify(result)}`);
1314
+ assert.equal(result.unitType, "discuss-project");
1315
+ assert.equal(result.unitId, "PROJECT");
1316
+ // The stale retry must be preserved for a future tick, not consumed by this
1317
+ // no-active-milestone path (mirrors pre-#712-fix behavior where !active
1318
+ // returned null before touching the retry).
1319
+ const sess = session as unknown as { pendingVerificationRetryDispatch: unknown };
1320
+ assert.equal(sess.pendingVerificationRetryDispatch, stalePendingRetry);
1321
+ });
1322
+
1226
1323
  test("decideOrchestratorDispatch adopts next active milestone after the session milestone is closed", async (t) => {
1227
1324
  const base = mkdtempSync(join(tmpdir(), "gsd-orchestrator-milestone-adopt-"));
1228
1325
  t.after(() => rmSync(base, { recursive: true, force: true }));
@@ -8,7 +8,10 @@ import { join } from "node:path";
8
8
 
9
9
  import {
10
10
  blockModel,
11
+ blockModelUntil,
12
+ clearTemporaryModelBlocksForTest,
11
13
  isModelBlocked,
14
+ isModelTemporarilyUnavailable,
12
15
  loadBlockedModels,
13
16
  } from "../blocked-models.ts";
14
17
 
@@ -96,3 +99,19 @@ test("blocked-models: file created under .gsd/runtime/", () => {
96
99
  rmSync(base, { recursive: true, force: true });
97
100
  }
98
101
  });
102
+
103
+ test("blocked-models: temporary rate-limit blocks expire without persisting", () => {
104
+ const base = mkBase();
105
+ try {
106
+ clearTemporaryModelBlocksForTest();
107
+ blockModelUntil(base, "openai-codex", "gpt-5.5", Date.now() + 60_000, "session limit");
108
+ assert.equal(isModelTemporarilyUnavailable(base, "openai-codex", "gpt-5.5"), true);
109
+ assert.equal(loadBlockedModels(base).length, 0, "rate-limit windows must not persist as account blocks");
110
+
111
+ blockModelUntil(base, "openai-codex", "gpt-5.5", Date.now() - 1, "expired");
112
+ assert.equal(isModelTemporarilyUnavailable(base, "openai-codex", "gpt-5.5"), false);
113
+ } finally {
114
+ clearTemporaryModelBlocksForTest();
115
+ rmSync(base, { recursive: true, force: true });
116
+ }
117
+ });
@@ -238,6 +238,21 @@ test("isAwaitingUserInput does not trigger on thinking-block approval phrases",
238
238
  assert.equal(shouldPauseForQuestion("discuss-requirements", messages), false);
239
239
  });
240
240
 
241
+ test("isAwaitingUserInput treats plain-text next steps menus as waiting for the user (#454)", () => {
242
+ const messages = [
243
+ {
244
+ role: "assistant",
245
+ content: [
246
+ "Next steps:",
247
+ "1. Walk through the runtime placement check above.",
248
+ "2. Build a release once you're satisfied.",
249
+ "3. Other.",
250
+ ].join("\n"),
251
+ },
252
+ ];
253
+ assert.equal(isAwaitingUserInput(messages), true);
254
+ });
255
+
241
256
  test("isAwaitingUserInput still triggers on text-block question marks when thinking is also present", () => {
242
257
  // When thinking + text are both present and the text asks a question, it should still pause.
243
258
  const messages = [
@@ -0,0 +1,73 @@
1
+ // Project/App: gsd-pi
2
+ // File Purpose: Doctor git checks treat validation-pass closeout as terminal without SUMMARY.
3
+
4
+ import test from "node:test";
5
+ import assert from "node:assert/strict";
6
+ import { execFileSync } from "node:child_process";
7
+ import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
8
+ import { tmpdir } from "node:os";
9
+ import { join } from "node:path";
10
+
11
+ import { runGSDDoctor } from "../doctor.ts";
12
+ import { openDatabase, insertMilestone, insertSlice, insertAssessment, closeDatabase } from "../gsd-db.js";
13
+ import { createWorktree, worktreePath } from "../worktree-manager.ts";
14
+
15
+ function runGit(args: string[], cwd: string): string {
16
+ return execFileSync("git", args, {
17
+ cwd,
18
+ stdio: ["ignore", "pipe", "pipe"],
19
+ encoding: "utf-8",
20
+ }).trim();
21
+ }
22
+
23
+ function makeRepo(): string {
24
+ const base = mkdtempSync(join(tmpdir(), "gsd-doctor-terminal-"));
25
+ runGit(["init", "-b", "main"], base);
26
+ runGit(["config", "user.name", "Test User"], base);
27
+ runGit(["config", "user.email", "test@example.com"], base);
28
+ writeFileSync(join(base, "package.json"), "{\"scripts\":{}}\n", "utf-8");
29
+ runGit(["add", "."], base);
30
+ runGit(["commit", "-m", "chore: init"], base);
31
+ return base;
32
+ }
33
+
34
+ test.after(() => {
35
+ closeDatabase();
36
+ });
37
+
38
+ test("doctor flags orphaned worktree for DB-complete milestone without SUMMARY", async (t) => {
39
+ const base = makeRepo();
40
+ t.after(() => rmSync(base, { recursive: true, force: true }));
41
+
42
+ mkdirSync(join(base, ".gsd"), { recursive: true });
43
+ openDatabase(join(base, ".gsd", "gsd.db"));
44
+ insertMilestone({ id: "M008", title: "Done", status: "complete" });
45
+ insertSlice({ id: "S01", milestoneId: "M008", title: "Slice", status: "complete" });
46
+ insertAssessment({
47
+ path: "milestones/M008/M008-VALIDATION.md",
48
+ milestoneId: "M008",
49
+ status: "pass",
50
+ scope: "milestone-validation",
51
+ fullContent: "verdict: pass",
52
+ });
53
+ writeFileSync(
54
+ join(base, ".gsd", "PREFERENCES.md"),
55
+ "---\ngit:\n isolation: worktree\n---\n",
56
+ );
57
+ mkdirSync(join(base, ".gsd", "milestones", "M008"), { recursive: true });
58
+ writeFileSync(
59
+ join(base, ".gsd", "milestones", "M008", "M008-ROADMAP.md"),
60
+ "# M008 Roadmap\n\n- [x] **S01: Slice** `risk:low` `depends:[]`\n",
61
+ );
62
+
63
+ createWorktree(base, "M008", { branch: "milestone/M008" });
64
+ const wtPath = worktreePath(base, "M008");
65
+ assert.ok(existsSync(wtPath), "worktree should exist for the test");
66
+
67
+ const report = await runGSDDoctor(base, { isolationMode: "worktree" });
68
+
69
+ assert.ok(
70
+ report.issues.some((issue) => issue.code === "orphaned_auto_worktree" && issue.unitId === "M008"),
71
+ "doctor should treat DB-complete milestone without SUMMARY as terminal for cleanup",
72
+ );
73
+ });