@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
@@ -29,6 +29,7 @@ function isPlainObject(value) {
29
29
  // built via a template string so TypeScript's NodeNext resolver treats them as
30
30
  // `any` and skips static checking.
31
31
  const MCP_PKG = '@modelcontextprotocol/sdk';
32
+ import { sanitizeSchemaForMoonshot } from '@gsd/pi-ai';
32
33
  export function mcpSdkSpecifier(subpath) {
33
34
  return `${MCP_PKG}/${subpath}.js`;
34
35
  }
@@ -65,7 +66,7 @@ export async function startMcpServer(options) {
65
66
  tools: tools.map((t) => ({
66
67
  name: t.name,
67
68
  description: t.description,
68
- inputSchema: t.parameters,
69
+ inputSchema: sanitizeSchemaForMoonshot(t.parameters),
69
70
  })),
70
71
  }));
71
72
  // tools/call — execute the requested tool and return content blocks.
@@ -1 +1 @@
1
- c63bf325c75b710f
1
+ 47400075b94e1392
@@ -12,7 +12,7 @@
12
12
  import { debugCount, debugLog, debugTime } from "../debug-logger.js";
13
13
  import { reconcileBeforeDispatch } from "../state-reconciliation.js";
14
14
  import { isLegalEdge, IllegalPhaseTransitionError } from "../state-transition-matrix.js";
15
- import { resolveDispatch } from "../auto-dispatch.js";
15
+ import { hasPendingDeepStage, resolveDispatch } from "../auto-dispatch.js";
16
16
  import { classifyFailure } from "../recovery-classification.js";
17
17
  import { verifyExpectedArtifact, refreshRecoveryDbForArtifact } from "../auto-recovery.js";
18
18
  import { invalidateAllCaches } from "../cache.js";
