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
@@ -107,6 +107,84 @@ export function resolveModelWithFallbacksForUnit(unitType: string): ResolvedMode
107
107
  };
108
108
  }
109
109
 
110
+ /**
111
+ * Resolve the default session model from GSD preferences.
112
+ *
113
+ * Used at auto-mode bootstrap to override the session model that was
114
+ * determined by settings.json (defaultProvider/defaultModel). When
115
+ * PREFERENCES.md (or project preferences) configures an `execution` model
116
+ * we treat that as the session default. Falls back through execution →
117
+ * planning → first configured model.
118
+ *
119
+ * Accepts an optional `sessionProvider` for bare model IDs that don't
120
+ * include an explicit provider prefix (e.g. `gpt-5.4` instead of
121
+ * `openai-codex/gpt-5.4`). When a bare ID is found and sessionProvider
122
+ * is available, the session provider is used. Without sessionProvider,
123
+ * bare IDs are still returned with provider set to the bare ID itself
124
+ * so downstream resolution (resolveModelId) can match it.
125
+ *
126
+ * Returns `{ provider, id }` or `undefined` if no model preference is
127
+ * configured.
128
+ */
129
+ export function resolveDefaultSessionModel(
130
+ sessionProvider?: string,
131
+ ): { provider: string; id: string } | undefined {
132
+ const prefs = loadEffectiveGSDPreferences();
133
+ if (!prefs?.preferences.models) return undefined;
134
+
135
+ const m = prefs.preferences.models as GSDModelConfigV2;
136
+
137
+ // Priority: execution → planning → first configured value
138
+ const candidates: Array<string | GSDPhaseModelConfig | undefined> = [
139
+ m.execution,
140
+ m.planning,
141
+ m.research,
142
+ m.discuss,
143
+ m.completion,
144
+ m.validation,
145
+ m.subagent,
146
+ ];
147
+
148
+ for (const cfg of candidates) {
149
+ if (!cfg) continue;
150
+
151
+ // Normalize to provider + id from the various config shapes
152
+ let provider: string | undefined;
153
+ let id: string;
154
+
155
+ if (typeof cfg === "string") {
156
+ const slashIdx = cfg.indexOf("/");
157
+ if (slashIdx !== -1) {
158
+ provider = cfg.slice(0, slashIdx);
159
+ id = cfg.slice(slashIdx + 1);
160
+ } else {
161
+ // Bare model ID (e.g. "gpt-5.4") — use session provider as context
162
+ provider = sessionProvider;
163
+ id = cfg;
164
+ }
165
+ } else {
166
+ // Object config: { model, provider?, fallbacks? }
167
+ if (cfg.provider) {
168
+ provider = cfg.provider;
169
+ } else if (cfg.model.includes("/")) {
170
+ const slashIdx = cfg.model.indexOf("/");
171
+ provider = cfg.model.slice(0, slashIdx);
172
+ id = cfg.model.slice(slashIdx + 1);
173
+ return { provider, id };
174
+ } else {
175
+ provider = sessionProvider;
176
+ }
177
+ id = cfg.model;
178
+ }
179
+
180
+ if (provider && id) {
181
+ return { provider, id };
182
+ }
183
+ }
184
+
185
+ return undefined;
186
+ }
187
+
110
188
  /**
111
189
  * Determines the next fallback model to try when the current model fails.
112
190
  * If the current model is not in the configured list, returns the primary model.
@@ -24,13 +24,18 @@ export type { GSDSkillRule, SkillDiscoveryMode, SkillResolution, SkillResolution
24
24
 
25
25
  /**
26
26
  * Known skill directories, in priority order.
27
- * Global skills (~/.agents/skills/) take precedence over project skills.
27
+ * Searches both the skills.sh ecosystem directory (~/.agents/skills/) and
28
+ * Claude Code's official directory (~/.claude/skills/). Project-level
29
+ * directories for both conventions are included as well.
28
30
  * Legacy ~/.gsd/agent/skills/ is included as a fallback for pre-migration installs.
29
31
  */
