gsd-pi 2.67.0-dev.1cd1e0f → 2.67.0-dev.2142d3e

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/dist/resources/extensions/claude-code-cli/stream-adapter.js +3 -0
  2. package/dist/resources/extensions/gsd/auto/phases.js +17 -0
  3. package/dist/resources/extensions/gsd/auto-direct-dispatch.js +12 -0
  4. package/dist/resources/extensions/gsd/bootstrap/db-tools.js +11 -435
  5. package/dist/resources/extensions/gsd/bootstrap/dynamic-tools.js +1 -4
  6. package/dist/resources/extensions/gsd/bootstrap/query-tools.js +7 -64
  7. package/dist/resources/extensions/gsd/bootstrap/write-gate.js +88 -8
  8. package/dist/resources/extensions/gsd/commands/handlers/core.js +38 -24
  9. package/dist/resources/extensions/gsd/commands/index.js +8 -1
  10. package/dist/resources/extensions/gsd/guided-flow.js +16 -0
  11. package/dist/resources/extensions/gsd/init-wizard.js +34 -0
  12. package/dist/resources/extensions/gsd/tools/workflow-tool-executors.js +508 -0
  13. package/dist/resources/extensions/gsd/workflow-logger.js +18 -3
  14. package/dist/resources/extensions/gsd/workflow-mcp.js +190 -0
  15. package/dist/web/standalone/.next/BUILD_ID +1 -1
  16. package/dist/web/standalone/.next/app-path-routes-manifest.json +10 -10
  17. package/dist/web/standalone/.next/build-manifest.json +2 -2
  18. package/dist/web/standalone/.next/prerender-manifest.json +3 -3
  19. package/dist/web/standalone/.next/server/app/_global-error.html +1 -1
  20. package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
  21. package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  22. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  23. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  24. package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  25. package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  26. package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  27. package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
  28. package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
  29. package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  30. package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  31. package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  32. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  33. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  34. package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  35. package/dist/web/standalone/.next/server/app/index.html +1 -1
  36. package/dist/web/standalone/.next/server/app/index.rsc +1 -1
  37. package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
  38. package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
  39. package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  40. package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
  41. package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  42. package/dist/web/standalone/.next/server/app-paths-manifest.json +10 -10
  43. package/dist/web/standalone/.next/server/middleware-build-manifest.js +1 -1
  44. package/dist/web/standalone/.next/server/pages/404.html +1 -1
  45. package/dist/web/standalone/.next/server/pages/500.html +1 -1
  46. package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
  47. package/package.json +1 -1
  48. package/packages/mcp-server/README.md +38 -0
  49. package/packages/mcp-server/src/server.ts +6 -2
  50. package/packages/mcp-server/src/workflow-tools.test.ts +976 -0
  51. package/packages/mcp-server/src/workflow-tools.ts +986 -0
  52. package/src/resources/extensions/claude-code-cli/stream-adapter.ts +3 -0
  53. package/src/resources/extensions/claude-code-cli/tests/stream-adapter.test.ts +121 -0
  54. package/src/resources/extensions/gsd/auto/phases.ts +25 -0
  55. package/src/resources/extensions/gsd/auto-direct-dispatch.ts +20 -0
  56. package/src/resources/extensions/gsd/bootstrap/db-tools.ts +22 -435
  57. package/src/resources/extensions/gsd/bootstrap/dynamic-tools.ts +1 -5
  58. package/src/resources/extensions/gsd/bootstrap/query-tools.ts +7 -72
  59. package/src/resources/extensions/gsd/bootstrap/write-gate.ts +122 -6
  60. package/src/resources/extensions/gsd/commands/handlers/core.ts +52 -25
  61. package/src/resources/extensions/gsd/commands/index.ts +7 -1
  62. package/src/resources/extensions/gsd/guided-flow.ts +24 -0
  63. package/src/resources/extensions/gsd/init-wizard.ts +34 -0
  64. package/src/resources/extensions/gsd/tests/core-overlay-fallback.test.ts +101 -0
  65. package/src/resources/extensions/gsd/tests/ensure-db-open.test.ts +66 -0
  66. package/src/resources/extensions/gsd/tests/init-bootstrap-completeness.test.ts +121 -0
  67. package/src/resources/extensions/gsd/tests/workflow-logger.test.ts +16 -0
  68. package/src/resources/extensions/gsd/tests/workflow-mcp.test.ts +301 -0
  69. package/src/resources/extensions/gsd/tests/workflow-tool-executors.test.ts +625 -0
  70. package/src/resources/extensions/gsd/tools/workflow-tool-executors.ts +629 -0
  71. package/src/resources/extensions/gsd/workflow-logger.ts +19 -3
  72. package/src/resources/extensions/gsd/workflow-mcp.ts +233 -0
  73. /package/dist/web/standalone/.next/static/{PHqEommYRR8CRn3i84CGM → xR6qurkuYSvyjBjRyJLxG}/_buildManifest.js +0 -0
  74. /package/dist/web/standalone/.next/static/{PHqEommYRR8CRn3i84CGM → xR6qurkuYSvyjBjRyJLxG}/_ssgManifest.js +0 -0
