gsd-pi 2.61.0-dev.7aed0bf → 2.62.0-dev.a987556

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 (119) hide show
  1. package/dist/resources/extensions/ask-user-questions.js +47 -3
  2. package/dist/resources/extensions/gsd/auto-start.js +11 -6
  3. package/dist/resources/extensions/gsd/auto-timers.js +8 -2
  4. package/dist/resources/extensions/gsd/auto-worktree.js +19 -0
  5. package/dist/resources/extensions/gsd/auto.js +24 -0
  6. package/dist/resources/extensions/gsd/bootstrap/register-hooks.js +4 -0
  7. package/dist/resources/extensions/gsd/bootstrap/tool-call-loop-guard.js +11 -1
  8. package/dist/resources/extensions/gsd/commands-handlers.js +18 -7
  9. package/dist/resources/extensions/gsd/db-writer.js +64 -28
  10. package/dist/resources/extensions/gsd/preferences-models.js +74 -0
  11. package/dist/resources/extensions/gsd/preferences-skills.js +6 -1
  12. package/dist/resources/extensions/gsd/prompts/guided-discuss-milestone.md +1 -1
  13. package/dist/resources/extensions/gsd/prompts/guided-discuss-slice.md +1 -1
  14. package/dist/resources/extensions/gsd/skill-catalog.js +6 -4
  15. package/dist/resources/extensions/gsd/skill-discovery.js +24 -6
  16. package/dist/resources/extensions/gsd/skill-health.js +7 -3
  17. package/dist/resources/extensions/gsd/skill-telemetry.js +5 -2
  18. package/dist/web/standalone/.next/BUILD_ID +1 -1
  19. package/dist/web/standalone/.next/app-path-routes-manifest.json +16 -16
  20. package/dist/web/standalone/.next/build-manifest.json +2 -2
  21. package/dist/web/standalone/.next/prerender-manifest.json +3 -3
  22. package/dist/web/standalone/.next/required-server-files.json +1 -1
  23. package/dist/web/standalone/.next/server/app/_global-error.html +2 -2
  24. package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
  25. package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  26. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  27. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  28. package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  29. package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  30. package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  31. package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
  32. package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
  33. package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  34. package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  35. package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  36. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  37. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  38. package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  39. package/dist/web/standalone/.next/server/app/index.html +1 -1
  40. package/dist/web/standalone/.next/server/app/index.rsc +1 -1
  41. package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
  42. package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
  43. package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  44. package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
  45. package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  46. package/dist/web/standalone/.next/server/app-paths-manifest.json +16 -16
  47. package/dist/web/standalone/.next/server/pages/404.html +1 -1
  48. package/dist/web/standalone/.next/server/pages/500.html +2 -2
  49. package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
  50. package/dist/web/standalone/server.js +1 -1
  51. package/package.json +1 -1
  52. package/packages/mcp-server/src/cli.ts +1 -1
  53. package/packages/mcp-server/src/index.ts +15 -1
  54. package/packages/mcp-server/src/readers/captures.ts +119 -0
  55. package/packages/mcp-server/src/readers/doctor-lite.ts +225 -0
  56. package/packages/mcp-server/src/readers/index.ts +16 -0
  57. package/packages/mcp-server/src/readers/knowledge.ts +111 -0
  58. package/packages/mcp-server/src/readers/metrics.ts +118 -0
  59. package/packages/mcp-server/src/readers/paths.ts +217 -0
  60. package/packages/mcp-server/src/readers/readers.test.ts +509 -0
  61. package/packages/mcp-server/src/readers/roadmap.ts +263 -0
  62. package/packages/mcp-server/src/readers/state.ts +223 -0
  63. package/packages/mcp-server/src/server.ts +134 -3
  64. package/packages/pi-ai/dist/utils/repair-tool-json.d.ts +26 -6
  65. package/packages/pi-ai/dist/utils/repair-tool-json.d.ts.map +1 -1
  66. package/packages/pi-ai/dist/utils/repair-tool-json.js +67 -9
  67. package/packages/pi-ai/dist/utils/repair-tool-json.js.map +1 -1
  68. package/packages/pi-ai/dist/utils/tests/repair-tool-json.test.js +73 -1
  69. package/packages/pi-ai/dist/utils/tests/repair-tool-json.test.js.map +1 -1
  70. package/packages/pi-ai/src/utils/repair-tool-json.ts +74 -10
  71. package/packages/pi-ai/src/utils/tests/repair-tool-json.test.ts +94 -1
  72. package/packages/pi-coding-agent/dist/core/agent-session-model-switch.test.d.ts +2 -0
  73. package/packages/pi-coding-agent/dist/core/agent-session-model-switch.test.d.ts.map +1 -0
  74. package/packages/pi-coding-agent/dist/core/agent-session-model-switch.test.js +16 -0
  75. package/packages/pi-coding-agent/dist/core/agent-session-model-switch.test.js.map +1 -0
  76. package/packages/pi-coding-agent/dist/core/agent-session.d.ts.map +1 -1
  77. package/packages/pi-coding-agent/dist/core/agent-session.js +4 -0
  78. package/packages/pi-coding-agent/dist/core/agent-session.js.map +1 -1
  79. package/packages/pi-coding-agent/dist/core/retry-handler.d.ts +3 -0
  80. package/packages/pi-coding-agent/dist/core/retry-handler.d.ts.map +1 -1
  81. package/packages/pi-coding-agent/dist/core/retry-handler.js +48 -16
  82. package/packages/pi-coding-agent/dist/core/retry-handler.js.map +1 -1
  83. package/packages/pi-coding-agent/dist/core/retry-handler.test.js +20 -3
  84. package/packages/pi-coding-agent/dist/core/retry-handler.test.js.map +1 -1
  85. package/packages/pi-coding-agent/package.json +1 -1
  86. package/packages/pi-coding-agent/src/core/agent-session-model-switch.test.ts +21 -0
  87. package/packages/pi-coding-agent/src/core/agent-session.ts +4 -0
  88. package/packages/pi-coding-agent/src/core/retry-handler.test.ts +30 -3
  89. package/packages/pi-coding-agent/src/core/retry-handler.ts +49 -16
  90. package/pkg/package.json +1 -1
  91. package/src/resources/extensions/ask-user-questions.ts +60 -4
  92. package/src/resources/extensions/gsd/auto-start.ts +11 -6
  93. package/src/resources/extensions/gsd/auto-timers.ts +8 -2
  94. package/src/resources/extensions/gsd/auto-worktree.ts +18 -0
  95. package/src/resources/extensions/gsd/auto.ts +25 -0
  96. package/src/resources/extensions/gsd/bootstrap/register-hooks.ts +4 -0
  97. package/src/resources/extensions/gsd/bootstrap/tool-call-loop-guard.ts +13 -1
  98. package/src/resources/extensions/gsd/commands-handlers.ts +20 -7
  99. package/src/resources/extensions/gsd/db-writer.ts +67 -30
  100. package/src/resources/extensions/gsd/preferences-models.ts +78 -0
  101. package/src/resources/extensions/gsd/preferences-skills.ts +6 -1
  102. package/src/resources/extensions/gsd/prompts/guided-discuss-milestone.md +1 -1
  103. package/src/resources/extensions/gsd/prompts/guided-discuss-slice.md +1 -1
  104. package/src/resources/extensions/gsd/skill-catalog.ts +6 -3
  105. package/src/resources/extensions/gsd/skill-discovery.ts +23 -6
  106. package/src/resources/extensions/gsd/skill-health.ts +7 -3
  107. package/src/resources/extensions/gsd/skill-telemetry.ts +5 -2
  108. package/src/resources/extensions/gsd/tests/ask-user-questions-dedup.test.ts +120 -0
  109. package/src/resources/extensions/gsd/tests/auto-start-model-capture.test.ts +22 -2
  110. package/src/resources/extensions/gsd/tests/auto-wrapup-inflight-guard.test.ts +107 -0
  111. package/src/resources/extensions/gsd/tests/claude-skill-dirs.test.ts +51 -0
  112. package/src/resources/extensions/gsd/tests/db-writer.test.ts +41 -0
  113. package/src/resources/extensions/gsd/tests/model-isolation.test.ts +75 -1
  114. package/src/resources/extensions/gsd/tests/steer-worktree-path.test.ts +108 -0
  115. package/src/resources/extensions/gsd/tests/tool-call-loop-guard.test.ts +17 -4
  116. package/src/resources/extensions/gsd/tests/worktree-db-respawn-truncation.test.ts +81 -2
  117. package/src/resources/extensions/shared/tests/ask-user-freetext.test.ts +6 -1
  118. /package/dist/web/standalone/.next/static/{b7FOoMHaUb3FPoLNbxar4 → AsCFY1___4XTI6GkTQkFb}/_buildManifest.js +0 -0
  119. /package/dist/web/standalone/.next/static/{b7FOoMHaUb3FPoLNbxar4 → AsCFY1___4XTI6GkTQkFb}/_ssgManifest.js +0 -0
