Skip to content

Commit f9da661

Browse files
author
Tehan
committed
fix(skill-memory): harden name-based fallback per review findings
- Fallback only resolves project-tier candidates when the session directory is authoritative (sessionDirectoryBySession hit); a launch-dir guess must not register a wrong same-named project skill (cubic P1). Global tier resolves from HOME regardless. - Ancestor walk for project-tier candidates (nearest-first, bounded at 20 levels, stops at $HOME/root) — sessions rooted in a worktree subdir now find repo-root project skills, matching opencode's discoverSkills walk-up. - DB cleanup (try/finally closeQuietly) in the truncation tests. - Drop redundant test-provider reset in reembed.test.ts.
1 parent 49a66ab commit f9da661

5 files changed

Lines changed: 232 additions & 63 deletions

File tree

packages/plugin/src/features/magic-context/skill-memory/provenance.test.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,4 +272,81 @@ describe("resolveSkillPathByName", () => {
272272
rmSync(globalHome, { recursive: true, force: true });
273273
}
274274
});
275+
276+
test("null projectDirectory skips project candidates, resolves global only", () => {
277+
const savedHome = process.env.HOME;
278+
const projectRoot = `${tmpdir()}/skill-null-proj-${Date.now()}`;
279+
const globalHome = `${tmpdir()}/skill-null-global-${Date.now()}`;
280+
try {
281+
// Create a project-local skill — should be IGNORED when null is passed
282+
const projectSkillDir = `${projectRoot}/.opencode/skills/null-guard-skill`;
283+
mkdirSync(projectSkillDir, { recursive: true });
284+
writeFileSync(`${projectSkillDir}/SKILL.md`, "---\n---\n\n# Project\n");
285+
286+
// Create a global skill with a DIFFERENT name — should resolve
287+
const globalSkillDir = `${globalHome}/.config/opencode/skills/null-global-skill`;
288+
mkdirSync(globalSkillDir, { recursive: true });
289+
writeFileSync(`${globalSkillDir}/SKILL.md`, "---\n---\n\n# Global\n");
290+
291+
process.env.HOME = globalHome;
292+
// Pass null → project candidates are skipped
293+
const resultNull = resolveSkillPathByName("null-guard-skill", null);
294+
// The project skill exists but should NOT be found (null skips project)
295+
expect(resultNull).toBeNull();
296+
297+
// The global skill with different name SHOULD resolve
298+
const resultGlobal = resolveSkillPathByName("null-global-skill", null);
299+
expect(resultGlobal).not.toBeNull();
300+
expect(resultGlobal!.resolvedPath).toBe(`${globalSkillDir}/SKILL.md`);
301+
expect(resultGlobal!.tier).toBe("global");
302+
} finally {
303+
process.env.HOME = savedHome;
304+
rmSync(projectRoot, { recursive: true, force: true });
305+
rmSync(globalHome, { recursive: true, force: true });
306+
}
307+
});
308+
309+
test("finds skill from ancestor walk (subdir misses, root has it)", () => {
310+
const root = `${tmpdir()}/skill-ancestor-root-${Date.now()}`;
311+
const subdir = `${root}/sub/deep/here`;
312+
try {
313+
// Skill at root level: .opencode/skills/ancestor-skill/SKILL.md
314+
const skillDir = `${root}/.opencode/skills/ancestor-skill`;
315+
mkdirSync(subdir, { recursive: true });
316+
mkdirSync(skillDir, { recursive: true });
317+
writeFileSync(`${skillDir}/SKILL.md`, "---\n---\n\n# Ancestor\n");
318+
319+
// Call from deep subdir — ancestor walk should find it at root
320+
const result = resolveSkillPathByName("ancestor-skill", subdir);
321+
expect(result).not.toBeNull();
322+
expect(result!.resolvedPath).toBe(`${skillDir}/SKILL.md`);
323+
expect(result!.tier).toBe("project");
324+
} finally {
325+
rmSync(root, { recursive: true, force: true });
326+
}
327+
});
328+
329+
test("ancestor walk: nearest ancestor wins when two levels both have the skill", () => {
330+
const root = `${tmpdir()}/skill-nearest-wins-${Date.now()}`;
331+
const child = `${root}/child`;
332+
try {
333+
// Root-level skill (farther ancestor)
334+
const rootSkillDir = `${root}/.opencode/skills/nearest-skill`;
335+
mkdirSync(rootSkillDir, { recursive: true });
336+
writeFileSync(`${rootSkillDir}/SKILL.md`, "---\n---\n\n# Root\n");
337+
338+
// Child-level skill (nearer ancestor) — should win
339+
const childSkillDir = `${child}/.opencode/skills/nearest-skill`;
340+
mkdirSync(childSkillDir, { recursive: true });
341+
writeFileSync(`${childSkillDir}/SKILL.md`, "---\n---\n\n# Child\n");
342+
343+
// Call from child — nearest ancestor (child dir) should win
344+
const result = resolveSkillPathByName("nearest-skill", child);
345+
expect(result).not.toBeNull();
346+
expect(result!.resolvedPath).toBe(`${childSkillDir}/SKILL.md`);
347+
expect(result!.tier).toBe("project");
348+
} finally {
349+
rmSync(root, { recursive: true, force: true });
350+
}
351+
});
275352
});