@@ -111,14 +111,25 @@ function shouldAdoptActiveMilestone(state, activeSession, activeDispatchBasePath
111
111
  export async function decideOrchestratorDispatch(ctx, pi, dispatchBasePath, session, input) {
112
112
  const state = input.stateSnapshot;
113
113
  const active = state.activeMilestone;
114
- if (!active)
115
- return null;
116
114
  const activeSession = input.session ?? session;
117
115
  const activeDispatchBasePath = activeSession?.basePath || dispatchBasePath;
118
- if (activeSession && shouldAdoptActiveMilestone(state, activeSession, activeDispatchBasePath)) {
116
+ const prefs = loadEffectiveGSDPreferences(activeDispatchBasePath)?.preferences;
117
+ if (!active) {
118
+ if (state.phase !== "pre-planning")
119
+ return null;
120
+ if (!hasPendingDeepStage(prefs, activeDispatchBasePath)) {
121
+ return {
122
+ kind: "blocked",
123
+ reason: state.nextAction || "No active milestone. Run /gsd unpark <id> or create a new milestone.",
124
+ action: "stop",
125
+ };
126
+ }
127
+ }
128
+ if (active && activeSession && shouldAdoptActiveMilestone(state, activeSession, activeDispatchBasePath)) {
119
129
  activeSession.currentMilestoneId = active.id;
120
130
  }
121
- const prefs = loadEffectiveGSDPreferences(activeDispatchBasePath)?.preferences;
131
+ const dispatchMid = active?.id ?? activeSession?.currentMilestoneId ?? "";
132
+ const dispatchMidTitle = active?.title ?? "";
122
133
  // Derive session-derived dispatch inputs the same way phases.ts:runDispatch does
123
134
  // (#5789). Prefer caller-supplied values when present so test harnesses and
124
135
  // alternative wirings can inject deterministic snapshots; otherwise pull from
@@ -146,8 +157,15 @@ export async function decideOrchestratorDispatch(ctx, pi, dispatchBasePath, sess
146
157
  })
147
158
  ? "true"
148
159
  : "false");
160
+ // Only replay a milestone-scoped verification retry when a milestone is
161
+ // active. Pre-PR (#712 fix), `!active` returned null before reaching this
162
+ // block, so the retry was preserved for a future tick. The new
163
+ // pre-planning + deep-pending fall-through must keep that contract:
164
+ // otherwise a stale execute-task / complete-slice / complete-milestone
165
+ // retry whose target milestone has since been parked would preempt
166
+ // project-level deep rules like `discuss-project`.
149
167
  const pendingRetry = session?.pendingVerificationRetryDispatch;
150
- if (session && pendingRetry) {
168
+ if (session && pendingRetry && active) {
151
169
  session.pendingVerificationRetryDispatch = null;
152
170
  const alreadyClosedReason = getAlreadyClosedDispatchReason(pendingRetry.unitType, pendingRetry.unitId);
153
171
  if (alreadyClosedReason) {
@@ -165,8 +183,8 @@ export async function decideOrchestratorDispatch(ctx, pi, dispatchBasePath, sess
165
183
  }
166
184
  const action = await resolveDispatch({
167
185
  basePath: activeDispatchBasePath,
168
- mid: active.id,
169
- midTitle: active.title,
186
+ mid: dispatchMid,
187
+ midTitle: dispatchMidTitle,
170
188
  state,
171
189
  prefs,
172
190
  session: activeSession,
@@ -211,8 +229,8 @@ export async function decideOrchestratorDispatch(ctx, pi, dispatchBasePath, sess
211
229
  prompt: action.prompt,
212
230
  pauseAfterUatDispatch: action.pauseAfterDispatch ?? false,
213
231
  state,
214
- mid: active.id,
215
- midTitle: active.title,
232
+ mid: dispatchMid,
233
+ midTitle: dispatchMidTitle,
216
234
  };
217
235
  session.pendingOrchestrationDispatch = pending;
218
236
  }
@@ -13,7 +13,7 @@ import { getSessionModelOverride } from "./session-model-override.js";
13
13
  import { logWarning } from "./workflow-logger.js";
14
14
  import { resolveUokFlags } from "./uok/flags.js";
15
15
  import { applyModelPolicyFilter } from "./uok/model-policy.js";
16
- import { isModelBlocked } from "./blocked-models.js";
16
+ import { isModelBlocked, isModelTemporarilyUnavailable } from "./blocked-models.js";
17
17
  import { getRequiredWorkflowToolsForAutoUnit, isWorkflowMcpSurfaceTool } from "./workflow-mcp.js";
18
18
  /**
19
19
  * Thrown when the model-policy gate rejects every candidate model for a unit
@@ -218,6 +218,10 @@ function buildModelPolicyBlockReasons(policyDenyReasons, availableModels, routin
218
218
  reason: `configured model(s) did not resolve against policy-eligible registry [${eligibleSummary}]`,
219
219
  }];
220
220
  }
221
+ function isModelUnavailable(basePath, provider, id) {
222
+ return isModelBlocked(basePath, provider, id) ||
223
+ isModelTemporarilyUnavailable(basePath, provider, id);
224
+ }
221
225
  function restoreToolBaseline(pi) {
222
226
  const key = pi;
223
227
  const baseline = TOOL_BASELINE.get(key);
@@ -657,8 +661,8 @@ autoModeStartThinkingLevel) {
657
661
  // (issue #4513). The block is persisted in .gsd/runtime/blocked-models.json
658
662
  // so it survives /gsd auto restarts — without this, the same dead model
659
663
  // gets reselected after every restart.
660
- if (isModelBlocked(basePath, model.provider, model.id)) {
661
- ctx.ui.notify(`Skipping blocked model ${model.provider}/${model.id} (provider rejected it for this account).`, "warning");
664
+ if (isModelUnavailable(basePath, model.provider, model.id)) {
665
+ ctx.ui.notify(`Skipping unavailable model ${model.provider}/${model.id}.`, "warning");
662
666
  continue;
663
667
  }
664
668
  // Warn if the ID is ambiguous across providers
@@ -724,7 +728,7 @@ autoModeStartThinkingLevel) {
724
728
  const key = `${model.provider.toLowerCase()}/${model.id.toLowerCase()}`;
725
729
  if (!policyAllowedModelKeys.has(key))
726
730
  continue;
727
- if (isModelBlocked(basePath, model.provider, model.id))
731
+ if (isModelUnavailable(basePath, model.provider, model.id))
728
732
  continue;
729
733
  const ok = await pi.setModel(model, { persist: false });
730
734
  if (!ok)
@@ -746,16 +750,16 @@ autoModeStartThinkingLevel) {
746
750
  // No model preference for this unit type — re-apply the model captured
747
751
  // at auto-mode start to prevent bleed from shared global settings.json (#650).
748
752
  const availableModels = buildModelPolicyCandidates(ctx, autoModeStartModel, effectiveSessionModelOverride);
749
- const startBlocked = isModelBlocked(basePath, autoModeStartModel.provider, autoModeStartModel.id);
753
+ const startBlocked = isModelUnavailable(basePath, autoModeStartModel.provider, autoModeStartModel.id);
750
754
  if (startBlocked) {
751
- ctx.ui.notify(`Auto-mode start model ${autoModeStartModel.provider}/${autoModeStartModel.id} is blocked for this account. Using current session model instead.`, "warning");
755
+ ctx.ui.notify(`Auto-mode start model ${autoModeStartModel.provider}/${autoModeStartModel.id} is unavailable. Using current session model instead.`, "warning");
752
756
  }
753
757
  else {
754
758
  const startModel = availableModels.find(m => m.provider === autoModeStartModel.provider && m.id === autoModeStartModel.id);
755
759
  if (startModel) {
756
760
  const ok = await pi.setModel(startModel, { persist: false });
757
761
  if (!ok) {
758
- const byId = availableModels.find(m => m.id === autoModeStartModel.id && !isModelBlocked(basePath, m.provider, m.id));
762
+ const byId = availableModels.find(m => m.id === autoModeStartModel.id && !isModelUnavailable(basePath, m.provider, m.id));
759
763
  if (byId) {
760
764
  const fallbackOk = await pi.setModel(byId, { persist: false });
761
765
  if (fallbackOk) {
@@ -557,6 +557,13 @@ export function getAutoModeStartModel() {
557
557
  export function setCurrentDispatchedModelId(model) {
558
558
  s.currentDispatchedModelId = model ? `${model.provider}/${model.id}` : null;
559
559
  }
560
+ /**
561
+ * Update the active unit model after runtime recovery switches models mid-unit.
562
+ * The next session restore path reads this field before dispatching again.
563
+ */
564
+ export function setCurrentUnitModelForRecovery(model) {
565
+ s.currentUnitModel = model;
566
+ }
560
567
  // Tool tracking — delegates to auto-tool-tracking.ts
561
568
  export function markToolStart(toolCallId, toolName) {
562
569
  _markToolStart(toolCallId, s.active, toolName);
@@ -9,12 +9,16 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
9
9
  import { dirname, join } from "node:path";
10
10
  import { gsdRoot } from "./paths.js";
11
11
  import { withFileLockSync } from "./file-lock.js";
12
+ const temporaryBlockedModels = new Map();
12
13
  function blockedModelsPath(basePath) {
13
14
  return join(gsdRoot(basePath), "runtime", "blocked-models.json");
14
15
  }
15
16
  function modelKey(provider, id) {
16
17
  return `${provider.toLowerCase()}/${id.toLowerCase()}`;
17
18
  }
19
+ function temporaryModelKey(basePath, provider, id) {
20
+ return `${basePath}:${modelKey(provider, id)}`;
21
+ }
18
22
  function readFileSafe(path) {
19
23
  if (!existsSync(path))
20
24
  return { version: 1, blocked: [] };
@@ -41,6 +45,30 @@ export function isModelBlocked(basePath, provider, id) {
41
45
  const target = modelKey(provider, id);
42
46
  return loadBlockedModels(basePath).some((e) => modelKey(e.provider, e.id) === target);
43
47
  }
48
+ export function blockModelUntil(basePath, provider, id, blockedUntil, reason) {
49
+ const key = temporaryModelKey(basePath, provider, id);
50
+ if (blockedUntil <= Date.now()) {
51
+ temporaryBlockedModels.delete(key);
52
+ return;
53
+ }
54
+ temporaryBlockedModels.set(key, { provider, id, reason, blockedUntil });
55
+ }
56
+ export function isModelTemporarilyUnavailable(basePath, provider, id, now = Date.now()) {
57
+ if (!provider || !id)
58
+ return false;
59
+ const key = temporaryModelKey(basePath, provider, id);
60
+ const entry = temporaryBlockedModels.get(key);
61
+ if (!entry)
62
+ return false;
63
+ if (entry.blockedUntil <= now) {
64
+ temporaryBlockedModels.delete(key);
65
+ return false;
66
+ }
67
+ return true;
68
+ }
69
+ export function clearTemporaryModelBlocksForTest() {
70
+ temporaryBlockedModels.clear();
71
+ }
44
72
  export function blockModel(basePath, provider, id, reason) {
45
73
  const path = blockedModelsPath(basePath);
46
74
  mkdirSync(dirname(path), { recursive: true });
@@ -2,7 +2,7 @@
2
2
  import { logWarning } from "../workflow-logger.js";
3
3
  import { checkDeepProjectSetupAfterTurn, checkAutoStartAfterDiscuss, maybeHandleReadyPhraseWithoutFiles, maybeHandleEmptyIntentTurn, resetEmptyTurnCounter, } from "../guided-flow.js";
4
4
  import { clearPathCache } from "../paths.js";
5
- import { getAutoDashboardData, getAutoModeStartModel, isAutoActive, isAutoCompletionStopInProgress, pauseAuto, setCurrentDispatchedModelId, } from "../auto.js";
5
+ import { getAutoDashboardData, getAutoModeStartModel, isAutoActive, isAutoCompletionStopInProgress, pauseAuto, setCurrentDispatchedModelId, setCurrentUnitModelForRecovery, } from "../auto.js";
6
6
  import { getNextFallbackModel, resolveModelWithFallbacksForUnit } from "../preferences.js";
7
7
  import { pauseAutoForProviderError } from "../provider-error-pause.js";
8
8
  import { isSessionSwitchAbortGraceActive, isSessionSwitchInFlight, resolveAgentEnd, resolveAgentEndCancelled, } from "../auto/resolve.js";
@@ -13,7 +13,7 @@ import { clearDiscussionFlowState } from "./write-gate.js";
13
13
  import { clearGuidedUnitContext } from "../guided-unit-context.js";
14
14
  import { resumeAutoAfterProviderDelay } from "./provider-error-resume.js";
15
15
  import { classifyError, createRetryState, resetRetryState, isTransient, } from "../error-classifier.js";
16
- import { blockModel, isModelBlocked } from "../blocked-models.js";
16
+ import { blockModel, blockModelUntil, isModelBlocked, isModelTemporarilyUnavailable } from "../blocked-models.js";
17
17
  import { getProjectGSDPreferencesPath } from "../preferences.js";
18
18
  import { resolveProviderErrorGuidance } from "../provider-error-guidance.js";
19
19
  import { formatGuidance } from "../guidance.js";
@@ -96,9 +96,12 @@ async function tryProviderModelFallback(params) {
96
96
  if (!nextModelId)
97
97
  break;
98
98
  const candidate = resolveModelId(nextModelId, availableModels, rejectedProvider);
99
- if (candidate && !isModelBlocked(basePath, candidate.provider, candidate.id)) {
99
+ if (candidate &&
100
+ !isModelBlocked(basePath, candidate.provider, candidate.id) &&
101
+ !isModelTemporarilyUnavailable(basePath, candidate.provider, candidate.id)) {
100
102
  const ok = await pi.setModel(candidate, { persist: false });
101
103
  if (ok) {
104
+ setCurrentUnitModelForRecovery(candidate);
102
105
  setCurrentDispatchedModelId({ provider: candidate.provider, id: candidate.id });
103
106
  switchedNotify(`${candidate.provider}/${candidate.id}`);
104
107
  pi.sendMessage({ customType: "gsd-auto-timeout-recovery", content: "Continue execution.", display: false }, { triggerTurn: true });
@@ -111,11 +114,13 @@ async function tryProviderModelFallback(params) {
111
114
  const sessionModel = getAutoModeStartModel();
112
115
  if (sessionModel &&
113
116
  !(sessionModel.provider === rejectedProvider && sessionModel.id === rejectedId) &&
114
- !isModelBlocked(basePath, sessionModel.provider, sessionModel.id)) {
117
+ !isModelBlocked(basePath, sessionModel.provider, sessionModel.id) &&
118
+ !isModelTemporarilyUnavailable(basePath, sessionModel.provider, sessionModel.id)) {
115
119
  const startModel = availableModels.find((m) => m.provider === sessionModel.provider && m.id === sessionModel.id);
116
120
  if (startModel) {
117
121
  const ok = await pi.setModel(startModel, { persist: false });
118
122
  if (ok) {
123
+ setCurrentUnitModelForRecovery(startModel);
119
124
  setCurrentDispatchedModelId({ provider: startModel.provider, id: startModel.id });
120
125
  switchedNotify(`${startModel.provider}/${startModel.id}`);
121
126
  pi.sendMessage({ customType: "gsd-auto-timeout-recovery", content: "Continue execution.", display: false }, { triggerTurn: true });
@@ -533,6 +538,10 @@ export async function handleAgentEnd(pi, event, ctx) {
533
538
  if (currentProvider === "openai-codex" || currentProvider === "google-gemini-cli") {
534
539
  cls.retryAfterMs = Math.min(cls.retryAfterMs, 30_000);
535
540
  }
541
+ const dash = getAutoDashboardData();
542
+ if (dash.basePath && ctx.model?.provider && ctx.model?.id) {
543
+ blockModelUntil(dash.basePath, ctx.model.provider, ctx.model.id, Date.now() + cls.retryAfterMs, rawErrorMsg || displayMsg || "rate limit");
544
+ }
536
545
  }
537
546
  // ── 2. Decide & Act ──────────────────────────────────────────────────
538
547
  // --- Network errors: same-model retry with backoff ---
@@ -572,9 +581,14 @@ export async function handleAgentEnd(pi, event, ctx) {
572
581
  retryState.networkRetryCount = 0;
573
582
  retryState.currentRetryModelId = undefined;
574
583
  const modelToSet = resolveModelId(nextModelId, availableModels, ctx.model?.provider);
575
- if (modelToSet) {
584
+ const modelUnavailable = dash.basePath && modelToSet
585
+ ? isModelBlocked(dash.basePath, modelToSet.provider, modelToSet.id) ||
586
+ isModelTemporarilyUnavailable(dash.basePath, modelToSet.provider, modelToSet.id)
587
+ : false;
588
+ if (modelToSet && !modelUnavailable) {
576
589
  const ok = await pi.setModel(modelToSet, { persist: false });
577
590
  if (ok) {
591
+ setCurrentUnitModelForRecovery(modelToSet);
578
592
  setCurrentDispatchedModelId({ provider: modelToSet.provider, id: modelToSet.id });
579
593
  ctx.ui.notify(`Model error${errorDetail}. Switched to fallback: ${nextModelId} and resuming.`, "warning");
580
594
  pi.sendMessage({ customType: "gsd-auto-timeout-recovery", content: "Continue execution.", display: false }, { triggerTurn: true });
@@ -587,11 +601,17 @@ export async function handleAgentEnd(pi, event, ctx) {
587
601
  // Try restoring session model
588
602
  const sessionModel = getAutoModeStartModel();
589
603
  if (sessionModel) {
590
- if (ctx.model?.id !== sessionModel.id || ctx.model?.provider !== sessionModel.provider) {
604
+ const dash = getAutoDashboardData();
605
+ const sessionModelUnavailable = dash.basePath
606
+ ? isModelBlocked(dash.basePath, sessionModel.provider, sessionModel.id) ||
607
+ isModelTemporarilyUnavailable(dash.basePath, sessionModel.provider, sessionModel.id)
608
+ : false;
609
+ if (!sessionModelUnavailable && (ctx.model?.id !== sessionModel.id || ctx.model?.provider !== sessionModel.provider)) {
591
610
  const startModel = ctx.modelRegistry.getAvailable().find((m) => m.provider === sessionModel.provider && m.id === sessionModel.id);
592
611
  if (startModel) {
593
612
  const ok = await pi.setModel(startModel, { persist: false });
594
613
  if (ok) {
614
+ setCurrentUnitModelForRecovery(startModel);
595
615
  setCurrentDispatchedModelId({ provider: startModel.provider, id: startModel.id });
596
616
  retryState.networkRetryCount = 0;
597
617
  retryState.currentRetryModelId = undefined;
@@ -114,8 +114,8 @@ export function registerExecTools(pi) {
114
114
  ],
115
115
  parameters: Type.Object({
116
116
  query: Type.Optional(Type.String({ description: "Substring matched against id and purpose (case-insensitive)." })),
117
- runtime: Type.Optional(Type.Union([Type.Literal("bash"), Type.Literal("node"), Type.Literal("python")], {
118
- description: "Restrict to one runtime.",
117
+ runtime: Type.Optional(Type.String({
118
+ description: "Restrict to one runtime: bash, node, or python.",
119
119
  })),
120
120
  failing_only: Type.Optional(Type.Boolean({ description: "Only non-zero exit codes and timeouts." })),
121
121
  limit: Type.Optional(Type.Number({ description: "Max results (default 20, cap 200)", minimum: 1, maximum: 200 })),
@@ -1,22 +1,107 @@
1
1
  // Project/App: gsd-pi
2
2
  // File Purpose: Shared closeout detection and merge actions for /gsd home and smart entry.
3
+ import { existsSync, readdirSync, statSync } from "node:fs";
4
+ import { join } from "node:path";
3
5
  import { setAutoOutcomeWidget } from "./auto-dashboard.js";
4
6
  import { invalidateAllCaches } from "./cache.js";
7
+ import { isDbAvailable } from "./db/engine.js";
8
+ import { getMilestone } from "./db/queries.js";
9
+ import { MILESTONE_ID_RE } from "./milestone-ids.js";
5
10
  import { mergeCompletedMilestone } from "./parallel-merge.js";
6
11
  import { cleanupQuickBranch, detectStrandedQuickBranch } from "./quick.js";
12
+ import { isClosedStatus } from "./status-guards.js";
7
13
  import { findUnmergedCompletedMilestones, } from "./unmerged-milestone-guard.js";
8
14
  import { appendRequirementsBacklogToSummary } from "./requirements-backlog.js";
15
+ import { nativeBranchList, nativeIsRepo } from "./native-git-bridge.js";
16
+ import { allWorktreesDirs } from "./worktree-manager.js";
9
17
  const MILESTONE_MERGE_CLOSEOUT_COMMANDS = [
10
18
  "/gsd status for overview",
11
19
  "/gsd visualize to inspect",
12
20
  "/gsd notifications for history",
13
21
  "/gsd start for new work",
14
22
  ];
23
+ function listMilestoneWorktreeIds(basePath) {
24
+ const ids = new Set();
25
+ for (const wtDir of allWorktreesDirs(basePath)) {
26
+ if (!existsSync(wtDir))
27
+ continue;
28
+ for (const entry of readdirSync(wtDir)) {
29
+ if (!MILESTONE_ID_RE.test(entry))
30
+ continue;
31
+ try {
32
+ if (statSync(join(wtDir, entry)).isDirectory())
33
+ ids.add(entry);
34
+ }
35
+ catch {
36
+ // skip unreadable entries
37
+ }
38
+ }
39
+ }
40
+ return [...ids].sort();
41
+ }
42
+ function listMilestoneBranchIds(basePath) {
43
+ try {
44
+ return nativeBranchList(basePath, "milestone/*")
45
+ .map((branch) => branch.replace(/^milestone\//, ""))
46
+ .filter((id) => MILESTONE_ID_RE.test(id))
47
+ .sort();
48
+ }
49
+ catch {
50
+ return [];
51
+ }
52
+ }
53
+ /**
54
+ * A milestone ID is "stranded residue" only when its worktree/branch artifacts
55
+ * exist for a milestone the DB does not consider currently in flight — i.e. the
56
+ * row is closed (complete/done/skipped/closed) or absent. Active, pending,
57
+ * blocked, parked, queued, and deferred rows describe normal in-flight or
58
+ * intentionally-preserved state, never residue. Returning `false` skips the ID;
59
+ * returning `true` keeps it in the hint.
60
+ */
61
+ function isStrandedMilestoneId(milestoneId) {
62
+ if (!isDbAvailable())
63
+ return true;
64
+ const row = getMilestone(milestoneId);
65
+ if (!row)
66
+ return true;
67
+ return isClosedStatus(row.status);
68
+ }
69
+ /** Surface stranded milestone git residue when closeout guards did not classify it. */
70
+ export function detectIdleMilestoneResidueHint(basePath) {
71
+ if (!nativeIsRepo(basePath))
72
+ return null;
73
+ const gsdDir = join(basePath, ".gsd");
74
+ const dbPath = join(gsdDir, "gsd.db");
75
+ if (!existsSync(gsdDir) || !existsSync(dbPath)) {
76
+ return {
77
+ milestoneIds: [],
78
+ message: "This git repo has no local GSD workflow database (.gsd/gsd.db). " +
79
+ "Workflow state may live in an external worktree, or run /gsd new-project to initialize here.",
80
+ };
81
+ }
82
+ const worktreeIds = listMilestoneWorktreeIds(basePath);
83
+ const branchIds = listMilestoneBranchIds(basePath);
84
+ const candidateIds = [...new Set([...worktreeIds, ...branchIds])].sort();
85
+ const milestoneIds = candidateIds.filter(isStrandedMilestoneId);
86
+ if (milestoneIds.length === 0)
87
+ return null;
88
+ const listed = milestoneIds.join(", ");
89
+ const recovery = milestoneIds.length === 1
90
+ ? `/gsd dispatch complete-milestone ${milestoneIds[0]}`
91
+ : "/gsd doctor --fix";
92
+ return {
93
+ milestoneIds,
94
+ message: `Stranded milestone git residue detected (${listed}: worktree dir and/or milestone/* branch). ` +
95
+ `Run ${recovery} or /gsd status to recover closeout before starting new work.`,
96
+ };
97
+ }
15
98
  export async function loadCloseoutContext(basePath) {
16
99
  const unmergedMilestones = await findUnmergedCompletedMilestones(basePath);
100
+ const idleResidueHint = unmergedMilestones.length === 0 ? detectIdleMilestoneResidueHint(basePath) : null;
17
101
  return {
18
102
  strandedQuick: detectStrandedQuickBranch(basePath),
19
103
  unmergedMilestones,
104
+ idleResidueHint,
20
105
  };
21
106
  }
22
107
  export function getPrimaryCloseoutRecommendation(closeout) {
@@ -62,6 +147,13 @@ export function buildIdleMenuSummary(state, closeout) {
62
147
  `${blocker.milestoneId} is complete but not merged into ${blocker.integrationBranch}.`,
63
148
  ];
64
149
  }
150
+ // Surface idle residue before the completion summary so smart entry shows
151
+ // the same recovery text /gsd home would: a closed/unknown milestone with
152
+ // lingering worktree/branch artifacts must not be hidden behind the
153
+ // "all milestones complete" message.
154
+ if (closeout.idleResidueHint) {
155
+ return [closeout.idleResidueHint.message];
156
+ }
65
157
  if (state.phase === "complete") {
66
158
  const last = state.lastCompletedMilestone;
67
159
  return appendRequirementsBacklogToSummary(state, [
@@ -7,7 +7,7 @@
7
7
  import { existsSync, readFileSync, mkdirSync } from "node:fs";
8
8
  import { execFileSync } from "node:child_process";
9
9
  import { createRequire } from "node:module";
10
- import { join, resolve as resolvePath, sep } from "node:path";
10
+ import { join, resolve as resolvePath, sep, win32 as pathWin32 } from "node:path";
11
11
  import { homedir } from "node:os";
12
12
  import { deriveState } from "./state.js";
13
13
  import { gsdRoot } from "./paths.js";
@@ -20,6 +20,7 @@ import { getAutoWorktreePath } from "./auto-worktree.js";
20
20
  import { currentDirectoryRoot, projectRoot } from "./commands/context.js";
21
21
  import { loadPrompt } from "./prompt-loader.js";
22
22
  import { buildClaudeRuntimeFloorAdvisory } from "../../shared/claude-runtime-floor.js";
23
+ import { reconcileGsdBrowserPathAfterInstall } from "../../shared/gsd-browser-path-sync.js";
23
24
  import { isPnpmInstall } from "../../shared/package-manager-detection.js";
24
25
  import { buildDoctorHealIssuePayload, buildDoctorHealSummary, buildWorkflowDispatchContent, } from "./workflow-protocol.js";
25
26
  import { restoreGsdWorkflowTools, scopeGsdWorkflowToolsForDispatch, } from "./bootstrap/register-hooks.js";
@@ -51,8 +52,31 @@ function resolveInstallCommand(pkg) {
51
52
  return `bun add -g ${pkg}`;
52
53
  if (isPnpmInstall())
53
54
  return `pnpm add -g ${pkg}`;
55
+ const npmPrefix = resolveWindowsNpmGlobalPrefix();
56
+ if (npmPrefix)
57
+ return `npm --prefix ${quoteWindowsArg(npmPrefix)} install -g ${pkg}`;
54
58
  return `npm install -g ${pkg}`;
55
59
  }
60
+ function resolveWindowsNpmGlobalPrefix(argv1 = process.argv[1], platform = process.platform) {
61
+ if (platform !== "win32" || !argv1)
62
+ return null;
63
+ const normalized = pathWin32.normalize(argv1);
64
+ const marker = `${pathWin32.sep}node_modules${pathWin32.sep}`;
65
+ const index = normalized.toLowerCase().lastIndexOf(marker);
66
+ if (index <= 0)
67
+ return null;
68
+ const prefix = normalized.slice(0, index);
69
+ // Verify this is a real npm global prefix: such a directory always contains
70
+ // npm's own bin shim (`npm.cmd`) as a sibling of `node_modules/`. Local
71
+ // project `node_modules/`, npx caches, and other non-global layouts do not,
72
+ // so without this check `--prefix` would target the wrong directory.
73
+ if (!existsSync(pathWin32.join(prefix, "npm.cmd")))
74
+ return null;
75
+ return prefix;
76
+ }
77
+ function quoteWindowsArg(value) {
78
+ return `"${value.replace(/"/g, '\\"')}"`;
79
+ }
56
80
  function notifyClaudeRuntimeFloorAdvisory(ctx) {
57
81
  let advisory = null;
58
82
  try {
@@ -484,11 +508,30 @@ export async function handleUpdate(ctx, args = "") {
484
508
  execSync(installCmd, {
485
509
  stdio: ["ignore", "pipe", "ignore"],
486
510
  });
511
+ let reconcile = null;
512
+ if (browserUpdate) {
513
+ try {
514
+ reconcile = reconcileGsdBrowserPathAfterInstall({
515
+ latestVersion: latest,
516
+ compareSemver: compareSemverLocal,
517
+ resolvePathVersion: resolveGsdBrowserPathVersionForCommand,
518
+ });
519
+ }
520
+ catch {
521
+ // Reconciliation is best-effort: the install above already succeeded,
522
+ // so a reconcile failure must not flip the result to "Update failed".
523
+ reconcile = null;
524
+ }
525
+ }
487
526
  const newPathVersion = browserUpdate ? resolveGsdBrowserPathVersionForCommand() : null;
488
- const pathReady = !browserUpdate || (!!newPathVersion && compareSemverLocal(newPathVersion, latest) >= 0);
527
+ const pathNote = browserUpdate && !(newPathVersion && compareSemverLocal(newPathVersion, latest) >= 0)
528
+ ? (reconcile?.message
529
+ ?? "Ensure the npm global bin directory is on your PATH so MCP automation uses the updated binary.")
530
+ : "";
489
531
  ctx.ui.notify(browserUpdate
490
532
  ? `Updated gsd-browser to v${latest}. Restart your GSD session to use the new browser automation version.` +
491
- (pathReady ? "" : "\nNote: Ensure the npm global bin directory is on your PATH so MCP automation uses the updated binary.")
533
+ (reconcile?.action === "synced" && reconcile.message ? `\n${reconcile.message}` : "") +
534
+ (pathNote ? `\nNote: ${pathNote}` : "")
492
535
  : `Updated to v${latest}. Restart your GSD session to use the new version.`, "info");
493
536
  if (!browserUpdate)
494
537
  notifyClaudeRuntimeFloorAdvisory(ctx);
@@ -62,6 +62,20 @@ export function hasApprovalQuestion(text) {
62
62
  export function hasResearchDecisionQuestion(text) {
63
63
  return hasQuestionMatching(text, [RESEARCH_DECISION_QUESTION_RE]);
64
64
  }
65
+ /**
66
+ * Detect a plain-text "Next steps:" menu — numbered options with an "Other"
67
+ * choice — emitted as prose instead of a structured ask_user_questions call.
68
+ * Without this, auto-mode treats the menu as informational and loops on its
69
+ * own turn until tokens are exhausted (#454).
70
+ */
71
+ export function hasPlainTextNextStepsMenu(lines) {
72
+ const nextStepsIndex = lines.findIndex((line) => /^next steps\s*:?$/i.test(line));
73
+ if (nextStepsIndex < 0)
74
+ return false;
75
+ const menuLines = lines.slice(nextStepsIndex + 1);
76
+ const numberedOptions = menuLines.filter((line) => /^\d+[.)]\s+\S/.test(line));
77
+ return numberedOptions.length >= 2 && numberedOptions.some((line) => /\bother\b/i.test(line));
78
+ }
65
79
  // ── Message text extraction (moved from user-input-boundary) ────────────────
66
80
  function extractMessageText(msg, includeThinking) {
67
81
  if (!msg || typeof msg !== "object")
@@ -273,6 +287,8 @@ export function isAwaitingUserInput(messages) {
273
287
  const lines = text.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
274
288
  if (lines.some((line) => line.endsWith("?")))
275
289
  return true;
290
+ if (hasPlainTextNextStepsMenu(lines))
291
+ return true;
276
292
  return hasApprovalQuestion(text);
277
293
  }
278
294
  export function isAwaitingApprovalBoundary(messages) {
@@ -3,10 +3,9 @@ import { spawnSync } from "node:child_process";
3
3
  import { cpSync, existsSync, mkdirSync, readdirSync, realpathSync, rmSync, statSync } from "node:fs";
4
4
  import { dirname, join } from "node:path";
5
5
  import { loadFile } from "./files.js";
6
- import { parseRoadmap as parseLegacyRoadmap } from "./parsers-legacy.js";
7
- import { isDbAvailable, getMilestone } from "./gsd-db.js";
8
6
  import { resolveMilestoneFile } from "./paths.js";
9
- import { deriveState, isMilestoneComplete } from "./state.js";
7
+ import { isCompletedMilestoneTerminal } from "./milestone-closeout.js";
8
+ import { deriveState } from "./state.js";
10
9
  import { allWorktreesDirs, createWorktree, listWorktrees, resolveGitDir } from "./worktree-manager.js";
11
10
  import { abortAndReset } from "./git-self-heal.js";
12
11
  import { RUNTIME_EXCLUSION_PATHS, resolveMilestoneIntegrationBranch, writeIntegrationBranch } from "./git-service.js";
@@ -141,21 +140,6 @@ function getSnapshotDiffCheckFailure(basePath) {
141
140
  }
142
141
  return failures.length > 0 ? failures.join("\n") : null;
143
142
  }
144
- async function isCompletedMilestoneTerminal(basePath, milestoneId) {
145
- const summaryPath = resolveMilestoneFile(basePath, milestoneId, "SUMMARY");
146
- if (!summaryPath)
147
- return false;
148
- if (isDbAvailable()) {
149
- const milestone = getMilestone(milestoneId);
150
- return !!milestone && milestone.status === "complete";
151
- }
152
- const roadmapPath = resolveMilestoneFile(basePath, milestoneId, "ROADMAP");
153
- const roadmapContent = roadmapPath ? await loadFile(roadmapPath) : null;
154
- if (!roadmapContent)
155
- return false;
156
- const roadmap = parseLegacyRoadmap(roadmapContent);
157
- return isMilestoneComplete(roadmap);
158
- }
159
143
  export async function checkGitHealth(basePath, issues, fixesApplied, shouldFix, isolationMode = "none") {
160
144
  // Degrade gracefully if not a git repo
161
145
  if (!nativeIsRepo(basePath)) {