@@ -37,6 +37,8 @@ export class RetryHandler {
37
37
  private _retryAttempt = 0;
38
38
  private _retryPromise: Promise<void> | undefined = undefined;
39
39
  private _retryResolve: (() => void) | undefined = undefined;
40
+ private _retryGeneration = 0;
41
+ private _continueTimeout: ReturnType<typeof setTimeout> | undefined = undefined;
40
42
 
41
43
  constructor(private readonly _deps: RetryHandlerDeps) {}
42
44
 
@@ -134,6 +136,7 @@ export class RetryHandler {
134
136
  }
135
137
 
136
138
  // Try credential fallback before counting against retry budget.
139
+ const retryGeneration = this._retryGeneration;
137
140
  if (this._deps.getModel() && message.errorMessage) {
138
141
  const errorType = this._classifyErrorType(message.errorMessage);
139
142
  const isCredentialError = errorType === "rate_limit" || errorType === "quota_exhausted";
@@ -157,9 +160,7 @@ export class RetryHandler {
157
160
  });
158
161
 
159
162
  // Retry immediately with the next credential - don't increment _retryAttempt
160
- setTimeout(() => {
161
- this._deps.agent.continue().catch(() => {});
162
- }, 0);
163
+ this._scheduleContinue(retryGeneration);
163
164
 
164
165
  return true;
165
166
  }
