FN-7858: honor per-project plugin-skill toggles in session merging

Session skill merging (collectPluginSkillNames) previously ignored per-project
Skills view enable/disable toggles and only consulted each plugin's static
default, so a user disabling a plugin skill in the Skills view would still see
it merged into live agent sessions. Extracted the effective-enablement
resolver shared by dashboard discovery and engine session assembly into
@fusion/core so both surfaces stay in sync.

- Added packages/core/src/skill-settings.ts with computeSkillId/parseSkillId/
  normalizeStoredSkillPath/getSkillSettingState/resolvePluginSkillEnabled,
  exported from @fusion/core's index.
- packages/dashboard/src/skills-adapter.ts now re-exports and delegates to the
  shared @fusion/core resolver instead of duplicating its own
  getSkillSettingState/computeSkillId/parseSkillId implementations.
- packages/engine/src/session-skill-context.ts: collectPluginSkillNames now
  accepts a projectRootDir, reads project settings via skill-resolver's newly
  exported readProjectSettings/resolveProjectRoot, and calls
  resolvePluginSkillEnabled instead of only checking the plugin's static
  skill.enabled flag; mergePluginSkills passes projectRootDir through.
- packages/engine/src/skill-resolver.ts: exported readProjectSettings and
  ProjectSkillSettings for reuse by session-skill-context.
- Updated docs/plugin-management.md to document that per-project Skills view
  toggles now apply to runtime agent sessions, not just discovery.
- Added unit tests for the new core resolver and updated dashboard/engine
  tests to cover per-project toggle overrides in session merging.
- Added a patch changeset for @runfusion/fusion.

Files changed:
 .changeset/fn-7858-plugin-skill-session-toggle.md  |   7 ++
 docs/plugin-management.md                          |   4 +-
 packages/core/src/__tests__/skill-settings.test.ts |  62 +++++++++
 packages/core/src/index.ts                         |   8 ++
 packages/core/src/skill-settings.ts                | 102 +++++++++++++++
 .../dashboard/src/__tests__/skills-adapter.test.ts |  60 ++++++++-
 packages/dashboard/src/skills-adapter.ts           | 107 +++-------------
 .../src/__tests__/session-skill-context.test.ts    | 140 ++++++++++++++++++++-
 packages/engine/src/session-skill-context.ts       |  23 +++-
 packages/engine/src/skill-resolver.ts              |   4 +-
 10 files changed, 409 insertions(+), 108 deletions(-)

Fusion-Task-Id: FN-7858

Fusion-Task-Lineage: 90e44d24-e385-4a74-b8e4-3c864ec39a95

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-12 11:15:52 -07:00
parent 729298fa36
commit c13d2ee9c2
10 changed files with 409 additions and 108 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Per-project plugin-skill toggles now apply to agent sessions, not just the Skills view.
category: fix
dev: collectPluginSkillNames now resolves effective enablement via the shared @fusion/core resolver (getSkillSettingState), matching discoverSkills; fixes issue #2016 (FN-7858).

View File

@@ -151,7 +151,7 @@ After a plugin is installed/enabled, these are the current user-visible capabili
### A) Plugin-contributed skills (runtime behavior)
Plugin-contributed skills are merged into agent sessions automatically at runtime when enabled.
Plugin-contributed skills are merged into agent sessions automatically at runtime when enabled; per-project Skills view toggles override the plugin's default for those sessions.
1. Install + enable the plugin.
2. Run a task through an agent flow (triage/executor/reviewer/merger).
@@ -159,7 +159,7 @@ Plugin-contributed skills are merged into agent sessions automatically at runtim
Expected outcome: plugin skills affect session behavior, but there is no dedicated "plugin skills" management panel in Fusion Plugins.
> Note: **Skills view** shows discovered execution skills and toggles, but plugin-contributed skills are documented as runtime session behavior here (not a plugin-manager-specific skills UI).
> Note: **Skills view** shows discovered execution skills and toggles, and its per-project plugin-skill toggles apply to runtime agent sessions as well as discovery (not a plugin-manager-specific skills UI).
### B) Plugin-contributed workflow step templates (dashboard + API)

View File