@@ -1,3 +1,6 @@
1
+ import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+
1
4
  const MILESTONE_CONTEXT_RE = /M\d+(?:-[a-z0-9]{6})?-CONTEXT\.md$/;
2
5
  const CONTEXT_MILESTONE_RE = /(?:^|[/\\])(M\d+(?:-[a-z0-9]{6})?)-CONTEXT\.md$/i;
3
6
  const DEPTH_VERIFICATION_MILESTONE_RE = /depth_verification[_-](M\d+(?:-[a-z0-9]{6})?)/i;
@@ -65,6 +68,69 @@ const GATE_SAFE_TOOLS = new Set([
65
68
  "search_and_read",
66
69
  ]);
67
70
 
71
+ export interface WriteGateSnapshot {
72
+ verifiedDepthMilestones: string[];
73
+ activeQueuePhase: boolean;
74
+ pendingGateId: string | null;
75
+ }
76
+
77
+ function shouldPersistWriteGateSnapshot(env: NodeJS.ProcessEnv = process.env): boolean {
78
+ return env.GSD_PERSIST_WRITE_GATE_STATE === "1";
79
+ }
80
+
81
+ function writeGateSnapshotPath(basePath: string = process.cwd()): string {
82
+ return join(basePath, ".gsd", "runtime", "write-gate-state.json");
83
+ }
84
+
85
+ function currentWriteGateSnapshot(): WriteGateSnapshot {
86
+ return {
87
+ verifiedDepthMilestones: [...verifiedDepthMilestones].sort(),
88
+ activeQueuePhase,
89
+ pendingGateId,
90
+ };
91
+ }
92
+
93
+ function persistWriteGateSnapshot(basePath: string = process.cwd()): void {
94
+ if (!shouldPersistWriteGateSnapshot()) return;
95
+ const path = writeGateSnapshotPath(basePath);
96
+ mkdirSync(join(basePath, ".gsd", "runtime"), { recursive: true });
97
+ const tempPath = `${path}.tmp`;
98
+ writeFileSync(tempPath, JSON.stringify(currentWriteGateSnapshot(), null, 2), "utf-8");
99
+ renameSync(tempPath, path);
100
+ }
101
+
102
+ function clearPersistedWriteGateSnapshot(basePath: string = process.cwd()): void {
103
+ if (!shouldPersistWriteGateSnapshot()) return;
104
+ const path = writeGateSnapshotPath(basePath);
105
+ try {
106
+ unlinkSync(path);
107
+ } catch {
108
+ // swallow
109
+ }
110
+ }
111
+
112
+ function normalizeWriteGateSnapshot(value: unknown): WriteGateSnapshot {
113
+ const record = value && typeof value === "object" ? value as Record<string, unknown> : {};
114
+ const verified = Array.isArray(record.verifiedDepthMilestones)
115
+ ? record.verifiedDepthMilestones.filter((item): item is string => typeof item === "string")
116
+ : [];
117
+ return {
118
+ verifiedDepthMilestones: [...new Set(verified)].sort(),
119
+ activeQueuePhase: record.activeQueuePhase === true,
120
+ pendingGateId: typeof record.pendingGateId === "string" ? record.pendingGateId : null,
121
+ };
122
+ }
123
+
124
+ export function loadWriteGateSnapshot(basePath: string = process.cwd()): WriteGateSnapshot {
125
+ const path = writeGateSnapshotPath(basePath);
126
+ if (!existsSync(path)) return currentWriteGateSnapshot();
127
+ try {
128
+ return normalizeWriteGateSnapshot(JSON.parse(readFileSync(path, "utf-8")));
129
+ } catch {
130
+ return currentWriteGateSnapshot();
131
+ }
132
+ }
133
+
68
134
  export function isDepthVerified(): boolean {
69
135
  return verifiedDepthMilestones.size > 0;
70
136
  }
