From 6d19cd6baf18d2397c2288cec84af97d3c97c4b4 Mon Sep 17 00:00:00 2001 From: Jeremy Date: Sun, 5 Apr 2026 15:12:19 -0500 Subject: [PATCH] fix(gsd): replace hardcoded agent skill paths with dynamic resolution (#3575) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The system prompt hardcoded ~/.gsd/agent/skills/ paths for bundled skills, causing ENOENT loops when skills weren't installed at those locations. The auto-mode loop treated ENOENT as transient and retried indefinitely. - Replace hardcoded skill paths in system.md with {{bundledSkillsTable}} template variable, resolved dynamically via resolveSkillReference() at runtime - Replace hardcoded templates dir path with {{templatesDir}} variable - Add buildBundledSkillsTable() to system-context.ts — only includes skills that actually exist on disk - Export getTemplatesDir() from prompt-loader.ts - Add Rule 4 to detect-stuck.ts: same ENOENT path seen twice in the sliding window triggers immediate stuck detection (missing files don't self-heal) - Add 4 tests for Rule 4 coverage Closes #3575 --- .../extensions/gsd/auto/detect-stuck.ts | 27 ++++++++++++ .../gsd/bootstrap/system-context.ts | 33 ++++++++++++++- src/resources/extensions/gsd/prompt-loader.ts | 8 ++++ .../extensions/gsd/prompts/system.md | 10 ++--- .../tests/stuck-detection-coverage.test.ts | 42 +++++++++++++++++++ 5 files changed, 111 insertions(+), 9 deletions(-) diff --git a/src/resources/extensions/gsd/auto/detect-stuck.ts b/src/resources/extensions/gsd/auto/detect-stuck.ts index 4d6cba5d2..ab28f4850 100644 --- a/src/resources/extensions/gsd/auto/detect-stuck.ts +++ b/src/resources/extensions/gsd/auto/detect-stuck.ts @@ -6,6 +6,13 @@ import type { WindowEntry } from "./types.js"; +/** + * Pattern matching ENOENT errors with a file path. + * Matches: "ENOENT: no such file or directory, access '/path/to/file'" + * and similar Node.js filesystem error messages. + */ +const ENOENT_PATH_RE = /ENOENT[^']*'([^']+)'/; + /** * Analyze a sliding window of recent unit dispatches for stuck patterns. * Returns a signal with reason if stuck, null otherwise. @@ -13,6 +20,8 @@ import type { WindowEntry } from "./types.js"; * Rule 1: Same error string twice in a row → stuck immediately. * Rule 2: Same unit key 3+ consecutive times → stuck (preserves prior behavior). * Rule 3: Oscillation A→B→A→B in last 4 entries → stuck. + * Rule 4: Same ENOENT path in any 2 entries within the window → stuck (#3575). + * Missing files don't self-heal between retries — retrying wastes budget. */ export function detectStuck( window: readonly WindowEntry[], @@ -56,5 +65,23 @@ export function detectStuck( } } + // Rule 4: Same ENOENT path seen twice in window (#3575) + // Missing files don't appear between retries — stop immediately. + const enoentPaths = new Map(); + for (const entry of window) { + if (!entry.error) continue; + const match = ENOENT_PATH_RE.exec(entry.error); + if (!match) continue; + const filePath = match[1]; + const count = (enoentPaths.get(filePath) ?? 0) + 1; + if (count >= 2) { + return { + stuck: true, + reason: `Missing file referenced twice: ${filePath} (ENOENT)`, + }; + } + enoentPaths.set(filePath, count); + } + return null; } diff --git a/src/resources/extensions/gsd/bootstrap/system-context.ts b/src/resources/extensions/gsd/bootstrap/system-context.ts index bf0329257..ef51c9ff0 100644 --- a/src/resources/extensions/gsd/bootstrap/system-context.ts +++ b/src/resources/extensions/gsd/bootstrap/system-context.ts @@ -6,9 +6,10 @@ import type { ExtensionContext } from "@gsd/pi-coding-agent"; import { logWarning } from "../workflow-logger.js"; import { debugTime } from "../debug-logger.js"; -import { loadPrompt } from "../prompt-loader.js"; +import { loadPrompt, getTemplatesDir } from "../prompt-loader.js"; import { readForensicsMarker } from "../forensics.js"; import { resolveAllSkillReferences, renderPreferencesForSystemPrompt, loadEffectiveGSDPreferences } from "../preferences.js"; +import { resolveSkillReference } from "../preferences-skills.js"; import { resolveGsdRootFile, resolveSliceFile, resolveSlicePath, resolveTaskFile, resolveTaskFiles, resolveTasksDir, relSliceFile, relSlicePath, relTaskFile } from "../paths.js"; import { hasSkillSnapshot, detectNewSkills, formatSkillsXml } from "../skill-discovery.js"; import { getActiveAutoWorktreeContext } from "../auto-worktree.js"; @@ -20,6 +21,31 @@ import { markCmuxPromptShown, shouldPromptToEnableCmux } from "../../cmux/index. const gsdHome = process.env.GSD_HOME || join(homedir(), ".gsd"); +/** + * Bundled skill triggers — resolved dynamically at runtime instead of + * hardcoding absolute paths in the system prompt template. Only skills + * that actually exist on disk are included in the table. (#3575) + */ +const BUNDLED_SKILL_TRIGGERS: Array<{ trigger: string; skill: string }> = [ + { trigger: "Frontend UI - web components, pages, landing pages, dashboards, React/HTML/CSS, styling", skill: "frontend-design" }, + { trigger: "macOS or iOS apps - SwiftUI, Xcode, App Store", skill: "swiftui" }, + { trigger: "Debugging - complex bugs, failing tests, root-cause investigation after standard approaches fail", skill: "debug-like-expert" }, +]; + +function buildBundledSkillsTable(): string { + const cwd = process.cwd(); + const rows: string[] = []; + for (const { trigger, skill } of BUNDLED_SKILL_TRIGGERS) { + const resolution = resolveSkillReference(skill, cwd); + if (resolution.method === "unresolved") continue; // skill not installed — omit from prompt + rows.push(`| ${trigger} | \`${resolution.resolvedPath}\` |`); + } + if (rows.length === 0) { + return "*No bundled skills found. Install skills to `~/.agents/skills/` or `~/.claude/skills/`.*"; + } + return `| Trigger | Skill to load |\n|---|---|\n${rows.join("\n")}`; +} + function warnDeprecatedAgentInstructions(): void { const paths = [ join(gsdHome, "agent-instructions.md"), @@ -43,7 +69,10 @@ export async function buildBeforeAgentStartResult( if (!existsSync(join(process.cwd(), ".gsd"))) return undefined; const stopContextTimer = debugTime("context-inject"); - const systemContent = loadPrompt("system"); + const systemContent = loadPrompt("system", { + bundledSkillsTable: buildBundledSkillsTable(), + templatesDir: getTemplatesDir(), + }); const loadedPreferences = loadEffectiveGSDPreferences(); if (shouldPromptToEnableCmux(loadedPreferences?.preferences)) { markCmuxPromptShown(); diff --git a/src/resources/extensions/gsd/prompt-loader.ts b/src/resources/extensions/gsd/prompt-loader.ts index d2e2c4a5b..aa01d583a 100644 --- a/src/resources/extensions/gsd/prompt-loader.ts +++ b/src/resources/extensions/gsd/prompt-loader.ts @@ -51,6 +51,14 @@ const __extensionDir = resolveExtensionDir(); const promptsDir = join(__extensionDir, "prompts"); const templatesDir = join(__extensionDir, "templates"); +/** + * Return the resolved templates directory path for use in prompts. + * Avoids hardcoding `~/.gsd/agent/extensions/gsd/templates/` in templates. (#3575) + */ +export function getTemplatesDir(): string { + return templatesDir; +} + // Cache all templates eagerly at module load — a running session uses the // template versions that were on disk at startup, immune to later overwrites. const templateCache = new Map(); diff --git a/src/resources/extensions/gsd/prompts/system.md b/src/resources/extensions/gsd/prompts/system.md index d7625a201..a78c82daf 100644 --- a/src/resources/extensions/gsd/prompts/system.md +++ b/src/resources/extensions/gsd/prompts/system.md @@ -24,13 +24,9 @@ Leave the project in a state where the next agent can immediately understand wha ## Skills -GSD ships with bundled skills. Load the relevant skill file with the `read` tool before starting work when the task matches. +GSD ships with bundled skills. Load the relevant skill file with the `read` tool before starting work when the task matches. Use bare skill names — GSD resolves them to the correct path automatically. -| Trigger | Skill to load | -|---|---| -| Frontend UI - web components, pages, landing pages, dashboards, React/HTML/CSS, styling | `~/.gsd/agent/skills/frontend-design/SKILL.md` | -| macOS or iOS apps - SwiftUI, Xcode, App Store | `~/.gsd/agent/skills/swiftui/SKILL.md` | -| Debugging - complex bugs, failing tests, root-cause investigation after standard approaches fail | `~/.gsd/agent/skills/debug-like-expert/SKILL.md` | +{{bundledSkillsTable}} ## Hard Rules @@ -119,7 +115,7 @@ In all modes, slices commit sequentially on the active branch; there are no per- ### Artifact Templates Templates showing the expected format for each artifact type are in: -`~/.gsd/agent/extensions/gsd/templates/` +`{{templatesDir}}` **Always read the relevant template before writing an artifact** to match the expected structure exactly. The parsers that read these files depend on specific formatting: diff --git a/src/resources/extensions/gsd/tests/stuck-detection-coverage.test.ts b/src/resources/extensions/gsd/tests/stuck-detection-coverage.test.ts index c0d4d748e..f15bfec5d 100644 --- a/src/resources/extensions/gsd/tests/stuck-detection-coverage.test.ts +++ b/src/resources/extensions/gsd/tests/stuck-detection-coverage.test.ts @@ -123,6 +123,48 @@ test("Rule 3: A-A-A-A triggers Rule 2 not Rule 3", () => { ); }); +// ─── Rule 4: ENOENT same path twice in window (#3575) ─────────────────────── + +test("Rule 4: same ENOENT path in two entries triggers stuck", () => { + const result = detectStuck([ + { key: "A", error: "ENOENT: no such file or directory, access '/home/user/.gsd/agent/skills/debug-like-expert/SKILL.md'" }, + { key: "B" }, + { key: "A", error: "ENOENT: no such file or directory, access '/home/user/.gsd/agent/skills/debug-like-expert/SKILL.md'" }, + ]); + assert.notEqual(result, null); + assert.equal(result!.stuck, true); + assert.ok(result!.reason.includes("Missing file"), `reason was: ${result!.reason}`); + assert.ok(result!.reason.includes("ENOENT"), `reason was: ${result!.reason}`); +}); + +test("Rule 4: different ENOENT paths do not trigger stuck", () => { + const result = detectStuck([ + { key: "A", error: "ENOENT: no such file or directory, access '/path/a'" }, + { key: "B", error: "ENOENT: no such file or directory, access '/path/b'" }, + ]); + assert.equal(result, null); +}); + +test("Rule 4: single ENOENT does not trigger stuck", () => { + const result = detectStuck([ + { key: "A", error: "ENOENT: no such file or directory, access '/path/a'" }, + { key: "B" }, + ]); + assert.equal(result, null); +}); + +test("Rule 4: ENOENT paths non-consecutive still triggers", () => { + const result = detectStuck([ + { key: "A", error: "ENOENT: no such file or directory, access '/missing/skill'" }, + { key: "B" }, + { key: "C" }, + { key: "D", error: "ENOENT: no such file or directory, access '/missing/skill'" }, + ]); + assert.notEqual(result, null); + assert.equal(result!.stuck, true); + assert.ok(result!.reason.includes("/missing/skill"), `reason was: ${result!.reason}`); +}); + // ─── Gap documentation: 3-unit cycle evades detection ──────────────────────── test("Three-unit cycle A-B-C-A-B-C does NOT trigger stuck (documents gap L13)", () => {