30
32
  export function getSkillSearchDirs(cwd: string): Array<{ dir: string; method: SkillResolution["method"] }> {
31
33
  const dirs: Array<{ dir: string; method: SkillResolution["method"] }> = [
32
34
  { dir: join(homedir(), ".agents", "skills"), method: "user-skill" },
33
35
  { dir: join(cwd, ".agents", "skills"), method: "project-skill" },
36
+ // Claude Code official skill directories
37
+ { dir: join(homedir(), ".claude", "skills"), method: "user-skill" },
38
+ { dir: join(cwd, ".claude", "skills"), method: "project-skill" },
34
39
  ];
35
40
  // Legacy fallback — read skills from old GSD directory only if migration hasn't completed
36
41
  const legacyDir = join(homedir(), ".gsd", "agent", "skills");
@@ -30,7 +30,7 @@ Ask **1–3 questions per round**. Keep each question focused on one of:
30
30
  - **The biggest technical unknowns / risks** — what could fail, what hasn't been proven
31
31
  - **What external systems/services this touches** — APIs, databases, third-party services
32
32
 
33
- **If `{{structuredQuestionsAvailable}}` is `true`:** use `ask_user_questions` for each round. 1–3 questions per call, each as a separate question object. Keep option labels short (3–5 words). Always include a freeform "Other / let me explain" option. When the user picks that option or writes a long freeform answer, switch to plain text follow-up for that thread before resuming structured questions.
33
+ **If `{{structuredQuestionsAvailable}}` is `true`:** use `ask_user_questions` for each round. 1–3 questions per call, each as a separate question object. Keep option labels short (3–5 words). Always include a freeform "Other / let me explain" option. When the user picks that option or writes a long freeform answer, switch to plain text follow-up for that thread before resuming structured questions. **IMPORTANT: Call `ask_user_questions` exactly once per turn. Never make multiple calls with the same or overlapping questions — wait for the user's response before asking the next round.**
34
34
 
35
35
  **If `{{structuredQuestionsAvailable}}` is `false`:** ask questions in plain text. Keep each round to 1–3 focused questions. Wait for answers before asking the next round.
36
36
 
@@ -22,7 +22,7 @@ Do **not** go deep — just enough that your questions reflect what's actually t
22
22
 
23
23
  ### Question rounds
24
24
 
25
- Ask **1–3 questions per round** using `ask_user_questions`. Keep each question focused on one of:
25
+ Ask **1–3 questions per round** using `ask_user_questions`. **Call `ask_user_questions` exactly once per turn — never make multiple calls with the same or overlapping questions. Wait for the user's response before asking the next round.** Keep each question focused on one of:
26
26
  - **UX and user-facing behaviour** — what does the user see, click, trigger, or experience?
27
27
  - **Edge cases and failure states** — what happens when things go wrong or are in unusual states?
28
28
  - **Scope boundaries** — what is explicitly in vs out for this slice? What deferred to later?
@@ -935,13 +935,16 @@ export async function installPacksBatched(
935
935
 
936
936
  /**
937
937
  * Check if any skills from a pack are already installed.
938
+ * Searches both the skills.sh ecosystem directory and Claude Code's official directory.
938
939
  */
939
940
  export function isPackInstalled(pack: SkillPack): boolean {
940
- const skillsDir = join(homedir(), ".agents", "skills");
941
- if (!existsSync(skillsDir)) return false;
941
+ const skillsDirs = [
942
+ join(homedir(), ".agents", "skills"),
943
+ join(homedir(), ".claude", "skills"),
944
+ ];
942
945
 
943
946
  return pack.skills.every((name) =>
944
- existsSync(join(skillsDir, name, "SKILL.md")),
947
+ skillsDirs.some((dir) => existsSync(join(dir, name, "SKILL.md"))),
945
948
  );
946
949
  }
947
950
 
@@ -12,8 +12,9 @@ import { existsSync, readdirSync, readFileSync } from "node:fs";
12
12
  import { join } from "node:path";
13
13
  import { homedir } from "node:os";
14
14
 
15
- /** Industry-standard skills.sh global skills directory */
15
+ /** Skills directories — skills.sh ecosystem + Claude Code official */
16
16
  const SKILLS_DIR = join(homedir(), ".agents", "skills");
17
+ const CLAUDE_SKILLS_DIR = join(homedir(), ".claude", "skills");
17
18
 
18
19
  export interface DiscoveredSkill {
19
20
  name: string;
@@ -58,8 +59,9 @@ export function detectNewSkills(): DiscoveredSkill[] {
58
59
  for (const dir of current) {
59
60
  if (baselineSkills.has(dir)) continue;
60
61
 
61
- const skillMdPath = join(SKILLS_DIR, dir, "SKILL.md");
62
- if (!existsSync(skillMdPath)) continue;
62
+ // Check both skill directories for the SKILL.md file
63
+ const skillMdPath = resolveSkillMdPath(dir);
64
+ if (!skillMdPath) continue;
63
65
 
64
66
  const meta = parseSkillFrontmatter(skillMdPath);
65
67
  if (meta) {
@@ -97,10 +99,10 @@ ${entries}
97
99
 
98
100
  // ─── Internals ────────────────────────────────────────────────────────────────
99
101
 
100
- function listSkillDirs(): string[] {
101
- if (!existsSync(SKILLS_DIR)) return [];
102
+ function listSkillDirsFrom(dir: string): string[] {
103
+ if (!existsSync(dir)) return [];
102
104
  try {
103
- return readdirSync(SKILLS_DIR, { withFileTypes: true })
105
+ return readdirSync(dir, { withFileTypes: true })
104
106
  .filter(d => d.isDirectory())
105
107
  .map(d => d.name);
106
108
  } catch {
@@ -108,6 +110,13 @@ function listSkillDirs(): string[] {
108
110
  }
109
111
  }
110
112
 
113
+ function listSkillDirs(): string[] {
114
+ const names = new Set<string>();
115
+ for (const name of listSkillDirsFrom(SKILLS_DIR)) names.add(name);
116
+ for (const name of listSkillDirsFrom(CLAUDE_SKILLS_DIR)) names.add(name);
117
+ return [...names];
118
+ }
119
+
111
120
  function parseSkillFrontmatter(path: string): { name?: string; description?: string } | null {
112
121
  try {
113
122
  const content = readFileSync(path, "utf-8");
@@ -131,6 +140,14 @@ function parseSkillFrontmatter(path: string): { name?: string; description?: str
131
140
  }
132
141
  }
133
142
 
143
+ function resolveSkillMdPath(skillName: string): string | null {
144
+ for (const dir of [SKILLS_DIR, CLAUDE_SKILLS_DIR]) {
145
+ const candidate = join(dir, skillName, "SKILL.md");
146
+ if (existsSync(candidate)) return candidate;
147
+ }
148
+ return null;
149
+ }
150
+
134
151
  function escapeXml(text: string): string {
135
152
  return text
136
153
  .replace(/&/g, "&amp;")
@@ -207,9 +207,13 @@ export function formatSkillDetail(basePath: string, skillName: string): string {
207
207
  lines.push(` ${date} ${u.id.padEnd(20)} ${formatTokenCount(u.tokens.total).padStart(8)} tokens ${formatCost(u.cost)}`);
208
208
  }
209
209
 
210
- // Check for SKILL.md existence
211
- const skillPath = join(homedir(), ".agents", "skills", skillName, "SKILL.md");
212
- if (existsSync(skillPath)) {
210
+ // Check for SKILL.md existence — search both ecosystem and Claude Code directories
211
+ const candidatePaths = [
212
+ join(homedir(), ".agents", "skills", skillName, "SKILL.md"),
213
+ join(homedir(), ".claude", "skills", skillName, "SKILL.md"),
214
+ ];
215
+ const skillPath = candidatePaths.find(p => existsSync(p));
216
+ if (skillPath) {
213
217
  const stat = statSync(skillPath);
214
218
  lines.push("");
215
219
  lines.push(`SKILL.md: ${skillPath}`);
@@ -31,12 +31,14 @@ const activelyLoadedSkills = new Set<string>();
31
31
  */
32
32
  export function captureAvailableSkills(): void {
33
33
  const skillsDir = join(homedir(), ".agents", "skills");
34
+ const claudeSkillsDir = join(homedir(), ".claude", "skills");
34
35
  const legacyDir = join(homedir(), ".gsd", "agent", "skills");
35
36
  const names = listSkillNames(skillsDir);
37
+ const claudeNames = listSkillNames(claudeSkillsDir);
36
38
  // Include skills still in the legacy directory only if migration hasn't completed
37
39
  const legacyMigrated = existsSync(join(legacyDir, ".migrated-to-agents"));
38
40
  const legacyNames = legacyMigrated ? [] : listSkillNames(legacyDir);
39
- const all = new Set([...names, ...legacyNames]);
41
+ const all = new Set([...names, ...claudeNames, ...legacyNames]);
40
42
  availableSkills = [...all];
41
43
  activelyLoadedSkills.clear();
42
44
  }
@@ -106,10 +108,11 @@ export function detectStaleSkills(
106
108
 
107
109
  // Check all installed skills, not just those with usage data
108
110
  const skillsDir = join(homedir(), ".agents", "skills");
111
+ const claudeSkillsDir = join(homedir(), ".claude", "skills");
109
112
  const legacyDir = join(homedir(), ".gsd", "agent", "skills");
110
113
  const legacyMigrated = existsSync(join(legacyDir, ".migrated-to-agents"));
111
114
  const legacyNames = legacyMigrated ? [] : listSkillNames(legacyDir);
112
- const installedSet = new Set([...listSkillNames(skillsDir), ...legacyNames]);
115
+ const installedSet = new Set([...listSkillNames(skillsDir), ...listSkillNames(claudeSkillsDir), ...legacyNames]);
113
116
  const installed = [...installedSet];
114
117
 
115
118
  for (const skill of installed) {
@@ -0,0 +1,120 @@
1
+ // ask-user-questions-dedup — Regression tests for per-turn deduplication
2
+ //
3
+ // Verifies that duplicate ask_user_questions calls within a single turn
4
+ // return cached results instead of re-dispatching (especially to remote
5
+ // channels like Discord). Also verifies the strict loop guard threshold
6
+ // for interactive tools.
7
+ //
8
+ // Regression: duplicate questions were sent to Discord when the LLM called
9
+ // ask_user_questions multiple times with the same question set in one turn,
10
+ // causing user confusion and tool failure cascading to plain text fallback.
11
+
12
+ import { describe, test, beforeEach } from "node:test";
13
+ import assert from "node:assert/strict";
14
+ import {
15
+ checkToolCallLoop,
16
+ resetToolCallLoopGuard,
17
+ } from "../bootstrap/tool-call-loop-guard.ts";
18
+ import {
19
+ resetAskUserQuestionsCache,
20
+ questionSignature,
21
+ } from "../../ask-user-questions.ts";
22
+
23
+ // ═══════════════════════════════════════════════════════════════════════════
24
+ // Strict loop guard: ask_user_questions blocks on 2nd identical call
25
+ // ═══════════════════════════════════════════════════════════════════════════
26
+
27
+ describe("ask_user_questions dedup", () => {
28
+ beforeEach(() => {
29
+ resetToolCallLoopGuard();
30
+ resetAskUserQuestionsCache();
31
+ });
32
+
33
+ test("loop guard blocks 2nd identical ask_user_questions call", () => {
34
+ const args = { questions: [{ id: "app_coverage", question: "Which apps?" }] };
35
+
36
+ const first = checkToolCallLoop("ask_user_questions", args);
37
+ assert.equal(first.block, false, "First call should be allowed");
38
+
39
+ const second = checkToolCallLoop("ask_user_questions", args);
40
+ assert.equal(second.block, true, "2nd identical call should be blocked");
41
+ assert.ok(second.reason!.includes("ask_user_questions"), "Reason should name the tool");
42
+ });
43
+
44
+ test("loop guard allows different ask_user_questions calls", () => {
45
+ const args1 = { questions: [{ id: "app_coverage", question: "Which apps?" }] };
46
+ const args2 = { questions: [{ id: "testing_focus", question: "What priority?" }] };
47
+
48
+ const first = checkToolCallLoop("ask_user_questions", args1);
49
+ assert.equal(first.block, false, "First call allowed");
50
+
51
+ const second = checkToolCallLoop("ask_user_questions", args2);
52
+ assert.equal(second.block, false, "Different question set should be allowed");
53
+ });
54
+
55
+ test("non-interactive tools still use normal threshold of 4", () => {
56
+ const args = { query: "same query" };
57
+
58
+ for (let i = 1; i <= 4; i++) {
59
+ const result = checkToolCallLoop("web_search", args);
60
+ assert.equal(result.block, false, `web_search call ${i} should be allowed`);
61
+ }
62
+
63
+ const fifth = checkToolCallLoop("web_search", args);
64
+ assert.equal(fifth.block, true, "5th identical web_search should be blocked");
65
+ });
66
+
67
+ test("cache resets independently from loop guard", () => {
68
+ // Verify the reset function exists and is callable
69
+ resetAskUserQuestionsCache();
70
+ // No error means the cache module is properly exported and functional
71
+ });
72
+
73
+ // ═══════════════════════════════════════════════════════════════════════════
74
+ // questionSignature: full-payload hashing prevents stale cache hits
75
+ // ═══════════════════════════════════════════════════════════════════════════
76
+
77
+ test("same IDs with different question text produce different signatures", () => {
78
+ const q1 = [{ id: "scope", header: "Scope", question: "Which apps to cover?",
79
+ options: [{ label: "All", description: "Everything" }] }];
80
+ const q2 = [{ id: "scope", header: "Scope", question: "Which services to test?",
81
+ options: [{ label: "All", description: "Everything" }] }];
82
+
83
+ assert.notEqual(questionSignature(q1), questionSignature(q2),
84
+ "Different question text with same ID must produce different signatures");
85
+ });
86
+
87
+ test("same IDs with different options produce different signatures", () => {
88
+ const q1 = [{ id: "scope", header: "Scope", question: "Pick one",
89
+ options: [{ label: "A", description: "Option A" }] }];
90
+ const q2 = [{ id: "scope", header: "Scope", question: "Pick one",
91
+ options: [{ label: "B", description: "Option B" }] }];
92
+
93
+ assert.notEqual(questionSignature(q1), questionSignature(q2),
94
+ "Different options with same ID must produce different signatures");
95
+ });
96
+
97
+ test("identical payloads in different order produce same signature", () => {
98
+ const q1 = [
99
+ { id: "b", header: "B", question: "Q2", options: [{ label: "X", description: "x" }] },
100
+ { id: "a", header: "A", question: "Q1", options: [{ label: "Y", description: "y" }] },
101
+ ];
102
+ const q2 = [
103
+ { id: "a", header: "A", question: "Q1", options: [{ label: "Y", description: "y" }] },
104
+ { id: "b", header: "B", question: "Q2", options: [{ label: "X", description: "x" }] },
105
+ ];
106
+
107
+ assert.equal(questionSignature(q1), questionSignature(q2),
108
+ "Same questions in different order must produce the same signature");
109
+ });
110
+
111
+ test("allowMultiple difference produces different signature", () => {
112
+ const q1 = [{ id: "scope", header: "Scope", question: "Pick",
113
+ options: [{ label: "A", description: "a" }], allowMultiple: false }];
114
+ const q2 = [{ id: "scope", header: "Scope", question: "Pick",
115
+ options: [{ label: "A", description: "a" }], allowMultiple: true }];
116
+
117
+ assert.notEqual(questionSignature(q1), questionSignature(q2),
118
+ "allowMultiple difference must produce different signatures");
119
+ });
120
+ });
@@ -7,8 +7,10 @@ const sourcePath = join(import.meta.dirname, "..", "auto-start.ts");
7
7
  const source = readFileSync(sourcePath, "utf-8");
8
8
 
9
9
  test("bootstrapAutoSession snapshots ctx.model before guided-flow entry (#2829)", () => {
10
- const snapshotIdx = source.indexOf("const startModelSnapshot = ctx.model");
11
- assert.ok(snapshotIdx > -1, "auto-start.ts should snapshot ctx.model at bootstrap start");
10
+ // #3517 changed the snapshot to prefer GSD preferences, but the ordering
11
+ // guarantee still holds: the snapshot must be built before guided-flow.
12
+ const snapshotIdx = source.indexOf("const startModelSnapshot = preferredModel");
13
+ assert.ok(snapshotIdx > -1, "auto-start.ts should snapshot model at bootstrap start");
12
14
 
13
15
  const firstDiscussIdx = source.indexOf('await showSmartEntry(ctx, pi, base, { step: requestedStepMode });');
14
16
  assert.ok(firstDiscussIdx > -1, "auto-start.ts should route through showSmartEntry during guided flow");
@@ -26,3 +28,21 @@ test("bootstrapAutoSession restores autoModeStartModel from the early snapshot (
26
28
  const snapshotRefIdx = source.indexOf("provider: startModelSnapshot.provider", assignmentIdx);
27
29
  assert.ok(snapshotRefIdx > -1, "autoModeStartModel should be restored from startModelSnapshot");
28
30
  });
31
+
32
+ test("bootstrapAutoSession prefers GSD PREFERENCES.md over settings.json for start model (#3517)", () => {
33
+ // resolveDefaultSessionModel() should be called before the snapshot is built
34
+ const preferredIdx = source.indexOf("const preferredModel = resolveDefaultSessionModel(");
35
+ assert.ok(preferredIdx > -1, "auto-start.ts should call resolveDefaultSessionModel()");
36
+
37
+ // Session provider should be passed for bare model ID resolution
38
+ const withProviderIdx = source.indexOf("resolveDefaultSessionModel(ctx.model?.provider)");
39
+ assert.ok(withProviderIdx > -1, "auto-start.ts should pass ctx.model?.provider for bare ID resolution");
40
+
41
+ const snapshotIdx = source.indexOf("const startModelSnapshot = preferredModel");
42
+ assert.ok(snapshotIdx > -1, "startModelSnapshot should use preferredModel when available");
43
+
44
+ assert.ok(
45
+ preferredIdx < snapshotIdx,
46
+ "resolveDefaultSessionModel() must be called before building startModelSnapshot",
47
+ );
48
+ });
@@ -0,0 +1,107 @@
1
+ // GSD-2 — Regression tests for #3512: gsd-auto-wrapup mid-turn interruption
2
+ // Copyright (c) 2026 Jeremy McSpadden <jeremy@fluxlabs.net>
3
+
4
+ import { describe, test } from "node:test";
5
+ import assert from "node:assert/strict";
6
+ import { readFileSync } from "node:fs";
7
+ import { join } from "node:path";
8
+
9
+ const autoTimersPath = join(import.meta.dirname, "..", "auto-timers.ts");
10
+ const autoTimersSrc = readFileSync(autoTimersPath, "utf-8");
11
+
12
+ const autoPath = join(import.meta.dirname, "..", "auto.ts");
13
+ const autoSrc = readFileSync(autoPath, "utf-8");
14
+
15
+ const runUnitPath = join(import.meta.dirname, "..", "auto", "run-unit.ts");
16
+ const runUnitSrc = readFileSync(runUnitPath, "utf-8");
17
+
18
+ describe("#3512: gsd-auto-wrapup must not interrupt in-flight tool calls", () => {
19
+ test("soft timeout wrapup gates triggerTurn on getInFlightToolCount() === 0", () => {
20
+ // The soft timeout sendMessage must NOT use a hardcoded `triggerTurn: true`.
21
+ // It must check getInFlightToolCount() before deciding whether to trigger.
22
+ // Use the section marker comment to isolate the soft timeout block.
23
+ const startMarker = "── 1. Soft timeout warning";
24
+ const endMarker = "── 2. Idle watchdog";
25
+ const softTimeoutSection = autoTimersSrc.slice(
26
+ autoTimersSrc.indexOf(startMarker),
27
+ autoTimersSrc.indexOf(endMarker),
28
+ );
29
+ assert.ok(
30
+ softTimeoutSection.length > 0,
31
+ "Could not locate soft timeout section",
32
+ );
33
+
34
+ // Must reference getInFlightToolCount to gate the trigger
35
+ assert.ok(
36
+ softTimeoutSection.includes("getInFlightToolCount"),
37
+ "Soft timeout wrapup must gate triggerTurn behind getInFlightToolCount() check",
38
+ );
39
+
40
+ // Must NOT have a hardcoded triggerTurn: true
41
+ assert.ok(
42
+ !softTimeoutSection.includes("triggerTurn: true"),
43
+ "Soft timeout wrapup must not use hardcoded triggerTurn: true",
44
+ );
45
+ });
46
+
47
+ test("context-pressure wrapup gates triggerTurn on getInFlightToolCount() === 0", () => {
48
+ // The context budget sendMessage must NOT use a hardcoded `triggerTurn: true`.
49
+ // Use the section marker to isolate the context-pressure block.
50
+ const startMarker = "── 4. Context-pressure continue-here monitor";
51
+ const contextSection = autoTimersSrc.slice(
52
+ autoTimersSrc.indexOf(startMarker),
53
+ );
54
+ assert.ok(
55
+ contextSection.length > 0,
56
+ "Could not locate context budget section",
57
+ );
58
+
59
+ // Must reference getInFlightToolCount to gate the trigger
60
+ assert.ok(
61
+ contextSection.includes("getInFlightToolCount"),
62
+ "Context budget wrapup must gate triggerTurn behind getInFlightToolCount() check",
63
+ );
64
+
65
+ // Must NOT have a hardcoded triggerTurn: true
66
+ assert.ok(
67
+ !contextSection.includes("triggerTurn: true"),
68
+ "Context budget wrapup must not use hardcoded triggerTurn: true",
69
+ );
70
+ });
71
+ });
72
+
73
+ describe("#3512: pauseAuto and stopAuto must flush queued follow-up messages", () => {
74
+ test("stopAuto calls clearQueue()", () => {
75
+ // stopAuto must flush queued messages to prevent late async_job_result
76
+ // notifications from triggering extra LLM turns after stop.
77
+ const stopAutoSection = autoSrc.slice(
78
+ autoSrc.indexOf("export async function stopAuto("),
79
+ autoSrc.indexOf("export async function pauseAuto("),
80
+ );
81
+ assert.ok(stopAutoSection, "Could not locate stopAuto function");
82
+ assert.ok(
83
+ stopAutoSection.includes("clearQueue"),
84
+ "stopAuto must call clearQueue() to flush queued follow-up messages",
85
+ );
86
+ });
87
+
88
+ test("pauseAuto calls clearQueue()", () => {
89
+ // pauseAuto must also flush queued messages — same issue as stopAuto.
90
+ const pauseAutoSection = autoSrc.slice(
91
+ autoSrc.indexOf("export async function pauseAuto("),
92
+ );
93
+ assert.ok(pauseAutoSection, "Could not locate pauseAuto function");
94
+ assert.ok(
95
+ pauseAutoSection.includes("clearQueue"),
96
+ "pauseAuto must call clearQueue() to flush queued follow-up messages",
97
+ );
98
+ });
99
+
100
+ test("run-unit.ts still has its existing clearQueue() call (baseline)", () => {
101
+ // Verify the original clearQueue pattern in run-unit.ts hasn't been removed.
102
+ assert.ok(
103
+ runUnitSrc.includes("clearQueue"),
104
+ "run-unit.ts must retain its clearQueue() call after unit completion",
105
+ );
106
+ });
107
+ });
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Tests for Claude Code skill directory support in getSkillSearchDirs().
3
+ *
4
+ * Verifies that ~/.claude/skills/ and .claude/skills/ are included in
5
+ * the skill search path alongside ~/.agents/skills/ and .agents/skills/.
6
+ */
7
+
8
+ import { describe, test } from "node:test";
9
+ import assert from "node:assert/strict";
10
+ import { join } from "node:path";
11
+ import { homedir } from "node:os";
12
+ import { getSkillSearchDirs } from "../preferences-skills.ts";
13
+
14
+ describe("getSkillSearchDirs — Claude Code directory support", () => {
15
+ const cwd = "/tmp/test-project";
16
+
17
+ test("includes ~/.agents/skills/ as user-skill", () => {
18
+ const dirs = getSkillSearchDirs(cwd);
19
+ const agents = dirs.find((d) => d.dir === join(homedir(), ".agents", "skills"));
20
+ assert.ok(agents, "should include ~/.agents/skills/");
21
+ assert.equal(agents!.method, "user-skill");
22
+ });
23
+
24
+ test("includes .agents/skills/ as project-skill", () => {
25
+ const dirs = getSkillSearchDirs(cwd);
26
+ const projectAgents = dirs.find((d) => d.dir === join(cwd, ".agents", "skills"));
27
+ assert.ok(projectAgents, "should include .agents/skills/");
28
+ assert.equal(projectAgents!.method, "project-skill");
29
+ });
30
+
31
+ test("includes ~/.claude/skills/ as user-skill", () => {
32
+ const dirs = getSkillSearchDirs(cwd);
33
+ const claude = dirs.find((d) => d.dir === join(homedir(), ".claude", "skills"));
34
+ assert.ok(claude, "should include ~/.claude/skills/");
35
+ assert.equal(claude!.method, "user-skill");
36
+ });
37
+
38
+ test("includes .claude/skills/ as project-skill", () => {
39
+ const dirs = getSkillSearchDirs(cwd);
40
+ const projectClaude = dirs.find((d) => d.dir === join(cwd, ".claude", "skills"));
41
+ assert.ok(projectClaude, "should include .claude/skills/");
42
+ assert.equal(projectClaude!.method, "project-skill");
43
+ });
44
+
45
+ test("~/.agents/skills/ appears before ~/.claude/skills/ (priority order)", () => {
46
+ const dirs = getSkillSearchDirs(cwd);
47
+ const agentsIdx = dirs.findIndex((d) => d.dir === join(homedir(), ".agents", "skills"));
48
+ const claudeIdx = dirs.findIndex((d) => d.dir === join(homedir(), ".claude", "skills"));
49
+ assert.ok(agentsIdx < claudeIdx, "~/.agents/skills/ should have higher priority than ~/.claude/skills/");
50
+ });
51
+ });
@@ -358,6 +358,47 @@ describe('db-writer', () => {
358
358
  }
359
359
  });
360
360
 
361
+ // ═══════════════════════════════════════════════════════════════════════════
362
+ // Parallel save race condition regression (#3326, #3339, #3459)
363
+ // ═══════════════════════════════════════════════════════════════════════════
364
+
365
+ test('parallel saveDecisionToDb calls produce unique IDs', async () => {
366
+ const tmpDir = makeTmpDir();
367
+ const dbPath = path.join(tmpDir, '.gsd', 'gsd.db');
368
+ openDatabase(dbPath);
369
+
370
+ try {
371
+ // Fire 5 saves concurrently — before the fix, all would get D001
372
+ const results = await Promise.all([
373
+ saveDecisionToDb({ scope: 'a', decision: 'd1', choice: 'c1', rationale: 'r1' }, tmpDir),
374
+ saveDecisionToDb({ scope: 'b', decision: 'd2', choice: 'c2', rationale: 'r2' }, tmpDir),
375
+ saveDecisionToDb({ scope: 'c', decision: 'd3', choice: 'c3', rationale: 'r3' }, tmpDir),
376
+ saveDecisionToDb({ scope: 'd', decision: 'd4', choice: 'c4', rationale: 'r4' }, tmpDir),
377
+ saveDecisionToDb({ scope: 'e', decision: 'd5', choice: 'c5', rationale: 'r5' }, tmpDir),
378
+ ]);
379
+
380
+ const ids = results.map((r) => r.id);
381
+ const uniqueIds = new Set(ids);
382
+
383
+ // All 5 IDs must be unique
384
+ assert.equal(uniqueIds.size, 5, `Expected 5 unique IDs, got ${uniqueIds.size}: ${ids.join(', ')}`);
385
+
386
+ // IDs should be D001-D005 (order may vary due to concurrency)
387
+ for (const id of ids) {
388
+ assert.match(id, /^D\d{3}$/, `ID ${id} should match D### pattern`);
389
+ }
390
+
391
+ // Verify all 5 exist in DB
392
+ for (const id of ids) {
393
+ const row = getDecisionById(id);
394
+ assert.ok(row, `Decision ${id} should exist in DB`);
395
+ }
396
+ } finally {
397
+ closeDatabase();
398
+ cleanupDir(tmpDir);
399
+ }
400
+ });
401
+
361
402
  // ═══════════════════════════════════════════════════════════════════════════
362
403
  // updateRequirementInDb Tests
363
404
  // ═══════════════════════════════════════════════════════════════════════════