@@ -0,0 +1,62 @@
import { describe, expect, it } from "vitest";
import {
computeSkillId,
getSkillSettingState,
parseSkillId,
resolvePluginSkillEnabled,
} from "../skill-settings.js";
describe("skill-settings", () => {
it("computes and parses stable skill IDs", () => {
const id = computeSkillId("plugin:fusion-plugin", "skills/ce-plan/SKILL.md");
expect(id).toBe("plugin%3Afusion-plugin::skills/ce-plan/SKILL.md");
expect(parseSkillId(id)).toEqual({
source: "plugin:fusion-plugin",
relativePath: "skills/ce-plan/SKILL.md",
});
expect(parseSkillId("not-a-skill-id")).toBeNull();
});
it("resolves top-level + and - skill entries by path or wildcard ID", () => {
const skillId = computeSkillId("plugin:fusion-plugin", "skills/ce-plan/SKILL.md");
expect(getSkillSettingState(skillId, { skills: ["+ce-plan/SKILL.md"] })).toBe("enabled");
expect(getSkillSettingState(skillId, { skills: ["-skills/ce-plan/SKILL.md"] })).toBe("disabled");
expect(getSkillSettingState(computeSkillId("*", "skills/ce-plan/SKILL.md"), {
skills: ["+ce-plan/SKILL.md"],
})).toBe("enabled");
});
it("resolves package-scoped plugin skill entries", () => {
const skillId = computeSkillId("plugin:fusion-plugin", "skills/ce-plan/SKILL.md");
expect(getSkillSettingState(skillId, {
packages: [{ source: "plugin:fusion-plugin", skills: ["+skills/ce-plan/SKILL.md"] }],
})).toBe("enabled");
expect(getSkillSettingState(skillId, {
packages: [{ source: "plugin:fusion-plugin", skills: ["-ce-plan/SKILL.md"] }],
})).toBe("disabled");
});
it("uses project toggles ahead of static plugin defaults", () => {
expect(resolvePluginSkillEnabled({
packages: [{ source: "plugin:fusion-plugin", skills: ["+skills/opt-in/SKILL.md"] }],
}, "fusion-plugin", "opt-in", false)).toBe(true);
expect(resolvePluginSkillEnabled({
packages: [{ source: "plugin:fusion-plugin", skills: ["-skills/default-on/SKILL.md"] }],
}, "fusion-plugin", "default-on", true)).toBe(false);
});
it("falls back to static defaults when settings omit the plugin skill", () => {
expect(resolvePluginSkillEnabled({}, "fusion-plugin", "default-on", undefined)).toBe(true);
expect(resolvePluginSkillEnabled({}, "fusion-plugin", "default-off", false)).toBe(false);
});
it("honors both top-level and package-scoped settings entries", () => {
expect(resolvePluginSkillEnabled({ skills: ["+skills/top-level/SKILL.md"] }, "fusion-plugin", "top-level", false)).toBe(true);
expect(resolvePluginSkillEnabled({
packages: [{ source: "plugin:fusion-plugin", skills: ["-skills/package-level/SKILL.md"] }],
}, "fusion-plugin", "package-level", true)).toBe(false);
});
});

View File