packages/plugin/src/features/magic-context/skill-memory/provenance.ts

Lines changed: 49 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -92,35 +92,73 @@ export function deriveSkillSource(
9292
* truncated from the skill tool output (MAX_BYTES=51200 cutoff).
9393
*
9494
* Search order matches opencode's `discoverSkills()`:
95-
* - Project dirs first (project shadows global — finding U3)
96-
* - Global dirs second
95+
* - Project-dir ancestors first (project shadows global — finding U3).
96+
* When `projectDirectory` is non-null, walks UP the ancestor chain from
97+
* that directory (each level checking the 4 project patterns in order —
98+
* nearest dir first), stopping at $HOME or the filesystem root, with a
99+
* hard bound of 20 levels.
100+
* - Global dirs second (always checked, regardless of projectDirectory).
101+
*
102+
* When `projectDirectory` is null, project-tier candidates are SKIPPED
103+
* entirely — only global dirs are searched. Callers should pass null when
104+
* the directory is a guess (e.g. Desktop-launched session without an
105+
* authoritative sessionDirectoryBySession entry); a wrong dir could resolve
106+
* a same-named project skill and poison the registry.
97107
*
98108
* Does NOT read SKILL.md content — callers are responsible for their own
99109
* frontmatter parsing.
100110
*/
101111
export function resolveSkillPathByName(
102112
skillName: string,
103-
projectDirectory: string,
113+
projectDirectory: string | null,
104114
): {
105115
resolvedPath: string;
106116
tier: "project" | "global";
107117
skillSource: SkillProvenance["skillSource"];
108118
} | null {
109119
const home = (process.env.HOME ?? process.env.USERPROFILE ?? "").replace(/\\/g, "/");
110-
const candidateDirs = [
111-
// Project-local dirs first (project shadows global — finding U3)
112-
`${projectDirectory}/.opencode/skill/${skillName}`,
113-
`${projectDirectory}/.opencode/skills/${skillName}`,
114-
`${projectDirectory}/.agents/skills/${skillName}`,
115-
`${projectDirectory}/.claude/skills/${skillName}`,
116-
// Global dirs second
120+
const projectPatterns = [
121+
".opencode/skill",
122+
".opencode/skills",
123+
".agents/skills",
124+
".claude/skills",
125+
] as const;
126+
const globalDirs = [
117127
`${home}/.config/opencode/skills/${skillName}`, // Global.Path.config + {skill,skills}/**/SKILL.md
118128
`${home}/.config/opencode/skill/${skillName}`, // singular — OPENCODE_SKILL_PATTERN covers both
119129
`${home}/.agents/skills/${skillName}`, // AGENTS_EXTERNAL_DIR
120130
`${home}/.claude/skills/${skillName}`, // CLAUDE_EXTERNAL_DIR
121131
];
122132

123-
for (const dir of candidateDirs) {
133+
// ── Project-tier ancestor walk ──────────────────────────────────────
134+
if (projectDirectory !== null) {
135+
const normalizedHome = home.endsWith("/") ? home.slice(0, -1) : home;
136+
let current = projectDirectory;
137+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
138+
for (let depth = 0; depth < 20 && current.length > 0; depth++) {
139+
// Stop at $HOME (exclusive) or filesystem root
140+
if (current === normalizedHome || current === "/") break;
141+
142+
for (const pat of projectPatterns) {
143+
const dir = `${current}/${pat}/${skillName}`;
144+
const candidate = `${dir}/SKILL.md`;
145+
if (existsSync(candidate)) {
146+
return {
147+
resolvedPath: candidate,
148+
tier: deriveSkillTier(dir),
149+
skillSource: deriveSkillSource(dir),
150+
};
151+
}
152+
}
153+
154+
// Walk up: strip last path segment (or empty string if none left)
155+
const sep = current.lastIndexOf("/");
156+
current = sep > 0 ? current.slice(0, sep) : "";
157+
}
158+
}
159+
160+
// ── Global dirs (always checked) ────────────────────────────────────
161+
for (const dir of globalDirs) {
124162
const candidate = `${dir}/SKILL.md`;
125163
if (existsSync(candidate)) {
126164
return {

packages/plugin/src/features/magic-context/skill-memory/reembed.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,8 @@ function registerReembedTestProject(
5050
describe("reembed", () => {
5151
test("reembedStaleSkillNotes fills NULL embeddings (bounded, idempotent)", async () => {
5252
_resetProjectEmbeddingRegistryForTests();
53-
_setTestProviderFactoryForProject(null);
53+
// installReembedTestProvider() unconditionally sets the factory, so
54+
// a null-reset before it is redundant.
5455
installReembedTestProvider();
5556

5657
const db = new Database(":memory:");

packages/plugin/src/hooks/magic-context/hook-handlers.ts

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -635,14 +635,16 @@ export function createToolExecuteAfterHook(args: {
635635
// (lazy-loaded only when the skill tool actually fires).
636636
const { parseSkillProvenance, resolveSkillPathByName, registryKey } =
637637
await import("../../features/magic-context/skill-memory/provenance");
638-
// Compute sessionDir once so it's shared between the
639-
// provenance-resolve and injection blocks below.
640-
// First-turn fallback: skill tool fires before
641-
// sessionDirectoryBySession is populated → fall back
642-
// to args.defaultDirectory.
643-
const sessionDir =
644-
args.sessionDirectoryBySession.get(typedInput.sessionID) ??
645-
args.defaultDirectory;
638+
// Split sessionDir into two signals:
639+
// - mappedDir: authoritative (from sessionDirectoryBySession)
640+
// → fed to the fallback's project-tier resolution.
641+
// - sessionDir: the injection block's fallback (mappedDir ?? defaultDirectory)
642+
// → same pre-existing behaviour, not changed here.
643+
// When mappedDir is undefined (map miss), the fallback receives null
644+
// and SKIPS project-tier candidates — a wrong launch-dir guess
645+
// must not resolve a same-named project skill and poison the registry.
646+
const mappedDir = args.sessionDirectoryBySession.get(typedInput.sessionID);
647+
const sessionDir = mappedDir ?? args.defaultDirectory;
646648
try {
647649
const { parseFrontmatterConfig } = await import(
648650
"../../features/magic-context/skill-memory/frontmatter"
@@ -654,7 +656,11 @@ export function createToolExecuteAfterHook(args: {
654656
// For large skills (e.g. delegating at 52KB), the line
655657
// sits past the cutoff and is dropped from output.
656658
if (!provenance) {
657-
const resolved = resolveSkillPathByName(skillId, sessionDir);
659+
// mappedDir is authoritative (from sessionDirectoryBySession);
660+
// null means "don't resolve project-tier candidates" — the
661+
// session directory is a guess and could resolve the wrong
662+
// same-named project skill.
663+
const resolved = resolveSkillPathByName(skillId, mappedDir ?? null);
658664
if (resolved) {
659665
provenance = {
660666
resolvedPath: resolved.resolvedPath,

packages/plugin/src/hooks/magic-context/skill-memory-injection.test.ts

Lines changed: 89 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -242,44 +242,48 @@ describe("createToolExecuteAfterHook skill registry (truncation fallback)", () =
242242
// "No recent skill load found... provenance parse failure".
243243
const db = makeDb();
244244
const registry = createSkillLoadRegistry();
245+
// Seed the map so project-tier resolution is authoritative (P1 fix).
245246
const hook = createToolExecuteAfterHook({
246247
db,
247248
channel1StateBySession: new Map(),
248249
skillLoadRegistry: registry,
249-
sessionDirectoryBySession: new Map(),
250-
defaultDirectory: projectDir,
250+
sessionDirectoryBySession: new Map([["ses_trunc", projectDir]]),
251+
defaultDirectory: "/tmp/irrelevant",
251252
intentByCallId: createIntentByCallIdMap(),
252253
});
254+
try {
255+
// Simulated truncated skill output: NO "Base directory for this skill:" line
256+
const output = {
257+
output: [
258+
'<skill_content name="truncated-skill">',
259+
"# Truncated Skill",
260+
"",
261+
"Some skill content that would normally be long enough",
262+
"to push the provenance line past the 51200-byte cutoff.",
263+
"",
264+
"More content...",
265+
"</skill_content>",
266+
].join("\n"),
267+
};
253268

254-
// Simulated truncated skill output: NO "Base directory for this skill:" line
255-
const output = {
256-
output: [
257-
'<skill_content name="truncated-skill">',
258-
"# Truncated Skill",
259-
"",
260-
"Some skill content that would normally be long enough",
261-
"to push the provenance line past the 51200-byte cutoff.",
262-
"",
263-
"More content...",
264-
"</skill_content>",
265-
].join("\n"),
266-
};
267-
268-
await hook(
269-
{ tool: "skill", sessionID: "ses_trunc", args: { name: "truncated-skill" } },
270-
output,
271-
);
269+
await hook(
270+
{ tool: "skill", sessionID: "ses_trunc", args: { name: "truncated-skill" } },
271+
output,
272+
);
272273

273-
const entry = registry.get(registryKey("ses_trunc", "truncated-skill"));
274-
// Without the name-based fallback this would be undefined (RED test).
275-
expect(entry).not.toBeUndefined();
276-
expect(entry!.resolvedPath).toBe(`${skillDir}/SKILL.md`);
277-
expect(entry!.tier).toBe("project");
278-
expect(entry!.skillSource).toBe("opencode-project");
279-
expect(entry!.skillId).toBe("truncated-skill");
280-
// Frontmatter should be parsed from the on-disk SKILL.md
281-
expect(entry!.frontmatterConfig).not.toBeNull();
282-
expect(entry!.frontmatterConfig!.enabled).toBe(true);
274+
const entry = registry.get(registryKey("ses_trunc", "truncated-skill"));
275+
// Without the name-based fallback this would be undefined (RED test).
276+
expect(entry).not.toBeUndefined();
277+
expect(entry!.resolvedPath).toBe(`${skillDir}/SKILL.md`);
278+
expect(entry!.tier).toBe("project");
279+
expect(entry!.skillSource).toBe("opencode-project");
280+
expect(entry!.skillId).toBe("truncated-skill");
281+
// Frontmatter should be parsed from the on-disk SKILL.md
282+
expect(entry!.frontmatterConfig).not.toBeNull();
283+
expect(entry!.frontmatterConfig!.enabled).toBe(true);
284+
} finally {
285+
closeQuietly(db);
286+
}
283287
});
284288

285289
test("registry stays empty when fallback also cannot find SKILL.md", async () => {
@@ -289,22 +293,65 @@ describe("createToolExecuteAfterHook skill registry (truncation fallback)", () =
289293
db,
290294
channel1StateBySession: new Map(),
291295
skillLoadRegistry: registry,
292-
sessionDirectoryBySession: new Map(),
293-
defaultDirectory: projectDir,
296+
sessionDirectoryBySession: new Map([["ses_miss", projectDir]]),
297+
defaultDirectory: "/tmp/irrelevant",
294298
intentByCallId: createIntentByCallIdMap(),
295299
});
300+
try {
301+
// Skill that doesn't exist on disk at all
302+
const output = {
303+
output: "# Nonexistent Skill\n\nNo base dir here either.",
304+
};
296305

297-
// Skill that doesn't exist on disk at all
298-
const output = {
299-
output: "# Nonexistent Skill\n\nNo base dir here either.",
300-
};
306+
await hook(
307+
{ tool: "skill", sessionID: "ses_miss", args: { name: "nonexistent-skill" } },
308+
output,
309+
);
301310

302-
await hook(
303-
{ tool: "skill", sessionID: "ses_miss", args: { name: "nonexistent-skill" } },
304-
output,
305-
);
311+
const entry = registry.get(registryKey("ses_miss", "nonexistent-skill"));
312+
expect(entry).toBeUndefined();
313+
} finally {
314+
closeQuietly(db);
315+
}
316+
});
306317

307-
const entry = registry.get(registryKey("ses_miss", "nonexistent-skill"));
308-
expect(entry).toBeUndefined();
318+
test("map-miss does NOT resolve a project-local skill (P1 guard)", async () => {
319+
// When sessionDirectoryBySession has no entry for the session,
320+
// the fallback receives null as projectDirectory and MUST NOT
321+
// resolve project-tier candidates — the defaultDirectory is a guess.
322+
const db = makeDb();
323+
const registry = createSkillLoadRegistry();
324+
// Empty map (no entry for ses_guess) → mappedDir is undefined → null → skip project
325+
const hook = createToolExecuteAfterHook({
326+
db,
327+
channel1StateBySession: new Map(),
328+
skillLoadRegistry: registry,
329+
sessionDirectoryBySession: new Map(),
330+
defaultDirectory: projectDir, // projectDir HAS the skill, but it's a guess
331+
intentByCallId: createIntentByCallIdMap(),
332+
});
333+
try {
334+
const output = {
335+
output: [
336+
'<skill_content name="truncated-skill">',
337+
"# Truncated Skill",
338+
"",
339+
"Some content...",
340+
"</skill_content>",
341+
].join("\n"),
342+
};
343+
344+
await hook(
345+
{ tool: "skill", sessionID: "ses_guess", args: { name: "truncated-skill" } },
346+
output,
347+
);
348+
349+
// Registry must NOT contain the project-local skill — the fallback
350+
// skips project candidates when mappedDir is null.
351+
const entry = registry.get(registryKey("ses_guess", "truncated-skill"));
352+
expect(entry).toBeUndefined();
353+
} finally {
354+
closeQuietly(db);
355+
}
309356
});
310357
});

0 commit comments

Comments
 (0)