diff --git a/.changeset/fn-7857-plugin-skill-body-delivery.md b/.changeset/fn-7857-plugin-skill-body-delivery.md new file mode 100644 index 0000000000..545c6ae716 --- /dev/null +++ b/.changeset/fn-7857-plugin-skill-body-delivery.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Deliver plugin skill bodies to agent sessions and the Skills view. +category: fix +dev: Threads plugin skill discovery paths into sessions and reads plugin SKILL.md files from disk. diff --git a/docs/PLUGIN_AUTHORING.md b/docs/PLUGIN_AUTHORING.md index 2f2f7d0e74..ec3721fffb 100644 --- a/docs/PLUGIN_AUTHORING.md +++ b/docs/PLUGIN_AUTHORING.md @@ -1507,6 +1507,9 @@ const skills: PluginSkillContribution[] = [ 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. + +Enabled plugin skills are delivered to agent sessions from their plugin-package `SKILL.md` files. Fusion resolves the first `skillFiles` entry (or the compatibility fallback) through the plugin root, adds the skill body directory to the session's skill discovery paths, and keeps the requested skill name in the same selection filter used for native and installed skills. The Skills view also reads the resolved `SKILL.md` plus sibling reference files from disk, so users can inspect the exact guidance agents receive. + ## 16. Registering Workflow Steps Plugins can ship workflow step templates that users can enable like built-in quality gates. diff --git a/packages/dashboard/src/__tests__/skills-adapter.test.ts b/packages/dashboard/src/__tests__/skills-adapter.test.ts index d2c653268d..2e3fba1fc4 100644 --- a/packages/dashboard/src/__tests__/skills-adapter.test.ts +++ b/packages/dashboard/src/__tests__/skills-adapter.test.ts @@ -1,7 +1,7 @@ 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 { writeFile, mkdir, access, readFile, rm, mkdtemp } from "node:fs/promises"; import { join, dirname, resolve } from "node:path"; import { tmpdir } from "node:os"; import { EventEmitter } from "node:events"; @@ -1011,28 +1011,76 @@ describe("createSkillsAdapter - plugin skill merge", () => { }); describe("createSkillsAdapter - readSkillContent for plugin skills", () => { - it("returns synthesized content (not a blank panel) for plugin-contributed skills", async () => { - const adapter = createSkillsAdapter({ - packageManager: { resolve: vi.fn().mockResolvedValue({ skills: [] }) }, - getSettingsPath: () => "/tmp/does-not-exist-settings.json", - getPluginSkills: () => [ - { - pluginId: "fusion-plugin-compound-engineering", - skill: { name: "ce-plan", description: "Create structured plans." }, - }, - ], - }); + it("reads the real SKILL.md and reference files for plugin-contributed skills", async () => { + const dir = await mkdtemp(join(tmpdir(), "plugin-skill-content-")); + const pluginRoot = join(dir, "plugin"); + const skillDir = join(pluginRoot, "skills", "ce-plan"); + await mkdir(skillDir, { recursive: true }); + await writeFile(join(skillDir, "SKILL.md"), "# CE Plan\n\nDistinctive plugin body marker.", "utf-8"); + await writeFile(join(skillDir, "reference.md"), "Plugin reference marker.", "utf-8"); - const skills = await adapter.discoverSkills("/tmp/project"); - const cePlan = skills.find((s) => s.name === "ce-plan")!; + try { + const adapter = createSkillsAdapter({ + packageManager: { resolve: vi.fn().mockResolvedValue({ skills: [] }) }, + getSettingsPath: () => join(dir, "missing-settings.json"), + getPluginSkills: () => [ + { + pluginId: "fusion-plugin-compound-engineering", + pluginRoot, + skill: { name: "ce-plan", description: "Create structured plans." }, + }, + ], + }); - const content = await adapter.readSkillContent("/tmp/project", cePlan.id); - expect(content.name).toBe("ce-plan"); - expect(content.skillMd).toContain("ce-plan"); - expect(content.skillMd).toContain("Create structured plans."); - expect(content.skillMd).toContain("fusion-plugin-compound-engineering"); - expect(content.skillMd).not.toBe(""); - expect(content.files).toEqual([]); + const skills = await adapter.discoverSkills(dir); + const cePlan = skills.find((s) => s.name === "ce-plan")!; + expect(cePlan.path).toBe(join(skillDir, "SKILL.md")); + + const content = await adapter.readSkillContent(dir, cePlan.id); + expect(content.name).toBe("ce-plan"); + expect(content.skillMd).toContain("Distinctive plugin body marker."); + expect(content.skillMd).not.toContain("materialized at runtime"); + expect(content.files).toEqual([{ name: "reference.md", relativePath: "reference.md", type: "file" }]); + + const reference = await adapter.readSkillFileContent(dir, cePlan.id, "reference.md"); + expect(reference).toEqual({ + name: "reference.md", + relativePath: "reference.md", + content: "Plugin reference marker.", + isText: true, + }); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + it("handles a missing plugin SKILL.md gracefully", async () => { + const dir = await mkdtemp(join(tmpdir(), "plugin-skill-missing-")); + const pluginRoot = join(dir, "plugin"); + const skillDir = join(pluginRoot, "skills", "ce-plan"); + await mkdir(skillDir, { recursive: true }); + + try { + const adapter = createSkillsAdapter({ + packageManager: { resolve: vi.fn().mockResolvedValue({ skills: [] }) }, + getSettingsPath: () => join(dir, "missing-settings.json"), + getPluginSkills: () => [ + { + pluginId: "fusion-plugin-compound-engineering", + pluginRoot, + skill: { name: "ce-plan" }, + }, + ], + }); + + const cePlan = (await adapter.discoverSkills(dir)).find((s) => s.name === "ce-plan")!; + const content = await adapter.readSkillContent(dir, cePlan.id); + expect(content.name).toBe("ce-plan"); + expect(content.skillMd).toBe(""); + expect(content.files).toEqual([]); + } finally { + await rm(dir, { recursive: true, force: true }); + } }); }); diff --git a/packages/dashboard/src/skills-adapter.ts b/packages/dashboard/src/skills-adapter.ts index dd338de434..eaf7c74dc8 100644 --- a/packages/dashboard/src/skills-adapter.ts +++ b/packages/dashboard/src/skills-adapter.ts @@ -668,27 +668,10 @@ export function createSkillsAdapter(options: { throw new Error(`Skill not found: ${skillId}`); } - // Plugin-contributed skills have no on-disk representation in this - // catalog: the engine materializes them for executor sessions at runtime, - // so `path` is a virtual relative path with no filesystem backing. Reading - // it would silently return a blank panel, so surface what we know (name + - // description) and explain where the definition lives instead. - if (skill.metadata.source.startsWith("plugin:")) { - const pluginId = skill.metadata.source.slice("plugin:".length); - const lines = [`# ${skill.name}`, ""]; - if (skill.description) { - lines.push(skill.description, ""); - } - lines.push( - `_Contributed by the \`${pluginId}\` plugin. Its definition is materialized at runtime and has no editable file in this project._`, - ); - return { - name: skill.name, - skillMd: lines.join("\n"), - files: [], - }; - } - + /* + FNXC:PluginSkills 2026-07-12-00:00: + GitHub #2017 requires plugin skills to be inspectable from the same on-disk SKILL.md/reference files that sessions load. FN-7860 makes plugin skill.path an absolute package path, so plugin entries now use the regular traversal-guarded disk reader instead of a runtime placeholder. + */ let skillDir = skill.path; try { const skillPathStat = await stat(skill.path); @@ -739,10 +722,10 @@ export function createSkillsAdapter(options: { if (!skill) { throw new Error(`Skill not found: ${skillId}`); } - if (skill.metadata.source.startsWith("plugin:")) { - throw new Error(`Skill file not found: ${relativePath}`); - } - + /* + FNXC:PluginSkills 2026-07-12-00:00: + Plugin skill reference files are read from the resolved plugin skill directory with the same traversal guard as native skills, so dashboard inspection matches the body delivered to sessions. + */ let skillDir = skill.path; try { const skillPathStat = await stat(skill.path); diff --git a/packages/engine/src/__tests__/plugin-skill-body-delivery.test.ts b/packages/engine/src/__tests__/plugin-skill-body-delivery.test.ts new file mode 100644 index 0000000000..fe674c8428 --- /dev/null +++ b/packages/engine/src/__tests__/plugin-skill-body-delivery.test.ts @@ -0,0 +1,75 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { DefaultResourceLoader, type Skill } from "@earendil-works/pi-coding-agent"; +import type { AgentStore } from "@fusion/core"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { buildSessionSkillContext } from "../session-skill-context.js"; +import { createSkillsOverrideFromSelection, resolveSessionSkills } from "../skill-resolver.js"; +import type { PluginRunner } from "../plugin-runner.js"; + +const tempDirs: string[] = []; + +afterEach(async () => { + await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); +}); + +describe("plugin skill body delivery", () => { + it("discovers an enabled plugin skill through additionalSkillPaths and keeps it through requested-name filtering", async () => { + const projectRootDir = await mkdtemp(join(tmpdir(), "plugin-skill-project-")); + const agentDir = await mkdtemp(join(tmpdir(), "plugin-skill-agent-")); + const pluginRoot = await mkdtemp(join(tmpdir(), "plugin-skill-package-")); + tempDirs.push(projectRootDir, agentDir, pluginRoot); + await mkdir(join(projectRootDir, ".fusion"), { recursive: true }); + const skillDir = join(pluginRoot, "skills", "plugin-plan"); + await mkdir(skillDir, { recursive: true }); + await writeFile( + join(skillDir, "SKILL.md"), + "---\nname: plugin-plan\ndescription: Plugin planning guidance\n---\n\n# Plugin Plan\n\nDistinctive body delivered by plugin additionalSkillPaths.", + "utf-8", + ); + + const pluginRunner = { + getPluginSkills: vi.fn().mockReturnValue([ + { pluginId: "plugin-a", pluginRoot, skill: { name: "plugin-plan" } }, + ]), + } as unknown as PluginRunner; + const agentStore = { getAgent: vi.fn().mockResolvedValue(null) } as unknown as AgentStore; + + const context = await buildSessionSkillContext({ + agentStore, + task: {}, + sessionPurpose: "executor", + projectRootDir, + pluginRunner, + }); + + expect(context.resolvedSkillNames).toContain("plugin-plan"); + expect(context.additionalSkillPaths).toEqual([skillDir, dirname(skillDir)]); + + const selection = resolveSessionSkills({ + projectRootDir, + requestedSkillNames: context.skillSelectionContext?.requestedSkillNames, + sessionPurpose: "executor", + }); + const skillsOverride = createSkillsOverrideFromSelection(selection, { + requestedSkillNames: context.skillSelectionContext?.requestedSkillNames, + sessionPurpose: "executor", + }); + + const loader = new DefaultResourceLoader({ + cwd: projectRootDir, + agentDir, + additionalSkillPaths: context.additionalSkillPaths, + skillsOverride, + }); + await loader.reload(); + + const skills = loader.getSkills().skills as Skill[]; + const names = skills.map((skill) => skill.name); + expect(names).toContain("plugin-plan"); + const pluginSkill = skills.find((skill) => skill.name === "plugin-plan"); + expect(pluginSkill?.filePath).toBe(join(skillDir, "SKILL.md")); + await expect(readFile(pluginSkill!.filePath, "utf-8")).resolves.toContain("Distinctive body delivered by plugin additionalSkillPaths."); + }); +}); diff --git a/packages/engine/src/__tests__/session-skill-context.test.ts b/packages/engine/src/__tests__/session-skill-context.test.ts index 6794114052..a0cf7ae405 100644 --- a/packages/engine/src/__tests__/session-skill-context.test.ts +++ b/packages/engine/src/__tests__/session-skill-context.test.ts @@ -1,7 +1,7 @@ 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 { dirname, join } from "node:path"; import { normalizeAgentSkills, collectPluginSkillNames, @@ -24,11 +24,20 @@ async function createProjectWithSettings(settings: Record): Pro } function pluginRunnerWithSkills( - skills: Array<{ pluginId: string; skill: { name: string; enabled?: boolean } }>, + skills: Array<{ pluginId: string; pluginRoot?: string; skill: { name: string; enabled?: boolean; skillFiles?: string[] } }>, ): PluginRunner { return { getPluginSkills: vi.fn().mockReturnValue(skills) } as unknown as PluginRunner; } +async function createPluginSkillRoot(skillName: string, body = "# Plugin skill\n"): Promise<{ pluginRoot: string; skillDir: string }> { + const pluginRoot = await mkdtemp(join(tmpdir(), "session-plugin-skill-")); + tempDirs.push(pluginRoot); + const skillDir = join(pluginRoot, "skills", skillName); + await mkdir(skillDir, { recursive: true }); + await writeFile(join(skillDir, "SKILL.md"), body, "utf-8"); + return { pluginRoot, skillDir }; +} + afterEach(async () => { await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); }); @@ -319,20 +328,32 @@ describe("buildSessionSkillContextSync", () => { expect(result.resolvedSkillNames).toEqual(["fusion", "opt-in", "default-on"]); }); + it("threads enabled plugin skill body directories into sync session context", async () => { + const { pluginRoot, skillDir } = await createPluginSkillRoot("plugin-skill", "# Plugin body\n"); + const pluginRunner = pluginRunnerWithSkills([ + { pluginId: "plugin-a", pluginRoot, skill: { name: "plugin-skill" } }, + ]); + + const result = buildSessionSkillContextSync(null, "executor", projectRootDir, pluginRunner); + expect(result.resolvedSkillNames).toEqual(["fusion", "plugin-skill"]); + expect(result.additionalSkillPaths).toEqual([skillDir, dirname(skillDir)]); + }); + it("keeps legacy behavior in sync path when pluginRunner is omitted", () => { const result = buildSessionSkillContextSync(null, "executor", projectRootDir); expect(result.resolvedSkillNames).toEqual(["fusion"]); + expect(result.additionalSkillPaths).toEqual([]); }); }); describe("collectPluginSkillNames", () => { it("returns empty arrays when pluginRunner is undefined", () => { - expect(collectPluginSkillNames(undefined)).toEqual({ names: [], pluginIds: [] }); + expect(collectPluginSkillNames(undefined)).toEqual({ names: [], pluginIds: [], additionalSkillPaths: [] }); }); it("returns empty arrays when no plugin skills are contributed", () => { const pluginRunner = { getPluginSkills: vi.fn().mockReturnValue([]) } as unknown as PluginRunner; - expect(collectPluginSkillNames(pluginRunner)).toEqual({ names: [], pluginIds: [] }); + expect(collectPluginSkillNames(pluginRunner)).toEqual({ names: [], pluginIds: [], additionalSkillPaths: [] }); }); it("returns enabled plugin skill names and dedupes by first occurrence", () => { @@ -347,6 +368,7 @@ describe("collectPluginSkillNames", () => { expect(collectPluginSkillNames(pluginRunner)).toEqual({ names: ["alpha", "beta"], pluginIds: ["plugin-a", "plugin-b"], + additionalSkillPaths: [], }); }); @@ -361,6 +383,7 @@ describe("collectPluginSkillNames", () => { expect(collectPluginSkillNames(pluginRunner)).toEqual({ names: ["beta"], pluginIds: ["plugin-b"], + additionalSkillPaths: [], }); }); @@ -375,6 +398,7 @@ describe("collectPluginSkillNames", () => { expect(collectPluginSkillNames(pluginRunner, projectRoot)).toEqual({ names: ["alpha"], pluginIds: ["plugin-a"], + additionalSkillPaths: [], }); }); @@ -390,6 +414,7 @@ describe("collectPluginSkillNames", () => { expect(collectPluginSkillNames(pluginRunner, projectRoot)).toEqual({ names: ["beta"], pluginIds: ["plugin-b"], + additionalSkillPaths: [], }); }); @@ -403,6 +428,7 @@ describe("collectPluginSkillNames", () => { expect(collectPluginSkillNames(pluginRunner, projectRoot)).toEqual({ names: ["beta"], pluginIds: ["plugin-b"], + additionalSkillPaths: [], }); }); @@ -417,6 +443,7 @@ describe("collectPluginSkillNames", () => { expect(collectPluginSkillNames(pluginRunner, projectRoot)).toEqual({ names: ["alpha"], pluginIds: ["plugin-a"], + additionalSkillPaths: [], }); }); @@ -433,6 +460,36 @@ describe("collectPluginSkillNames", () => { expect(collectPluginSkillNames(pluginRunner, worktreeRoot)).toEqual({ names: ["alpha"], pluginIds: ["plugin-a"], + additionalSkillPaths: [], + }); + }); + + it("adds enabled plugin skill body directories and dedupes duplicate directories", async () => { + const { pluginRoot, skillDir } = await createPluginSkillRoot("alpha"); + const pluginRunner = pluginRunnerWithSkills([ + { pluginId: "plugin-a", pluginRoot, skill: { name: "alpha" } }, + { pluginId: "plugin-b", pluginRoot, skill: { name: "alpha" } }, + { pluginId: "plugin-c", pluginRoot, skill: { name: "beta", skillFiles: ["skills/alpha/SKILL.md"] } }, + ]); + + expect(collectPluginSkillNames(pluginRunner)).toEqual({ + names: ["alpha", "beta"], + pluginIds: ["plugin-a", "plugin-c"], + additionalSkillPaths: [skillDir, dirname(skillDir)], + }); + }); + + it("does not add body directories for disabled skills or missing pluginRoot", async () => { + const { pluginRoot } = await createPluginSkillRoot("alpha"); + const pluginRunner = pluginRunnerWithSkills([ + { pluginId: "plugin-a", pluginRoot, skill: { name: "alpha", enabled: false } }, + { pluginId: "plugin-b", skill: { name: "beta" } }, + ]); + + expect(collectPluginSkillNames(pluginRunner)).toEqual({ + names: ["beta"], + pluginIds: ["plugin-b"], + additionalSkillPaths: [], }); }); }); @@ -601,6 +658,25 @@ describe("buildSessionSkillContext", () => { expect(result.skillSelectionContext?.requestedSkillNames).toEqual(["fusion", "plugin-skill"]); }); + it("threads enabled plugin skill body directories into async session context", async () => { + const { pluginRoot, skillDir } = await createPluginSkillRoot("plugin-skill", "# Plugin body\n"); + const mockAgentStore = { getAgent: vi.fn().mockResolvedValue(null) } as unknown as AgentStore; + const pluginRunner = pluginRunnerWithSkills([ + { pluginId: "plugin-a", pluginRoot, skill: { name: "plugin-skill" } }, + ]); + + const result = await buildSessionSkillContext({ + agentStore: mockAgentStore, + task: {}, + sessionPurpose: "executor", + projectRootDir, + pluginRunner, + }); + + expect(result.resolvedSkillNames).toEqual(["fusion", "plugin-skill"]); + expect(result.additionalSkillPaths).toEqual([skillDir, dirname(skillDir)]); + }); + it("honors per-project plugin skill toggles in async path", async () => { const projectRoot = await createProjectWithSettings({ packages: [ diff --git a/packages/engine/src/agent-heartbeat.ts b/packages/engine/src/agent-heartbeat.ts index b15e4cd919..2f28e279f1 100644 --- a/packages/engine/src/agent-heartbeat.ts +++ b/packages/engine/src/agent-heartbeat.ts @@ -3043,8 +3043,9 @@ export class HeartbeatMonitor { toolCallCount++; agentLogger?.onToolEnd(name, isError, result); }, - // Skill selection: use waking agent's skills (heartbeat has no role fallback) + // FNXC:PluginSkills 2026-07-12-00:00: Heartbeat sessions forward plugin skill body dirs with waking-agent requested names so durable agents can use plugin-provided guidance. ...(skillContext.skillSelectionContext ? { skillSelection: skillContext.skillSelectionContext } : {}), + ...(skillContext.additionalSkillPaths.length > 0 ? { additionalSkillPaths: skillContext.additionalSkillPaths } : {}), actionGateContext: this.buildActionGateContext(agent, taskId, run.id, heartbeatModelSettings?.defaultAgentPermissionPolicy), permanentAgentGating: this.buildPermanentAgentGatingContext(agent, taskId, run.id, heartbeatModelSettings?.defaultAgentPermissionPolicy), }); diff --git a/packages/engine/src/cron-runner.ts b/packages/engine/src/cron-runner.ts index 4fed99556a..747ac73dbb 100644 --- a/packages/engine/src/cron-runner.ts +++ b/packages/engine/src/cron-runner.ts @@ -1046,7 +1046,9 @@ export async function createAiPromptExecutor(cwd: string, store?: TaskStore): Pr systemPrompt: AI_AUTOMATION_SYSTEM_PROMPT, tools: "coding", toolsAllowlist: allowedTools, + // FNXC:PluginSkills 2026-07-12-00:00: Automation sessions use the shared executor skill context; forward plugin body dirs when a plugin runner is supplied so requested plugin skills can be discovered. ...(skillContext.skillSelectionContext ? { skillSelection: skillContext.skillSelectionContext } : {}), + ...(skillContext.additionalSkillPaths.length > 0 ? { additionalSkillPaths: skillContext.additionalSkillPaths } : {}), defaultProvider: modelProvider, defaultModelId: modelId, mcpServers, diff --git a/packages/engine/src/executor.ts b/packages/engine/src/executor.ts index 3305b9c94e..34662a75f2 100644 --- a/packages/engine/src/executor.ts +++ b/packages/engine/src/executor.ts @@ -343,6 +343,11 @@ export function augmentSessionSkillsForBrowserStep( }; } +function mergeAdditionalSkillPaths(...pathGroups: Array): string[] | undefined { + const merged = Array.from(new Set(pathGroups.flatMap((paths) => paths ?? []))); + return merged.length > 0 ? merged : undefined; +} + export function formatAgentBrowserAvailabilityLog(result: AgentBrowserAvailabilityProbeResult): string { if (result.available) { return `[browser-verification] agent-browser available — version ${result.version ?? "unknown"}`; @@ -10017,8 +10022,9 @@ export class TaskExecutor { // FNXC:McpConfig 2026-06-25-23:03: Per-step workflow sessions are an executor lane, so they inherit the task's resolved MCP set from the effective step identity agent and never re-read or log plaintext secret values. mcpServers: await this.resolveMcpServers(stepIdentityAgent?.id), workflowStepThinkingLevel: this.graphSeamThinkingLevel.get(task.id), - // Pass skill selection context from the main executor session + // FNXC:PluginSkills 2026-07-12-00:00: Step sessions must forward plugin skill body dirs alongside requested names; otherwise plugin-provided SKILL.md bodies are invisible to the inner createFnAgent loader. skillSelection: skillContext.skillSelectionContext, + additionalSkillPaths: skillContext.additionalSkillPaths, // Pass agentStore and messageStore for delegation and messaging tools agentStore: this.options.agentStore, messageStore: this.options.messageStore, @@ -10836,8 +10842,9 @@ export class TaskExecutor { sessionManager, taskEnv, mcpServers: await this.resolveMcpServers(identityAgent?.id), - // Skill selection: use assigned agent skills if available, otherwise role fallback + // FNXC:PluginSkills 2026-07-12-00:00: Plugin skill session delivery requires forwarding both requested names and body directories so the pi loader can discover plugin-package SKILL.md files. ...(skillContext.skillSelectionContext ? { skillSelection: skillContext.skillSelectionContext } : {}), + ...(skillContext.additionalSkillPaths.length > 0 ? { additionalSkillPaths: skillContext.additionalSkillPaths } : {}), // Column-agent principal alignment (plan U5, R5): action gating is // computed for the agent ACTUALLY RUNNING. When the governing execute // seam's column binds an agent that supersedes the assigned agent, @@ -11264,8 +11271,9 @@ export class TaskExecutor { sessionManager: SessionManager.create(worktreePath), taskEnv, mcpServers: await this.resolveMcpServers(identityAgent?.id), - // Skill selection: use assigned agent skills if available, otherwise role fallback + // FNXC:PluginSkills 2026-07-12-00:00: Retry executor sessions must keep the same plugin skill body discovery paths as the primary attempt so requested plugin skill names resolve to real bodies. ...(skillContext.skillSelectionContext ? { skillSelection: skillContext.skillSelectionContext } : {}), + ...(skillContext.additionalSkillPaths.length > 0 ? { additionalSkillPaths: skillContext.additionalSkillPaths } : {}), // U5 (R5): retry session re-keys gating to the effective principal, // mirroring the primary execute-seam session above. actionGateContext: this.buildActionGateContext(task.id, identityAgent, settings.defaultAgentPermissionPolicy), @@ -14224,7 +14232,9 @@ Do not refactor, rename broadly, or make opportunistic improvements. // #1675: propagate task id so verification-fix requests carry the same // X-Session-Id/X-Session-Affinity as the primary session. taskId: task.id, + // FNXC:PluginSkills 2026-07-12-00:00: Verification-fix sessions share task skill selection; include plugin skill body dirs so fixes can use plugin-authored guidance. ...(skillContext?.skillSelectionContext ? { skillSelection: skillContext.skillSelectionContext } : {}), + ...(skillContext && skillContext.additionalSkillPaths.length > 0 ? { additionalSkillPaths: skillContext.additionalSkillPaths } : {}), }); await this.store.logEntry( @@ -15241,7 +15251,7 @@ You have access to the file system to review changes.${inlineFixBlock}${verdictB `[skill-load] Workflow step '${workflowStep.name}' requests skill '${workflowStep.skillName}' but FUSION_CE_SKILLS_DIR is unset — the skill cannot be discovered; the step runs with role-fallback skills only.`, ); } - const additionalSkillPaths = ceSkillsDir ? [ceSkillsDir] : undefined; + const additionalSkillPaths = mergeAdditionalSkillPaths(skillContext.additionalSkillPaths, ceSkillsDir ? [ceSkillsDir] : undefined); const logBrowserVerificationActivity = async (message: string) => { await this.store.logEntry(task.id, message); await this.store.appendAgentLog(task.id, message, "text", undefined, "reviewer"); @@ -15328,8 +15338,8 @@ You have access to the file system to review changes.${inlineFixBlock}${verdictB // #1675: propagate task id so workflow-step requests carry the same // X-Session-Id/X-Session-Affinity as the primary session. taskId: task.id, - // Skill selection: assigned-agent / role-fallback skills, plus the step's - // own named skill (U1) made discoverable via additionalSkillPaths. + // FNXC:PluginSkills 2026-07-12-00:00: Workflow-step sessions union plugin skill body dirs with CE's FUSION_CE_SKILLS_DIR so neither plugin-package nor compound-engineering skills are overwritten. + // Skill selection: assigned-agent / role-fallback skills, plus the step's own named skill (U1) made discoverable via additionalSkillPaths. ...(effectiveSkillSelection ? { skillSelection: effectiveSkillSelection } : {}), ...(additionalSkillPaths ? { additionalSkillPaths } : {}), ...(readonlyCustomTools.allowed.length > 0 ? { customTools: readonlyCustomTools.allowed } : {}), @@ -18204,8 +18214,9 @@ Child agent: ${agent.id} (${name})`; // #1675: propagate task id so child-agent requests carry the same // X-Session-Id/X-Session-Affinity as the parent task session. taskId, - // Skill selection: use assigned agent skills if available, otherwise role fallback + // FNXC:PluginSkills 2026-07-12-00:00: Child-agent sessions inherit plugin skill body directories from the task skill context so delegated work can load plugin skill guidance. ...(skillContext.skillSelectionContext ? { skillSelection: skillContext.skillSelectionContext } : {}), + ...(skillContext.additionalSkillPaths.length > 0 ? { additionalSkillPaths: skillContext.additionalSkillPaths } : {}), }); // Store tracking state diff --git a/packages/engine/src/merger.ts b/packages/engine/src/merger.ts index f6990c08f4..3818e009d7 100644 --- a/packages/engine/src/merger.ts +++ b/packages/engine/src/merger.ts @@ -1986,8 +1986,9 @@ Do not refactor, rename broadly, or make opportunistic improvements. }), settings, mcpServers: await resolveMergerMcpServers(store, assignedAgent?.id), - // Skill selection: use assigned agent skills if available, otherwise role fallback + // FNXC:PluginSkills 2026-07-12-00:00: Merger verification-fix sessions forward plugin skill body dirs with requested names so plugin merge guidance is discoverable in live sessions. ...(skillContext?.skillSelectionContext ? { skillSelection: skillContext.skillSelectionContext } : {}), + ...(skillContext && skillContext.additionalSkillPaths.length > 0 ? { additionalSkillPaths: skillContext.additionalSkillPaths } : {}), taskId, taskTitle: taskForSkillContext?.title, onFallbackModelUsed: createFallbackModelObserver({ @@ -3181,7 +3182,9 @@ ${fileList} }), settings, mcpServers: await resolveMergerMcpServers(store, assignedAgent?.id), + // FNXC:PluginSkills 2026-07-12-00:00: Autostash conflict sessions must preserve plugin skill body dirs from the shared skill context. ...(skillContext?.skillSelectionContext ? { skillSelection: skillContext.skillSelectionContext } : {}), + ...(skillContext && skillContext.additionalSkillPaths.length > 0 ? { additionalSkillPaths: skillContext.additionalSkillPaths } : {}), taskId, taskTitle: taskForSkillContext?.title, onFallbackModelUsed: createFallbackModelObserver({ @@ -3599,7 +3602,9 @@ ${fileList} }), settings, mcpServers: await resolveMergerMcpServers(store, assignedAgent?.id), + // FNXC:PluginSkills 2026-07-12-00:00: Autostash hard-fail recovery sessions keep plugin body discovery paths aligned with requested plugin skills. ...(skillContext?.skillSelectionContext ? { skillSelection: skillContext.skillSelectionContext } : {}), + ...(skillContext && skillContext.additionalSkillPaths.length > 0 ? { additionalSkillPaths: skillContext.additionalSkillPaths } : {}), taskId, taskTitle: taskForSkillContext?.title, onFallbackModelUsed: createFallbackModelObserver({ @@ -12084,8 +12089,9 @@ async function runAiAgentForCommit(params: AiAgentParams): Promise<{ success: bo settings, // FNXC:McpConfig 2026-06-25-23:04: The primary merge-authoring agent is part of the merger lane and receives the resolved MCP set under the shared runtime-support guard, matching conflict/verification merge sessions without exposing secret material. mcpServers: await resolveMergerMcpServers(store, assignedAgent?.id), - // Skill selection: use assigned agent skills if available, otherwise role fallback + // FNXC:PluginSkills 2026-07-12-00:00: Merge-authoring sessions forward plugin skill body dirs so plugin-contributed merger skills load their bodies. ...(skillContext?.skillSelectionContext ? { skillSelection: skillContext.skillSelectionContext } : {}), + ...(skillContext && skillContext.additionalSkillPaths.length > 0 ? { additionalSkillPaths: skillContext.additionalSkillPaths } : {}), taskId, taskTitle: taskForSkillContext?.title, onFallbackModelUsed: createFallbackModelObserver({ diff --git a/packages/engine/src/reviewer.ts b/packages/engine/src/reviewer.ts index 3262c9efa1..5f8d3844b7 100644 --- a/packages/engine/src/reviewer.ts +++ b/packages/engine/src/reviewer.ts @@ -424,7 +424,9 @@ export async function reviewStep( defaultThinkingLevel: options.defaultThinkingLevel, runAuditor, settings: effectiveSettings, + // FNXC:PluginSkills 2026-07-12-00:00: Reviewer sessions use the shared skill context; forward plugin body dirs so requested plugin review skills include their SKILL.md content. ...(skillContext?.skillSelectionContext ? { skillSelection: skillContext.skillSelectionContext } : {}), + ...(skillContext && skillContext.additionalSkillPaths.length > 0 ? { additionalSkillPaths: skillContext.additionalSkillPaths } : {}), taskId: options.taskId, taskTitle: options.taskTitle, // FNXC:McpConfig 2026-06-25-22:45: Reviewer and validator sessions resolve the same trusted MCP server set as executor lanes at session creation; secret values are passed only in memory to the runtime guard. diff --git a/packages/engine/src/session-skill-context.ts b/packages/engine/src/session-skill-context.ts index cd01f5924b..26ac373d32 100644 --- a/packages/engine/src/session-skill-context.ts +++ b/packages/engine/src/session-skill-context.ts @@ -29,8 +29,9 @@ * - Results are deduplicated preserving stable insertion order */ +import { dirname } from "node:path"; import type { Agent, AgentStore } from "@fusion/core"; -import { resolvePluginSkillEnabled } from "@fusion/core"; +import { resolvePluginSkillBodyPath, resolvePluginSkillEnabled } from "@fusion/core"; import { piLog } from "./logger.js"; import type { PluginRunner } from "./plugin-runner.js"; import { readProjectSettings, resolveProjectRoot, type SkillSelectionContext } from "./skill-resolver.js"; @@ -70,6 +71,8 @@ export interface SessionSkillContextResult { resolvedSkillNames: string[]; /** Source of the skills: 'assigned-agent', 'role-fallback', or 'none' */ skillSource: "assigned-agent" | "role-fallback" | "none"; + /** Extra skill body directories to pass to createFnAgent's additionalSkillPaths */ + additionalSkillPaths: string[]; } // ── Skill Normalization ───────────────────────────────────────────────────── @@ -127,13 +130,16 @@ export function normalizeAgentSkills( /** * 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. + * + * FNXC:PluginSkills 2026-07-12-00:00: + * GitHub #2017 showed that plugin skill names alone never delivered bodies because the pi loader does not scan plugin packages. Resolve each enabled plugin skill body through @fusion/core's traversal-guarded primitive and thread its body directory plus parent discovery root as additionalSkillPaths, mirroring the compound-engineering FUSION_CE_SKILLS_DIR mechanism while preserving the explicit body-dir contract. */ export function collectPluginSkillNames( pluginRunner: PluginRunner | undefined, projectRootDir?: string, -): { names: string[]; pluginIds: string[] } { +): { names: string[]; pluginIds: string[]; additionalSkillPaths: string[] } { if (!pluginRunner) { - return { names: [], pluginIds: [] }; + return { names: [], pluginIds: [], additionalSkillPaths: [] }; } let settings = {}; @@ -148,6 +154,7 @@ export function collectPluginSkillNames( const pluginSkills = pluginRunner.getPluginSkills(); const seenNames = new Set(); const pluginIds = new Set(); + const additionalSkillPathSet = new Set(); const names: string[] = []; for (const contribution of pluginSkills) { @@ -164,12 +171,27 @@ export function collectPluginSkillNames( seenNames.add(name); pluginIds.add(pluginId); names.push(name); + + if (contribution.pluginRoot) { + try { + const bodyPath = resolvePluginSkillBodyPath(skill, contribution.pluginRoot); + const bodyDir = dirname(bodyPath.absolutePath); + additionalSkillPathSet.add(bodyDir); + additionalSkillPathSet.add(dirname(bodyDir)); + } catch (error) { + piLog.warn( + `[skills] Plugin ${pluginId} skill ${name} body path could not be resolved: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + piLog.log(`[skills] Plugin ${pluginId} contributes skill: ${name}`); } return { names, pluginIds: Array.from(pluginIds), + additionalSkillPaths: Array.from(additionalSkillPathSet), }; } @@ -260,6 +282,7 @@ export async function buildSessionSkillContext( }, resolvedSkillNames: agentSkills, skillSource: "assigned-agent", + additionalSkillPaths: [], }, sessionPurpose, projectRootDir, @@ -295,6 +318,7 @@ function resolveRoleFallback( }, resolvedSkillNames: roleFallbackSkills, skillSource: "role-fallback", + additionalSkillPaths: [], }; } @@ -302,18 +326,23 @@ function resolveRoleFallback( skillSelectionContext: undefined, resolvedSkillNames: [], skillSource: "none", + additionalSkillPaths: [], }; } +function mergeAdditionalSkillPaths(...pathGroups: string[][]): string[] { + return Array.from(new Set(pathGroups.flat())); +} + function mergePluginSkills( baseResult: SessionSkillContextResult, sessionPurpose: SessionPurpose, projectRootDir: string, pluginRunner: PluginRunner | undefined, ): SessionSkillContextResult { - const { names: pluginSkillNames } = collectPluginSkillNames(pluginRunner, projectRootDir); + const { names: pluginSkillNames, additionalSkillPaths } = collectPluginSkillNames(pluginRunner, projectRootDir); if (pluginSkillNames.length === 0) { - return baseResult; + return { ...baseResult, additionalSkillPaths: mergeAdditionalSkillPaths(baseResult.additionalSkillPaths, additionalSkillPaths) }; } const mergedNames = [...baseResult.resolvedSkillNames]; @@ -338,7 +367,7 @@ function mergePluginSkills( } if (mergedNames.length === 0) { - return baseResult; + return { ...baseResult, additionalSkillPaths: mergeAdditionalSkillPaths(baseResult.additionalSkillPaths, additionalSkillPaths) }; } return { @@ -349,6 +378,7 @@ function mergePluginSkills( }, resolvedSkillNames: mergedNames, skillSource: baseResult.skillSource === "none" ? "role-fallback" : baseResult.skillSource, + additionalSkillPaths: mergeAdditionalSkillPaths(baseResult.additionalSkillPaths, additionalSkillPaths), }; } @@ -382,6 +412,7 @@ export function buildSessionSkillContextSync( }, resolvedSkillNames: agentSkills, skillSource: "assigned-agent", + additionalSkillPaths: [], }, sessionPurpose, projectRootDir, diff --git a/packages/engine/src/step-session-executor.ts b/packages/engine/src/step-session-executor.ts index 412675bc85..29a2e8e744 100644 --- a/packages/engine/src/step-session-executor.ts +++ b/packages/engine/src/step-session-executor.ts @@ -125,6 +125,8 @@ export interface StepSessionExecutorOptions { onStepComplete?: (stepIndex: number, result: StepResult) => void; /** Optional skill selection context for session creation. */ skillSelection?: SkillSelectionContext; + /** Optional extra skill body directories for session resource discovery. */ + additionalSkillPaths?: string[]; /** Optional agent store for delegation tools. */ agentStore?: AgentStore; /** Optional message store for messaging tools. */ @@ -1399,8 +1401,9 @@ Follow instructions precisely and avoid unrelated changes.`, telemetry.agentLogger.onToolEnd(name, isError, result); stuckTaskDetector?.recordActivity(telemetry.trackingKey); }, - // Skill selection from step-session executor options + // FNXC:PluginSkills 2026-07-12-00:00: Step-session createFnAgent must receive plugin skill body dirs from TaskExecutor; names alone do not make plugin-package SKILL.md files discoverable. ...(this.options.skillSelection ? { skillSelection: this.options.skillSelection } : {}), + ...(this.options.additionalSkillPaths && this.options.additionalSkillPaths.length > 0 ? { additionalSkillPaths: this.options.additionalSkillPaths } : {}), actionGateContext: this.options.actionGateContext, permanentAgentGating: this.options.permanentAgentGating, taskId: taskDetail.id, diff --git a/packages/engine/src/triage.ts b/packages/engine/src/triage.ts index 5f4cc49a11..19a93cd41e 100644 --- a/packages/engine/src/triage.ts +++ b/packages/engine/src/triage.ts @@ -1170,8 +1170,9 @@ export class TriageProcessor { settings, // FNXC:McpConfig 2026-06-25-23:17: Primary triage planning is an AI lane, so it receives the store-resolved MCP set while the pi runtime-support guard decides whether to forward it without logging secret material. mcpServers: (await resolveMcpServersForStore(this.store)).servers, - // Skill selection: use assigned agent skills if available, otherwise role fallback + // FNXC:PluginSkills 2026-07-12-00:00: Triage sessions forward plugin skill body dirs with requested names so plugin-authored planning guidance is discoverable by the pi loader. ...(skillContext.skillSelectionContext ? { skillSelection: skillContext.skillSelectionContext } : {}), + ...(skillContext.additionalSkillPaths.length > 0 ? { additionalSkillPaths: skillContext.additionalSkillPaths } : {}), taskId: task.id, taskTitle: task.title, onFallbackModelUsed: createFallbackModelObserver({