gsd-pi 2.8.1 → 2.8.3

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 (109) hide show
  1. package/dist/loader.js +5 -0
  2. package/node_modules/@gsd/pi-coding-agent/dist/core/bash-executor.js +2 -2
  3. package/node_modules/@gsd/pi-coding-agent/dist/core/bash-executor.js.map +1 -1
  4. package/node_modules/@gsd/pi-coding-agent/dist/core/extensions/types.d.ts +4 -2
  5. package/node_modules/@gsd/pi-coding-agent/dist/core/extensions/types.d.ts.map +1 -1
  6. package/node_modules/@gsd/pi-coding-agent/dist/core/extensions/types.js.map +1 -1
  7. package/node_modules/@gsd/pi-coding-agent/dist/core/tools/bash.js +2 -2
  8. package/node_modules/@gsd/pi-coding-agent/dist/core/tools/bash.js.map +1 -1
  9. package/node_modules/@gsd/pi-coding-agent/dist/core/tools/path-utils.d.ts +1 -1
  10. package/node_modules/@gsd/pi-coding-agent/dist/core/tools/path-utils.d.ts.map +1 -1
  11. package/node_modules/@gsd/pi-coding-agent/dist/core/tools/path-utils.js +13 -2
  12. package/node_modules/@gsd/pi-coding-agent/dist/core/tools/path-utils.js.map +1 -1
  13. package/node_modules/@gsd/pi-coding-agent/dist/core/tools/path-utils.test.d.ts +2 -0
  14. package/node_modules/@gsd/pi-coding-agent/dist/core/tools/path-utils.test.d.ts.map +1 -0
  15. package/node_modules/@gsd/pi-coding-agent/dist/core/tools/path-utils.test.js +57 -0
  16. package/node_modules/@gsd/pi-coding-agent/dist/core/tools/path-utils.test.js.map +1 -0
  17. package/node_modules/@gsd/pi-coding-agent/dist/index.d.ts +1 -1
  18. package/node_modules/@gsd/pi-coding-agent/dist/index.d.ts.map +1 -1
  19. package/node_modules/@gsd/pi-coding-agent/dist/index.js +1 -1
  20. package/node_modules/@gsd/pi-coding-agent/dist/index.js.map +1 -1
  21. package/node_modules/@gsd/pi-coding-agent/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  22. package/node_modules/@gsd/pi-coding-agent/dist/modes/interactive/interactive-mode.js +41 -5
  23. package/node_modules/@gsd/pi-coding-agent/dist/modes/interactive/interactive-mode.js.map +1 -1
  24. package/node_modules/@gsd/pi-coding-agent/dist/modes/rpc/rpc-mode.js +1 -1
  25. package/node_modules/@gsd/pi-coding-agent/dist/modes/rpc/rpc-mode.js.map +1 -1
  26. package/node_modules/@gsd/pi-coding-agent/dist/modes/rpc/rpc-types.d.ts +5 -0
  27. package/node_modules/@gsd/pi-coding-agent/dist/modes/rpc/rpc-types.d.ts.map +1 -1
  28. package/node_modules/@gsd/pi-coding-agent/dist/modes/rpc/rpc-types.js.map +1 -1
  29. package/node_modules/@gsd/pi-coding-agent/dist/utils/shell.d.ts +7 -0
  30. package/node_modules/@gsd/pi-coding-agent/dist/utils/shell.d.ts.map +1 -1
  31. package/node_modules/@gsd/pi-coding-agent/dist/utils/shell.js +11 -0
  32. package/node_modules/@gsd/pi-coding-agent/dist/utils/shell.js.map +1 -1
  33. package/node_modules/@gsd/pi-coding-agent/src/core/bash-executor.ts +2 -2
  34. package/node_modules/@gsd/pi-coding-agent/src/core/extensions/types.ts +4 -2
  35. package/node_modules/@gsd/pi-coding-agent/src/core/tools/bash.ts +2 -2
  36. package/node_modules/@gsd/pi-coding-agent/src/core/tools/path-utils.test.ts +66 -0
  37. package/node_modules/@gsd/pi-coding-agent/src/core/tools/path-utils.ts +14 -2
  38. package/node_modules/@gsd/pi-coding-agent/src/index.ts +1 -1
  39. package/node_modules/@gsd/pi-coding-agent/src/modes/interactive/interactive-mode.ts +41 -4
  40. package/node_modules/@gsd/pi-coding-agent/src/modes/rpc/rpc-mode.ts +2 -2
  41. package/node_modules/@gsd/pi-coding-agent/src/modes/rpc/rpc-types.ts +2 -1
  42. package/node_modules/@gsd/pi-coding-agent/src/utils/shell.ts +11 -0
  43. package/package.json +1 -1
  44. package/packages/pi-coding-agent/dist/core/bash-executor.js +2 -2
  45. package/packages/pi-coding-agent/dist/core/bash-executor.js.map +1 -1
  46. package/packages/pi-coding-agent/dist/core/extensions/types.d.ts +4 -2
  47. package/packages/pi-coding-agent/dist/core/extensions/types.d.ts.map +1 -1
  48. package/packages/pi-coding-agent/dist/core/extensions/types.js.map +1 -1
  49. package/packages/pi-coding-agent/dist/core/tools/bash.js +2 -2
  50. package/packages/pi-coding-agent/dist/core/tools/bash.js.map +1 -1
  51. package/packages/pi-coding-agent/dist/core/tools/path-utils.d.ts +1 -1
  52. package/packages/pi-coding-agent/dist/core/tools/path-utils.d.ts.map +1 -1
  53. package/packages/pi-coding-agent/dist/core/tools/path-utils.js +13 -2
  54. package/packages/pi-coding-agent/dist/core/tools/path-utils.js.map +1 -1
  55. package/packages/pi-coding-agent/dist/core/tools/path-utils.test.d.ts +2 -0
  56. package/packages/pi-coding-agent/dist/core/tools/path-utils.test.d.ts.map +1 -0
  57. package/packages/pi-coding-agent/dist/core/tools/path-utils.test.js +57 -0
  58. package/packages/pi-coding-agent/dist/core/tools/path-utils.test.js.map +1 -0
  59. package/packages/pi-coding-agent/dist/index.d.ts +1 -1
  60. package/packages/pi-coding-agent/dist/index.d.ts.map +1 -1
  61. package/packages/pi-coding-agent/dist/index.js +1 -1
  62. package/packages/pi-coding-agent/dist/index.js.map +1 -1
  63. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  64. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js +41 -5
  65. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js.map +1 -1
  66. package/packages/pi-coding-agent/dist/modes/rpc/rpc-mode.js +1 -1
  67. package/packages/pi-coding-agent/dist/modes/rpc/rpc-mode.js.map +1 -1
  68. package/packages/pi-coding-agent/dist/modes/rpc/rpc-types.d.ts +5 -0
  69. package/packages/pi-coding-agent/dist/modes/rpc/rpc-types.d.ts.map +1 -1
  70. package/packages/pi-coding-agent/dist/modes/rpc/rpc-types.js.map +1 -1
  71. package/packages/pi-coding-agent/dist/utils/shell.d.ts +7 -0
  72. package/packages/pi-coding-agent/dist/utils/shell.d.ts.map +1 -1
  73. package/packages/pi-coding-agent/dist/utils/shell.js +11 -0
  74. package/packages/pi-coding-agent/dist/utils/shell.js.map +1 -1
  75. package/packages/pi-coding-agent/src/core/bash-executor.ts +2 -2
  76. package/packages/pi-coding-agent/src/core/extensions/types.ts +4 -2
  77. package/packages/pi-coding-agent/src/core/tools/bash.ts +2 -2
  78. package/packages/pi-coding-agent/src/core/tools/path-utils.test.ts +66 -0
  79. package/packages/pi-coding-agent/src/core/tools/path-utils.ts +14 -2
  80. package/packages/pi-coding-agent/src/index.ts +1 -1
  81. package/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts +41 -4
  82. package/packages/pi-coding-agent/src/modes/rpc/rpc-mode.ts +2 -2
  83. package/packages/pi-coding-agent/src/modes/rpc/rpc-types.ts +2 -1
  84. package/packages/pi-coding-agent/src/utils/shell.ts +11 -0
  85. package/src/resources/extensions/ask-user-questions.ts +40 -0
  86. package/src/resources/extensions/bg-shell/index.ts +2 -1
  87. package/src/resources/extensions/gsd/auto.ts +103 -44
  88. package/src/resources/extensions/gsd/docs/preferences-reference.md +76 -0
  89. package/src/resources/extensions/gsd/git-service.ts +47 -9
  90. package/src/resources/extensions/gsd/gitignore.ts +27 -0
  91. package/src/resources/extensions/gsd/guided-flow.ts +10 -9
  92. package/src/resources/extensions/gsd/prompts/complete-milestone.md +2 -2
  93. package/src/resources/extensions/gsd/prompts/complete-slice.md +3 -3
  94. package/src/resources/extensions/gsd/prompts/discuss.md +2 -2
  95. package/src/resources/extensions/gsd/prompts/execute-task.md +2 -2
  96. package/src/resources/extensions/gsd/prompts/guided-discuss-slice.md +3 -3
  97. package/src/resources/extensions/gsd/prompts/plan-milestone.md +1 -1
  98. package/src/resources/extensions/gsd/prompts/plan-slice.md +2 -2
  99. package/src/resources/extensions/gsd/prompts/reassess-roadmap.md +3 -3
  100. package/src/resources/extensions/gsd/prompts/replan-slice.md +2 -2
  101. package/src/resources/extensions/gsd/prompts/research-milestone.md +1 -1
  102. package/src/resources/extensions/gsd/prompts/research-slice.md +1 -1
  103. package/src/resources/extensions/gsd/prompts/run-uat.md +4 -4
  104. package/src/resources/extensions/gsd/tests/git-service.test.ts +53 -1
  105. package/src/resources/extensions/gsd/tests/reassess-prompt.test.ts +5 -5
  106. package/src/resources/extensions/gsd/tests/replan-slice.test.ts +2 -1
  107. package/src/resources/extensions/gsd/tests/run-uat.test.ts +2 -4
  108. package/src/resources/extensions/search-the-web/command-search-provider.ts +1 -1
  109. package/src/resources/extensions/search-the-web/native-search.ts +5 -6