@@ -77,28 +143,40 @@ export function isMilestoneDepthVerified(milestoneId: string | null | undefined)
77
143
  return verifiedDepthMilestones.has(milestoneId);
78
144
  }
79
145
 
146
+ export function isMilestoneDepthVerifiedInSnapshot(
147
+ snapshot: WriteGateSnapshot,
148
+ milestoneId: string | null | undefined,
149
+ ): boolean {
150
+ if (!milestoneId) return false;
151
+ return snapshot.verifiedDepthMilestones.includes(milestoneId);
152
+ }
153
+
80
154
  export function isQueuePhaseActive(): boolean {
81
155
  return activeQueuePhase;
82
156
  }
83
157
 
84
158
  export function setQueuePhaseActive(active: boolean): void {
85
159
  activeQueuePhase = active;
160
+ persistWriteGateSnapshot();
86
161
  }
87
162
 
88
163
  export function resetWriteGateState(): void {
89
164
  verifiedDepthMilestones.clear();
90
165
  pendingGateId = null;
166
+ persistWriteGateSnapshot();
91
167
  }
92
168
 
93
169
  export function clearDiscussionFlowState(): void {
94
170
  verifiedDepthMilestones.clear();
95
171
  activeQueuePhase = false;
96
172
  pendingGateId = null;
173
+ clearPersistedWriteGateSnapshot();
97
174
  }
98
175
 