@@ -193,9 +194,7 @@ export class RetryHandler {
193
194
  });
194
195
 
195
196
  // Retry immediately with fallback provider - don't increment _retryAttempt
196
- setTimeout(() => {
197
- this._deps.agent.continue().catch(() => {});
198
- }, 0);
197
+ this._scheduleContinue(retryGeneration);
199
198
 
200
199
  return true;
201
200
  }
@@ -203,7 +202,7 @@ export class RetryHandler {
203
202
  // No fallback available either
204
203
  if (errorType === "quota_exhausted") {
205
204
  // Try long-context model downgrade ([1m] → base) before giving up
206
- const downgraded = this._tryLongContextDowngrade(message);
205
+ const downgraded = this._tryLongContextDowngrade(message, retryGeneration);
207
206
  if (downgraded) return true;
208
207
 
209
208
  this._deps.emit({
@@ -274,7 +273,12 @@ export class RetryHandler {
274
273
  try {
275
274
  await sleep(delayMs, this._retryAbortController.signal);
276
275
  } catch {
277
- // Aborted during sleep
276
+ // Aborted during sleep. If the retry generation already advanced, this
277
+ // cancellation was handled externally (e.g. explicit model switch).
278
+ if (retryGeneration !== this._retryGeneration) {
279
+ this._retryAbortController = undefined;
280
+ return false;
281
+ }
278
282
  const attempt = this._retryAttempt;
279
283
  this._retryAttempt = 0;
280
284
  this._retryAbortController = undefined;
@@ -290,16 +294,36 @@ export class RetryHandler {
290
294
  this._retryAbortController = undefined;
291
295
 
292
296
  // Retry via continue() - use setTimeout to break out of event handler chain
293
- setTimeout(() => {
294
- this._deps.agent.continue().catch(() => {});
295
- }, 0);
297
+ this._scheduleContinue(retryGeneration);
296
298
 
297
299
  return true;
298
300
  }
299
301
 
300
302
  /** Cancel in-progress retry */
301
303
  abortRetry(): void {
302
- this._retryAbortController?.abort();
304
+ const hadRetry =
305
+ this._retryPromise !== undefined
306
+ || this._retryAbortController !== undefined
307
+ || this._continueTimeout !== undefined;
308
+ if (!hadRetry) return;
309
+
310
+ const attempt = this._retryAttempt > 0 ? this._retryAttempt : 1;
311
+ this._retryGeneration++;
312
+ if (this._continueTimeout) {
313
+ clearTimeout(this._continueTimeout);
314
+ this._continueTimeout = undefined;
315
+ }
316
+ if (this._retryAbortController) {
317
+ this._retryAbortController.abort();
318
+ this._retryAbortController = undefined;
319
+ }
320
+ this._retryAttempt = 0;
321
+ this._deps.emit({
322
+ type: "auto_retry_end",
323
+ success: false,
324
+ attempt,
325
+ finalError: "Retry cancelled",
326
+ });
303
327
  this._resolveRetry();
304
328
  }
305
329
 
@@ -330,6 +354,17 @@ export class RetryHandler {
330
354
  }
331
355
  }
332
356
 
357
+ private _scheduleContinue(retryGeneration: number): void {
358
+ if (this._continueTimeout) {
359
+ clearTimeout(this._continueTimeout);
360
+ }
361
+ this._continueTimeout = setTimeout(() => {
362
+ this._continueTimeout = undefined;
363
+ if (retryGeneration !== this._retryGeneration) return;
364
+ this._deps.agent.continue().catch(() => {});
365
+ }, 0);
366
+ }
367
+
333
368
  private _findLastAssistantInMessages(
334
369
  messages: Array<{ role: string } & Record<string, any>>,
335
370
  ): AssistantMessage | undefined {
@@ -361,7 +396,7 @@ export class RetryHandler {
361
396
  * base model (claude-opus-4-6) when the account lacks the long-context billing
362
397
  * entitlement. Returns true if the downgrade was initiated.
363
398
  */
364
- private _tryLongContextDowngrade(message: AssistantMessage): boolean {
399
+ private _tryLongContextDowngrade(message: AssistantMessage, retryGeneration: number): boolean {
365
400
  const currentModel = this._deps.getModel();
366
401
  if (!currentModel) return false;
367
402
 
@@ -393,9 +428,7 @@ export class RetryHandler {
393
428
  errorMessage: `${message.errorMessage} (long context downgrade)`,
394
429
  });
395
430
 
396
- setTimeout(() => {
397
- this._deps.agent.continue().catch(() => {});
398
- }, 0);
431
+ this._scheduleContinue(retryGeneration);
399
432
 
400
433
  return true;
401
434
  }
package/pkg/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glittercowboy/gsd",
3
- "version": "2.61.0",
3
+ "version": "2.62.0",
4
4
  "piConfig": {
5
5
  "name": "gsd",
6
6
  "configDir": ".gsd"
@@ -72,6 +72,41 @@ const AskUserQuestionsParams = Type.Object({
72
72
  }),
73
73
  });
74
74
 
75
+ // ─── Per-turn deduplication ──────────────────────────────────────────────────
76
+ // Prevents duplicate question dispatches (especially to remote channels like
77
+ // Discord) when the LLM calls ask_user_questions multiple times with the same
78
+ // questions in a single turn. Keyed by full canonicalized payload (id, header,
79
+ // question, options, allowMultiple) — not just IDs — so that calls with the
80
+ // same IDs but different text/options are treated as distinct.
81
+
82
+ import { createHash } from "node:crypto";
83
+
84
+ interface CachedResult {
85
+ content: { type: "text"; text: string }[];
86
+ details: AskUserQuestionsDetails;
87
+ }
88
+
89
+ const turnCache = new Map<string, CachedResult>();
90
+
91
+ /** @internal Exported for testing only. */
92
+ export function questionSignature(questions: Question[]): string {
93
+ const canonical = questions
94
+ .map((q) => ({
95
+ id: q.id,
96
+ header: q.header,
97
+ question: q.question,
98
+ options: (q.options || []).map((o) => ({ label: o.label, description: o.description })),
99
+ allowMultiple: !!q.allowMultiple,
100
+ }))
101
+ .sort((a, b) => a.id.localeCompare(b.id));
102
+ return createHash("sha256").update(JSON.stringify(canonical)).digest("hex").slice(0, 16);
103
+ }
104
+
105
+ /** Reset the dedup cache. Called on session boundaries. */
106
+ export function resetAskUserQuestionsCache(): void {
107
+ turnCache.clear();
108
+ }
109
+
75
110
  // ─── Helpers ──────────────────────────────────────────────────────────────────
76
111
 
77
112
  const OTHER_OPTION_LABEL = "None of the above";
@@ -121,6 +156,16 @@ export default function AskUserQuestions(pi: ExtensionAPI) {
121
156
  parameters: AskUserQuestionsParams,
122
157
 
123
158
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
159
+ // ── Per-turn dedup: return cached result for identical question sets ──
160
+ const sig = questionSignature(params.questions);
161
+ const cached = turnCache.get(sig);
162
+ if (cached) {
163
+ return {
164
+ content: [{ type: "text" as const, text: cached.content[0].text + "\n(Returned cached answer — this question set was already asked this turn.)" }],
165
+ details: cached.details,
166
+ };
167
+ }
168
+
124
169
  // Validation
125
170
  if (params.questions.length === 0 || params.questions.length > 3) {
126
171
  return errorResult("Error: questions must contain 1-3 items", params.questions);
@@ -140,7 +185,14 @@ export default function AskUserQuestions(pi: ExtensionAPI) {
140
185
  // this is a no-op when the user has not set up Slack/Discord/Telegram.
141
186
  const { tryRemoteQuestions } = await import("./remote-questions/manager.js");
142
187
  const remoteResult = await tryRemoteQuestions(params.questions, signal);
143
- if (remoteResult) return { ...remoteResult, details: remoteResult.details as unknown };
188
+ if (remoteResult) {
189
+ // Cache successful remote results to prevent duplicate Discord dispatches
190
+ const remoteDetails = remoteResult.details as Record<string, unknown> | undefined;
191
+ if (remoteDetails && !remoteDetails.timed_out && !remoteDetails.error) {
192
+ turnCache.set(sig, remoteResult as unknown as CachedResult);
193
+ }
194
+ return { ...remoteResult, details: remoteResult.details as unknown };
195
+ }
144
196
 
145
197
  if (!ctx.hasUI) {
146
198
  return errorResult("Error: UI not available (non-interactive mode)", params.questions);
@@ -197,7 +249,7 @@ export default function AskUserQuestions(pi: ExtensionAPI) {
197
249
  ]),
198
250
  ),
199
251
  };
200
- return {
252
+ const fallbackResult = {
201
253
  content: [{ type: "text" as const, text: JSON.stringify({ answers }) }],
202
254
  details: {
203
255
  questions: params.questions,
@@ -205,6 +257,8 @@ export default function AskUserQuestions(pi: ExtensionAPI) {
205
257
  cancelled: false,
206
258
  } satisfies LocalResultDetails,
207
259
  };
260
+ turnCache.set(sig, fallbackResult);
261
+ return fallbackResult;
208
262
  }
209
263
 
210
264
  // Check if cancelled (empty answers = user exited)
@@ -216,10 +270,12 @@ export default function AskUserQuestions(pi: ExtensionAPI) {
216
270
  };
217
271
  }
218
272
 
219
- return {
220
- content: [{ type: "text", text: formatForLLM(result) }],
273
+ const successResult = {
274
+ content: [{ type: "text" as const, text: formatForLLM(result) }],
221
275
  details: { questions: params.questions, response: result, cancelled: false } satisfies LocalResultDetails,
222
276
  };
277
+ turnCache.set(sig, successResult);
278
+ return successResult;
223
279
  },
224
280
 
225
281
  // ─── Rendering ────────────────────────────────────────────────────────
@@ -80,6 +80,7 @@ import { join } from "node:path";
80
80
  import { sep as pathSep } from "node:path";
81
81
 
82
82
  import { resolveProjectRootDbPath } from "./bootstrap/dynamic-tools.js";
83
+ import { resolveDefaultSessionModel } from "./preferences-models.js";
83
84
  import type { WorktreeResolver } from "./worktree-resolver.js";
84
85
 
85
86
  export interface BootstrapDeps {
@@ -155,12 +156,16 @@ export async function bootstrapAutoSession(
155
156
 
156
157
  // Capture the user's session model before guided-flow dispatch can apply a
157
158
  // phase-specific planning model for a discuss turn (#2829).
158
- const startModelSnapshot = ctx.model
159
- ? {
160
- provider: ctx.model.provider,
161
- id: ctx.model.id,
162
- }
163
- : null;
159
+ //
160
+ // GSD PREFERENCES.md takes priority over the session model from settings.json
161
+ // (#3517). The session model (ctx.model) comes from findInitialModel() which
162
+ // reads defaultProvider/defaultModel from ~/.gsd/agent/settings.json. When
163
+ // the user has explicit model preferences in PREFERENCES.md, those should win.
164
+ const preferredModel = resolveDefaultSessionModel(ctx.model?.provider);
165
+ const startModelSnapshot = preferredModel
166
+ ?? (ctx.model
167
+ ? { provider: ctx.model.provider, id: ctx.model.id }
168
+ : null);
164
169
 
165
170
  try {
166
171
  // Validate GSD_PROJECT_ID early so the user gets immediate feedback
@@ -122,6 +122,10 @@ export function startUnitSupervision(sctx: SupervisionContext): void {
122
122
  phase: "wrapup-warning-sent",
123
123
  wrapupWarningSent: true,
124
124
  });
125
+ // Only trigger a new turn if no tools are currently in flight.
126
+ // Triggering during active tool calls causes tool results to be skipped
127
+ // with "Skipped due to queued user message", leading to provider errors (#3512).
128
+ const softTrigger = getInFlightToolCount() === 0;
125
129
  pi.sendMessage(
126
130
  {
127
131
  customType: "gsd-auto-wrapup",
@@ -136,7 +140,7 @@ export function startUnitSupervision(sctx: SupervisionContext): void {
136
140
  "4. leave precise resume notes if anything remains unfinished",
137
141
  ].join("\n"),
138
142
  },
139
- { triggerTurn: true },
143
+ { triggerTurn: softTrigger },
140
144
  );
141
145
  }, softTimeoutMs);
142
146
 
@@ -293,6 +297,8 @@ export function startUnitSupervision(sctx: SupervisionContext): void {
293
297
  );
294
298
  }
295
299
 
300
+ // Only trigger a new turn if no tools are currently in flight (#3512).
301
+ const contextTrigger = getInFlightToolCount() === 0;
296
302
  pi.sendMessage(
297
303
  {
298
304
  customType: "gsd-auto-wrapup",
@@ -308,7 +314,7 @@ export function startUnitSupervision(sctx: SupervisionContext): void {
308
314
  "Do NOT start new sub-tasks or investigations.",
309
315
  ].join("\n"),
310
316
  },
311
- { triggerTurn: true },
317
+ { triggerTurn: contextTrigger },
312
318
  );
313
319
 
314
320
  if (s.continueHereHandle) {
@@ -314,10 +314,28 @@ export function syncProjectRootToWorktree(
314
314
  // openDatabase re-creates it, causing "no such table" failures (#2815).
315
315
  try {
316
316
  const wtDb = join(wtGsd, "gsd.db");
317
+ let deleteSidecars = false;
317
318
  if (existsSync(wtDb)) {
318
319
  const size = statSync(wtDb).size;
319
320
  if (size === 0) {
320
321
  unlinkSync(wtDb);
322
+ deleteSidecars = true;
323
+ }
324
+ } else {
325
+ // Main DB already missing — sidecars are orphaned from a previous
326
+ // partial cleanup and must still be removed.
327
+ deleteSidecars = true;
328
+ }
329
+ // Always clean up WAL/SHM sidecar files when the main DB was deleted
330
+ // or is already missing. Orphaned WAL/SHM files cause SQLite WAL
331
+ // recovery on next open, which triggers a CPU spin on Node 24's
332
+ // node:sqlite DatabaseSync implementation (#2478).
333
+ if (deleteSidecars) {
334
+ for (const suffix of ["-wal", "-shm"]) {
335
+ const f = wtDb + suffix;
336
+ if (existsSync(f)) {
337
+ unlinkSync(f);
338
+ }
321
339
  }
322
340
  }
323
341
  } catch (err) {
@@ -606,6 +606,18 @@ export async function stopAuto(
606
606
  debugLog("stop-cleanup-locks", { error: e instanceof Error ? e.message : String(e) });
607
607
  }
608
608
 
609
+ // ── Step 1b: Flush queued follow-up messages (#3512) ──
610
+ // Late async notifications (async_job_result, gsd-auto-wrapup) can trigger
611
+ // extra LLM turns after stop. Flush them the same way run-unit.ts does.
612
+ try {
613
+ const cmdCtxAny = s.cmdCtx as Record<string, unknown> | null;
614
+ if (typeof cmdCtxAny?.clearQueue === "function") {
615
+ (cmdCtxAny.clearQueue as () => unknown)();
616
+ }
617
+ } catch (e) {
618
+ debugLog("stop-cleanup-queue", { error: e instanceof Error ? e.message : String(e) });
619
+ }
620
+
609
621
  // ── Step 2: Skill state ──
610
622
  try {
611
623
  clearSkillSnapshot();
@@ -834,6 +846,19 @@ export async function pauseAuto(
834
846
  ): Promise<void> {
835
847
  if (!s.active) return;
836
848
  clearUnitTimeout();
849
+
850
+ // Flush queued follow-up messages (#3512).
851
+ // Late async notifications (async_job_result, gsd-auto-wrapup) can trigger
852
+ // extra LLM turns after pause. Flush them the same way run-unit.ts does.
853
+ try {
854
+ const cmdCtxAny = s.cmdCtx as Record<string, unknown> | null;
855
+ if (typeof cmdCtxAny?.clearQueue === "function") {
856
+ (cmdCtxAny.clearQueue as () => unknown)();
857
+ }
858
+ } catch (e) {
859
+ debugLog("pause-cleanup-queue", { error: e instanceof Error ? e.message : String(e) });
860
+ }
861
+
837
862
  // Unblock any pending unit promise so the auto-loop is not orphaned.
838
863
  // Pass errorContext so runUnitPhase can distinguish user-initiated pause
839
864
  // from provider-error pause and avoid hard-stopping (#2762).
@@ -17,6 +17,7 @@ import { getAutoDashboardData, isAutoActive, isAutoPaused, markToolEnd, markTool
17
17
  import { isParallelActive, shutdownParallel } from "../parallel-orchestrator.js";
18
18
  import { checkToolCallLoop, resetToolCallLoopGuard } from "./tool-call-loop-guard.js";
19
19
  import { saveActivityLog } from "../activity-log.js";
20
+ import { resetAskUserQuestionsCache } from "../../ask-user-questions.js";
20
21
 
21
22
  // Skip the welcome screen on the very first session_start — cli.ts already
22
23
  // printed it before the TUI launched. Only re-print on /clear (subsequent sessions).
@@ -31,6 +32,7 @@ export function registerHooks(pi: ExtensionAPI): void {
31
32
  pi.on("session_start", async (_event, ctx) => {
32
33
  resetWriteGateState();
33
34
  resetToolCallLoopGuard();
35
+ resetAskUserQuestionsCache();
34
36
  await syncServiceTierStatus(ctx);
35
37
 
36
38
  // Apply show_token_cost preference (#1515)
@@ -67,6 +69,7 @@ export function registerHooks(pi: ExtensionAPI): void {
67
69
  pi.on("session_switch", async (_event, ctx) => {
68
70
  resetWriteGateState();
69
71
  resetToolCallLoopGuard();
72
+ resetAskUserQuestionsCache();
70
73
  clearDiscussionFlowState();
71
74
  await syncServiceTierStatus(ctx);
72
75
  loadToolApiKeys();
@@ -78,6 +81,7 @@ export function registerHooks(pi: ExtensionAPI): void {
78
81
 
79
82
  pi.on("agent_end", async (event, ctx: ExtensionContext) => {
80
83
  resetToolCallLoopGuard();
84
+ resetAskUserQuestionsCache();
81
85
  await handleAgentEnd(pi, event, ctx);
82
86
  });
83
87
 
@@ -16,8 +16,13 @@ import { createHash } from "node:crypto";
16
16
 
17
17
  const MAX_CONSECUTIVE_IDENTICAL_CALLS = 4;
18
18
 
19
+ /** Interactive/user-facing tools where even 1 duplicate is confusing. */
20
+ const STRICT_LOOP_TOOLS = new Set(["ask_user_questions"]);
21
+ const MAX_CONSECUTIVE_STRICT = 1;
22
+
19
23
  let consecutiveCount = 0;
20
24
  let lastSignature = "";
25
+ let lastToolName = "";
21
26
  let enabled = true;
22
27
 
23
28
  /** Hash tool name + args into a compact signature for comparison. */
@@ -55,9 +60,14 @@ export function checkToolCallLoop(
55
60
  } else {
56
61
  consecutiveCount = 1;
57
62
  lastSignature = sig;
63
+ lastToolName = toolName;
58
64
  }
59
65
 
60
- if (consecutiveCount > MAX_CONSECUTIVE_IDENTICAL_CALLS) {
66
+ const threshold = STRICT_LOOP_TOOLS.has(toolName)
67
+ ? MAX_CONSECUTIVE_STRICT
68
+ : MAX_CONSECUTIVE_IDENTICAL_CALLS;
69
+
70
+ if (consecutiveCount > threshold) {
61
71
  return {
62
72
  block: true,
63
73
  reason:
@@ -75,6 +85,7 @@ export function checkToolCallLoop(
75
85
  export function resetToolCallLoopGuard(): void {
76
86
  consecutiveCount = 0;
77
87
  lastSignature = "";
88
+ lastToolName = "";
78
89
  enabled = true;
79
90
  }
80
91
 
@@ -83,6 +94,7 @@ export function disableToolCallLoopGuard(): void {
83
94
  enabled = false;
84
95
  consecutiveCount = 0;
85
96
  lastSignature = "";
97
+ lastToolName = "";
86
98
  }
87
99
 
88
100
  /** Get current consecutive count for diagnostics. */
@@ -20,7 +20,8 @@ import {
20
20
  selectDoctorScope,
21
21
  filterDoctorIssues,
22
22
  } from "./doctor.js";
23
- import { isAutoActive } from "./auto.js";
23
+ import { isAutoActive, checkRemoteAutoSession } from "./auto.js";
24
+ import { getAutoWorktreePath } from "./auto-worktree.js";
24
25
  import { projectRoot } from "./commands/context.js";
25
26
  import { loadPrompt } from "./prompt-loader.js";
26
27
 
@@ -222,7 +223,19 @@ export async function handleSteer(change: string, ctx: ExtensionCommandContext,
222
223
  const sid = state.activeSlice?.id ?? "none";
223
224
  const tid = state.activeTask?.id ?? "none";
224
225
  const appliedAt = `${mid}/${sid}/${tid}`;
225
- await appendOverride(basePath, change, appliedAt);
226
+
227
+ // Resolve the correct target path: only route to a worktree when auto-mode
228
+ // is actively running there (in-process or remote). A worktree directory may
229
+ // exist from a previous session without being the active runtime path —
230
+ // writing there without a live session would silently drop the override.
231
+ const autoRunning = isAutoActive() || checkRemoteAutoSession(basePath).running;
232
+ const wtPath = autoRunning && mid !== "none"
233
+ ? getAutoWorktreePath(basePath, mid)
234
+ : null;
235
+ const targetPath = wtPath ?? basePath;
236
+ await appendOverride(targetPath, change, appliedAt);
237
+
238
+ const overrideLoc = wtPath ? "worktree `.gsd/OVERRIDES.md`" : "`.gsd/OVERRIDES.md`";
226
239
 
227
240
  if (isAutoActive()) {
228
241
  pi.sendMessage({
@@ -232,14 +245,14 @@ export async function handleSteer(change: string, ctx: ExtensionCommandContext,
232
245
  "",
233
246
  `**Override:** ${change}`,
234
247
  "",
235
- "This override has been saved to `.gsd/OVERRIDES.md` and will be injected into all future task prompts.",
248
+ `This override has been saved to ${overrideLoc} and will be injected into all future task prompts.`,
236
249
  "A document rewrite unit will run before the next task to propagate this change across all active plan documents.",
237
250
  "",
238
251
  "If you are mid-task, finish your current work respecting this override. The next dispatched unit will be a document rewrite.",
239
252
  ].join("\n"),
240
253
  display: false,
241
254
  }, { triggerTurn: true });
242
- ctx.ui.notify(`Override registered: "${change}". Will be applied before next task dispatch.`, "info");
255
+ ctx.ui.notify(`Override registered (${overrideLoc}): "${change}". Will be applied before next task dispatch.`, "info");
243
256
  } else {
244
257
  pi.sendMessage({
245
258
  customType: "gsd-hard-steer",
@@ -248,13 +261,13 @@ export async function handleSteer(change: string, ctx: ExtensionCommandContext,
248
261
  "",
249
262
  `**Override:** ${change}`,
250
263
  "",
251
- "This override has been saved to `.gsd/OVERRIDES.md`.",
252
- "Before continuing, read `.gsd/OVERRIDES.md` and update the current plan documents to reflect this change.",
264
+ `This override has been saved to ${overrideLoc}.`,
265
+ `Before continuing, read ${overrideLoc} and update the current plan documents to reflect this change.`,
253
266
  "Focus on: active slice plan, incomplete task plans, and DECISIONS.md.",
254
267
  ].join("\n"),
255
268
  display: false,
256
269
  }, { triggerTurn: true });
257
- ctx.ui.notify(`Override registered: "${change}". Update plan documents to reflect this change.`, "info");
270
+ ctx.ui.notify(`Override registered (${overrideLoc}): "${change}". Update plan documents to reflect this change.`, "info");
258
271
  }
259
272
  }
260
273
 
@@ -272,6 +272,10 @@ export interface SaveRequirementFields {
272
272
  /**
273
273
  * Save a new requirement to DB and regenerate REQUIREMENTS.md.
274
274
  * Auto-assigns the next ID via nextRequirementId().
275
+ *
276
+ * The ID computation and insert are wrapped in a single transaction
277
+ * to prevent parallel race conditions (same pattern as saveDecisionToDb).
278
+ *
275
279
  * Returns the assigned ID.
276
280
  */
277
281
  export async function saveRequirementToDb(
@@ -281,24 +285,37 @@ export async function saveRequirementToDb(
281
285
  try {
282
286
  const db = await import('./gsd-db.js');
283
287
 
284
- const id = await nextRequirementId();
285
-
286
- const requirement: Requirement = {
287
- id,
288
- class: fields.class,
289
- status: fields.status ?? 'active',
290
- description: fields.description,
291
- why: fields.why,
292
- source: fields.source,
293
- primary_owner: fields.primary_owner ?? '',
294
- supporting_slices: fields.supporting_slices ?? '',
295
- validation: fields.validation ?? '',
296
- notes: fields.notes ?? '',
297
- full_content: '',
298
- superseded_by: null,
299
- };
300
-
301
- db.upsertRequirement(requirement);
288
+ // Atomic ID assignment + insert inside a transaction.
289
+ const id = db.transaction(() => {
290
+ const adapter = db._getAdapter();
291
+ if (!adapter) throw new GSDError(GSD_STALE_STATE, "gsd-db: No database open");
292
+
293
+ const row = adapter
294
+ .prepare('SELECT MAX(CAST(SUBSTR(id, 2) AS INTEGER)) as max_num FROM requirements')
295
+ .get();
296
+ const maxNum = row ? (row['max_num'] as number | null) : null;
297
+ const nextId = (maxNum == null || isNaN(maxNum))
298
+ ? 'R001'
299
+ : `R${String(maxNum + 1).padStart(3, '0')}`;
300
+
301
+ const requirement: Requirement = {
302
+ id: nextId,
303
+ class: fields.class,
304
+ status: fields.status ?? 'active',
305
+ description: fields.description,
306
+ why: fields.why,
307
+ source: fields.source,
308
+ primary_owner: fields.primary_owner ?? '',
309
+ supporting_slices: fields.supporting_slices ?? '',
310
+ validation: fields.validation ?? '',
311
+ notes: fields.notes ?? '',
312
+ full_content: '',
313
+ superseded_by: null,
314
+ };
315
+
316
+ db.upsertRequirement(requirement);
317
+ return nextId;
318
+ });
302
319
 
303
320
  // Fetch all requirements for full file regeneration
304
321
  const adapter = db._getAdapter();
@@ -358,6 +375,11 @@ export interface SaveDecisionFields {
358
375
  /**
359
376
  * Save a new decision to DB and regenerate DECISIONS.md.
360
377
  * Auto-assigns the next ID via nextDecisionId().
378
+ *
379
+ * The ID computation (SELECT MAX) and insert are wrapped in a single
380
+ * transaction to prevent parallel tool calls from computing the same ID
381
+ * and silently overwriting each other (#3326, #3339, #3459).
382
+ *
361
383
  * Returns the assigned ID.
362
384
  */
363
385
  export async function saveDecisionToDb(
@@ -367,18 +389,33 @@ export async function saveDecisionToDb(
367
389
  try {
368
390
  const db = await import('./gsd-db.js');
369
391
 
370
- const id = await nextDecisionId();
371
-
372
- db.upsertDecision({
373
- id,
374
- when_context: fields.when_context ?? '',
375
- scope: fields.scope,
376
- decision: fields.decision,
377
- choice: fields.choice,
378
- rationale: fields.rationale,
379
- revisable: fields.revisable ?? 'Yes',
380
- made_by: fields.made_by ?? 'agent',
381
- superseded_by: null,
392
+ // Atomic ID assignment + insert inside a transaction to prevent
393
+ // parallel calls from racing on the same MAX(id) value.
394
+ const id = db.transaction(() => {
395
+ const adapter = db._getAdapter();
396
+ if (!adapter) throw new GSDError(GSD_STALE_STATE, "gsd-db: No database open");
397
+
398
+ const row = adapter
399
+ .prepare('SELECT MAX(CAST(SUBSTR(id, 2) AS INTEGER)) as max_num FROM decisions')
400
+ .get();
401
+ const maxNum = row ? (row['max_num'] as number | null) : null;
402
+ const nextId = (maxNum == null || isNaN(maxNum))
403
+ ? 'D001'
404
+ : `D${String(maxNum + 1).padStart(3, '0')}`;
405
+
406
+ db.upsertDecision({
407
+ id: nextId,
408
+ when_context: fields.when_context ?? '',
409
+ scope: fields.scope,
410
+ decision: fields.decision,
411
+ choice: fields.choice,
412
+ rationale: fields.rationale,
413
+ revisable: fields.revisable ?? 'Yes',
414
+ made_by: fields.made_by ?? 'agent',
415
+ superseded_by: null,
416
+ });
417
+
418
+ return nextId;
382
419
  });
383
420
 
384
421
  // Fetch all decisions (including superseded for the full register)