Merge pull request #3577 from jeremymcs/fix/hardcoded-agent-paths-3575

fix(gsd): replace hardcoded agent skill paths with dynamic resolution
This commit is contained in:
Jeremy McSpadden 2026-04-05 15:56:49 -05:00 committed by GitHub
commit e17b50b8a4
5 changed files with 111 additions and 9 deletions

View file

@ -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 ABAB 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<string, number>();
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;
}

View file

@ -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();

View file

@ -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<string, string>();

View file

@ -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:

View file

@ -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)", () => {