99
- export function markDepthVerified(milestoneId?: string | null): void {
176
+ export function markDepthVerified(milestoneId?: string | null, basePath: string = process.cwd()): void {
100
177
  if (!milestoneId) return;
101
178
  verifiedDepthMilestones.add(milestoneId);
179
+ persistWriteGateSnapshot(basePath);
102
180
  }
103
181
 
104
182
  /**
@@ -130,6 +208,7 @@ function extractContextMilestoneId(inputPath: string): string | null {
130
208
  */
131
209
  export function setPendingGate(gateId: string): void {
132
210
  pendingGateId = gateId;
211
+ persistWriteGateSnapshot();
133
212
  }
134
213
 
135
214
  /**
@@ -137,6 +216,7 @@ export function setPendingGate(gateId: string): void {
137
216
  */
138
217
  export function clearPendingGate(): void {
139
218
  pendingGateId = null;
219
+ persistWriteGateSnapshot();
140
220
  }
141
221
 
142
222
  /**
@@ -154,11 +234,20 @@ export function getPendingGate(): string | null {
154
234
  * Read-only tools and ask_user_questions itself are always allowed.
155
235
  */
156
236
  export function shouldBlockPendingGate(
237
+ toolName: string,
238
+ milestoneId: string | null,
239
+ queuePhaseActive?: boolean,
240
+ ): { block: boolean; reason?: string } {
241
+ return shouldBlockPendingGateInSnapshot(currentWriteGateSnapshot(), toolName, milestoneId, queuePhaseActive);
242
+ }
243
+
244
+ export function shouldBlockPendingGateInSnapshot(
245
+ snapshot: WriteGateSnapshot,
157
246
  toolName: string,
158
247
  _milestoneId: string | null,
159
248
  _queuePhaseActive?: boolean,
160
249
  ): { block: boolean; reason?: string } {
161
- if (!pendingGateId) return { block: false };
250
+ if (!snapshot.pendingGateId) return { block: false };
162
251
 
163
252
  if (GATE_SAFE_TOOLS.has(toolName)) return { block: false };
164
253
 
@@ -168,7 +257,7 @@ export function shouldBlockPendingGate(
168
257
  return {
169
258
  block: true,
170
259
  reason: [
171
- `HARD BLOCK: Discussion gate "${pendingGateId}" has not been confirmed by the user.`,
260
+ `HARD BLOCK: Discussion gate "${snapshot.pendingGateId}" has not been confirmed by the user.`,
172
261
  `You MUST re-call ask_user_questions with the gate question before making any other tool calls.`,
173
262
  `If the previous ask_user_questions call failed, errored, was cancelled, or the user's response`,
174
263
  `did not match a provided option, you MUST re-ask — never rationalize past the block.`,
@@ -182,11 +271,20 @@ export function shouldBlockPendingGate(
182
271
  * Read-only bash commands are allowed; mutating commands are blocked.
183
272
  */
184
273
  export function shouldBlockPendingGateBash(
274
+ command: string,
275
+ milestoneId: string | null,
276
+ queuePhaseActive?: boolean,
277
+ ): { block: boolean; reason?: string } {
278
+ return shouldBlockPendingGateBashInSnapshot(currentWriteGateSnapshot(), command, milestoneId, queuePhaseActive);
279
+ }
280
+
281
+ export function shouldBlockPendingGateBashInSnapshot(
282
+ snapshot: WriteGateSnapshot,
185
283
  command: string,
186
284
  _milestoneId: string | null,
187
285
  _queuePhaseActive?: boolean,
188
286
  ): { block: boolean; reason?: string } {
189
- if (!pendingGateId) return { block: false };
287
+ if (!snapshot.pendingGateId) return { block: false };
190
288
 
191
289
  // Allow read-only bash commands
192
290
  if (BASH_READ_ONLY_RE.test(command)) return { block: false };
@@ -194,7 +292,7 @@ export function shouldBlockPendingGateBash(
194
292
  return {
195
293
  block: true,
196
294
  reason: [
197
- `HARD BLOCK: Discussion gate "${pendingGateId}" has not been confirmed by the user.`,
295
+ `HARD BLOCK: Discussion gate "${snapshot.pendingGateId}" has not been confirmed by the user.`,
198
296
  `You MUST re-call ask_user_questions with the gate question before running mutating commands.`,
199
297
  `If the previous ask_user_questions call failed, errored, was cancelled, or the user's response`,
200
298
  `did not match a provided option, you MUST re-ask — never rationalize past the block.`,
@@ -275,6 +373,15 @@ export function shouldBlockContextArtifactSave(
275
373
  artifactType: string,
276
374
  milestoneId: string | null,
277
375
  sliceId?: string | null,
376
+ ): { block: boolean; reason?: string } {
377
+ return shouldBlockContextArtifactSaveInSnapshot(currentWriteGateSnapshot(), artifactType, milestoneId, sliceId);
378
+ }
379
+
380
+ export function shouldBlockContextArtifactSaveInSnapshot(
381
+ snapshot: WriteGateSnapshot,
382
+ artifactType: string,
383
+ milestoneId: string | null,
384
+ sliceId?: string | null,
278
385
  ): { block: boolean; reason?: string } {
279
386
  if (artifactType !== "CONTEXT") return { block: false };
280
387
  if (sliceId) return { block: false };
@@ -287,7 +394,7 @@ export function shouldBlockContextArtifactSave(
287
394
  ].join(" "),
288
395
  };
289
396
  }
290
- if (isMilestoneDepthVerified(milestoneId)) return { block: false };
397
+ if (isMilestoneDepthVerifiedInSnapshot(snapshot, milestoneId)) return { block: false };
291
398
 
292
399
  return {
293
400
  block: true,
@@ -317,6 +424,15 @@ export function shouldBlockQueueExecution(
317
424
  toolName: string,
318
425
  input: string,
319
426
  queuePhaseActive: boolean,
427
+ ): { block: boolean; reason?: string } {
428
+ return shouldBlockQueueExecutionInSnapshot(currentWriteGateSnapshot(), toolName, input, queuePhaseActive);
429
+ }
430
+
431
+ export function shouldBlockQueueExecutionInSnapshot(
432
+ snapshot: WriteGateSnapshot,
433
+ toolName: string,
434
+ input: string,
435
+ queuePhaseActive: boolean = snapshot.activeQueuePhase,
320
436
  ): { block: boolean; reason?: string } {
321
437
  if (!queuePhaseActive) return { block: false };
322
438
 
@@ -194,6 +194,56 @@ function sortModelsForSelection(models: Model<any>[], currentModel: Model<any> |
194
194
  });
195
195
  }
196
196
 
197
+ function buildProviderModelGroups(
198
+ models: Model<any>[],
199
+ currentModel: Model<any> | undefined,
200
+ ): Map<string, Model<any>[]> {
201
+ const byProvider = new Map<string, Model<any>[]>();
202
+
203
+ for (const model of sortModelsForSelection(models, currentModel)) {
204
+ let group = byProvider.get(model.provider);
205
+ if (!group) {
206
+ group = [];
207
+ byProvider.set(model.provider, group);
208
+ }
209
+ group.push(model);
210
+ }
211
+ return byProvider;
212
+ }
213
+
214
+ async function selectModelByProvider(
215
+ title: string,
216
+ models: Model<any>[],
217
+ ctx: ExtensionCommandContext,
218
+ currentModel: Model<any> | undefined,
219
+ ): Promise<Model<any> | undefined> {
220
+ const byProvider = buildProviderModelGroups(models, currentModel);
221
+ const providerOptions = Array.from(byProvider.entries()).map(([provider, group]) =>
222
+ `${provider} (${group.length} model${group.length === 1 ? "" : "s"})`,
223
+ );
224
+ providerOptions.push("(cancel)");
225
+
226
+ const providerChoice = await ctx.ui.select(`${title} — choose provider:`, providerOptions);
227
+ if (!providerChoice || typeof providerChoice !== "string" || providerChoice === "(cancel)") return undefined;
228
+
229
+ const providerName = providerChoice.replace(/ \(\d+ models?\)$/, "");
230
+ const providerModels = byProvider.get(providerName);
231
+ if (!providerModels || providerModels.length === 0) return undefined;
232
+
233
+ const optionToModel = new Map<string, Model<any>>();
234
+ const modelOptions = providerModels.map((model) => {
235
+ const isCurrent = currentModel && model.provider === currentModel.provider && model.id === currentModel.id;
236
+ const label = `${isCurrent ? "* " : ""}${model.id}`;
237
+ optionToModel.set(label, model);
238
+ return label;
239
+ });
240
+ modelOptions.push("(cancel)");
241
+
242
+ const modelChoice = await ctx.ui.select(`${title} — ${providerName}:`, modelOptions);
243
+ if (!modelChoice || typeof modelChoice !== "string" || modelChoice === "(cancel)") return undefined;
244
+ return optionToModel.get(modelChoice);
245
+ }
246
+
197
247
  async function resolveRequestedModel(
198
248
  query: string,
199
249
  ctx: ExtensionCommandContext,
@@ -211,19 +261,7 @@ async function resolveRequestedModel(
211
261
 
212
262
  if (partialMatches.length === 1) return partialMatches[0];
213
263
  if (partialMatches.length === 0 || !ctx.hasUI) return undefined;
214
-
215
- const sorted = sortModelsForSelection(partialMatches, ctx.model);
216
- const optionToModel = new Map<string, Model<any>>();
217
- const options = sorted.map((model) => {
218
- const label = `${model.provider}/${model.id}`;
219
- optionToModel.set(label, model);
220
- return label;
221
- });
222
- options.push("(cancel)");
223
-
224
- const choice = await ctx.ui.select(`Multiple models match "${query}" — choose one:`, options);
225
- if (!choice || typeof choice !== "string" || choice === "(cancel)") return undefined;
226
- return optionToModel.get(choice);
264
+ return selectModelByProvider(`Multiple models match "${query}"`, partialMatches, ctx, ctx.model);
227
265
  }
228
266
 
229
267
  async function handleModel(trimmedArgs: string, ctx: ExtensionCommandContext, pi: ExtensionAPI | undefined): Promise<void> {
@@ -247,18 +285,7 @@ async function handleModel(trimmedArgs: string, ctx: ExtensionCommandContext, pi
247
285
  return;
248
286
  }
249
287
 
250
- const optionToModel = new Map<string, Model<any>>();
251
- const options = sortModelsForSelection(availableModels, ctx.model).map((model) => {
252
- const isCurrent = ctx.model && model.provider === ctx.model.provider && model.id === ctx.model.id;
253
- const label = `${isCurrent ? "* " : ""}${model.provider}/${model.id}`;
254
- optionToModel.set(label, model);
255
- return label;
256
- });
257
- options.push("(cancel)");
258
-
259
- const choice = await ctx.ui.select("Select session model:", options);
260
- if (!choice || typeof choice !== "string" || choice === "(cancel)") return;
261
- targetModel = optionToModel.get(choice);
288
+ targetModel = await selectModelByProvider("Select session model:", availableModels, ctx, ctx.model);
262
289
  } else {
263
290
  targetModel = await resolveRequestedModel(trimmed, ctx);
264
291
  }
@@ -8,7 +8,13 @@ export function registerGSDCommand(pi: ExtensionAPI): void {
8
8
  getArgumentCompletions: getGsdArgumentCompletions,
9
9
  handler: async (args: string, ctx: ExtensionCommandContext) => {
10
10
  const { handleGSDCommand } = await import("./dispatcher.js");
11
- await handleGSDCommand(args, ctx, pi);
11
+ const { setStderrLoggingEnabled } = await import("../workflow-logger.js");
12
+ const previousStderrSetting = setStderrLoggingEnabled(false);
13
+ try {
14
+ await handleGSDCommand(args, ctx, pi);
15
+ } finally {
16
+ setStderrLoggingEnabled(previousStderrSetting);
17
+ }
12
18
  },
13
19
  });
14
20
  }
@@ -40,6 +40,10 @@ import { findMilestoneIds, nextMilestoneId, reserveMilestoneId, getReservedMiles
40
40
  import { parkMilestone, discardMilestone } from "./milestone-actions.js";
41
41
  import { selectAndApplyModel } from "./auto-model-selection.js";
42
42
  import { DISCUSS_TOOLS_ALLOWLIST } from "./constants.js";
43
+ import {
44
+ getWorkflowTransportSupportError,
45
+ getRequiredWorkflowToolsForGuidedUnit,
46
+ } from "./workflow-mcp.js";
43
47
  import {
44
48
  runPreparation,
45
49
  formatCodebaseBrief,
@@ -318,6 +322,26 @@ async function dispatchWorkflow(
318
322
  routing: result.routing,
319
323
  });
320
324
  }
325
+
326
+ const compatibilityError = getWorkflowTransportSupportError(
327
+ result.appliedModel?.provider ?? ctx.model?.provider,
328
+ getRequiredWorkflowToolsForGuidedUnit(unitType),
329
+ {
330
+ projectRoot: process.cwd(),
331
+ surface: "guided flow",
332
+ unitType,
333
+ authMode: result.appliedModel?.provider
334
+ ? ctx.modelRegistry.getProviderAuthMode(result.appliedModel.provider)
335
+ : ctx.model?.provider
336
+ ? ctx.modelRegistry.getProviderAuthMode(ctx.model.provider)
337
+ : undefined,
338
+ baseUrl: result.appliedModel?.baseUrl ?? ctx.model?.baseUrl,
339
+ },
340
+ );
341
+ if (compatibilityError) {
342
+ ctx.ui.notify(compatibilityError, "error");
343
+ return;
344
+ }
321
345
  }
322
346
 
323
347
  // Scope tools for discuss flows (#2949).
@@ -235,6 +235,20 @@ export async function showProjectInit(
235
235
  // ── Step 9: Bootstrap .gsd/ ────────────────────────────────────────────────
236
236
  bootstrapGsdDirectory(basePath, prefs, signals);
237
237
 
238
+ // Initialize SQLite database so GSD starts in full-capability mode (#3880).
239
+ // Without this, isDbAvailable() returns false and GSD enters degraded
240
+ // markdown-only mode until a tool handler happens to call ensureDbOpen().
241
+ let dbReady = false;
242
+ try {
243
+ const { ensureDbOpen } = await import("./bootstrap/dynamic-tools.js");
244
+ dbReady = await ensureDbOpen(basePath);
245
+ } catch {
246
+ // Swallowed — warning surfaced below
247
+ }
248
+ if (!dbReady) {
249
+ ctx.ui.notify("Warning: database initialization failed — GSD will run in degraded mode until the next /gsd invocation.", "warning");
250
+ }
251
+
238
252
  // Ensure .gitignore
239
253
  ensureGitignore(basePath);
240
254
  untrackRuntimeFiles(basePath);
@@ -250,6 +264,25 @@ export async function showProjectInit(
250
264
  // Non-fatal — codebase map generation failure should never block project init
251
265
  }
252
266
 
267
+ // Write initial STATE.md so it exists before the first /gsd invocation.
268
+ // The explicit /gsd init path (ops.ts) returns without entering showSmartEntry(),
269
+ // which would otherwise generate STATE.md at guided-flow.ts:1358.
270
+ let stateReady = false;
271
+ try {
272
+ const { deriveState } = await import("./state.js");
273
+ const { buildStateMarkdown } = await import("./doctor.js");
274
+ const { saveFile } = await import("./files.js");
275
+ const { resolveGsdRootFile } = await import("./paths.js");
276
+ const state = await deriveState(basePath);
277
+ await saveFile(resolveGsdRootFile(basePath, "STATE"), buildStateMarkdown(state));
278
+ stateReady = true;
279
+ } catch {
280
+ // Swallowed — warning surfaced below
281
+ }
282
+ if (!stateReady) {
283
+ ctx.ui.notify("Warning: initial STATE.md generation failed — it will be created on the next /gsd invocation.", "warning");
284
+ }
285
+
253
286
  ctx.ui.notify("GSD initialized. Starting your first milestone...", "info");
254
287
 
255
288
  return { completed: true, bootstrapped: true };
@@ -433,6 +466,7 @@ function bootstrapGsdDirectory(
433
466
 
434
467
  const gsd = gsdRoot(basePath);
435
468
  mkdirSync(join(gsd, "milestones"), { recursive: true });
469
+ mkdirSync(join(gsd, "runtime"), { recursive: true });
436
470
 
437
471
  // Write PREFERENCES.md from wizard answers
438
472
  const preferencesContent = buildPreferencesFile(prefs);
@@ -74,3 +74,104 @@ test("model command resolves and persists exact provider-qualified selection", a
74
74
  assert.deepEqual(applied, selectedModel);
75
75
  assert.match(notices[0]!.message, /openai\/gpt-5\.4/);
76
76
  });
77
+
78
+ test("interactive model picker chooses provider first, then model", async () => {
79
+ const selectedModel = { provider: "openai", id: "gpt-5.4" };
80
+ let applied: typeof selectedModel | null = null;
81
+ const selects: Array<{ title: string; options: string[] }> = [];
82
+ const notices: Array<{ message: string; type?: string }> = [];
83
+
84
+ const ctx = {
85
+ hasUI: true,
86
+ model: { provider: "anthropic", id: "claude-sonnet-4-6" },
87
+ modelRegistry: {
88
+ getAvailable: () => [
89
+ { provider: "openai", id: "gpt-5.4" },
90
+ { provider: "anthropic", id: "claude-opus-4-6" },
91
+ { provider: "openai", id: "gpt-5.3-mini" },
92
+ { provider: "anthropic", id: "claude-sonnet-4-6" },
93
+ ],
94
+ },
95
+ ui: {
96
+ select: async (title: string, options: string[]) => {
97
+ selects.push({ title, options });
98
+ return selects.length === 1 ? "openai (2 models)" : "gpt-5.4";
99
+ },
100
+ notify: (message: string, type?: string) => {
101
+ notices.push({ message, type });
102
+ },
103
+ },
104
+ } as any;
105
+
106
+ const pi = {
107
+ setModel: async (model: typeof selectedModel) => {
108
+ applied = model;
109
+ return true;
110
+ },
111
+ } as any;
112
+
113
+ const handled = await handleCoreCommand("model", ctx, pi);
114
+ assert.equal(handled, true);
115
+ assert.deepEqual(selects, [
116
+ {
117
+ title: "Select session model: — choose provider:",
118
+ options: ["anthropic (2 models)", "openai (2 models)", "(cancel)"],
119
+ },
120
+ {
121
+ title: "Select session model: — openai:",
122
+ options: ["gpt-5.3-mini", "gpt-5.4", "(cancel)"],
123
+ },
124
+ ]);
125
+ assert.deepEqual(applied, selectedModel);
126
+ assert.match(notices[0]!.message, /openai\/gpt-5\.4/);
127
+ });
128
+
129
+ test("ambiguous typed model selection chooses provider first, then model", async () => {
130
+ const selectedModel = { provider: "github-copilot", id: "gpt-5" };
131
+ let applied: typeof selectedModel | null = null;
132
+ const selects: Array<{ title: string; options: string[] }> = [];
133
+ const notices: Array<{ message: string; type?: string }> = [];
134
+
135
+ const ctx = {
136
+ hasUI: true,
137
+ model: { provider: "anthropic", id: "claude-sonnet-4-6" },
138
+ modelRegistry: {
139
+ getAvailable: () => [
140
+ { provider: "openai", id: "gpt-5" },
141
+ { provider: "github-copilot", id: "gpt-5" },
142
+ { provider: "openai", id: "gpt-5-mini" },
143
+ ],
144
+ },
145
+ ui: {
146
+ select: async (title: string, options: string[]) => {
147
+ selects.push({ title, options });
148
+ return selects.length === 1 ? "github-copilot (1 model)" : "gpt-5";
149
+ },
150
+ notify: (message: string, type?: string) => {
151
+ notices.push({ message, type });
152
+ },
153
+ },
154
+ } as any;
155
+
156
+ const pi = {
157
+ setModel: async (model: typeof selectedModel) => {
158
+ applied = model;
159
+ return true;
160
+ },
161
+ } as any;
162
+
163
+ const handled = await handleCoreCommand("model gpt", ctx, pi);
164
+ assert.equal(handled, true);
165
+ assert.deepEqual(selects, [
166
+ {
167
+ title: "Multiple models match \"gpt\" — choose provider:",
168
+ options: ["github-copilot (1 model)", "openai (2 models)", "(cancel)"],
169
+ },
170
+ {
171
+ title: "Multiple models match \"gpt\" — github-copilot:",
172
+ options: ["gpt-5", "(cancel)"],
173
+ },
174
+ ]);
175
+ assert.deepEqual(applied, selectedModel);
176
+ assert.match(notices[0]!.message, /github-copilot\/gpt-5/);
177
+ });
@@ -77,6 +77,36 @@ describe('ensure-db-open', () => {
77
77
  }
78
78
  });
79
79
 
80
+ test('ensureDbOpen: explicit basePath opens target project without cwd override', async () => {
81
+ const tmpDir = makeTmpDir();
82
+ const gsdDir = path.join(tmpDir, '.gsd');
83
+ fs.mkdirSync(gsdDir, { recursive: true });
84
+ fs.writeFileSync(path.join(gsdDir, 'DECISIONS.md'), `# Decisions
85
+
86
+ | # | When | Scope | Decision | Choice | Rationale | Revisable |
87
+ |---|------|-------|----------|--------|-----------|-----------|
88
+ | D777 | M001 | architecture | Use explicit basePath | BasePath | Avoid cwd coupling | Yes |
89
+ `);
90
+
91
+ try {
92
+ closeDatabase();
93
+ } catch { /* ok */ }
94
+
95
+ const originalCwd = process.cwd();
96
+ try {
97
+ const { ensureDbOpen } = await import('../bootstrap/dynamic-tools.ts');
98
+ const result = await ensureDbOpen(tmpDir);
99
+
100
+ assert.ok(result === true, 'ensureDbOpen should honor explicit basePath');
101
+ assert.equal(process.cwd(), originalCwd, 'ensureDbOpen should not mutate process.cwd');
102
+ assert.ok(isDbAvailable(), 'DB should be available after explicit open');
103
+ assert.ok(getDecisionById('D777') !== null, 'explicit basePath DB should be opened');
104
+ } finally {
105
+ closeDatabase();
106
+ cleanupDir(tmpDir);
107
+ }
108
+ });
109
+
80
110
  // ═══════════════════════════════════════════════════════════════════════════
81
111
  // ensureDbOpen returns false when no .gsd/ exists
82
112
  // ═══════════════════════════════════════════════════════════════════════════
@@ -159,6 +189,42 @@ describe('ensure-db-open', () => {
159
189
  }
160
190
  });
161
191
 
192
+ test('ensureDbOpen: switches open database when basePath changes', async () => {
193
+ const firstDir = makeTmpDir();
194
+ const secondDir = makeTmpDir();
195
+ fs.mkdirSync(path.join(firstDir, '.gsd'), { recursive: true });
196
+ fs.mkdirSync(path.join(secondDir, '.gsd'), { recursive: true });
197
+ fs.writeFileSync(path.join(firstDir, '.gsd', 'DECISIONS.md'), `# Decisions
198
+
199
+ | # | When | Scope | Decision | Choice | Rationale | Revisable |
200
+ |---|------|-------|----------|--------|-----------|-----------|
201
+ | D101 | M001 | architecture | First DB | First | First rationale | Yes |
202
+ `);
203
+ fs.writeFileSync(path.join(secondDir, '.gsd', 'DECISIONS.md'), `# Decisions
204
+
205
+ | # | When | Scope | Decision | Choice | Rationale | Revisable |
206
+ |---|------|-------|----------|--------|-----------|-----------|
207
+ | D202 | M001 | architecture | Second DB | Second | Second rationale | Yes |
208
+ `);
209
+
210
+ try {
211
+ closeDatabase();
212
+ } catch { /* ok */ }
213
+
214
+ try {
215
+ const { ensureDbOpen } = await import('../bootstrap/dynamic-tools.ts');
216
+ assert.equal(await ensureDbOpen(firstDir), true);
217
+ assert.ok(getDecisionById('D101') !== null, 'first DB should be active');
218
+ assert.equal(await ensureDbOpen(secondDir), true);
219
+ assert.ok(getDecisionById('D202') !== null, 'second DB should be active after switch');
220
+ assert.equal(getDecisionById('D101'), null, 'first DB should no longer be active after switch');
221
+ } finally {
222
+ closeDatabase();
223
+ cleanupDir(firstDir);
224
+ cleanupDir(secondDir);
225
+ }
226
+ });
227
+
162
228
  // ═══════════════════════════════════════════════════════════════════════════
163
229
 
164
230
  });