fix(gsd): replace hardcoded agent skill paths with dynamic resolution (#3575)
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
This commit is contained in:
parent
3e09184493
commit
6d19cd6baf
5 changed files with 111 additions and 9 deletions
|
|
@ -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<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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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>();
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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)", () => {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue