diff --git a/.changeset/fn-7778-plugin-skills-project-scope.md b/.changeset/fn-7778-plugin-skills-project-scope.md new file mode 100644 index 0000000000..91177c61b2 --- /dev/null +++ b/.changeset/fn-7778-plugin-skills-project-scope.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Plugin skills now show for the project that enabled them, even when the daemon starts elsewhere. +category: fix +dev: getPluginSkills is now project-aware — resolved per requesting rootDir against project_plugin_states instead of the daemon-root PluginLoader scope; plugins skipped as disabled are now logged at load time. Wired in dashboard.ts/serve.ts/daemon.ts. Strategy: B per-project resolution. diff --git a/docs/PLUGIN_AUTHORING.md b/docs/PLUGIN_AUTHORING.md index a89c6c698d..175868d915 100644 --- a/docs/PLUGIN_AUTHORING.md +++ b/docs/PLUGIN_AUTHORING.md @@ -1495,6 +1495,8 @@ const skills: PluginSkillContribution[] = [ `skillFiles` are relative to the plugin root. `skillId` must be kebab-case. +Plugin skills are discovered per requesting project: the Skills view and workflow editor surface `plugin:` skills only when that plugin is enabled for that project's plugin state, even if the daemon was started from a different directory. + ## 16. Registering Workflow Steps Plugins can ship workflow step templates that users can enable like built-in quality gates. diff --git a/docs/agents.md b/docs/agents.md index 5c3edda820..2aac9b9566 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -31,7 +31,7 @@ fn chat [message…] [--once] [--non-interactive] [--poll-ms ] - `fn chat ` opens an interactive REPL. - Each message is stored as a `user-to-agent` MessageStore message from `cli` with `metadata.wakeRecipient=true`. - Agent replies are polled from your inbox and printed as they arrive. -- Dashboard-created agent chat sessions request the target agent's declared `metadata.skills` plus enabled plugin-contributed skills, so skills such as `ce-debug` are available in chat when the contributing plugin is enabled. Model-only QuickChat sessions request enabled plugin skills, and room responder sessions request the responder agent's skills. +- Dashboard-created agent chat sessions request the target agent's declared `metadata.skills` plus enabled plugin-contributed skills, so skills such as `ce-debug` are available in chat when the contributing plugin is enabled for the requesting project. Model-only QuickChat sessions request enabled plugin skills, and room responder sessions request the responder agent's skills. - Agent-acting session lanes share the same skill-injection contract as executor sessions: executor, merger, triage, reviewer, heartbeat, step-session, dashboard chat/room responders, CLI agent execution, planning, mission interview, milestone/slice interview, agent-onboarding interview, workflow design, memory dreams/insight extraction, and scheduled cron automation all request agent/fallback skills plus enabled plugin-contributed skills when a plugin runner is available. Utility-only lanes that only summarize/extract/generate JSON (title/PR summaries, memory compaction, subtask breakdown, text refinement, agent generation, PR metadata generation, evaluator/research synthesis, and similar one-shot helpers) intentionally stay exempt to avoid loading skills where no agent-style tool loop can use them. - In dashboard model-loop chat (main chat, QuickChat, and room responders), typing `/skill:{name}` requests that skill for the current AI session and strips the slash token from the prompt sent to the model. Slash and catalog-style names such as `/skill:review/pr`, `/skill:review/pr/SKILL.md`, and `source::skills/review/pr/SKILL.md` resolve to the matching discovered bare skill token across chat and agent session lanes. The requested skill is still subject to the normal enabled/disabled execution-skill filters; CLI-agent-backed PTY chat keeps raw terminal input semantics and does not interpret this command. - Dashboard chat and planning sessions with a scoped task store expose `fn_task_document_write` and `fn_task_document_read`; because neither lane has an ambient task, both tools require an explicit `task_id`. diff --git a/packages/cli/src/commands/daemon.ts b/packages/cli/src/commands/daemon.ts index 845974cdaf..fae75af26a 100644 --- a/packages/cli/src/commands/daemon.ts +++ b/packages/cli/src/commands/daemon.ts @@ -9,10 +9,12 @@ */ import type { AddressInfo } from "node:net"; -import { join } from "node:path"; +import { join, resolve as pathResolve } from "node:path"; import { CentralCore, + TaskStore, PluginLoader, + PluginStore, getTaskMergeBlocker, INSIGHT_EXTRACTION_SCHEDULE_NAME, processAndAuditInsightExtraction, @@ -727,14 +729,72 @@ export async function runDaemon(opts: DaemonOptions = {}) { }); // ── Skills adapter for skills discovery and execution toggling ───────────── + const pluginSkillCache = new Map< + string, + { enabledKey: string; skills: ReturnType } + >(); + const getProjectScopedPluginSkills = async (rootDir: string): Promise> => { + const normalizedRootDir = pathResolve(rootDir); + const stateStore = new PluginStore(normalizedRootDir, { centralGlobalDir: resolveGlobalDir() }); + await stateStore.init(); + try { + const enabledPlugins = await stateStore.listPlugins({ enabled: true }); + const enabledKey = enabledPlugins + .map((plugin) => `${plugin.id}:${plugin.updatedAt}`) + .sort() + .join("\0"); + const cached = pluginSkillCache.get(normalizedRootDir); + if (cached?.enabledKey === enabledKey) return cached.skills; + if (enabledPlugins.length === 0) { + const skills: ReturnType = []; + pluginSkillCache.set(normalizedRootDir, { enabledKey, skills }); + return skills; + } + + /* + * FNXC:PluginSkills 2026-07-10-00:00: + * Same-root skill discovery must reuse the daemon's active PluginLoader; request-scoped loaders are only for other project roots and are stopped after metadata collection to avoid leaking plugin side effects or SQLite handles. + */ + if (normalizedRootDir === pathResolve(store.getRootDir())) { + const enabledIds = new Set(enabledPlugins.map((plugin) => plugin.id)); + const skills = pluginLoader.getPluginSkills().filter((entry) => enabledIds.has(entry.pluginId)); + pluginSkillCache.set(normalizedRootDir, { enabledKey, skills }); + return skills; + } + + const scopedPluginStore = new PluginStore(normalizedRootDir, { centralGlobalDir: resolveGlobalDir() }); + const scopedTaskStore = new TaskStore(normalizedRootDir); + const scopedPluginLoader = new PluginLoader({ pluginStore: scopedPluginStore, taskStore: scopedTaskStore }); + try { + await scopedPluginStore.init(); + await scopedTaskStore.init(); + const { errors } = await scopedPluginLoader.loadAllPlugins(); + if (errors > 0) { + console.warn(`[plugins] Project-scoped plugin skill loading for ${normalizedRootDir} had ${errors} error(s)`); + } + const skills = scopedPluginLoader.getPluginSkills(); + pluginSkillCache.set(normalizedRootDir, { enabledKey, skills }); + return skills; + } finally { + await scopedPluginLoader.stopAllPlugins(); + scopedPluginStore.close(); + scopedTaskStore.close(); + } + } finally { + stateStore.close(); + } + }; + const skillsAdapter = packageManager ? createSkillsAdapter({ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- dashboard's resolve() uses a looser onMissing signature than pi's DefaultPackageManager packageManager: packageManager as any, getSettingsPath: (rootDir: string) => getProjectSettingsPath(rootDir), - // Surface plugin-contributed skills (e.g. compound-engineering ce-*) in - // the editor catalog; the package manager only scans disk. - getPluginSkills: () => pluginLoader.getPluginSkills(), + /* + * FNXC:PluginSkills 2026-07-10-00:00: + * `fn daemon` serves managed projects independently from its startup root. Resolve plugin skills with a PluginStore scoped to the requesting rootDir so disabled daemon-root plugins do not suppress project-enabled plugin: skills. + */ + getPluginSkills: getProjectScopedPluginSkills, }) : undefined; diff --git a/packages/cli/src/commands/dashboard.ts b/packages/cli/src/commands/dashboard.ts index 09a24b9d06..7239ec132d 100644 --- a/packages/cli/src/commands/dashboard.ts +++ b/packages/cli/src/commands/dashboard.ts @@ -9,6 +9,7 @@ import { CentralCore, AgentStore, PluginLoader, + PluginStore, getTaskMergeBlocker, getEnabledPiExtensionPaths, isEphemeralAgent, @@ -1679,15 +1680,77 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?: // // Create the skills adapter using the same DefaultPackageManager instance // that was set up earlier for extension resolution. + const pluginSkillCache = new Map< + string, + { enabledKey: string; skills: ReturnType } + >(); + const getProjectScopedPluginSkills = async (rootDir: string): Promise> => { + const normalizedRootDir = pathResolve(rootDir); + const stateStore = new PluginStore(normalizedRootDir, { centralGlobalDir: resolveGlobalDir() }); + await stateStore.init(); + try { + const enabledPlugins = await stateStore.listPlugins({ enabled: true }); + const enabledKey = enabledPlugins + .map((plugin) => `${plugin.id}:${plugin.updatedAt}`) + .sort() + .join("\0"); + const cached = pluginSkillCache.get(normalizedRootDir); + if (cached?.enabledKey === enabledKey) { + return cached.skills; + } + if (enabledPlugins.length === 0) { + const skills: ReturnType = []; + pluginSkillCache.set(normalizedRootDir, { enabledKey, skills }); + return skills; + } + + if (!store) { + return []; + } + /* + * FNXC:PluginSkills 2026-07-10-00:00: + * Same-root skill discovery must reuse the dashboard daemon's active PluginLoader; request-scoped loaders are only for other project roots and are stopped after metadata collection to avoid leaking plugin side effects or SQLite handles. + */ + if (normalizedRootDir === pathResolve(store.getRootDir())) { + const enabledIds = new Set(enabledPlugins.map((plugin) => plugin.id)); + const skills = pluginLoader.getPluginSkills().filter((entry) => enabledIds.has(entry.pluginId)); + pluginSkillCache.set(normalizedRootDir, { enabledKey, skills }); + return skills; + } + + const scopedPluginStore = new PluginStore(normalizedRootDir, { centralGlobalDir: resolveGlobalDir() }); + const scopedTaskStore = new TaskStore(normalizedRootDir); + const scopedPluginLoader = new PluginLoader({ pluginStore: scopedPluginStore, taskStore: scopedTaskStore }); + try { + await scopedPluginStore.init(); + await scopedTaskStore.init(); + const { errors } = await scopedPluginLoader.loadAllPlugins(); + if (errors > 0) { + logSink.warn(`Project-scoped plugin skill loading for ${normalizedRootDir} had ${errors} error(s)`, "plugins"); + } + const skills = scopedPluginLoader.getPluginSkills(); + pluginSkillCache.set(normalizedRootDir, { enabledKey, skills }); + return skills; + } finally { + await scopedPluginLoader.stopAllPlugins(); + scopedPluginStore.close(); + scopedTaskStore.close(); + } + } finally { + stateStore.close(); + } + }; + const skillsAdapter = packageManager ? createSkillsAdapter({ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- dashboard's resolve() uses a looser onMissing signature than pi's DefaultPackageManager packageManager: packageManager as any, getSettingsPath: (rootDir: string) => getProjectSettingsPath(rootDir), - // Surface plugin-contributed skills (e.g. compound-engineering ce-*) in - // the discovered-skills catalog so the workflow editor can resolve and - // display them. Lazy thunk: plugins finish loading before discovery runs. - getPluginSkills: () => pluginLoader.getPluginSkills(), + /* + * FNXC:PluginSkills 2026-07-10-00:00: + * `fn dashboard` can start outside the managed project whose Skills view is being served. Resolve plugin skill contributions with a PluginStore scoped to the requesting rootDir so project_plugin_states, not the daemon root, decides which plugin: skills appear. + */ + getPluginSkills: getProjectScopedPluginSkills, }) : undefined; diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index 8c62c6dbb8..09d9caca35 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -10,10 +10,12 @@ */ import type { AddressInfo } from "node:net"; -import { join } from "node:path"; +import { join, resolve as pathResolve } from "node:path"; import { CentralCore, + TaskStore, PluginLoader, + PluginStore, getTaskMergeBlocker, INSIGHT_EXTRACTION_SCHEDULE_NAME, processAndAuditInsightExtraction, @@ -808,14 +810,72 @@ export async function runServe( // // Create the skills adapter using the same DefaultPackageManager instance // that was set up earlier for extension resolution. + const pluginSkillCache = new Map< + string, + { enabledKey: string; skills: ReturnType } + >(); + const getProjectScopedPluginSkills = async (rootDir: string): Promise> => { + const normalizedRootDir = pathResolve(rootDir); + const stateStore = new PluginStore(normalizedRootDir, { centralGlobalDir: resolveGlobalDir() }); + await stateStore.init(); + try { + const enabledPlugins = await stateStore.listPlugins({ enabled: true }); + const enabledKey = enabledPlugins + .map((plugin) => `${plugin.id}:${plugin.updatedAt}`) + .sort() + .join("\0"); + const cached = pluginSkillCache.get(normalizedRootDir); + if (cached?.enabledKey === enabledKey) return cached.skills; + if (enabledPlugins.length === 0) { + const skills: ReturnType = []; + pluginSkillCache.set(normalizedRootDir, { enabledKey, skills }); + return skills; + } + + /* + * FNXC:PluginSkills 2026-07-10-00:00: + * Same-root skill discovery must reuse the daemon's active PluginLoader; request-scoped loaders are only for other project roots and are stopped after metadata collection to avoid leaking plugin side effects or SQLite handles. + */ + if (normalizedRootDir === pathResolve(store.getRootDir())) { + const enabledIds = new Set(enabledPlugins.map((plugin) => plugin.id)); + const skills = pluginLoader.getPluginSkills().filter((entry) => enabledIds.has(entry.pluginId)); + pluginSkillCache.set(normalizedRootDir, { enabledKey, skills }); + return skills; + } + + const scopedPluginStore = new PluginStore(normalizedRootDir, { centralGlobalDir: resolveGlobalDir() }); + const scopedTaskStore = new TaskStore(normalizedRootDir); + const scopedPluginLoader = new PluginLoader({ pluginStore: scopedPluginStore, taskStore: scopedTaskStore }); + try { + await scopedPluginStore.init(); + await scopedTaskStore.init(); + const { errors } = await scopedPluginLoader.loadAllPlugins(); + if (errors > 0) { + console.warn(`[plugins] Project-scoped plugin skill loading for ${normalizedRootDir} had ${errors} error(s)`); + } + const skills = scopedPluginLoader.getPluginSkills(); + pluginSkillCache.set(normalizedRootDir, { enabledKey, skills }); + return skills; + } finally { + await scopedPluginLoader.stopAllPlugins(); + scopedPluginStore.close(); + scopedTaskStore.close(); + } + } finally { + stateStore.close(); + } + }; + const skillsAdapter = packageManager ? createSkillsAdapter({ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- dashboard's resolve() uses a looser onMissing signature than pi's DefaultPackageManager packageManager: packageManager as any, getSettingsPath: (rootDir: string) => getProjectSettingsPath(rootDir), - // Surface plugin-contributed skills (e.g. compound-engineering ce-*) in - // the editor catalog; the package manager only scans disk. - getPluginSkills: () => pluginLoader.getPluginSkills(), + /* + * FNXC:PluginSkills 2026-07-10-00:00: + * `fn serve` may serve a project other than the daemon startup root. Resolve plugin skills through a requesting-root PluginStore so plugin: catalog entries follow that project's enablement and cannot leak from the daemon root. + */ + getPluginSkills: getProjectScopedPluginSkills, }) : undefined; diff --git a/packages/core/src/__tests__/plugin-loader.test.ts b/packages/core/src/__tests__/plugin-loader.test.ts index 02b5b0feb3..6dd027f339 100644 --- a/packages/core/src/__tests__/plugin-loader.test.ts +++ b/packages/core/src/__tests__/plugin-loader.test.ts @@ -64,6 +64,7 @@ const plugin = { hooks: {}, tools: ${JSON.stringify(plugin.tools || [])}, routes: ${JSON.stringify(plugin.routes || [])}, + skills: ${JSON.stringify(plugin.skills || [])}, }; export default plugin; @@ -915,12 +916,16 @@ export default plugin; await pluginStore.registerPlugin({ manifest: disabledPlugin.manifest, path: disabledPath }); await pluginStore.disablePlugin("disabled-plugin"); + const { loggerMap } = mockStructuredLoggerFactory(); const loader = new PluginLoader({ pluginStore, taskStore: mockTaskStore }); const result = await loader.loadAllPlugins(); expect(result).toEqual({ loaded: 1, errors: 0 }); expect(loader.isPluginLoaded("enabled-plugin")).toBe(true); expect(loader.isPluginLoaded("disabled-plugin")).toBe(false); + expect(loggerMap.get("plugin-loader")?.warn).toHaveBeenCalledWith( + "Skipped disabled plugin during loadAllPlugins: disabled-plugin", + ); }); /* @@ -2588,6 +2593,70 @@ export default plugin; ]); }); + it("loads plugin skills according to each project's enablement scope", async () => { + await pluginStore.init(); + const projectDir = join(rootDir, "managed-project"); + await mkdir(projectDir, { recursive: true }); + const projectPluginStore = new PluginStore(projectDir, { centralGlobalDir: rootDir }); + await projectPluginStore.init(); + + const pluginDir = join(rootDir, "plugins"); + const daemonPlugin = makePlugin(makeManifest({ id: "daemon-skill-plugin" })); + daemonPlugin.skills = [{ skillId: "daemon", name: "daemon-only", skillFiles: ["./SKILL.md"] }]; + const projectPlugin = makePlugin(makeManifest({ id: "project-skill-plugin" })); + projectPlugin.skills = [{ skillId: "project", name: "project-only", skillFiles: ["./SKILL.md"] }]; + const sharedPlugin = makePlugin(makeManifest({ id: "shared-skill-plugin" })); + sharedPlugin.skills = [{ skillId: "shared", name: "shared-skill", skillFiles: ["./SKILL.md"] }]; + const disabledPlugin = makePlugin(makeManifest({ id: "disabled-everywhere-skill-plugin" })); + disabledPlugin.skills = [{ skillId: "disabled", name: "disabled-skill", skillFiles: ["./SKILL.md"] }]; + + await pluginStore.registerPlugin({ + manifest: daemonPlugin.manifest, + path: await writePluginModule(pluginDir, "daemon-skill.js", daemonPlugin), + }); + const projectRelativePluginDir = join(projectDir, "plugins"); + await pluginStore.registerPlugin({ + manifest: projectPlugin.manifest, + path: "plugins/project-skill.js", + }); + await writePluginModule(projectRelativePluginDir, "project-skill.js", projectPlugin); + await pluginStore.registerPlugin({ + manifest: sharedPlugin.manifest, + path: await writePluginModule(pluginDir, "shared-skill.js", sharedPlugin), + }); + await pluginStore.registerPlugin({ + manifest: disabledPlugin.manifest, + path: await writePluginModule(pluginDir, "disabled-skill.js", disabledPlugin), + }); + + await pluginStore.disablePlugin("project-skill-plugin"); + await pluginStore.disablePlugin("disabled-everywhere-skill-plugin"); + await projectPluginStore.enablePlugin("project-skill-plugin"); + await projectPluginStore.enablePlugin("shared-skill-plugin"); + + const daemonTaskStore = { ...mockTaskStore, getRootDir: () => rootDir }; + const projectTaskStore = { ...mockTaskStore, getRootDir: () => projectDir }; + const daemonLoader = new PluginLoader({ pluginStore, taskStore: daemonTaskStore }); + const projectLoader = new PluginLoader({ pluginStore: projectPluginStore, taskStore: projectTaskStore }); + + await daemonLoader.loadAllPlugins(); + await projectLoader.loadAllPlugins(); + + expect(daemonLoader.getPluginSkills().map((entry) => `${entry.pluginId}:${entry.skill.name}`).sort()).toEqual([ + "daemon-skill-plugin:daemon-only", + "shared-skill-plugin:shared-skill", + ]); + expect(projectLoader.getPluginSkills().map((entry) => `${entry.pluginId}:${entry.skill.name}`).sort()).toEqual([ + "project-skill-plugin:project-only", + "shared-skill-plugin:shared-skill", + ]); + expect(projectLoader.getPluginSkills().some((entry) => entry.pluginId === "daemon-skill-plugin")).toBe(false); + expect(daemonLoader.getPluginSkills().some((entry) => entry.pluginId === "project-skill-plugin")).toBe(false); + expect(projectLoader.getPluginSkills().some((entry) => entry.pluginId === "disabled-everywhere-skill-plugin")).toBe(false); + + projectPluginStore.close(); + }); + it("returns workflow steps, prompt contributions, and setup info", async () => { await pluginStore.init(); loader = new PluginLoader({ pluginStore, taskStore: mockTaskStore }); diff --git a/packages/core/src/plugin-loader.ts b/packages/core/src/plugin-loader.ts index 27e4a39cf8..b70932eccd 100644 --- a/packages/core/src/plugin-loader.ts +++ b/packages/core/src/plugin-loader.ts @@ -705,11 +705,20 @@ export class PluginLoader extends EventEmitter<{ // ── Load All ────────────────────────────────────────────────────── /** - * Load all enabled plugins in dependency order. + * Load all plugins that are enabled for this PluginStore's project scope in dependency order. */ async loadAllPlugins(): Promise<{ loaded: number; errors: number }> { - const enabled = await this.options.pluginStore.listPlugins({ enabled: true }); - const sorted = this.resolveLoadOrder(enabled); + const plugins = await this.options.pluginStore.listPlugins(); + for (const installation of plugins) { + if (!installation.enabled) { + /* + * FNXC:PluginSkills 2026-07-10-00:00: + * Disabled-at-load plugins must be visible in normal daemon logs. Issue #1981 was expensive to diagnose because loadAllPlugins silently omitted disabled plugins, hiding that the loader was using the wrong project enablement scope. + */ + this.log.warn(`Skipped disabled plugin during loadAllPlugins: ${installation.id}`); + } + } + const sorted = this.resolveLoadOrder(plugins.filter((plugin) => plugin.enabled)); let loaded = 0; let errors = 0; @@ -719,13 +728,15 @@ export class PluginLoader extends EventEmitter<{ await this.loadPlugin(installation.id); loaded++; } catch (err) { - if ((err as { code?: string }).code !== "PLUGIN_DISABLED") { - errors++; - this.log.error( - `Failed to load plugin ${installation.id}:`, - err, - ); + if ((err as { code?: string }).code === "PLUGIN_DISABLED") { + this.log.warn(`Skipped disabled plugin during loadAllPlugins: ${installation.id}`); + continue; } + errors++; + this.log.error( + `Failed to load plugin ${installation.id}:`, + err, + ); } } diff --git a/packages/dashboard/src/__tests__/skills-adapter.test.ts b/packages/dashboard/src/__tests__/skills-adapter.test.ts index e06ce68d7a..7e33f36fb1 100644 --- a/packages/dashboard/src/__tests__/skills-adapter.test.ts +++ b/packages/dashboard/src/__tests__/skills-adapter.test.ts @@ -800,6 +800,35 @@ describe("createSkillsAdapter - plugin skill merge", () => { expect(byName.get("ce-plan")!.id).toContain("::"); }); + it("passes the requesting project root into async plugin-skill discovery", async () => { + const daemonRoot = "/tmp/daemon-root"; + const projectRoot = "/tmp/managed-project"; + const getPluginSkills = vi.fn(async (rootDir: string) => { + if (rootDir === projectRoot) { + return [{ pluginId: "project-plugin", skill: { name: "project-only-skill" } }]; + } + if (rootDir === daemonRoot) { + return [{ pluginId: "daemon-plugin", skill: { name: "daemon-only-skill" } }]; + } + return []; + }); + const adapter = createSkillsAdapter({ + packageManager: { resolve: vi.fn().mockResolvedValue({ skills: [] }) }, + getSettingsPath: () => "/tmp/does-not-exist-settings.json", + getPluginSkills, + }); + + const projectSkills = await adapter.discoverSkills(projectRoot); + const daemonSkills = await adapter.discoverSkills(daemonRoot); + + expect(getPluginSkills).toHaveBeenCalledWith(projectRoot); + expect(getPluginSkills).toHaveBeenCalledWith(daemonRoot); + expect(projectSkills.map((skill) => skill.metadata.source)).toEqual(["plugin:project-plugin"]); + expect(projectSkills.map((skill) => skill.name)).toEqual(["project-only-skill"]); + expect(daemonSkills.map((skill) => skill.metadata.source)).toEqual(["plugin:daemon-plugin"]); + expect(daemonSkills.map((skill) => skill.name)).toEqual(["daemon-only-skill"]); + }); + it("dedups a plugin skill that is already discovered on disk (by bare name)", async () => { const adapter = createSkillsAdapter({ packageManager: { resolve: vi.fn().mockResolvedValue({ skills: [diskSkillResource] }) }, diff --git a/packages/dashboard/src/skills-adapter.ts b/packages/dashboard/src/skills-adapter.ts index 34f833075d..ad4abce563 100644 --- a/packages/dashboard/src/skills-adapter.ts +++ b/packages/dashboard/src/skills-adapter.ts @@ -355,10 +355,15 @@ export function createSkillsAdapter(options: { * skill catalog omits them. Lazy thunk: plugins may load after the adapter is * created, so it is invoked per discovery rather than captured eagerly. */ - getPluginSkills?: () => Array<{ - pluginId: string; - skill: { name: string; description?: string; enabled?: boolean }; - }>; + getPluginSkills?: (rootDir: string) => + | Array<{ + pluginId: string; + skill: { name: string; description?: string; enabled?: boolean }; + }> + | Promise>; /** Optional superviseSpawn seam for tests */ superviseSpawn?: typeof superviseSpawn; }): SkillsAdapter { @@ -413,7 +418,11 @@ export function createSkillsAdapter(options: { // would otherwise never appear in the editor catalog. Dedup against disk // skills by bare name so a plugin skill that is also installed on disk is // not listed twice. - const pluginSkills = options.getPluginSkills?.() ?? []; + /* + * FNXC:PluginSkills 2026-07-10-00:00: + * Skill discovery is project-scoped: plugin contributions must be resolved for the requesting rootDir's project_plugin_states, not the daemon startup directory. This keeps /api/skills/discovered from leaking daemon-root plugin skills into unrelated projects while still surfacing skills enabled only for the requested managed project. + */ + const pluginSkills = await (options.getPluginSkills?.(rootDir) ?? []); if (pluginSkills.length > 0) { const seenBareNames = new Set(discoveredSkills.map((s) => bareSkillName(s.name))); for (const { pluginId, skill } of pluginSkills) {