@@ -824,10 +824,12 @@ export class InteractiveMode {
824
824
 
825
825
  // Try parent directories (package manager stores directory paths)
826
826
  let current = p;
827
- while (current.includes("/")) {
828
- current = current.substring(0, current.lastIndexOf("/"));
829
- const parent = metadata.get(current);
830
- if (parent) return parent;
827
+ let parent = path.dirname(current);
828
+ while (parent !== current) {
829
+ const meta = metadata.get(parent);
830
+ if (meta) return meta;
831
+ current = parent;
832
+ parent = path.dirname(current);
831
833
  }
832
834
 
833
835
  return undefined;
@@ -3693,6 +3695,21 @@ export class InteractiveMode {
3693
3695
  this.session.modelRegistry.authStorage.logout(providerId);
3694
3696
  this.session.modelRegistry.refresh();
3695
3697
  await this.updateAvailableProviderCount();
3698
+
3699
+ // Auto-switch model if current model belongs to the logged-out provider
3700
+ const currentModel = this.session.model;
3701
+ if (currentModel?.provider === providerId) {
3702
+ try {
3703
+ const available = this.session.modelRegistry.getAvailable();
3704
+ const fallback = available.find((m) => m.provider !== providerId);
3705
+ if (fallback) {
3706
+ await this.session.setModel(fallback);
3707
+ }
3708
+ } catch {
3709
+ // Model switch failed — user can manually switch via /model
3710
+ }
3711
+ }
3712
+
3696
3713
  this.showStatus(`Logged out of ${providerName}`);
3697
3714
  } catch (error: unknown) {
3698
3715
  this.showError(`Logout failed: ${error instanceof Error ? error.message : String(error)}`);
@@ -3787,6 +3804,26 @@ export class InteractiveMode {
3787
3804
  restoreEditor();
3788
3805
  this.session.modelRegistry.refresh();
3789
3806
  await this.updateAvailableProviderCount();
3807
+
3808
+ // Auto-switch model if current model has no valid API key
3809
+ try {
3810
+ const currentModel = this.session.model;
3811
+ if (currentModel) {
3812
+ const currentKey = await this.session.modelRegistry.getApiKey(currentModel);
3813
+ if (!currentKey) {
3814
+ const available = this.session.modelRegistry.getAvailable();
3815
+ const newProviderModel = available.find((m) => m.provider === providerId);
3816
+ if (newProviderModel) {
3817
+ await this.session.setModel(newProviderModel);
3818
+ } else if (available.length > 0) {
3819
+ await this.session.setModel(available[0]);
3820
+ }
3821
+ }
3822
+ }
3823
+ } catch (error: unknown) {
3824
+ // Model switch failed — user can manually switch via /model
3825
+ }
3826
+
3790
3827
  this.showStatus(`Logged in to ${providerName}. Credentials saved to ${getAuthPath()}`);
3791
3828
  } catch (error: unknown) {
3792
3829
  restoreEditor();
@@ -119,8 +119,8 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
119
119
  */
120
120
  const createExtensionUIContext = (): ExtensionUIContext => ({
121
121
  select: (title, options, opts) =>
122
- createDialogPromise(opts, undefined, { method: "select", title, options, timeout: opts?.timeout }, (r) =>
123
- "cancelled" in r && r.cancelled ? undefined : "value" in r ? r.value : undefined,
122
+ createDialogPromise(opts, undefined, { method: "select", title, options, timeout: opts?.timeout, allowMultiple: opts?.allowMultiple }, (r) =>
123
+ "cancelled" in r && r.cancelled ? undefined : "values" in r ? r.values : "value" in r ? r.value : undefined,
124
124
  ),
125
125
 
126
126
  confirm: (title, message, opts) =>
@@ -210,7 +210,7 @@ export type RpcResponse =
210
210
 
211
211
  /** Emitted when an extension needs user input */
212
212
  export type RpcExtensionUIRequest =
213
- | { type: "extension_ui_request"; id: string; method: "select"; title: string; options: string[]; timeout?: number }
213
+ | { type: "extension_ui_request"; id: string; method: "select"; title: string; options: string[]; timeout?: number; allowMultiple?: boolean }
214
214
  | { type: "extension_ui_request"; id: string; method: "confirm"; title: string; message: string; timeout?: number }
215
215
  | {
216
216
  type: "extension_ui_request";
@@ -253,6 +253,7 @@ export type RpcExtensionUIRequest =
253
253
  /** Response to an extension UI request */
254
254
  export type RpcExtensionUIResponse =
255
255
  | { type: "extension_ui_response"; id: string; value: string }
256
+ | { type: "extension_ui_response"; id: string; values: string[] }
256
257
  | { type: "extension_ui_response"; id: string; confirmed: boolean }
257
258
  | { type: "extension_ui_response"; id: string; cancelled: true };
258
259
 
@@ -118,6 +118,17 @@ export function getShellConfig(): { shell: string; args: string[] } {
118
118
  return cachedShellConfig;
119
119
  }
120
120
 
121
+ /**
122
+ * On Windows + Git Bash, rewrite Windows-style NUL redirects to /dev/null.
123
+ * Git Bash doesn't recognize NUL as a device name and creates a literal file
124
+ * that is undeletable due to NUL being a reserved Windows device name.
125
+ * No-op on non-Windows platforms.
126
+ */
127
+ export function sanitizeCommand(command: string): string {
128
+ if (process.platform !== "win32") return command;
129
+ return command.replace(/(\d*>>?) *\bNUL\b(?=\s|;|\||&|\)|$)/gi, "$1 /dev/null");
130
+ }
131
+
121
132
  export function getShellEnv(): NodeJS.ProcessEnv {
122
133
  const binDir = getBinDir();
123
134
  const pathKey = Object.keys(process.env).find((key) => key.toLowerCase() === "path") ?? "PATH";
@@ -144,6 +144,46 @@ export default function AskUserQuestions(pi: ExtensionAPI) {
144
144
  // Delegate to shared interview UI
145
145
  const result = await showInterviewRound(params.questions, {}, ctx);
146
146
 
147
+ // RPC mode fallback: custom() returns undefined, so showInterviewRound
148
+ // may return undefined. Fall back to sequential ctx.ui.select() calls.
149
+ if (!result) {
150
+ const answers: Record<string, { answers: string[] }> = {};
151
+ for (const q of params.questions) {
152
+ const options = q.options.map((o) => o.label);
153
+ if (!q.allowMultiple) {
154
+ options.push(OTHER_OPTION_LABEL);
155
+ }
156
+ const selected = await ctx.ui.select(
157
+ `${q.header}: ${q.question}`,
158
+ options,
159
+ { signal, ...(q.allowMultiple ? { allowMultiple: true } : {}) },
160
+ );
161
+ if (selected === undefined) {
162
+ return errorResult("ask_user_questions was cancelled", params.questions);
163
+ }
164
+ answers[q.id] = {
165
+ answers: Array.isArray(selected) ? selected : [selected],
166
+ };
167
+ }
168
+ const roundResult: RoundResult = {
169
+ endInterview: false,
170
+ answers: Object.fromEntries(
171
+ Object.entries(answers).map(([id, a]) => [
172
+ id,
173
+ { selected: a.answers.length === 1 ? a.answers[0] : a.answers, notes: "" },
174
+ ]),
175
+ ),
176
+ };
177
+ return {
178
+ content: [{ type: "text" as const, text: JSON.stringify({ answers }) }],
179
+ details: {
180
+ questions: params.questions,
181
+ response: roundResult,
182
+ cancelled: false,
183
+ } satisfies LocalResultDetails,
184
+ };
185
+ }
186
+
147
187
  // Check if cancelled (empty answers = user exited)
148
188
  const hasAnswers = Object.keys(result.answers).length > 0;
149
189
  if (!hasAnswers) {
@@ -34,6 +34,7 @@ import {
34
34
  DEFAULT_MAX_BYTES,
35
35
  DEFAULT_MAX_LINES,
36
36
  getShellConfig,
37
+ sanitizeCommand,
37
38
  } from "@gsd/pi-coding-agent";
38
39
  import {
39
40
  Text,
@@ -582,7 +583,7 @@ function startProcess(opts: StartOptions): BgProcess {
582
583
  const env = { ...process.env, ...(opts.env || {}) };
583
584
 
584
585
  const { shell, args: shellArgs } = getShellConfig();
585
- const proc = spawn(shell, [...shellArgs, opts.command], {
586
+ const proc = spawn(shell, [...shellArgs, sanitizeCommand(opts.command)], {
586
587
  cwd: opts.cwd,
587
588
  stdio: ["pipe", "pipe", "pipe"],
588
589
  env,
@@ -48,14 +48,14 @@ import {
48
48
  validateCompleteBoundary,
49
49
  formatValidationIssues,
50
50
  } from "./observability-validator.js";
51
- import { ensureGitignore } from "./gitignore.js";
51
+ import { ensureGitignore, untrackRuntimeFiles } from "./gitignore.js";
52
52
  import { runGSDDoctor, rebuildState } from "./doctor.js";
53
53
  import { snapshotSkills, clearSkillSnapshot } from "./skill-discovery.js";
54
54
  import {
55
55
  initMetrics, resetMetrics, snapshotUnitMetrics, getLedger,
56
56
  getProjectTotals, formatCost, formatTokenCount,
57
57
  } from "./metrics.js";
58
- import { join } from "node:path";
58
+ import { dirname, join } from "node:path";
59
59
  import { readdirSync, readFileSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
60
60
  import { execSync, execFileSync } from "node:child_process";
61
61
  import {
@@ -152,6 +152,7 @@ let currentMilestoneId: string | null = null;
152
152
 
153
153
  /** Model the user had selected before auto-mode started */
154
154
  let originalModelId: string | null = null;
155
+ let originalModelProvider: string | null = null;
155
156
 
156
157
  /** Progress-aware timeout supervision */
157
158
  let unitTimeoutHandle: ReturnType<typeof setTimeout> | null = null;
@@ -257,6 +258,11 @@ export async function stopAuto(ctx?: ExtensionContext, pi?: ExtensionAPI): Promi
257
258
  ctx?.ui.notify("Auto-mode stopped.", "info");
258
259
  }
259
260
 
261
+ // Sync disk state so next resume starts from accurate state
262
+ if (basePath) {
263
+ try { await rebuildState(basePath); } catch { /* non-fatal */ }
264
+ }
265
+
260
266
  resetMetrics();
261
267
  active = false;
262
268
  paused = false;
@@ -272,10 +278,11 @@ export async function stopAuto(ctx?: ExtensionContext, pi?: ExtensionAPI): Promi
272
278
  ctx?.ui.setFooter(undefined);
273
279
 
274
280
  // Restore the user's original model
275
- if (pi && ctx && originalModelId) {
276
- const original = ctx.modelRegistry.find("anthropic", originalModelId);
281
+ if (pi && ctx && originalModelId && originalModelProvider) {
282
+ const original = ctx.modelRegistry.find(originalModelProvider, originalModelId);
277
283
  if (original) await pi.setModel(original);
278
284
  originalModelId = null;
285
+ originalModelProvider = null;
279
286
  }
280
287
 
281
288
  cmdCtx = null;
@@ -381,6 +388,7 @@ export async function startAuto(
381
388
 
382
389
  // Ensure .gitignore has baseline patterns
383
390
  ensureGitignore(base);
391
+ untrackRuntimeFiles(base);
384
392
 
385
393
  // Bootstrap .gsd/ if it doesn't exist
386
394
  const gsdDir = join(base, ".gsd");
@@ -458,6 +466,7 @@ export async function startAuto(
458
466
  currentUnit = null;
459
467
  currentMilestoneId = state.activeMilestone?.id ?? null;
460
468
  originalModelId = ctx.model?.id ?? null;
469
+ originalModelProvider = ctx.model?.provider ?? null;
461
470
 
462
471
  // Initialize metrics — loads existing ledger from disk
463
472
  initMetrics(base);
@@ -1180,7 +1189,7 @@ async function dispatchNextUnit(
1180
1189
 
1181
1190
  // Research before roadmap if no research exists
1182
1191
  const researchFile = resolveMilestoneFile(basePath, mid, "RESEARCH");
1183
- const hasResearch = !!(researchFile && await loadFile(researchFile));
1192
+ const hasResearch = !!researchFile;
1184
1193
 
1185
1194
  if (!hasResearch) {
1186
1195
  unitType = "research-milestone";
@@ -1197,13 +1206,13 @@ async function dispatchNextUnit(
1197
1206
  const sid = state.activeSlice!.id;
1198
1207
  const sTitle = state.activeSlice!.title;
1199
1208
  const researchFile = resolveSliceFile(basePath, mid, sid, "RESEARCH");
1200
- const hasResearch = !!(researchFile && await loadFile(researchFile));
1209
+ const hasResearch = !!researchFile;
1201
1210
 
1202
1211
  if (!hasResearch) {
1203
1212
  // Skip slice research for S01 when milestone research already exists —
1204
1213
  // the milestone research already covers the same ground for the first slice.
1205
1214
  const milestoneResearchFile = resolveMilestoneFile(basePath, mid, "RESEARCH");
1206
- const hasMilestoneResearch = !!(milestoneResearchFile && await loadFile(milestoneResearchFile));
1215
+ const hasMilestoneResearch = !!milestoneResearchFile;
1207
1216
  if (hasMilestoneResearch && sid === "S01") {
1208
1217
  unitType = "plan-slice";
1209
1218
  unitId = `${mid}/${sid}`;
@@ -1311,6 +1320,26 @@ async function dispatchNextUnit(
1311
1320
  }
1312
1321
  unitDispatchCount.set(dispatchKey, prevCount + 1);
1313
1322
  if (prevCount > 0) {
1323
+ // Self-repair: if summary exists but checkbox not marked, fix it and re-derive
1324
+ if (unitType === "execute-task") {
1325
+ const status = await inspectExecuteTaskDurability(basePath, unitId);
1326
+ if (status?.summaryExists && !status.taskChecked) {
1327
+ const [mid, sid, tid] = unitId.split("/");
1328
+ if (mid && sid && tid) {
1329
+ const repaired = skipExecuteTask(basePath, mid, sid, tid, status, "self-repair", 0);
1330
+ if (repaired) {
1331
+ ctx.ui.notify(
1332
+ `Self-repaired ${unitId}: summary existed but checkbox was unmarked. Marked [x] and advancing.`,
1333
+ "warning",
1334
+ );
1335
+ unitDispatchCount.delete(dispatchKey);
1336
+ await new Promise(r => setImmediate(r));
1337
+ await dispatchNextUnit(ctx, pi);
1338
+ return;
1339
+ }
1340
+ }
1341
+ }
1342
+ }
1314
1343
  ctx.ui.notify(
1315
1344
  `${unitType} ${unitId} didn't produce expected artifact. Retrying (${prevCount + 1}/${MAX_UNIT_DISPATCHES}).`,
1316
1345
  "warning",
@@ -1413,14 +1442,46 @@ async function dispatchNextUnit(
1413
1442
  // Try primary model, then fallbacks in order if setting fails
1414
1443
  const modelConfig = resolveModelWithFallbacksForUnit(unitType);
1415
1444
  if (modelConfig) {
1416
- const allModels = ctx.modelRegistry.getAll();
1445
+ const availableModels = ctx.modelRegistry.getAvailable();
1417
1446
  const modelsToTry = [modelConfig.primary, ...modelConfig.fallbacks];
1418
1447
  let modelSet = false;
1419
1448
 
1420
1449
  for (const modelId of modelsToTry) {
1421
- const model = allModels.find(m => m.id === modelId);
1450
+ // Support "provider/model" format for explicit provider targeting
1451
+ const slashIdx = modelId.indexOf("/");
1452
+ let model;
1453
+ if (slashIdx !== -1) {
1454
+ const provider = modelId.substring(0, slashIdx);
1455
+ const id = modelId.substring(slashIdx + 1);
1456
+ model = availableModels.find(
1457
+ m => m.provider.toLowerCase() === provider.toLowerCase()
1458
+ && m.id.toLowerCase() === id.toLowerCase(),
1459
+ );
1460
+ } else {
1461
+ // For bare IDs, prefer the current session's provider, then first available match
1462
+ const currentProvider = ctx.model?.provider;
1463
+ const exactProviderMatch = availableModels.find(
1464
+ m => m.id === modelId && m.provider === currentProvider,
1465
+ );
1466
+ const anyMatch = availableModels.find(m => m.id === modelId);
1467
+ model = exactProviderMatch ?? anyMatch;
1468
+
1469
+ // Warn if the ID is ambiguous across providers
1470
+ if (anyMatch && !exactProviderMatch) {
1471
+ const providers = availableModels
1472
+ .filter(m => m.id === modelId)
1473
+ .map(m => m.provider);
1474
+ if (providers.length > 1) {
1475
+ ctx.ui.notify(
1476
+ `Model ID "${modelId}" exists in multiple providers (${providers.join(", ")}). ` +
1477
+ `Resolved to ${anyMatch.provider}. Use "provider/model" format for explicit targeting.`,
1478
+ "warning",
1479
+ );
1480
+ }
1481
+ }
1482
+ }
1422
1483
  if (!model) {
1423
- ctx.ui.notify(`Model ${modelId} not found in registry, trying fallback.`, "warning");
1484
+ ctx.ui.notify(`Model ${modelId} not found in available models, trying fallback.`, "warning");
1424
1485
  continue;
1425
1486
  }
1426
1487
 
@@ -1696,13 +1757,11 @@ async function buildResearchMilestonePrompt(mid: string, midTitle: string, base:
1696
1757
  const inlinedContext = `## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`;
1697
1758
 
1698
1759
  const outputRelPath = relMilestoneFile(base, mid, "RESEARCH");
1699
- const outputAbsPath = resolveMilestoneFile(base, mid, "RESEARCH") ?? join(base, outputRelPath);
1700
1760
  return loadPrompt("research-milestone", {
1701
1761
  milestoneId: mid, milestoneTitle: midTitle,
1702
1762
  milestonePath: relMilestonePath(base, mid),
1703
1763
  contextPath: contextRel,
1704
1764
  outputPath: outputRelPath,
1705
- outputAbsPath,
1706
1765
  inlinedContext,
1707
1766
  ...buildSkillDiscoveryVars(),
1708
1767
  });
@@ -1730,7 +1789,6 @@ async function buildPlanMilestonePrompt(mid: string, midTitle: string, base: str
1730
1789
  const inlinedContext = `## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`;
1731
1790
 
1732
1791
  const outputRelPath = relMilestoneFile(base, mid, "ROADMAP");
1733
- const outputAbsPath = resolveMilestoneFile(base, mid, "ROADMAP") ?? join(base, outputRelPath);
1734
1792
  const secretsOutputPath = relMilestoneFile(base, mid, "SECRETS");
1735
1793
  return loadPrompt("plan-milestone", {
1736
1794
  milestoneId: mid, milestoneTitle: midTitle,
@@ -1738,7 +1796,6 @@ async function buildPlanMilestonePrompt(mid: string, midTitle: string, base: str
1738
1796
  contextPath: contextRel,
1739
1797
  researchPath: researchRel,
1740
1798
  outputPath: outputRelPath,
1741
- outputAbsPath,
1742
1799
  secretsOutputPath,
1743
1800
  inlinedContext,
1744
1801
  });
@@ -1770,7 +1827,6 @@ async function buildResearchSlicePrompt(
1770
1827
  const inlinedContext = `## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`;
1771
1828
 
1772
1829
  const outputRelPath = relSliceFile(base, mid, sid, "RESEARCH");
1773
- const outputAbsPath = resolveSliceFile(base, mid, sid, "RESEARCH") ?? join(base, outputRelPath);
1774
1830
  return loadPrompt("research-slice", {
1775
1831
  milestoneId: mid, sliceId: sid, sliceTitle: sTitle,
1776
1832
  slicePath: relSlicePath(base, mid, sid),
@@ -1778,7 +1834,6 @@ async function buildResearchSlicePrompt(
1778
1834
  contextPath: contextRel,
1779
1835
  milestoneResearchPath: milestoneResearchRel,
1780
1836
  outputPath: outputRelPath,
1781
- outputAbsPath,
1782
1837
  inlinedContext,
1783
1838
  dependencySummaries: depContent,
1784
1839
  ...buildSkillDiscoveryVars(),
@@ -1807,16 +1862,12 @@ async function buildPlanSlicePrompt(
1807
1862
  const inlinedContext = `## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`;
1808
1863
 
1809
1864
  const outputRelPath = relSliceFile(base, mid, sid, "PLAN");
1810
- const outputAbsPath = resolveSliceFile(base, mid, sid, "PLAN") ?? join(base, outputRelPath);
1811
- const sliceAbsPath = resolveSlicePath(base, mid, sid) ?? join(base, relSlicePath(base, mid, sid));
1812
1865
  return loadPrompt("plan-slice", {
1813
1866
  milestoneId: mid, sliceId: sid, sliceTitle: sTitle,
1814
1867
  slicePath: relSlicePath(base, mid, sid),
1815
- sliceAbsPath,
1816
1868
  roadmapPath: roadmapRel,
1817
1869
  researchPath: researchRel,
1818
1870
  outputPath: outputRelPath,
1819
- outputAbsPath,
1820
1871
  inlinedContext,
1821
1872
  dependencySummaries: depContent,
1822
1873
  });
@@ -1867,8 +1918,7 @@ async function buildExecuteTaskPrompt(
1867
1918
 
1868
1919
  const carryForwardSection = await buildCarryForwardSection(priorSummaries, base);
1869
1920
 
1870
- const sliceDirAbs = resolveSlicePath(base, mid, sid) ?? join(base, relSlicePath(base, mid, sid));
1871
- const taskSummaryAbsPath = join(sliceDirAbs, "tasks", `${tid}-SUMMARY.md`);
1921
+ const taskSummaryPath = `${relSlicePath(base, mid, sid)}/tasks/${tid}-SUMMARY.md`;
1872
1922
 
1873
1923
  return loadPrompt("execute-task", {
1874
1924
  milestoneId: mid, sliceId: sid, sliceTitle: sTitle, taskId: tid, taskTitle: tTitle,
@@ -1880,7 +1930,7 @@ async function buildExecuteTaskPrompt(
1880
1930
  carryForwardSection,
1881
1931
  resumeSection,
1882
1932
  priorTaskLines: priorLines,
1883
- taskSummaryAbsPath,
1933
+ taskSummaryPath,
1884
1934
  });
1885
1935
  }
1886
1936
 
@@ -1916,17 +1966,17 @@ async function buildCompleteSlicePrompt(
1916
1966
 
1917
1967
  const inlinedContext = `## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`;
1918
1968
 
1919
- const sliceDirAbs = resolveSlicePath(base, mid, sid) ?? join(base, relSlicePath(base, mid, sid));
1920
- const sliceSummaryAbsPath = join(sliceDirAbs, `${sid}-SUMMARY.md`);
1921
- const sliceUatAbsPath = join(sliceDirAbs, `${sid}-UAT.md`);
1969
+ const sliceRel = relSlicePath(base, mid, sid);
1970
+ const sliceSummaryPath = `${sliceRel}/${sid}-SUMMARY.md`;
1971
+ const sliceUatPath = `${sliceRel}/${sid}-UAT.md`;
1922
1972
 
1923
1973
  return loadPrompt("complete-slice", {
1924
1974
  milestoneId: mid, sliceId: sid, sliceTitle: sTitle,
1925
- slicePath: relSlicePath(base, mid, sid),
1975
+ slicePath: sliceRel,
1926
1976
  roadmapPath: roadmapRel,
1927
1977
  inlinedContext,
1928
- sliceSummaryAbsPath,
1929
- sliceUatAbsPath,
1978
+ sliceSummaryPath,
1979
+ sliceUatPath,
1930
1980
  });
1931
1981
  }
1932
1982
 
@@ -1965,15 +2015,14 @@ async function buildCompleteMilestonePrompt(
1965
2015
 
1966
2016
  const inlinedContext = `## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`;
1967
2017
 
1968
- const milestoneDirAbs = resolveMilestonePath(base, mid) ?? join(base, relMilestonePath(base, mid));
1969
- const milestoneSummaryAbsPath = join(milestoneDirAbs, `${mid}-SUMMARY.md`);
2018
+ const milestoneSummaryPath = `${relMilestonePath(base, mid)}/${mid}-SUMMARY.md`;
1970
2019
 
1971
2020
  return loadPrompt("complete-milestone", {
1972
2021
  milestoneId: mid,
1973
2022
  milestoneTitle: midTitle,
1974
2023
  roadmapPath: roadmapRel,
1975
2024
  inlinedContext,
1976
- milestoneSummaryAbsPath,
2025
+ milestoneSummaryPath,
1977
2026
  });
1978
2027
  }
1979
2028
 
@@ -2016,8 +2065,7 @@ async function buildReplanSlicePrompt(
2016
2065
 
2017
2066
  const inlinedContext = `## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`;
2018
2067
 
2019
- const sliceDirAbs = resolveSlicePath(base, mid, sid) ?? join(base, relSlicePath(base, mid, sid));
2020
- const replanAbsPath = join(sliceDirAbs, `${sid}-REPLAN.md`);
2068
+ const replanPath = `${relSlicePath(base, mid, sid)}/${sid}-REPLAN.md`;
2021
2069
 
2022
2070
  return loadPrompt("replan-slice", {
2023
2071
  milestoneId: mid,
@@ -2027,7 +2075,7 @@ async function buildReplanSlicePrompt(
2027
2075
  planPath: slicePlanRel,
2028
2076
  blockerTaskId,
2029
2077
  inlinedContext,
2030
- replanAbsPath,
2078
+ replanPath,
2031
2079
  });
2032
2080
  }
2033
2081
 
@@ -2145,8 +2193,6 @@ async function buildRunUatPrompt(
2145
2193
 
2146
2194
  const inlinedContext = `## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`;
2147
2195
 
2148
- const sliceDirAbs = resolveSlicePath(base, mid, sliceId) ?? join(base, relSlicePath(base, mid, sliceId));
2149
- const uatResultAbsPath = join(sliceDirAbs, `${sliceId}-UAT-RESULT.md`);
2150
2196
  const uatResultPath = relSliceFile(base, mid, sliceId, "UAT-RESULT");
2151
2197
  const uatType = extractUatType(uatContent) ?? "human-experience";
2152
2198
 
@@ -2154,7 +2200,6 @@ async function buildRunUatPrompt(
2154
2200
  milestoneId: mid,
2155
2201
  sliceId,
2156
2202
  uatPath,
2157
- uatResultAbsPath,
2158
2203
  uatResultPath,
2159
2204
  uatType,
2160
2205
  inlinedContext,
@@ -2181,9 +2226,7 @@ async function buildReassessRoadmapPrompt(
2181
2226
 
2182
2227
  const inlinedContext = `## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`;
2183
2228
 
2184
- const assessmentRel = relSliceFile(base, mid, completedSliceId, "ASSESSMENT");
2185
- const sliceDirAbs = resolveSlicePath(base, mid, completedSliceId) ?? join(base, relSlicePath(base, mid, completedSliceId));
2186
- const assessmentAbsPath = join(sliceDirAbs, `${completedSliceId}-ASSESSMENT.md`);
2229
+ const assessmentPath = relSliceFile(base, mid, completedSliceId, "ASSESSMENT");
2187
2230
 
2188
2231
  return loadPrompt("reassess-roadmap", {
2189
2232
  milestoneId: mid,
@@ -2191,8 +2234,7 @@ async function buildReassessRoadmapPrompt(
2191
2234
  completedSliceId,
2192
2235
  roadmapPath: roadmapRel,
2193
2236
  completedSliceSummaryPath: summaryRel,
2194
- assessmentPath: assessmentRel,
2195
- assessmentAbsPath,
2237
+ assessmentPath,
2196
2238
  inlinedContext,
2197
2239
  });
2198
2240
  }
@@ -2790,6 +2832,23 @@ function verifyExpectedArtifact(unitType: string, unitId: string, base: string):
2790
2832
  if (!absPath) return true;
2791
2833
  if (!existsSync(absPath)) return false;
2792
2834
 
2835
+ // execute-task must also have its checkbox marked [x] in the slice plan
2836
+ if (unitType === "execute-task") {
2837
+ const parts = unitId.split("/");
2838
+ const mid = parts[0];
2839
+ const sid = parts[1];
2840
+ const tid = parts[2];
2841
+ if (mid && sid && tid) {
2842
+ const planAbs = resolveSliceFile(base, mid, sid, "PLAN");
2843
+ if (planAbs && existsSync(planAbs)) {
2844
+ const planContent = readFileSync(planAbs, "utf-8");
2845
+ const escapedTid = tid.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
2846
+ const re = new RegExp(`^- \\[[xX]\\] \\*\\*${escapedTid}:`, "m");
2847
+ if (!re.test(planContent)) return false;
2848
+ }
2849
+ }
2850
+ }
2851
+
2793
2852
  // complete-slice must also produce a UAT file
2794
2853
  if (unitType === "complete-slice") {
2795
2854
  const parts = unitId.split("/");
@@ -2814,7 +2873,7 @@ function verifyExpectedArtifact(unitType: string, unitId: string, base: string):
2814
2873
  export function writeBlockerPlaceholder(unitType: string, unitId: string, base: string, reason: string): string | null {
2815
2874
  const absPath = resolveExpectedArtifactPath(unitType, unitId, base);
2816
2875
  if (!absPath) return null;
2817
- const dir = absPath.substring(0, absPath.lastIndexOf("/"));
2876
+ const dir = dirname(absPath);
2818
2877
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
2819
2878
  const content = [
2820
2879
  `# BLOCKER — auto-mode recovery failed`,
@@ -13,6 +13,61 @@ Full documentation for `~/.gsd/preferences.md` (global) and `.gsd/preferences.md
13
13
 
14
14
  ---
15
15
 
16
+ ## Semantics
17
+
18
+ ### Empty Arrays vs Omitted Fields
19
+
20
+ **Empty arrays (`[]`) are equivalent to omitting the field entirely.** During validation, GSD deletes empty arrays from the preferences object (see `validatePreferences()` in `preferences.ts`):
21
+
22
+ ```typescript
23
+ for (const key of ["always_use_skills", "prefer_skills", "avoid_skills", "custom_instructions"] as const) {
24
+ if (validated[key] && validated[key]!.length === 0) {
25
+ delete validated[key];
26
+ }
27
+ }
28
+ ```
29
+
30
+ These are functionally identical:
31
+
32
+ ```yaml
33
+ # Explicit empty arrays — will be normalized away
34
+ prefer_skills: []
35
+ avoid_skills: []
36
+ skill_rules: []
37
+
38
+ # Omitted entirely — same result
39
+ # (just don't write these fields)
40
+ ```
41
+
42
+ **Recommendation:** Omit fields you don't need. Empty arrays add noise with no effect.
43
+
44
+ ### Global vs Project Preferences
45
+
46
+ Preferences are loaded from two locations and merged:
47
+
48
+ 1. **Global:** `~/.gsd/preferences.md` — applies to all projects
49
+ 2. **Project:** `.gsd/preferences.md` — applies to the current project only
50
+
51
+ **Merge behavior** (see `mergePreferences()` in `preferences.ts`):
52
+ - **Scalar fields** (`skill_discovery`, `budget_ceiling`, etc.): Project wins if defined, otherwise global. Uses nullish coalescing (`??`).
53
+ - **Array fields** (`always_use_skills`, `prefer_skills`, etc.): Concatenated via `mergeStringLists()` (global first, then project).
54
+ - **Object fields** (`models`, `git`, `auto_supervisor`): Shallow merge via spread operator `{ ...base, ...override }`.
55
+
56
+ For `models`, project settings override global at the phase level. If global has `planning: opus` and project has `planning: sonnet`, the project wins. But if project omits `research`, global's `research` setting is preserved.
57
+
58
+ ### Skill Discovery vs Skill Preferences
59
+
60
+ These are **separate concerns**:
61
+
62
+ | Field | What it controls | Code reference |
63
+ |-------|-----------------|----------------|
64
+ | `skill_discovery` | **Whether** GSD looks for relevant skills during research | `resolveSkillDiscoveryMode()` in `preferences.ts` |
65
+ | `always_use_skills`, `prefer_skills`, `avoid_skills` | **Which** skills to use when they're found relevant | `renderPreferencesForSystemPrompt()` in `preferences.ts` |
66
+
67
+ Setting `prefer_skills: []` does **not** disable skill discovery — it just means you have no preference overrides. Use `skill_discovery: off` to disable discovery entirely.
68
+
69
+ ---
70
+
16
71
  ## Field Guide
17
72
 
18
73
  - `version`: schema version. Start at `1`.
@@ -60,6 +115,27 @@ Full documentation for `~/.gsd/preferences.md` (global) and `.gsd/preferences.md
60
115
  - Use `skill_rules` for situational routing, not broad personality preferences.
61
116
  - Prefer skill names for stable built-in skills.
62
117
  - Prefer absolute paths for local personal skills.
118
+ - **Omit fields you don't need** — empty arrays add noise with no effect.
119
+
120
+ ---
121
+
122
+ ## Minimal Example
123
+
124
+ The cleanest preferences file only specifies what you actually want:
125
+
126
+ ```yaml
127
+ ---
128
+ version: 1
129
+ always_use_skills:
130
+ - debug-like-expert
131
+ skill_discovery: suggest
132
+ models:
133
+ planning: claude-opus-4-6
134
+ execution: claude-sonnet-4-6
135
+ ---
136
+ ```
137
+
138
+ Everything else uses defaults. No `prefer_skills: []`, no `avoid_skills: []`, no `auto_supervisor: {}` — those are just noise.
63
139
 
64
140
  ---
65
141