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.
- package/dist/resources/extensions/ask-user-questions.js +47 -3
- package/dist/resources/extensions/gsd/auto-start.js +11 -6
- package/dist/resources/extensions/gsd/auto-timers.js +8 -2
- package/dist/resources/extensions/gsd/auto-worktree.js +19 -0
- package/dist/resources/extensions/gsd/auto.js +24 -0
- package/dist/resources/extensions/gsd/bootstrap/register-hooks.js +4 -0
- package/dist/resources/extensions/gsd/bootstrap/tool-call-loop-guard.js +11 -1
- package/dist/resources/extensions/gsd/commands-handlers.js +18 -7
- package/dist/resources/extensions/gsd/db-writer.js +64 -28
- package/dist/resources/extensions/gsd/preferences-models.js +74 -0
- package/dist/resources/extensions/gsd/preferences-skills.js +6 -1
- package/dist/resources/extensions/gsd/prompts/guided-discuss-milestone.md +1 -1
- package/dist/resources/extensions/gsd/prompts/guided-discuss-slice.md +1 -1
- package/dist/resources/extensions/gsd/skill-catalog.js +6 -4
- package/dist/resources/extensions/gsd/skill-discovery.js +24 -6
- package/dist/resources/extensions/gsd/skill-health.js +7 -3
- package/dist/resources/extensions/gsd/skill-telemetry.js +5 -2
- package/dist/web/standalone/.next/BUILD_ID +1 -1
- package/dist/web/standalone/.next/app-path-routes-manifest.json +16 -16
- package/dist/web/standalone/.next/build-manifest.json +2 -2
- package/dist/web/standalone/.next/prerender-manifest.json +3 -3
- package/dist/web/standalone/.next/required-server-files.json +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.html +2 -2
- package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.html +1 -1
- package/dist/web/standalone/.next/server/app/index.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
- package/dist/web/standalone/.next/server/app-paths-manifest.json +16 -16
- package/dist/web/standalone/.next/server/pages/404.html +1 -1
- package/dist/web/standalone/.next/server/pages/500.html +2 -2
- package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
- package/dist/web/standalone/server.js +1 -1
- package/package.json +1 -1
- package/packages/mcp-server/src/cli.ts +1 -1
- package/packages/mcp-server/src/index.ts +15 -1
- package/packages/mcp-server/src/readers/captures.ts +119 -0
- package/packages/mcp-server/src/readers/doctor-lite.ts +225 -0
- package/packages/mcp-server/src/readers/index.ts +16 -0
- package/packages/mcp-server/src/readers/knowledge.ts +111 -0
- package/packages/mcp-server/src/readers/metrics.ts +118 -0
- package/packages/mcp-server/src/readers/paths.ts +217 -0
- package/packages/mcp-server/src/readers/readers.test.ts +509 -0
- package/packages/mcp-server/src/readers/roadmap.ts +263 -0
- package/packages/mcp-server/src/readers/state.ts +223 -0
- package/packages/mcp-server/src/server.ts +134 -3
- package/packages/pi-ai/dist/utils/repair-tool-json.d.ts +26 -6
- package/packages/pi-ai/dist/utils/repair-tool-json.d.ts.map +1 -1
- package/packages/pi-ai/dist/utils/repair-tool-json.js +67 -9
- package/packages/pi-ai/dist/utils/repair-tool-json.js.map +1 -1
- package/packages/pi-ai/dist/utils/tests/repair-tool-json.test.js +73 -1
- package/packages/pi-ai/dist/utils/tests/repair-tool-json.test.js.map +1 -1
- package/packages/pi-ai/src/utils/repair-tool-json.ts +74 -10
- package/packages/pi-ai/src/utils/tests/repair-tool-json.test.ts +94 -1
- package/packages/pi-coding-agent/dist/core/agent-session-model-switch.test.d.ts +2 -0
- package/packages/pi-coding-agent/dist/core/agent-session-model-switch.test.d.ts.map +1 -0
- package/packages/pi-coding-agent/dist/core/agent-session-model-switch.test.js +16 -0
- package/packages/pi-coding-agent/dist/core/agent-session-model-switch.test.js.map +1 -0
- package/packages/pi-coding-agent/dist/core/agent-session.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/core/agent-session.js +4 -0
- package/packages/pi-coding-agent/dist/core/agent-session.js.map +1 -1
- package/packages/pi-coding-agent/dist/core/retry-handler.d.ts +3 -0
- package/packages/pi-coding-agent/dist/core/retry-handler.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/core/retry-handler.js +48 -16
- package/packages/pi-coding-agent/dist/core/retry-handler.js.map +1 -1
- package/packages/pi-coding-agent/dist/core/retry-handler.test.js +20 -3
- package/packages/pi-coding-agent/dist/core/retry-handler.test.js.map +1 -1
- package/packages/pi-coding-agent/package.json +1 -1
- package/packages/pi-coding-agent/src/core/agent-session-model-switch.test.ts +21 -0
- package/packages/pi-coding-agent/src/core/agent-session.ts +4 -0
- package/packages/pi-coding-agent/src/core/retry-handler.test.ts +30 -3
- package/packages/pi-coding-agent/src/core/retry-handler.ts +49 -16
- package/pkg/package.json +1 -1
- package/src/resources/extensions/ask-user-questions.ts +60 -4
- package/src/resources/extensions/gsd/auto-start.ts +11 -6
- package/src/resources/extensions/gsd/auto-timers.ts +8 -2
- package/src/resources/extensions/gsd/auto-worktree.ts +18 -0
- package/src/resources/extensions/gsd/auto.ts +25 -0
- package/src/resources/extensions/gsd/bootstrap/register-hooks.ts +4 -0
- package/src/resources/extensions/gsd/bootstrap/tool-call-loop-guard.ts +13 -1
- package/src/resources/extensions/gsd/commands-handlers.ts +20 -7
- package/src/resources/extensions/gsd/db-writer.ts +67 -30
- package/src/resources/extensions/gsd/preferences-models.ts +78 -0
- package/src/resources/extensions/gsd/preferences-skills.ts +6 -1
- package/src/resources/extensions/gsd/prompts/guided-discuss-milestone.md +1 -1
- package/src/resources/extensions/gsd/prompts/guided-discuss-slice.md +1 -1
- package/src/resources/extensions/gsd/skill-catalog.ts +6 -3
- package/src/resources/extensions/gsd/skill-discovery.ts +23 -6
- package/src/resources/extensions/gsd/skill-health.ts +7 -3
- package/src/resources/extensions/gsd/skill-telemetry.ts +5 -2
- package/src/resources/extensions/gsd/tests/ask-user-questions-dedup.test.ts +120 -0
- package/src/resources/extensions/gsd/tests/auto-start-model-capture.test.ts +22 -2
- package/src/resources/extensions/gsd/tests/auto-wrapup-inflight-guard.test.ts +107 -0
- package/src/resources/extensions/gsd/tests/claude-skill-dirs.test.ts +51 -0
- package/src/resources/extensions/gsd/tests/db-writer.test.ts +41 -0
- package/src/resources/extensions/gsd/tests/model-isolation.test.ts +75 -1
- package/src/resources/extensions/gsd/tests/steer-worktree-path.test.ts +108 -0
- package/src/resources/extensions/gsd/tests/tool-call-loop-guard.test.ts +17 -4
- package/src/resources/extensions/gsd/tests/worktree-db-respawn-truncation.test.ts +81 -2
- package/src/resources/extensions/shared/tests/ask-user-freetext.test.ts +6 -1
- /package/dist/web/standalone/.next/static/{b7FOoMHaUb3FPoLNbxar4 → AsCFY1___4XTI6GkTQkFb}/_buildManifest.js +0 -0
- /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
|
-
*
|
|
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
|
|
941
|
-
|
|
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(
|
|
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
|
-
/**
|
|
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
|
-
|
|
62
|
-
|
|
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
|
|
101
|
-
if (!existsSync(
|
|
102
|
+
function listSkillDirsFrom(dir: string): string[] {
|
|
103
|
+
if (!existsSync(dir)) return [];
|
|
102
104
|
try {
|
|
103
|
-
return readdirSync(
|
|
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, "&")
|
|
@@ -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
|
|
212
|
-
|
|
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
|
-
|
|
11
|
-
|
|
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
|
// ═══════════════════════════════════════════════════════════════════════════
|