FN-7778: resolve plugin-contributed skills per requesting project instead of daemon root

Fixes plugin skills silently disappearing when the fn daemon is started outside the project that enabled the contributing plugin, by making skill resolution project-aware instead of scoped to the daemon's root PluginLoader.

- getPluginSkills now resolves per requesting rootDir against project_plugin_states rather than the daemon-root PluginLoader scope
- Plugins skipped as disabled are now logged at load time for visibility
- Wired the new project-aware resolution through dashboard.ts, serve.ts, and daemon.ts CLI commands
- Added regression coverage in plugin-loader.test.ts and skills-adapter.test.ts
- Documented the project-scoped behavior in docs/PLUGIN_AUTHORING.md and docs/agents.md
- Added a patch changeset for @runfusion/fusion

Files changed:
 .changeset/fn-7778-plugin-skills-project-scope.md  |  7 +++
 docs/PLUGIN_AUTHORING.md                           |  2 +
 docs/agents.md                                     |  2 +-
 packages/cli/src/commands/daemon.ts                | 68 +++++++++++++++++++--
 packages/cli/src/commands/dashboard.ts             | 71 ++++++++++++++++++++--
 packages/cli/src/commands/serve.ts                 | 68 +++++++++++++++++++--
 packages/core/src/__tests__/plugin-loader.test.ts  | 69 +++++++++++++++++++++
 packages/core/src/plugin-loader.ts                 | 29 ++++++---
 .../dashboard/src/__tests__/skills-adapter.test.ts | 29 +++++++++
 packages/dashboard/src/skills-adapter.ts           | 19 ++++--
 10 files changed, 337 insertions(+), 27 deletions(-)

Fusion-Task-Id: FN-7778
Fusion-Task-Lineage: 5d9a8ff2-ed0e-4859-bf9c-a16f715b081d
Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-10 08:18:47 -07:00
parent 2e97395cf3
commit a32307f8f1
10 changed files with 337 additions and 27 deletions

View File

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

View File

@@ -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:<id>` 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.

View File

@@ -31,7 +31,7 @@ fn chat <agent-id> [message…] [--once] [--non-interactive] [--poll-ms <n>]
- `fn chat <agent-id>` 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`.

View File

@@ -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<PluginLoader["getPluginSkills"]> }
>();
const getProjectScopedPluginSkills = async (rootDir: string): Promise<ReturnType<PluginLoader["getPluginSkills"]>> => {
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<PluginLoader["getPluginSkills"]> = [];
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:<id> skills.
*/
getPluginSkills: getProjectScopedPluginSkills,
})
: undefined;

View File

@@ -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<PluginLoader["getPluginSkills"]> }
>();
const getProjectScopedPluginSkills = async (rootDir: string): Promise<ReturnType<PluginLoader["getPluginSkills"]>> => {
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<PluginLoader["getPluginSkills"]> = [];
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:<id> skills appear.
*/
getPluginSkills: getProjectScopedPluginSkills,
})
: undefined;

View File

@@ -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<PluginLoader["getPluginSkills"]> }
>();
const getProjectScopedPluginSkills = async (rootDir: string): Promise<ReturnType<PluginLoader["getPluginSkills"]>> => {
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<PluginLoader["getPluginSkills"]> = [];
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:<id> catalog entries follow that project's enablement and cannot leak from the daemon root.
*/
getPluginSkills: getProjectScopedPluginSkills,
})
: undefined;

View File

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

View File

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

View File

@@ -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] }) },

View File

@@ -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<Array<{
pluginId: string;
skill: { name: string; description?: string; enabled?: boolean };
}>>;
/** 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) {