@@ -34,6 +34,14 @@ export {
export type { OpenAiCodexProviderRegistration } from "./openai-models.js";
export { detectImageMimeFromBytes } from "./image-mime.js";
export type { DetectedImageMime } from "./image-mime.js";
export {
computeSkillId,
getSkillSettingState,
normalizeStoredSkillPath,
parseSkillId,
resolvePluginSkillEnabled,
} from "./skill-settings.js";
export type { SkillSettingState, SkillSettingsScope } from "./skill-settings.js";
export { redactSecrets } from "./redact-secrets.js";
export {
evaluatePromptCondition,

View File

@@ -0,0 +1,102 @@
export type SkillSettingState = "enabled" | "disabled";
export interface SkillSettingsScope {
skills?: string[];
packages?: Array<string | { source: string; skills?: string[] }>;
}
/**
* Compute deterministic skill ID from metadata.
* Format: encodeURIComponent(metadata.source) + "::" + relativePath
*
* @param source - The package source identifier
* @param relativePath - Path relative to the skill directory
* @returns Deterministic skill ID
*/
export function computeSkillId(source: string, relativePath: string): string {
const normalizedPath = relativePath.replaceAll("\\", "/");
return `${encodeURIComponent(source)}::${normalizedPath}`;
}
/**
* Parse a skill ID back into source and relativePath components.
*/
export function parseSkillId(skillId: string): { source: string; relativePath: string } | null {
const parts = skillId.split("::");
if (parts.length !== 2) return null;
try {
return {
source: decodeURIComponent(parts[0]!),
relativePath: parts[1]!,
};
} catch {
return null;
}
}
export function normalizeStoredSkillPath(path: string): string {
return path.replaceAll("\\", "/").replace(/^skills\//, "");
}
function settingEntryState(entry: string): SkillSettingState {
return entry.startsWith("+") ? "enabled" : "disabled";
}
/**
* FNXC:PluginSkills 2026-07-12-00:00:
* Plugin-skill effective enablement must have one resolver shared by dashboard discovery and engine session assembly. FN-7858 fixed drift where Skills view honored per-project toggles but collectPluginSkillNames only used static plugin defaults.
*/
export function getSkillSettingState(
skillId: string,
settings: SkillSettingsScope,
): SkillSettingState | undefined {
const parsedSkillId = parseSkillId(skillId);
if (!parsedSkillId) {
return undefined;
}
const normalizedSkillPath = normalizeStoredSkillPath(parsedSkillId.relativePath);
const skills = settings.skills ?? [];
for (const entry of skills) {
if (typeof entry !== "string") continue;
const entryPath = normalizeStoredSkillPath(
entry.startsWith("+") || entry.startsWith("-") ? entry.slice(1) : entry,
);
const entryId = computeSkillId("*", `skills/${entryPath}`);
if (entryId === skillId || entryPath === normalizedSkillPath) {
return settingEntryState(entry);
}
}
const packages = settings.packages ?? [];
for (const pkg of packages) {
const source = typeof pkg === "string" ? pkg : pkg.source;
const pkgSkills = typeof pkg === "object" && pkg !== null ? pkg.skills : undefined;
if (!pkgSkills) continue;
for (const entry of pkgSkills) {
if (typeof entry !== "string") continue;
const entryPath = normalizeStoredSkillPath(
entry.startsWith("+") || entry.startsWith("-") ? entry.slice(1) : entry,
);
const entryId = computeSkillId(source, `skills/${entryPath}`);
if (entryId === skillId || entryPath === normalizedSkillPath) {
return settingEntryState(entry);
}
}
}
return undefined;
}
export function resolvePluginSkillEnabled(
settings: SkillSettingsScope,
pluginId: string,
skillName: string,
staticEnabled: boolean | undefined,
): boolean {
const skillId = computeSkillId(`plugin:${pluginId}`, `skills/${skillName}/SKILL.md`);
const settingState = getSkillSettingState(skillId, settings);
return settingState === undefined ? staticEnabled !== false : settingState === "enabled";
}

View File

@@ -1,5 +1,6 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { createSkillsAdapter, extractSkillName, computeSkillId, bareSkillName } from "../skills-adapter.js";
import { resolvePluginSkillEnabled } from "@fusion/core";
import { writeFile, mkdir, access, readFile, rm } from "node:fs/promises";
import { join, dirname } from "node:path";
import { tmpdir } from "node:os";
@@ -863,12 +864,10 @@ describe("createSkillsAdapter - plugin skill merge", () => {
// ce-plan defaults to enabled, but a "-" entry under its plugin package
// source must disable it; without the settings lookup the toggle is lost.
const relativePath = "skills/ce-plan/SKILL.md";
await writeFile(
settingsPath,
JSON.stringify({
packages: [{ source: "plugin:fusion-plugin-compound-engineering", skills: [`-${relativePath}`] }],
}),
);
const settings = {
packages: [{ source: "plugin:fusion-plugin-compound-engineering", skills: [`-${relativePath}`] }],
};
await writeFile(settingsPath, JSON.stringify(settings));
try {
const adapter = createSkillsAdapter({
@@ -883,6 +882,55 @@ describe("createSkillsAdapter - plugin skill merge", () => {
const cePlan = skills.find((s) => s.name === "ce-plan");
expect(cePlan).toBeDefined();
expect(cePlan!.enabled).toBe(false);
expect(cePlan!.enabled).toBe(resolvePluginSkillEnabled(
settings,
"fusion-plugin-compound-engineering",
"ce-plan",
true,
));
} finally {
await rm(dir, { recursive: true, force: true });
}
});
it("keeps plugin discovery enablement in parity with the shared session resolver", async () => {
const dir = join(tmpdir(), `skills-adapter-plugin-parity-${process.pid}-${Date.now()}`);
const settingsPath = join(dir, "settings.json");
await mkdir(dir, { recursive: true });
const settings = {
packages: [
{ source: "plugin:fusion-plugin-compound-engineering", skills: ["+skills/opt-in/SKILL.md"] },
{ source: "plugin:fusion-plugin-compound-engineering", skills: ["-skills/opt-out/SKILL.md"] },
],
};
await writeFile(settingsPath, JSON.stringify(settings));
try {
const adapter = createSkillsAdapter({
packageManager: { resolve: vi.fn().mockResolvedValue({ skills: [] }) },
getSettingsPath: () => settingsPath,
getPluginSkills: () => [
{ pluginId: "fusion-plugin-compound-engineering", skill: { name: "opt-in", enabled: false } },
{ pluginId: "fusion-plugin-compound-engineering", skill: { name: "opt-out", enabled: true } },
{ pluginId: "fusion-plugin-compound-engineering", skill: { name: "default-on" } },
],
});
const byName = new Map((await adapter.discoverSkills(dir)).map((skill) => [skill.name, skill.enabled]));
expect(byName).toEqual(new Map([
["opt-in", true],
["opt-out", false],
["default-on", true],
]));
for (const [name, enabled] of byName) {
const staticEnabled = name === "opt-in" ? false : true;
expect(enabled).toBe(resolvePluginSkillEnabled(
settings,
"fusion-plugin-compound-engineering",
name,
staticEnabled,
));
}
} finally {
await rm(dir, { recursive: true, force: true });
}

View File

@@ -7,7 +7,15 @@
import { access, readFile, writeFile, mkdir, readdir, stat } from "node:fs/promises";
import { join, relative, dirname, resolve, sep } from "node:path";
import { superviseSpawn } from "@fusion/core";
import {
computeSkillId,
getSkillSettingState,
normalizeStoredSkillPath,
parseSkillId,
resolvePluginSkillEnabled,
superviseSpawn,
} from "@fusion/core";
export { computeSkillId, getSkillSettingState, parseSkillId } from "@fusion/core";
import type { ChildProcess } from "node:child_process";
/**
@@ -197,39 +205,6 @@ export interface SkillFileContent {
isText: boolean;
}
/**
* Compute deterministic skill ID from metadata.
* Format: encodeURIComponent(metadata.source) + "::" + relativePath
*
* @param source - The package source identifier
* @param relativePath - Path relative to the skill directory
* @returns Deterministic skill ID
*/
export function computeSkillId(source: string, relativePath: string): string {
const normalizedPath = relativePath.replaceAll("\\", "/");
return `${encodeURIComponent(source)}::${normalizedPath}`;
}
/**
* Parse a skill ID back into source and relativePath components.
*/
export function parseSkillId(skillId: string): { source: string; relativePath: string } | null {
const parts = skillId.split("::");
if (parts.length !== 2) return null;
try {
return {
source: decodeURIComponent(parts[0]!),
relativePath: parts[1]!,
};
} catch {
return null;
}
}
function normalizeStoredSkillPath(path: string): string {
return path.replaceAll("\\", "/").replace(/^skills\//, "");
}
/**
* Reduce any skill-name form to a single bare token (lowercased) for dedup:
* "ce-work/SKILL.md" / "<src>::skills/ce-work/SKILL.md" / "ce-work" → "ce-work"
@@ -274,55 +249,6 @@ async function waitForSupervisedExit(
return Promise.race([exitPromise, spawnError]);
}
/**
* Resolve a skill's explicit enable/disable state from settings.
* Checks both top-level skills and package-scoped skills. Returns "enabled" or
* "disabled" when a settings entry matches, or undefined when the settings file
* says nothing about this skill -- so callers can apply their own default.
*/
function getSkillSettingState(
skillId: string,
settings: { skills?: string[]; packages?: Array<{ source: string; skills?: string[] }> },
): "enabled" | "disabled" | undefined {
const parsedSkillId = parseSkillId(skillId);
if (!parsedSkillId) {
return undefined;
}
const normalizedSkillPath = normalizeStoredSkillPath(parsedSkillId.relativePath);
const skills = settings.skills ?? [];
for (const entry of skills) {
const entryPath = normalizeStoredSkillPath(
entry.startsWith("+") || entry.startsWith("-") ? entry.slice(1) : entry,
);
const entryId = computeSkillId("*", `skills/${entryPath}`);
if (entryId === skillId || entryPath === normalizedSkillPath) {
return entry.startsWith("+") ? "enabled" : "disabled";
}
}
// Check package-scoped skills
const packages = settings.packages ?? [];
for (const pkg of packages) {
const source = typeof pkg === "string" ? pkg : pkg.source;
const pkgSkills = typeof pkg === "object" ? pkg.skills : undefined;
if (!pkgSkills) continue;
for (const entry of pkgSkills) {
const entryPath = normalizeStoredSkillPath(
entry.startsWith("+") || entry.startsWith("-") ? entry.slice(1) : entry,
);
const entryId = computeSkillId(source, `skills/${entryPath}`);
if (entryId === skillId || entryPath === normalizedSkillPath) {
return entry.startsWith("+") ? "enabled" : "disabled";
}
}
}
return undefined;
}
/**
* Check if a skill path is enabled in the settings.
* Checks both top-level skills and package-scoped skills.
@@ -433,17 +359,12 @@ export function createSkillsAdapter(options: {
seenBareNames.add(bare);
const relativePath = `skills/${name}/SKILL.md`;
const id = computeSkillId(`plugin:${pluginId}`, relativePath);
// Respect an explicit enable/disable written to project settings by
// toggleExecutionSkill, falling back to the plugin's declared default.
// Without consulting settings here, a user toggle on a plugin skill
// would be silently reverted on the very next discovery.
const settingState = getSkillSettingState(
id,
settings as Parameters<typeof getSkillSettingState>[1],
const enabled = resolvePluginSkillEnabled(
settings as Parameters<typeof resolvePluginSkillEnabled>[0],
pluginId,
name,
skill.enabled,
);
const enabled = settingState === undefined
? skill.enabled !== false
: settingState === "enabled";
discoveredSkills.push({
id,
name,

View File

@@ -1,4 +1,7 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { describe, it, expect, vi, afterEach } from "vitest";
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
normalizeAgentSkills,
collectPluginSkillNames,
@@ -10,6 +13,26 @@ import {
import type { Agent, AgentStore } from "@fusion/core";
import type { PluginRunner } from "../plugin-runner.js";
const tempDirs: string[] = [];
async function createProjectWithSettings(settings: Record<string, unknown>): Promise<string> {
const projectRoot = await mkdtemp(join(tmpdir(), "session-skill-context-"));
tempDirs.push(projectRoot);
await mkdir(join(projectRoot, ".fusion"), { recursive: true });
await writeFile(join(projectRoot, ".fusion", "settings.json"), JSON.stringify(settings), "utf-8");
return projectRoot;
}
function pluginRunnerWithSkills(
skills: Array<{ pluginId: string; skill: { name: string; enabled?: boolean } }>,
): PluginRunner {
return { getPluginSkills: vi.fn().mockReturnValue(skills) } as unknown as PluginRunner;
}
afterEach(async () => {
await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
});
describe("normalizeAgentSkills", () => {
it("returns empty array for non-array input", () => {
expect(normalizeAgentSkills(undefined)).toEqual([]);
@@ -279,6 +302,23 @@ describe("buildSessionSkillContextSync", () => {
expect(result.resolvedSkillNames).toEqual(["fusion", "plugin-skill"]);
});
it("honors per-project plugin skill toggles in sync path", async () => {
const projectRoot = await createProjectWithSettings({
packages: [
{ source: "plugin:plugin-a", skills: ["+skills/opt-in/SKILL.md"] },
{ source: "plugin:plugin-b", skills: ["-skills/opt-out/SKILL.md"] },
],
});
const pluginRunner = pluginRunnerWithSkills([
{ pluginId: "plugin-a", skill: { name: "opt-in", enabled: false } },
{ pluginId: "plugin-b", skill: { name: "opt-out", enabled: true } },
{ pluginId: "plugin-c", skill: { name: "default-on" } },
]);
const result = buildSessionSkillContextSync(null, "executor", projectRoot, pluginRunner);
expect(result.resolvedSkillNames).toEqual(["fusion", "opt-in", "default-on"]);
});
it("keeps legacy behavior in sync path when pluginRunner is omitted", () => {
const result = buildSessionSkillContextSync(null, "executor", projectRootDir);
expect(result.resolvedSkillNames).toEqual(["fusion"]);
@@ -323,6 +363,78 @@ describe("collectPluginSkillNames", () => {
pluginIds: ["plugin-b"],
});
});
it("uses project settings to enable a statically disabled plugin skill", async () => {
const projectRoot = await createProjectWithSettings({
packages: [{ source: "plugin:plugin-a", skills: ["+skills/alpha/SKILL.md"] }],
});
const pluginRunner = pluginRunnerWithSkills([
{ pluginId: "plugin-a", skill: { name: "alpha", enabled: false } },
]);
expect(collectPluginSkillNames(pluginRunner, projectRoot)).toEqual({
names: ["alpha"],
pluginIds: ["plugin-a"],
});
});
it("uses project settings to disable a statically enabled plugin skill", async () => {
const projectRoot = await createProjectWithSettings({
packages: [{ source: "plugin:plugin-a", skills: ["-skills/alpha/SKILL.md"] }],
});
const pluginRunner = pluginRunnerWithSkills([
{ pluginId: "plugin-a", skill: { name: "alpha", enabled: true } },
{ pluginId: "plugin-b", skill: { name: "beta" } },
]);
expect(collectPluginSkillNames(pluginRunner, projectRoot)).toEqual({
names: ["beta"],
pluginIds: ["plugin-b"],
});
});
it("falls back to static defaults when project settings omit a plugin skill", async () => {
const projectRoot = await createProjectWithSettings({ skills: [] });
const pluginRunner = pluginRunnerWithSkills([
{ pluginId: "plugin-a", skill: { name: "alpha", enabled: false } },
{ pluginId: "plugin-b", skill: { name: "beta" } },
]);
expect(collectPluginSkillNames(pluginRunner, projectRoot)).toEqual({
names: ["beta"],
pluginIds: ["plugin-b"],
});
});
it("honors top-level skill toggle entries for plugin skills", async () => {
const projectRoot = await createProjectWithSettings({
skills: ["+skills/alpha/SKILL.md"],
});
const pluginRunner = pluginRunnerWithSkills([
{ pluginId: "plugin-a", skill: { name: "alpha", enabled: false } },
]);
expect(collectPluginSkillNames(pluginRunner, projectRoot)).toEqual({
names: ["alpha"],
pluginIds: ["plugin-a"],
});
});
it("resolves settings from the real project root when called with a worktree path", async () => {
const projectRoot = await createProjectWithSettings({
packages: [{ source: "plugin:plugin-a", skills: ["+skills/alpha/SKILL.md"] }],
});
const worktreeRoot = join(projectRoot, ".worktrees", "branch-a");
await mkdir(worktreeRoot, { recursive: true });
const pluginRunner = pluginRunnerWithSkills([
{ pluginId: "plugin-a", skill: { name: "alpha", enabled: false } },
]);
expect(collectPluginSkillNames(pluginRunner, worktreeRoot)).toEqual({
names: ["alpha"],
pluginIds: ["plugin-a"],
});
});
});
describe("buildSessionSkillContext", () => {
@@ -489,6 +601,32 @@ describe("buildSessionSkillContext", () => {
expect(result.skillSelectionContext?.requestedSkillNames).toEqual(["fusion", "plugin-skill"]);
});
it("honors per-project plugin skill toggles in async path", async () => {
const projectRoot = await createProjectWithSettings({
packages: [
{ source: "plugin:plugin-a", skills: ["+skills/opt-in/SKILL.md"] },
{ source: "plugin:plugin-b", skills: ["-skills/opt-out/SKILL.md"] },
],
});
const mockAgentStore = { getAgent: vi.fn().mockResolvedValue(null) } as unknown as AgentStore;
const pluginRunner = pluginRunnerWithSkills([
{ pluginId: "plugin-a", skill: { name: "opt-in", enabled: false } },
{ pluginId: "plugin-b", skill: { name: "opt-out", enabled: true } },
{ pluginId: "plugin-c", skill: { name: "default-on" } },
]);
const result = await buildSessionSkillContext({
agentStore: mockAgentStore,
task: {},
sessionPurpose: "executor",
projectRootDir: projectRoot,
pluginRunner,
});
expect(result.resolvedSkillNames).toEqual(["fusion", "opt-in", "default-on"]);
expect(result.skillSelectionContext?.requestedSkillNames).toEqual(["fusion", "opt-in", "default-on"]);
});
it("deduplicates plugin skills against assigned-agent skills case-insensitively", async () => {
const mockAgent: Agent = {
id: "agent-001",

View File

@@ -30,9 +30,10 @@
*/
import type { Agent, AgentStore } from "@fusion/core";
import { resolvePluginSkillEnabled } from "@fusion/core";
import { piLog } from "./logger.js";
import type { PluginRunner } from "./plugin-runner.js";
import type { SkillSelectionContext } from "./skill-resolver.js";
import { readProjectSettings, resolveProjectRoot, type SkillSelectionContext } from "./skill-resolver.js";
// ── Types ───────────────────────────────────────────────────────────────────
@@ -123,13 +124,27 @@ export function normalizeAgentSkills(
return result;
}
/**
* FNXC:PluginSkills 2026-07-12-00:00:
* Session assembly must honor the same per-project plugin-skill override as the Skills view. Resolve worktree project roots before reading settings, then delegate effective enablement to @fusion/core so static plugin defaults cannot drift from project toggles again.
*/
export function collectPluginSkillNames(
pluginRunner: PluginRunner | undefined,
projectRootDir?: string,
): { names: string[]; pluginIds: string[] } {
if (!pluginRunner) {
return { names: [], pluginIds: [] };
}
let settings = {};
if (projectRootDir) {
try {
settings = readProjectSettings(resolveProjectRoot(projectRootDir));
} catch {
settings = {};
}
}
const pluginSkills = pluginRunner.getPluginSkills();
const seenNames = new Set<string>();
const pluginIds = new Set<string>();
@@ -137,11 +152,11 @@ export function collectPluginSkillNames(
for (const contribution of pluginSkills) {
const { pluginId, skill } = contribution;
if (skill.enabled === false) {
const name = skill.name.trim();
if (!resolvePluginSkillEnabled(settings, pluginId, name, skill.enabled)) {
continue;
}
const name = skill.name.trim();
if (name.length === 0 || seenNames.has(name)) {
continue;
}
@@ -296,7 +311,7 @@ function mergePluginSkills(
projectRootDir: string,
pluginRunner: PluginRunner | undefined,
): SessionSkillContextResult {
const { names: pluginSkillNames } = collectPluginSkillNames(pluginRunner);
const { names: pluginSkillNames } = collectPluginSkillNames(pluginRunner, projectRootDir);
if (pluginSkillNames.length === 0) {
return baseResult;
}

View File

@@ -110,7 +110,7 @@ export interface SkillSelectionResult {
/**
* Project settings structure relevant to skill selection.
*/
interface ProjectSkillSettings {
export interface ProjectSkillSettings {
skills?: string[];
packages?: Array<string | { source: string; skills?: string[] }>;
}
@@ -137,7 +137,7 @@ function readJsonObject(path: string): Record<string, unknown> {
/**
* Read project settings from .fusion/settings.json.
*/
function readProjectSettings(projectRootDir: string): ProjectSkillSettings {
export function readProjectSettings(projectRootDir: string): ProjectSkillSettings {
const fusionSettings = join(projectRootDir, ".fusion", "settings.json");
if (existsSync(fusionSettings)) {