From 2e0371fa9dc1ffe14ac168fe3d38448061c58976 Mon Sep 17 00:00:00 2001 From: Kevin O'Connell Date: Wed, 4 Nov 2026 10:24:17 -0800 Subject: [PATCH] fix(skill): resolve a skill name clash the same way on every load Skills were parsed and registered concurrently, so when two folders held a skill with the same name, whichever file finished parsing last won. Parse in parallel but register in discovery order, and move a file found again to its latest source, so the skills path listed last always wins. Co-Authored-By: Claude Opus 5.5 --- packages/opencode/src/skill/index.ts | 33 +++++++++++----------- packages/opencode/test/skill/skill.test.ts | 21 ++++++++++++++ 2 files changed, 38 insertions(+), 16 deletions(-) diff --git a/packages/opencode/src/skill/index.ts b/packages/opencode/src/skill/index.ts index 306968dc59bf..f8fd0ba093bc 100644 --- a/packages/opencode/src/skill/index.ts +++ b/packages/opencode/src/skill/index.ts @@ -103,8 +103,8 @@ export interface Interface { readonly invalidate: () => Effect.Effect } -const add = Effect.fnUntraced(function* (state: State, match: string, events: EventV2Bridge.Service["Service"]) { - const md = yield* Effect.tryPromise({ +const parse = Effect.fnUntraced(function* (match: string, events: EventV2Bridge.Service["Service"]) { + return yield* Effect.tryPromise({ try: () => ConfigMarkdown.parse(match), catch: (err) => err, }).pipe( @@ -118,19 +118,12 @@ const add = Effect.fnUntraced(function* (state: State, match: string, events: Ev }), ), ) +}) - if (!md) return - - if (!isSkillFrontmatter(md.data)) return - - if (state.skills[md.data.name]) { - yield* Effect.logWarning("duplicate skill name", { - name: md.data.name, - existing: state.skills[md.data.name].location, - duplicate: match, - }) - } +function add(state: State, match: string, md: Awaited>) { + if (!isSkillFrontmatter(md.data)) return Effect.void + const existing = state.skills[md.data.name] state.dirs.add(path.dirname(match)) state.skills[md.data.name] = { name: md.data.name, @@ -138,7 +131,10 @@ const add = Effect.fnUntraced(function* (state: State, match: string, events: Ev location: match, content: md.content, } -}) + return existing + ? Effect.logWarning("duplicate skill name", { name: md.data.name, existing: existing.location, duplicate: match }) + : Effect.void +} const scan = Effect.fnUntraced(function* ( state: ScanState, @@ -166,6 +162,8 @@ const scan = Effect.fnUntraced(function* ( ) for (const match of matches) { + // A file found again moves to its latest source, so the source listed last wins a name clash. + state.matches.delete(match) state.matches.add(match) state.dirs.add(path.dirname(match)) } @@ -238,10 +236,13 @@ const loadSkills = Effect.fnUntraced(function* ( discovered: DiscoveryState, events: EventV2Bridge.Service["Service"], ) { - yield* Effect.forEach(discovered.matches, (match) => add(state, match, events), { + // Parse in parallel but register in discovery order, so the same files always resolve a name clash the same way. + const parsed = yield* Effect.forEach(discovered.matches, (match) => parse(match, events), { concurrency: "unbounded", - discard: true, }) + for (const [index, md] of parsed.entries()) { + if (md) yield* add(state, discovered.matches[index], md) + } yield* Effect.logInfo("init", { count: Object.keys(state.skills).length }) }) diff --git a/packages/opencode/test/skill/skill.test.ts b/packages/opencode/test/skill/skill.test.ts index f365b63f0d72..435cd6b823e3 100644 --- a/packages/opencode/test/skill/skill.test.ts +++ b/packages/opencode/test/skill/skill.test.ts @@ -122,6 +122,27 @@ Instructions here. ), ) + it.live("the skills path listed last wins a name clash, every time", () => + provideTmpdirInstance( + (dir) => + Effect.gen(function* () { + const write = (folder: string, description: string) => + Bun.write( + path.join(dir, folder, "brand-voice", "SKILL.md"), + `---\nname: brand-voice\ndescription: ${description}\n---\n\n# Brand voice\n`, + ) + yield* Effect.promise(() => + Promise.all([write(path.join(".opencode", "skills"), "personal"), write("team", "team")]), + ) + + const skill = yield* Skill.Service + const item = (yield* skill.all()).find((x) => x.name === "brand-voice") + expect(item?.description).toBe("personal") + }), + { git: true, config: { skills: { paths: ["team", ".opencode/skills"] } } }, + ), + ) + it.live("returns skill directories from Skill.dirs", () => provideTmpdirInstance( (dir) =>