diff --git a/docs/solutions/integration-issues/plugin-bundled-skills-not-loading-in-interactive-sessions.md b/docs/solutions/integration-issues/plugin-bundled-skills-not-loading-in-interactive-sessions.md index 6265456976..1b8bc38208 100644 --- a/docs/solutions/integration-issues/plugin-bundled-skills-not-loading-in-interactive-sessions.md +++ b/docs/solutions/integration-issues/plugin-bundled-skills-not-loading-in-interactive-sessions.md @@ -115,6 +115,7 @@ A plugin author shipping a bundled skill should: 2. **Forward both** seam options when starting the session: `requestedSkillNames: [skillId]` (so the resolver keeps it) **and** `additionalSkillPaths: [installRoot]` (so the loader discovers it). One without the other silently no-ops — a name with no discovered file filters to `[]`; a discovered file with no requested name can be filtered out. 3. Remember `skillsOverride` only filters — declaring a `PluginSkillContribution` is **name-only** and never injects skill content into a live session. 4. **Prove it with a real `DefaultResourceLoader`** (see `packages/engine/src/__tests__/compound-engineering-skill-resolution.test.ts`) that asserts the skill actually appears in the resolved session skills — a scripted/fake session cannot catch a discovery gap. +5. **For stage registries, iterate the registry instead of sampling.** Compound Engineering now treats each `listStages()` entry as a skill-loading invariant: `` must have bundled source frontmatter, an installed `/SKILL.md`, a plugin-local discovery path, and session options that request the skill. A missing installed stage skill should emit a clear guard warning before session start, not silently run a degraded skill-less stage. ## Related Issues diff --git a/plugins/fusion-plugin-compound-engineering/src/__tests__/skill-reachability.test.ts b/plugins/fusion-plugin-compound-engineering/src/__tests__/skill-reachability.test.ts index 4f09390310..b132e457a4 100644 --- a/plugins/fusion-plugin-compound-engineering/src/__tests__/skill-reachability.test.ts +++ b/plugins/fusion-plugin-compound-engineering/src/__tests__/skill-reachability.test.ts @@ -1,27 +1,27 @@ -import { existsSync, mkdtempSync, rmSync } from "node:fs"; +import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { installBundledCeSkills } from "../skill-installation.js"; -import { resolveStageSkillPaths, buildStageSystemPrompt } from "../session/orchestrator.js"; -import { getStage } from "../session/stage-registry.js"; +import { installBundledCeSkills, resolveBundledSkillsRoot } from "../skill-installation.js"; +import { + buildStageSystemPrompt, + checkStageSkillResolution, + resolveStageSkillPaths, +} from "../session/orchestrator.js"; +import { listStages } from "../session/stage-registry.js"; /** - * Prove the launched stage's ce-* skill is REACHABLE for the session — now via + * Prove every registered stage's ce-* skill is REACHABLE for the session — via * the real seam wiring (closes the U2 → U5 carry-forward). * - * The U4 `CreateInteractiveAiSessionOptions` surface now carries + * FNXC:CompoundEngineering 2026-06-27-15:44: + * CE stage skill coverage must iterate `listStages()` rather than sample stages so newly registered stages automatically prove the source → install → discovery-path → prompt contract before they can silently launch without their bundled skill. + * + * The U4 `CreateInteractiveAiSessionOptions` surface carries * `requestedSkillNames` + `additionalSkillPaths`, which the engine adapter * forwards into `createFnAgent` (`skills` + the loader's `additionalSkillPaths`). * So the orchestrator hands the session BOTH the stage's skill id and the - * install directory to discover it from. This test asserts: - * 1. the install directory `resolveStageSkillPaths()` returns actually holds - * the stage's `/SKILL.md` after install, and - * 2. the system prompt names the stage's skill id. - * - * The engine package separately proves (compound-engineering-skill-resolution - * .test.ts) that `loadSkills` + the resolver resolve a ce-* skill once that - * directory is on the discovery path — together the chain is closed. + * install directory to discover it from. */ describe("stage skill reachability (real seam wiring)", () => { @@ -40,25 +40,36 @@ describe("stage skill reachability (real seam wiring)", () => { expect(skillPaths[0]).not.toMatch(/\.(claude|codex|gemini)[/\\]skills/); }); - it.each(["brainstorm", "debug"])( - "installing bundled skills onto a discovery root produces %s's SKILL.md", - (stageId) => { - const stage = getStage(stageId)!; - // Install into a temp discovery root (isolated; mirrors what the real - // plugin-local install produces, without writing into the repo dir). - const target = mkdtempSync(join(tmpdir(), "ce-skill-reach-")); - tmpTargets.push(target); - - const { results } = installBundledCeSkills({ targetRoot: target }); - expect(results.every((r) => r.outcome === "installed" || r.outcome === "skipped")).toBe(true); - - const installedSkillMd = join(target, stage.skillId, "SKILL.md"); - expect(existsSync(installedSkillMd)).toBe(true); + it.each(listStages())( + "the $stageId stage has a bundled source SKILL.md with matching frontmatter name", + (stage) => { + const skillMd = join(resolveBundledSkillsRoot(), stage.skillId, "SKILL.md"); + expect(existsSync(skillMd)).toBe(true); + const content = readFileSync(skillMd, "utf-8"); + expect(content).toMatch(new RegExp(`^name:\\s*${stage.skillId}$`, "m")); }, ); - it.each(["brainstorm", "debug"])("the %s stage system prompt names the stage's ce-* skill id", (stageId) => { - const stage = getStage(stageId)!; + it.each(listStages())("installing bundled skills onto a discovery root produces $stageId's SKILL.md", (stage) => { + // Install into a temp discovery root (isolated; mirrors what the real + // plugin-local install produces, without writing into the repo dir). + const target = mkdtempSync(join(tmpdir(), "ce-skill-reach-")); + tmpTargets.push(target); + + const { results } = installBundledCeSkills({ targetRoot: target }); + const stageResult = results.find((r) => r.skillId === stage.skillId); + expect(stageResult?.outcome).toBe("installed"); + + const installedSkillMd = join(target, stage.skillId, "SKILL.md"); + expect(existsSync(installedSkillMd)).toBe(true); + expect(checkStageSkillResolution(stage, [target])).toMatchObject({ + skillId: stage.skillId, + found: true, + expectedSkillMdPaths: [installedSkillMd], + }); + }); + + it.each(listStages())("the $stageId stage system prompt names the stage's ce-* skill id", (stage) => { const prompt = buildStageSystemPrompt(stage); expect(prompt).toContain(stage.skillId); expect(prompt).toContain("question"); diff --git a/plugins/fusion-plugin-compound-engineering/src/__tests__/stage-skill-loading.test.ts b/plugins/fusion-plugin-compound-engineering/src/__tests__/stage-skill-loading.test.ts new file mode 100644 index 0000000000..ae8cc38421 --- /dev/null +++ b/plugins/fusion-plugin-compound-engineering/src/__tests__/stage-skill-loading.test.ts @@ -0,0 +1,69 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { CreateInteractiveAiSessionFactory } from "@fusion/core"; +import { CeOrchestrator, warnIfStageSkillMissing } from "../session/orchestrator.js"; +import { listStages } from "../session/stage-registry.js"; +import { makeHarness, makeScriptedSession, type TestHarness } from "./_harness.js"; + +let h: TestHarness; + +beforeEach(() => { + h = makeHarness(); +}); + +afterEach(() => { + h.close(); + vi.restoreAllMocks(); +}); + +describe("CE stage skill loading session options", () => { + it.each(listStages())("starts $stageId with its registered skill selected and discoverable", async (stage) => { + const capturedOptions: Parameters[0][] = []; + const session = makeScriptedSession([{ type: "complete", data: { artifact: `# ${stage.stageId}` } }]); + const factory: CreateInteractiveAiSessionFactory = vi.fn(async (options) => { + capturedOptions.push(options); + return { session, sessionFile: join(h.projectRoot, `${stage.stageId}.json`) }; + }); + + const orch = new CeOrchestrator({ ctx: h.ctx, createInteractiveAiSession: factory, projectRoot: h.projectRoot }); + const result = await orch.start(stage.stageId, { openingMessage: `Run ${stage.stageId}` }); + + expect(result.session.status).toBe("completed"); + expect(capturedOptions).toHaveLength(1); + expect(capturedOptions[0].requestedSkillNames).toEqual([stage.skillId]); + expect(capturedOptions[0].additionalSkillPaths).toHaveLength(1); + expect(capturedOptions[0].additionalSkillPaths?.[0]).toMatch(/\.fusion-ce-skills$/); + expect(capturedOptions[0].additionalSkillPaths?.[0]).not.toMatch(/\.(claude|codex|gemini)[/\\]skills/); + expect(capturedOptions[0].systemPrompt).toContain(stage.skillId); + }); + + it("warns loudly when a stage skill is missing from the install root", () => { + const stage = listStages()[0]; + const missingRoot = mkdtempSync(join(tmpdir(), "ce-missing-skill-root-")); + try { + const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }; + + const guard = warnIfStageSkillMissing(logger, stage, [missingRoot]); + + expect(guard).toMatchObject({ + skillId: stage.skillId, + found: false, + expectedSkillMdPaths: [join(missingRoot, stage.skillId, "SKILL.md")], + }); + expect(logger.warn).toHaveBeenCalledTimes(1); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining(stage.skillId), + expect.objectContaining({ + stageId: stage.stageId, + skillId: stage.skillId, + expectedSkillMdPaths: [join(missingRoot, stage.skillId, "SKILL.md")], + additionalSkillPaths: [missingRoot], + }), + ); + } finally { + rmSync(missingRoot, { recursive: true, force: true }); + } + }); +}); diff --git a/plugins/fusion-plugin-compound-engineering/src/session/orchestrator.ts b/plugins/fusion-plugin-compound-engineering/src/session/orchestrator.ts index d53f8fed40..40b6b7527a 100644 --- a/plugins/fusion-plugin-compound-engineering/src/session/orchestrator.ts +++ b/plugins/fusion-plugin-compound-engineering/src/session/orchestrator.ts @@ -1,4 +1,4 @@ -import { mkdirSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, writeFileSync } from "node:fs"; import { dirname, isAbsolute, join } from "node:path"; import type { CreateInteractiveAiSessionFactory, @@ -149,6 +149,48 @@ export function resolveStageSkillPaths(): string[] { return [resolveDefaultInstallTargetRoot()]; } +export interface StageSkillResolutionGuardResult { + skillId: string; + expectedSkillMdPaths: string[]; + found: boolean; +} + +/** + * FNXC:CompoundEngineering 2026-06-27-15:33: + * Every interactive CE stage must launch with its registered `ce-*` skill both selected and discoverable. If the plugin-local install is missing `/SKILL.md`, surface that before session creation so operators see a skill-loading fault instead of a silent degraded stage. + */ +export function checkStageSkillResolution( + stage: CeStageDefinition, + skillPaths: string[] = resolveStageSkillPaths(), +): StageSkillResolutionGuardResult { + const expectedSkillMdPaths = skillPaths.map((root) => join(root, stage.skillId, "SKILL.md")); + return { + skillId: stage.skillId, + expectedSkillMdPaths, + found: expectedSkillMdPaths.some((skillMd) => existsSync(skillMd)), + }; +} + +export function warnIfStageSkillMissing( + logger: PluginContext["logger"], + stage: CeStageDefinition, + additionalSkillPaths: string[] = resolveStageSkillPaths(), +): StageSkillResolutionGuardResult { + const guard = checkStageSkillResolution(stage, additionalSkillPaths); + if (!guard.found) { + logger.warn( + `Compound Engineering stage '${stage.stageId}' requested skill '${stage.skillId}', but no SKILL.md was found on the plugin-local discovery paths. The session will still request the skill so the engine can resolve it if installation completes, but the current install appears missing.`, + { + stageId: stage.stageId, + skillId: stage.skillId, + expectedSkillMdPaths: guard.expectedSkillMdPaths, + additionalSkillPaths, + }, + ); + } + return guard; +} + /** * Build the system prompt: instruct the agent to (a) apply the named ce-* skill * and (b) emit the JSON question/complete protocol the U4 seam parses. @@ -252,12 +294,14 @@ export class CeOrchestrator { ): Parameters[0] { const defaultProvider = getDefaultProvider(this.ctx.settings); const defaultModelId = getDefaultModelId(this.ctx.settings); + const additionalSkillPaths = resolveStageSkillPaths(); + warnIfStageSkillMissing(this.ctx.logger, stage, additionalSkillPaths); return { cwd: this.projectRoot, systemPrompt: buildStageSystemPrompt(stage), tools: "coding", requestedSkillNames: [stage.skillId], - additionalSkillPaths: resolveStageSkillPaths(), + additionalSkillPaths, onProgress: (event) => this.handleProgress(sessionId, event), ...(defaultProvider ? { defaultProvider } : {}), ...(defaultModelId ? { defaultModelId } : {}),