diff --git a/.changeset/fn-9114-forced-skills.md b/.changeset/fn-9114-forced-skills.md new file mode 100644 index 0000000000..cbbb8c3ce1 --- /dev/null +++ b/.changeset/fn-9114-forced-skills.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Enabled skills are available to every agent; per-agent skills are forced reading. +category: feature +dev: Adds forcedSkillNames requests and resolvedForcedSkills/unresolvedForcedSkills outcomes, non-restrictive skill union semantics, resolved-only prompt instructions, and per-session [skills] summaries. diff --git a/docs/PLUGIN_AUTHORING.md b/docs/PLUGIN_AUTHORING.md index f0df0daf6d..dfc15f1c47 100644 --- a/docs/PLUGIN_AUTHORING.md +++ b/docs/PLUGIN_AUTHORING.md @@ -1641,7 +1641,7 @@ 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. +Enabled plugin skills are delivered to every agent session from their plugin-package `SKILL.md` files. Fusion resolves the first `skillFiles` entry (or the compatibility fallback) through the plugin root and adds the skill body directory to the session's skill discovery paths. Project skill `+`/`-` settings remain the authority for availability; per-agent skill metadata can force resolved enabled skills to be read first but never filters other available skills or re-enables a disabled skill. 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 diff --git a/docs/agents.md b/docs/agents.md index 87fc83873f..cb58641c5b 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -37,9 +37,9 @@ fn chat [message…] [--once] [--non-interactive] [--poll-ms ] [-- - On a direct-message reply, agents must pass `reply_to_message_id` and either set `to_id` to the exact `[from: type:id]` value reported by `fn_read_messages` (including `cli`) or omit it to use the safe parent-sender default. Parent-derived routing is allowed only when the parent was addressed to the replying agent; an explicit `to_id` remains available for intentional forwarding. - The default conversation ID is `cli-chat:cli:`; use `--conversation-id ` to name or share a different mailbox thread. - One-shot replies have a deadline independent of `--poll-ms`; polling sleeps are capped at the remaining deadline. The interactive REPL maintains one pending deadline per outbound message, reports and clears an unanswered request, then continues to receive later replies. -- Dashboard-created agent chat sessions request the target agent's declared `metadata.skills` plus enabled plugin-contributed skills, forwarding both requested skill names and resolved plugin body directories 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. +- Enabling a skill in project settings makes it automatically available to **all** agent sessions, including default agents with no `metadata.skills`; project `+`/`-` patterns remain the sole enablement authority. Enabled plugin skills and their body directories follow the same contract. +- `metadata.skills` is an additive forced-reading list, not an allow-list: resolved forced skills are required reading before work begins while every other enabled skill remains available on demand. A forced skill that is disabled by `-` settings or cannot be discovered is diagnosed rather than silently re-enabled or named in the prompt. Heartbeats receive their waking agent's forced skills and all enabled project/plugin skills, but have no `fusion` role fallback. +- In dashboard model-loop chat (main chat, QuickChat, and room responders), typing `/skill:{name}` ensures that skill is present for the current AI session and strips the slash token from the prompt. Slash and catalog-style names resolve to the matching discovered skill token and remain subject to project enable/disable settings; CLI-agent-backed PTY chat keeps raw terminal input semantics. - Dashboard chat and planning sessions with a scoped task store expose `fn_task_document_write`, `fn_task_document_read`, and `fn_task_logs_read`; because neither lane has an ambient task, each tool requires an explicit `task_id`. Document writers may pass `expected_revision` and/or `expected_content_hash` after a read for safe cross-task CAS publication; stale writes return typed conflict state and are never auto-retried. `fn_task_logs_read` pages the persisted full agent log for failure analysis. - Dashboard chat and room responders share a safe coordination/productivity toolset across pi and Grok CLI runtimes: board reads, task creation, delegation, agent listing/configuration, web fetch, and goal/memory/research retrieval. Destructive agent-lifecycle tools and memory append remain excluded because chat has no action-gate context. - Agent workflow-routing tools follow an intent boundary: agents may select or change a task workflow only when the user explicitly requested that workflow or when the agent created the task. Executors must not call `fn_workflow_select` to reroute the task they are executing unless the task instructions or a user steering comment explicitly asks for the workflow change. Lanes without an ambient task, including dashboard chat/planning and published/pi extension calls outside a task, must pass an explicit `task_id`; task-bound executor paths may default to the current task. @@ -161,7 +161,7 @@ Every first-class editable agent field has a defined create/edit/import/template | `reportsTo` | `reportsTo` | — | | `instructionBody` | `instructionsText` | — | | `memory` | `memory` | — | -| `skills` | `metadata.skills` | — | +| `skills` | `metadata.skills` | — (additive forced skills; cannot re-enable a project `-`-disabled skill) | ## Permission Policy Presets (Permanent and Ephemeral Agents) @@ -1014,7 +1014,7 @@ Heartbeat runs are composed from multiple prompt layers so each wake has full id This structure ensures every run re-anchors on identity, wake reason, and current context before taking action. -**Heartbeat skill policy:** heartbeat sessions load the waking agent’s `metadata.skills` plus enabled plugin skills. There is **no** role fallback to the published `fusion` operator skill on the heartbeat lane. +**Heartbeat skill policy:** heartbeat sessions automatically receive every project-enabled and enabled-plugin skill. The waking agent’s `metadata.skills` are additive forced-reading requests: only skills that resolve and remain enabled are instructed before work starts; disabled or missing requests are reported. There is **no** role fallback to the published `fusion` operator skill on the heartbeat lane. **Default HEARTBEAT.md seed:** `ensureDefaultHeartbeatProcedureFile` is create-if-missing only. Operator edits to an existing per-agent procedure file are preserved; upgrade without force does not overwrite content. diff --git a/docs/diagnostics.md b/docs/diagnostics.md index b145ca303c..61eadc32e3 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -49,7 +49,7 @@ FUSION_DEBUG=1 # everything (also: true, all, *) The variable is re-read per call, so it can be toggled on a long-lived process without recreating loggers. Debug lines emit under the `info` severity marker and render like any other info line. -Currently debug-gated classes include local/default routing, capacity and re-entrancy skips, poll/sweep no-actions, per-step success/progress, optional integration probes, successful verification bookkeeping, per-session agent setup (`agent-session` runtime/fallback resolution, planning mode, stuck-detector track bookkeeping), intentional skill-exclusion notices (`[skills] info: … disabled by project execution settings`), expected-missing PROMPT.md seed reads (ENOENT), token-cache metrics JSON, duplicate runtime `Specifying …` echoes, zero-count recovery summaries, mission "no linked feature" skips, event-driven "triggering scheduling" echoes, auto-claim snapshot invalidation, assignment-trigger skip guards (ephemeral/disabled/active-run), runtime `Scheduled`/`Started executing` echoes of executor `Starting`, worktree warm-reuse, runtime-env injection counts, baseCommitSha capture, parse-steps reconcile diagnostics, graph node worktree re-acquire, ephemeral already-owned worker skip, embedded-postgres rejoin "already running", and `fn_run_verification` non-timeout command-fail detail (failed `done` lines stay at info). State-changing recovery and dispatch outcomes remain visible (`Starting`/`Specifying`/`Worktree created`/`Auto-merge merged`/column moves/slow hold-release). +Currently debug-gated classes include local/default routing, capacity and re-entrancy skips, poll/sweep no-actions, per-step success/progress, optional integration probes, successful verification bookkeeping, per-session agent setup (`agent-session` runtime/fallback resolution, planning mode, stuck-detector track bookkeeping), per-skill selection chatter and intentional skill-exclusion notices (`[skills] info: … disabled by project execution settings`), expected-missing PROMPT.md seed reads (ENOENT), token-cache metrics JSON, duplicate runtime `Specifying …` echoes, zero-count recovery summaries, mission "no linked feature" skips, event-driven "triggering scheduling" echoes, auto-claim snapshot invalidation, assignment-trigger skip guards (ephemeral/disabled/active-run), runtime `Scheduled`/`Started executing` echoes of executor `Starting`, worktree warm-reuse, runtime-env injection counts, baseCommitSha capture, parse-steps reconcile diagnostics, graph node worktree re-acquire, ephemeral already-owned worker skip, embedded-postgres rejoin "already running", and `fn_run_verification` non-timeout command-fail detail (failed `done` lines stay at info). Each agent session instead emits one normal-level `[skills]` summary with the available count, resolved forced skills, and any unavailable forced requests. State-changing recovery and dispatch outcomes remain visible (`Starting`/`Specifying`/`Worktree created`/`Auto-merge merged`/column moves/slow hold-release). Dashboard server code uses the core logger only: `import { createLogger } from "@fusion/core";`. Do not import an engine logger, use a relative cross-package logger path, or add a dashboard-local logger implementation. diff --git a/docs/settings-reference.md b/docs/settings-reference.md index ce7d6980a5..c31adad8bd 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -1126,7 +1126,7 @@ Short-lived token bounds are enforced server-side: - Minimum TTL: `60_000` ms (60s) - Maximum TTL: `86_400_000` ms (24h) -> **Note:** Agent `metadata.skills` is not a top-level project setting, but it is the primary mechanism for controlling execution-time skill selection. The engine's `buildSessionSkillContext` function reads this metadata from the assigned agent and uses it to resolve which skills are available in the agent session. If `metadata.skills` is absent or empty, the engine falls back to the built-in `fusion` skill. +> **Note:** Agent `metadata.skills` is not a top-level project setting. Project `skills` and package-scoped `packages[].skills` `+`/`-` patterns control which discovered skills are available to every agent session, including default agents. `metadata.skills` is additive forced-reading intent only: a resolved enabled skill is required reading before work, while a missing or `-`-disabled request is reported and never re-enabled. Role lanes ensure the built-in `fusion` skill when it is available; heartbeat has no role fallback. --- diff --git a/packages/core/src/__tests__/skill-settings.test.ts b/packages/core/src/__tests__/skill-settings.test.ts index 219a9777df..e2e5e1007b 100644 --- a/packages/core/src/__tests__/skill-settings.test.ts +++ b/packages/core/src/__tests__/skill-settings.test.ts @@ -3,6 +3,7 @@ import { computeSkillId, getSkillSettingState, parseSkillId, + normalizeStoredSkillPath, resolvePluginSkillEnabled, } from "../config/skill-settings.js"; @@ -17,6 +18,14 @@ describe("skill-settings", () => { expect(parseSkillId("not-a-skill-id")).toBeNull(); }); + it("keeps stored skill identity normalization and the skills/packages schema stable (FN-9114)", () => { + expect(normalizeStoredSkillPath("skills/review/pr/SKILL.md")).toBe("review/pr/SKILL.md"); + expect(normalizeStoredSkillPath("review/pr")).toBe("review/pr"); + const settings = { skills: ["+review/pr/SKILL.md"], packages: [{ source: "plugin:x", skills: ["-x/SKILL.md"] }] }; + expect(Object.keys(settings)).toEqual(["skills", "packages"]); + expect(settings.packages[0]).toEqual({ source: "plugin:x", skills: ["-x/SKILL.md"] }); + }); + it("resolves top-level + and - skill entries by path or wildcard ID", () => { const skillId = computeSkillId("plugin:fusion-plugin", "skills/ce-plan/SKILL.md"); diff --git a/packages/dashboard/src/__tests__/chat-manager.test.ts b/packages/dashboard/src/__tests__/chat-manager.test.ts index b752b0814b..ce36ec20bc 100644 --- a/packages/dashboard/src/__tests__/chat-manager.test.ts +++ b/packages/dashboard/src/__tests__/chat-manager.test.ts @@ -1446,7 +1446,8 @@ describe("ChatManager.sendMessage", () => { projectRootDir: "/tmp/test", sessionPurpose: "executor", }); - expect(createOptions.skillSelection.requestedSkillNames).toEqual(["agent-debug", "ce-debug"]); + expect(createOptions.skillSelection.requestedSkillNames).toEqual(["fusion", "ce-debug"]); + expect(createOptions.skillSelection.forcedSkillNames).toEqual(["agent-debug", "ce-debug"]); expect(createOptions.skillSelection.requestedSkillNames).not.toContain("disabled-debug"); expect(createOptions.additionalSkillPaths).toEqual([pluginSkillDir, dirname(pluginSkillDir)]); }); @@ -1540,7 +1541,10 @@ describe("ChatManager.sendMessage", () => { const chatManager = createChatManager(); await chatManager.sendMessage("chat-001", "/skill:ce-debug please debug this"); - expect(createOptions.skillSelection.requestedSkillNames).toEqual(["agent-debug", "ce-debug"]); + expect(createOptions.skillSelection.requestedSkillNames).toEqual(["fusion", "ce-debug"]); + // FNXC:ChatSkills 2026-08-16-04:04: A typed `/skill:` command ensures + // availability but must not erase the bound agent's forced read-first intent. + expect(createOptions.skillSelection.forcedSkillNames).toEqual(["agent-debug"]); expect(promptSpy).toHaveBeenCalledTimes(1); const promptContent = promptSpy.mock.calls[0]?.[0] as string; expect(promptContent).toBe("please debug this"); @@ -1610,9 +1614,10 @@ describe("ChatManager.sendMessage", () => { const chatManager = createChatManager(pluginRunner); await chatManager.sendMessage("chat-001", "/skill:CE-DEBUG /skill:review/pr/SKILL.md use both"); - expect(createOptions.skillSelection.requestedSkillNames).toEqual(["ce-debug", "review/pr"]); + expect(createOptions.skillSelection.requestedSkillNames).toEqual(["fusion", "review/pr", "CE-DEBUG"]); const names = createOptions.skillSelection.requestedSkillNames.filter((name: string) => name.toLowerCase() === "ce-debug"); expect(names).toHaveLength(1); + expect(createOptions.skillSelection.forcedSkillNames).toEqual(["ce-debug"]); expect(promptSpy.mock.calls[0]?.[0]).toBe("use both"); }); @@ -1670,7 +1675,8 @@ describe("ChatManager.sendMessage", () => { const chatManager = createChatManager(); await chatManager.sendMessage("chat-001", "plain hello"); - expect(createOptions.skillSelection.requestedSkillNames).toEqual(["agent-debug"]); + expect(createOptions.skillSelection.requestedSkillNames).toEqual(["fusion"]); + expect(createOptions.skillSelection.forcedSkillNames).toEqual(["agent-debug"]); expect(promptSpy.mock.calls[0]?.[0]).toBe("plain hello"); }); @@ -1697,7 +1703,8 @@ describe("ChatManager.sendMessage", () => { const chatManager = createChatManager({ getRuntimeById: vi.fn() }); await chatManager.sendMessage("chat-001", "Hello"); - expect(createOptions.skillSelection.requestedSkillNames).toEqual(["agent-debug"]); + expect(createOptions.skillSelection.requestedSkillNames).toEqual(["fusion"]); + expect(createOptions.skillSelection.forcedSkillNames).toEqual(["agent-debug"]); }); it("accumulates thinking output separately from text", async () => { @@ -4025,7 +4032,7 @@ describe("ChatManager generation isolation", () => { roomId: "room-1", ...input, })); - mockAgentStore.listAgents.mockResolvedValue([{ id: "agent-001", name: "Avery", role: "executor", state: "idle" }]); + mockAgentStore.listAgents.mockResolvedValue([{ id: "agent-001", name: "Avery", role: "executor", state: "idle", metadata: { skills: ["agent-debug"] } }]); mockAgentStore.getAgent.mockResolvedValue({ id: "agent-001", name: "Avery", @@ -4053,9 +4060,12 @@ describe("ChatManager generation isolation", () => { ]), }; - await createChatManager(pluginRunner).sendRoomMessage("room-1", "hello @Avery"); + await createChatManager(pluginRunner).sendRoomMessage("room-1", "/skill:review/pr hello @Avery"); - expect(createOptions.skillSelection.requestedSkillNames).toEqual(["ce-debug"]); + expect(createOptions.skillSelection.requestedSkillNames).toEqual(["ce-debug", "review/pr"]); + // FNXC:ChatSkills 2026-08-16-04:04: Room responders use the same merge + // seam as direct chat, preserving metadata-forced skills beside `/skill:`. + expect(createOptions.skillSelection.forcedSkillNames).toEqual(["agent-debug"]); expect(createOptions.skillSelection.sessionPurpose).toBe("heartbeat"); expect(createOptions.additionalSkillPaths).toEqual([pluginSkillDir, dirname(pluginSkillDir)]); }); diff --git a/packages/dashboard/src/chat.ts b/packages/dashboard/src/chat.ts index cecfd3b3f9..7cd5efda10 100644 --- a/packages/dashboard/src/chat.ts +++ b/packages/dashboard/src/chat.ts @@ -237,9 +237,19 @@ function mergeTypedSkillCommands( FNXC:ChatSkills 2026-06-17-18:16: The advertised chat `/skill:{name}` command must request that skill for the model-loop session while keeping execution settings authoritative; this merge only adds requested names to the existing skill-selection context so the resolver still filters disabled or excluded skills. */ + /* + FNXC:ChatSkills 2026-08-16-04:04: + GitHub #1422 makes agent metadata skills forced read-first intent, while + `/skill:` only ensures availability. Preserve the base forced channel when a + typed command rebuilds selection so chat and room responders keep the shared + resolved-only prompt instruction. + */ return { projectRootDir: baseSkillSelection?.projectRootDir ?? projectRootDir, requestedSkillNames, + ...(baseSkillSelection?.forcedSkillNames?.length + ? { forcedSkillNames: baseSkillSelection.forcedSkillNames } + : {}), sessionPurpose: baseSkillSelection?.sessionPurpose ?? sessionPurpose, }; } diff --git a/packages/engine/src/__tests__/agent-skills-flow.test.ts b/packages/engine/src/__tests__/agent-skills-flow.test.ts index 7a5923d0c6..4044acdbbb 100644 --- a/packages/engine/src/__tests__/agent-skills-flow.test.ts +++ b/packages/engine/src/__tests__/agent-skills-flow.test.ts @@ -105,9 +105,10 @@ describe("agent skills flow - full integration", () => { // Verify skill source and resolved names expect(sessionResult.skillSource).toBe("assigned-agent"); - expect(sessionResult.resolvedSkillNames).toEqual(["review"]); + expect(sessionResult.resolvedSkillNames).toEqual(["fusion"]); + expect(sessionResult.forcedSkillNames).toEqual(["review"]); expect(sessionResult.skillSelectionContext).toBeDefined(); - expect(sessionResult.skillSelectionContext?.requestedSkillNames).toEqual(["review"]); + expect(sessionResult.skillSelectionContext?.requestedSkillNames).toEqual(["fusion"]); // Step 5: Resolve session skills from project settings const resolvedSkills = resolveSessionSkills(sessionResult.skillSelectionContext!); @@ -183,7 +184,8 @@ describe("agent skills flow - full integration", () => { }); expect(sessionResult.skillSource).toBe("assigned-agent"); - expect(sessionResult.resolvedSkillNames).toEqual(["review", "lint"]); + expect(sessionResult.resolvedSkillNames).toEqual(["fusion"]); + expect(sessionResult.forcedSkillNames).toEqual(["review", "lint"]); // Step 4: Resolve session skills from settings const resolvedSkills = resolveSessionSkills(sessionResult.skillSelectionContext!); @@ -240,11 +242,13 @@ describe("agent skills flow - full integration", () => { }); expect(sessionResult.skillSource).toBe("assigned-agent"); - expect(sessionResult.skillSelectionContext?.requestedSkillNames).toEqual(["review/pr", "gamma/SKILL.md"]); + expect(sessionResult.skillSelectionContext?.requestedSkillNames).toEqual(["fusion"]); + expect(sessionResult.forcedSkillNames).toEqual(["review/pr", "gamma/SKILL.md"]); const resolvedSkills = resolveSessionSkills(sessionResult.skillSelectionContext!); const override = createSkillsOverrideFromSelection(resolvedSkills, { requestedSkillNames: sessionResult.skillSelectionContext?.requestedSkillNames, + forcedSkillNames: sessionResult.skillSelectionContext?.forcedSkillNames, sessionPurpose: sessionResult.skillSelectionContext?.sessionPurpose, }); @@ -257,7 +261,7 @@ describe("agent skills flow - full integration", () => { diagnostics: [], }); - expect(result.skills.map((skill) => skill.name)).toEqual(["pr", "gamma"]); + expect(result.skills.map((skill) => skill.name)).toEqual(["pr", "gamma", "lint"]); expect(result.diagnostics.some((diagnostic) => diagnostic.message.includes("not found"))).toBe(false); }); diff --git a/packages/engine/src/__tests__/compound-engineering-skill-resolution.test.ts b/packages/engine/src/__tests__/compound-engineering-skill-resolution.test.ts index 4818187222..00e185592e 100644 --- a/packages/engine/src/__tests__/compound-engineering-skill-resolution.test.ts +++ b/packages/engine/src/__tests__/compound-engineering-skill-resolution.test.ts @@ -189,6 +189,6 @@ describe("U2: CE bundled skill session-resolution (empirical)", () => { // ce-plan is discoverable (via additionalSkillPaths) AND survives the filter; // ce-work is discovered but filtered out by the requested-name override. expect(names).toContain("ce-plan"); - expect(names).not.toContain("ce-work"); + expect(names).toContain("ce-work"); }); }); diff --git a/packages/engine/src/__tests__/heartbeat-skills.test.ts b/packages/engine/src/__tests__/heartbeat-skills.test.ts index 898dac9e58..3756f4d0f7 100644 --- a/packages/engine/src/__tests__/heartbeat-skills.test.ts +++ b/packages/engine/src/__tests__/heartbeat-skills.test.ts @@ -9,6 +9,7 @@ vi.mock("../logger.js", async () => { return { createLogger: vi.fn(() => createMockLogger()), heartbeatLog: createMockLogger(), + piLog: createMockLogger(), formatError: formatMockError, }; }); @@ -24,6 +25,7 @@ vi.mock("../pi.js", () => ({ }), })); import { createFnAgent } from "../pi.js"; +import { createSkillsOverrideFromSelection } from "../cli-runtime/skill-resolver.js"; vi.mock("../worktree/worktree-acquisition.js", () => ({ acquireTaskWorktree: vi.fn(async ({ rootDir }: { rootDir: string }) => ({ worktreePath: `${rootDir}/.worktrees/fn-001`, @@ -186,9 +188,48 @@ describe("executeHeartbeat — skill selection resolver contract (FN-1510/FN-151 const result = await monitor.executeHeartbeat({ agentId: "agent-001", source: "on_demand" }); expect(mockedCreateFnAgent).toHaveBeenCalled(); + const options = mockedCreateFnAgent.mock.calls[0]![0]; + // FNXC:SkillResolution 2026-08-16-03:52: heartbeat has no fusion fallback, + // but waking-agent skills remain forced intent for the shared prompt seam. + expect(options.skillSelection?.forcedSkillNames).toEqual(["heartbeat-skill"]); + expect(options.skillSelection?.requestedSkillNames ?? []).not.toContain("heartbeat-skill"); expect(result.status).toBe("completed"); }); + it("keeps enabled skills and resolves only available forced skills through the heartbeat lane", async () => { + mockedCreateFnAgent.mockResolvedValue({ session: createMockAgentSession() } as any); + const store = createStoreWithAgentForExec({ + taskId: "FN-001", + metadata: { skills: ["alpha", "delta"] }, + }); + const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" }); + + await monitor.executeHeartbeat({ agentId: "agent-001", source: "on_demand" }); + + const skillSelection = mockedCreateFnAgent.mock.calls[0]![0].skillSelection!; + const override = createSkillsOverrideFromSelection({ + allowedSkillPaths: new Set(["alpha/SKILL.md", "beta/SKILL.md"]), + excludedSkillPaths: new Set(["delta/SKILL.md"]), + filterActive: true, + diagnostics: [], + }, skillSelection); + const result = override({ + skills: [ + { name: "alpha", filePath: "/skills/alpha/SKILL.md" }, + { name: "beta", filePath: "/skills/beta/SKILL.md" }, + { name: "delta", filePath: "/skills/delta/SKILL.md" }, + ] as any, + diagnostics: [], + }); + + // FNXC:SkillResolution 2026-08-16-04:04: The production heartbeat passes + // forced intent into the shared resolver; enabled siblings survive and a + // disabled request never becomes a read-first prompt candidate. + expect(result.skills.map((skill) => skill.name)).toEqual(["alpha", "beta"]); + expect(result.resolvedForcedSkills).toEqual([{ requestedName: "alpha", skillName: "alpha" }]); + expect(result.unresolvedForcedSkills).toEqual([{ requestedName: "delta", reason: "disabled-by-settings" }]); + }); + it("createFnAgent is called with correct cwd for skill resolution", async () => { mockedCreateFnAgent.mockResolvedValue({ session: createMockAgentSession(), diff --git a/packages/engine/src/__tests__/hermes-runtime-integration.test.ts b/packages/engine/src/__tests__/hermes-runtime-integration.test.ts index 4a24783ecd..eccad68f1e 100644 --- a/packages/engine/src/__tests__/hermes-runtime-integration.test.ts +++ b/packages/engine/src/__tests__/hermes-runtime-integration.test.ts @@ -1,3 +1,6 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import type { AgentRuntime } from "../agents/agent-runtime.js"; import { resolveRuntime } from "../execution/runtime-resolution.js"; @@ -5,7 +8,15 @@ import { createResolvedAgentSession } from "../agents/agent-session-helpers.js"; import type { PluginRunner } from "../plugins/plugin-runner.js"; import type { PluginRuntimeRegistration } from "@fusion/core"; -const mockCreateFnAgent = vi.hoisted(() => vi.fn()); +const { mockCreateFnAgent, mockLoadSkills } = vi.hoisted(() => ({ + mockCreateFnAgent: vi.fn(), + mockLoadSkills: vi.fn(() => ({ skills: [], diagnostics: [] })), +})); + +vi.mock("@earendil-works/pi-coding-agent", () => ({ + getAgentDir: vi.fn(() => "/tmp/fusion-agent-skills"), + loadSkills: mockLoadSkills, +})); vi.mock("../logger.js", () => ({ createLogger: vi.fn(() => ({ @@ -13,6 +24,7 @@ vi.mock("../logger.js", () => ({ warn: vi.fn(), error: vi.fn(), })), + piLog: { log: vi.fn(), debug: vi.fn(), warn: vi.fn(), error: vi.fn() }, })); vi.mock("../pi.js", () => ({ @@ -200,6 +212,55 @@ describe("Hermes runtime integration via engine resolution pipeline", () => { })); }); + it("injects only resolved forced skills into a non-PI runtime prompt", async () => { + const projectRoot = mkdtempSync(join(tmpdir(), "fn-9114-hermes-skills-")); + mkdirSync(join(projectRoot, ".fusion"), { recursive: true }); + writeFileSync(join(projectRoot, ".fusion", "settings.json"), JSON.stringify({ + skills: ["+alpha/SKILL.md", "+beta/SKILL.md", "-delta/SKILL.md"], + })); + mockLoadSkills.mockReturnValueOnce({ + skills: [ + { name: "alpha", filePath: "skills/alpha/SKILL.md" }, + { name: "beta", filePath: "skills/beta/SKILL.md" }, + { name: "delta", filePath: "skills/delta/SKILL.md" }, + ], + diagnostics: [], + }); + const hermesCreateSession = vi.fn().mockResolvedValue({ session: { prompt: vi.fn() } }); + const pluginRunner = createMockPluginRunner({ getRuntimeById: vi.fn().mockReturnValue(createHermesRegistration(() => ({ + id: "hermes", name: "Hermes Runtime", createSession: hermesCreateSession, + promptWithFallback: vi.fn(), describeModel: vi.fn(), + }))) }); + const onSkillSummary = vi.fn(); + + try { + await createResolvedAgentSession({ + sessionPurpose: "executor", runtimeHint: "hermes", pluginRunner, cwd: projectRoot, + systemPrompt: "You are helpful", + skillSelection: { + projectRootDir: projectRoot, requestedSkillNames: ["fusion"], + forcedSkillNames: ["alpha", "delta", "missing"], sessionPurpose: "executor", + }, + onSkillSummary, + }); + expect(hermesCreateSession).toHaveBeenCalledWith(expect.objectContaining({ + skills: expect.arrayContaining(["alpha", "beta", "fusion"]), + systemPrompt: expect.stringContaining("REQUIRED to read these available skills: alpha"), + })); + const prompt = hermesCreateSession.mock.calls[0]![0].systemPrompt as string; + expect(prompt).not.toContain("delta, missing"); + expect(onSkillSummary).toHaveBeenCalledWith(expect.objectContaining({ + forcedSkillNames: ["alpha"], + unresolvedForcedSkills: expect.arrayContaining([ + { requestedName: "delta", reason: "disabled-by-settings" }, + { requestedName: "missing", reason: "not-found" }, + ]), + })); + } finally { + rmSync(projectRoot, { recursive: true, force: true }); + } + }); + it("routes a no-hint Hermes picker selection through Hermes before pi model resolution", async () => { const hermesRegistration = createHermesRegistration(); const pluginRunner = createMockPluginRunner({ getRuntimeById: vi.fn().mockReturnValue(hermesRegistration) }); diff --git a/packages/engine/src/__tests__/merger-skills.test.ts b/packages/engine/src/__tests__/merger-skills.test.ts index 454d7eadc1..b0bc57fd01 100644 --- a/packages/engine/src/__tests__/merger-skills.test.ts +++ b/packages/engine/src/__tests__/merger-skills.test.ts @@ -149,6 +149,7 @@ import { } from "../merger.js"; import { mergerLog } from "../logger.js"; import { createFnAgent } from "../pi.js"; +import { createSkillsOverrideFromSelection } from "../cli-runtime/skill-resolver.js"; import { execSync, exec } from "node:child_process"; import * as core from "@fusion/core"; import { type TaskStore, type Task, type MergeResult, DEFAULT_SETTINGS } from "@fusion/core"; @@ -325,9 +326,10 @@ describe("aiMergeTask — skill selection resolver contract (FN-1510/FN-1511)", const { buildSessionSkillContext } = await import("../cli-runtime/session-skill-context.js"); vi.mocked(buildSessionSkillContext).mockResolvedValue({ skillSelectionContext: { projectRootDir: "/tmp/root", - requestedSkillNames: ["custom-skill", "another-skill"], + requestedSkillNames: ["fusion"], + forcedSkillNames: ["custom-skill", "another-skill"], sessionPurpose: "merger", - }, resolvedSkillNames: ["custom-skill", "another-skill"], skillSource: "assigned-agent", additionalSkillPaths: [] }); + }, forcedSkillNames: ["custom-skill", "another-skill"], resolvedSkillNames: ["fusion"], skillSource: "assigned-agent", additionalSkillPaths: [] }); mockedCreateFnAgent.mockResolvedValue({ session: { @@ -359,7 +361,32 @@ describe("aiMergeTask — skill selection resolver contract (FN-1510/FN-1511)", const firstCall = mockedCreateFnAgent.mock.calls[0]; const opts = firstCall[0]; expect(opts.skillSelection).toBeDefined(); - expect(opts.skillSelection!.requestedSkillNames).toEqual(["custom-skill", "another-skill"]); + // FNXC:SkillResolution 2026-08-16-03:52: merger receives role fallback as + // ensure-present while assigned names are additive forced intent, never a filter. + expect(opts.skillSelection!.requestedSkillNames).toEqual(["fusion"]); + expect(opts.skillSelection!.forcedSkillNames).toEqual(["custom-skill", "another-skill"]); + + const override = createSkillsOverrideFromSelection({ + allowedSkillPaths: new Set(["alpha/SKILL.md", "beta/SKILL.md"]), + excludedSkillPaths: new Set(["another-skill/SKILL.md"]), + filterActive: true, + diagnostics: [], + }, opts.skillSelection!); + const resolution = override({ + skills: [ + { name: "alpha", filePath: "/skills/alpha/SKILL.md" }, + { name: "beta", filePath: "/skills/beta/SKILL.md" }, + { name: "custom-skill", filePath: "/skills/custom-skill/SKILL.md" }, + { name: "another-skill", filePath: "/skills/another-skill/SKILL.md" }, + ] as any, + diagnostics: [], + }); + // FNXC:SkillResolution 2026-08-16-04:04: The merger's production options + // feed the shared resolver, so forced names add to project-enabled skills + // and disabled intent is excluded from resolved-only prompt delivery. + expect(resolution.skills.map((skill) => skill.name)).toEqual(["alpha", "beta", "custom-skill"]); + expect(resolution.resolvedForcedSkills).toEqual([{ requestedName: "custom-skill", skillName: "custom-skill" }]); + expect(resolution.unresolvedForcedSkills).toEqual([{ requestedName: "another-skill", reason: "disabled-by-settings" }]); }); it("does not pass skillSelection when buildSessionSkillContext returns undefined context", async () => { diff --git a/packages/engine/src/__tests__/pi.test.ts b/packages/engine/src/__tests__/pi.test.ts index 9b4801ec33..6f0b0c7afc 100644 --- a/packages/engine/src/__tests__/pi.test.ts +++ b/packages/engine/src/__tests__/pi.test.ts @@ -3,6 +3,10 @@ import { describeModel, formatModelMarkerDetails, compactSessionContext, COMPACT import { createAgentSession, ModelRegistry, ModelRuntime, type AgentSession } from "@earendil-works/pi-coding-agent"; import { piLog } from "../logger.js"; +const { resourceLoaderOptions } = vi.hoisted(() => ({ + resourceLoaderOptions: { current: undefined as Record | undefined }, +})); + // Mock skill resolver functions - define inside factory to avoid hoisting issues vi.mock("../cli-runtime/skill-resolver.js", () => { const resolveSessionSkillsMock = vi.fn(); @@ -43,7 +47,8 @@ vi.mock("@earendil-works/pi-coding-agent", () => ({ createFindTool: vi.fn(() => ({ name: "find" })), createLsTool: vi.fn(() => ({ name: "ls" })), createExtensionRuntime: vi.fn(), - DefaultResourceLoader: vi.fn().mockImplementation(function () { + DefaultResourceLoader: vi.fn().mockImplementation(function (options: Record) { + resourceLoaderOptions.current = options; return { reload: vi.fn().mockResolvedValue(undefined), skillsOverride: undefined, @@ -361,9 +366,12 @@ describe("createFnAgent skills parameter", () => { diagnostics: [], filterActive: true, }); + resourceLoaderOptions.current = undefined; mockCreateSkillsOverride.mockReturnValue(() => ({ skills: [], diagnostics: [], + resolvedForcedSkills: [], + unresolvedForcedSkills: [], })); }); @@ -374,6 +382,31 @@ describe("createFnAgent skills parameter", () => { vi.clearAllMocks(); }); + it("injects only resolved forced skills through the shared resource-loader prompt seam (FN-9114)", async () => { + mockCreateSkillsOverride.mockReturnValue(() => ({ + skills: [], diagnostics: [], + resolvedForcedSkills: [{ requestedName: "alpha", skillName: "alpha" }], + unresolvedForcedSkills: [ + { requestedName: "delta", reason: "disabled-by-settings" }, + { requestedName: "missing", reason: "not-found" }, + ], + })); + await createFnAgent({ + cwd: "/test/project", systemPrompt: "Test", + systemPromptLayers: { stable: "Stable", dynamic: "Executor context" }, + skillSelection: { + projectRootDir: "/test/project", sessionPurpose: "executor", + forcedSkillNames: ["alpha", "delta", "missing"], + }, + }); + const override = resourceLoaderOptions.current?.skillsOverride as ((base: { skills: []; diagnostics: [] }) => unknown) | undefined; + override?.({ skills: [], diagnostics: [] }); + const append = resourceLoaderOptions.current?.appendSystemPromptOverride as (() => string[]) | undefined; + expect(append?.().join("\n")).toContain("REQUIRED to read these available skills: alpha"); + expect(append?.().join("\n")).not.toContain("delta"); + expect(append?.().join("\n")).not.toContain("missing"); + }); + it("skills parameter auto-derives SkillSelectionContext", async () => { const options: AgentOptions = { cwd: "/test/project", diff --git a/packages/engine/src/__tests__/plugin-skill-body-delivery.test.ts b/packages/engine/src/__tests__/plugin-skill-body-delivery.test.ts index c3544457a7..55028c013b 100644 --- a/packages/engine/src/__tests__/plugin-skill-body-delivery.test.ts +++ b/packages/engine/src/__tests__/plugin-skill-body-delivery.test.ts @@ -50,12 +50,15 @@ describe("plugin skill body delivery", () => { const selection = resolveSessionSkills({ projectRootDir, requestedSkillNames: context.skillSelectionContext?.requestedSkillNames, + forcedSkillNames: ["plugin-plan"], sessionPurpose: "executor", }); - const skillsOverride = createSkillsOverrideFromSelection(selection, { + const rawOverride = createSkillsOverrideFromSelection(selection, { requestedSkillNames: context.skillSelectionContext?.requestedSkillNames, + forcedSkillNames: ["plugin-plan"], sessionPurpose: "executor", }); + const skillsOverride = (base: { skills: Skill[]; diagnostics: [] }) => rawOverride(base); const loader = new DefaultResourceLoader({ cwd: projectRootDir, @@ -71,5 +74,8 @@ describe("plugin skill body delivery", () => { 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."); + const resolved = rawOverride({ skills, diagnostics: [] }); + expect(resolved.resolvedForcedSkills).toEqual([{ requestedName: "plugin-plan", skillName: "plugin-plan" }]); + expect(resolved.unresolvedForcedSkills).toEqual([]); }); }); diff --git a/packages/engine/src/__tests__/plugin-skill-integration.test.ts b/packages/engine/src/__tests__/plugin-skill-integration.test.ts index 79cdadc809..2da1744d27 100644 --- a/packages/engine/src/__tests__/plugin-skill-integration.test.ts +++ b/packages/engine/src/__tests__/plugin-skill-integration.test.ts @@ -34,7 +34,6 @@ describe("plugin skill integration", () => { expect(contextResult.skillSelectionContext?.requestedSkillNames).toEqual([ "fusion", - "agent-only", "plugin-enabled", ]); }); @@ -83,6 +82,6 @@ describe("plugin skill integration", () => { }); expect(contextResult.skillSelectionContext?.requestedSkillNames).toEqual(["fusion", "plugin-skill"]); - expect(filtered.skills.map((skill) => skill.name)).toEqual(["fusion", "plugin-skill"]); + expect(filtered.skills.map((skill) => skill.name)).toEqual(["fusion", "plugin-skill", "other"]); }); }); diff --git a/packages/engine/src/__tests__/session-skill-context.test.ts b/packages/engine/src/__tests__/session-skill-context.test.ts index 3301e2278a..97b24f96cb 100644 --- a/packages/engine/src/__tests__/session-skill-context.test.ts +++ b/packages/engine/src/__tests__/session-skill-context.test.ts @@ -160,10 +160,12 @@ describe("buildSessionSkillContextSync", () => { const result = buildSessionSkillContextSync(agent, "executor", projectRootDir); expect(result.skillSource).toBe("assigned-agent"); - expect(result.resolvedSkillNames).toEqual(["triage", "executor"]); + expect(result.resolvedSkillNames).toEqual(["fusion"]); + expect(result.forcedSkillNames).toEqual(["triage", "executor"]); expect(result.skillSelectionContext).toEqual({ projectRootDir, - requestedSkillNames: ["triage", "executor"], + requestedSkillNames: ["fusion"], + forcedSkillNames: ["triage", "executor"], sessionPurpose: "executor", }); }); @@ -180,7 +182,8 @@ describe("buildSessionSkillContextSync", () => { const result = buildSessionSkillContextSync(agent, "executor", projectRootDir); expect(result.skillSource).toBe("assigned-agent"); - expect(result.resolvedSkillNames).toEqual(["triage", "executor"]); + expect(result.resolvedSkillNames).toEqual(["fusion"]); + expect(result.forcedSkillNames).toEqual(["triage", "executor"]); }); it("correctly extracts skills from a cached agent object", () => { @@ -195,10 +198,12 @@ describe("buildSessionSkillContextSync", () => { const result = buildSessionSkillContextSync(agent, "executor", projectRootDir); expect(result.skillSource).toBe("assigned-agent"); - expect(result.resolvedSkillNames).toEqual(["triage", "executor"]); + expect(result.resolvedSkillNames).toEqual(["fusion"]); + expect(result.forcedSkillNames).toEqual(["triage", "executor"]); expect(result.skillSelectionContext).toEqual({ projectRootDir, - requestedSkillNames: ["triage", "executor"], + requestedSkillNames: ["fusion"], + forcedSkillNames: ["triage", "executor"], sessionPurpose: "executor", }); }); @@ -282,7 +287,7 @@ describe("buildSessionSkillContextSync", () => { expect(result.skillSource).toBe("none"); expect(result.resolvedSkillNames).toEqual([]); - expect(result.skillSelectionContext).toBeUndefined(); + expect(result.skillSelectionContext?.requestedSkillNames).toEqual([]); }); it("uses agent skills over role fallback", () => { @@ -297,15 +302,20 @@ describe("buildSessionSkillContextSync", () => { const result = buildSessionSkillContextSync(agent, "executor", projectRootDir); expect(result.skillSource).toBe("assigned-agent"); - expect(result.resolvedSkillNames).toEqual(["custom-skill-1", "custom-skill-2"]); + expect(result.resolvedSkillNames).toEqual(["fusion"]); + expect(result.forcedSkillNames).toEqual(["custom-skill-1", "custom-skill-2"]); }); }); describe("no skills available", () => { - it("returns undefined context when no skills and no fallback", () => { + it("keeps an empty context when no skills and no fallback so project exclusions still apply", () => { const result = buildSessionSkillContextSync(null, "heartbeat", projectRootDir); - expect(result.skillSelectionContext).toBeUndefined(); + expect(result.skillSelectionContext).toEqual({ + projectRootDir, + requestedSkillNames: [], + sessionPurpose: "heartbeat", + }); expect(result.resolvedSkillNames).toEqual([]); }); }); @@ -563,7 +573,8 @@ describe("buildSessionSkillContext", () => { }); expect(result.skillSource).toBe("assigned-agent"); - expect(result.resolvedSkillNames).toEqual(["triage", "executor"]); + expect(result.resolvedSkillNames).toEqual(["fusion"]); + expect(result.forcedSkillNames).toEqual(["triage", "executor"]); expect(mockAgentStore.getAgent).toHaveBeenCalledWith("agent-001"); }); @@ -588,8 +599,9 @@ describe("buildSessionSkillContext", () => { }); expect(result.skillSource).toBe("assigned-agent"); - expect(result.resolvedSkillNames).toEqual(["review", "custom-skill"]); - expect(result.skillSelectionContext?.requestedSkillNames).toEqual(["review", "custom-skill"]); + expect(result.resolvedSkillNames).toEqual(["fusion"]); + expect(result.forcedSkillNames).toEqual(["review", "custom-skill"]); + expect(result.skillSelectionContext?.requestedSkillNames).toEqual(["fusion"]); expect(mockAgentStore.getAgent).toHaveBeenCalledWith("agent-001"); }); @@ -680,7 +692,7 @@ describe("buildSessionSkillContext", () => { expect(result.skillSource).toBe("none"); expect(result.resolvedSkillNames).toEqual([]); - expect(result.skillSelectionContext).toBeUndefined(); + expect(result.skillSelectionContext?.requestedSkillNames).toEqual([]); }); it("appends plugin skills to requestedSkillNames", async () => { @@ -772,7 +784,7 @@ describe("buildSessionSkillContext", () => { pluginRunner, }); - expect(result.resolvedSkillNames).toEqual(["Fusion", "plugin-skill"]); + expect(result.resolvedSkillNames).toEqual(["fusion", "plugin-skill"]); }); it("creates heartbeat skill context from plugin skills when no agent skills exist", async () => { diff --git a/packages/engine/src/__tests__/skill-resolver.test.ts b/packages/engine/src/__tests__/skill-resolver.test.ts index 45893a56ee..e4cc622e12 100644 --- a/packages/engine/src/__tests__/skill-resolver.test.ts +++ b/packages/engine/src/__tests__/skill-resolver.test.ts @@ -637,7 +637,7 @@ describe("createSkillsOverrideFromSelection", () => { diagnostics: [], }); - expect(result.skills.map((skill) => skill.name)).toEqual(["pr"]); + expect(result.skills.map((skill) => skill.name)).toEqual(["pr", "gamma"]); expect(result.diagnostics.some((diagnostic) => diagnostic.message.includes("not found"))).toBe(false); }); @@ -661,7 +661,7 @@ describe("createSkillsOverrideFromSelection", () => { diagnostics: [], }); - expect(result.skills).toHaveLength(0); + expect(result.skills).toHaveLength(1); expect(result.diagnostics).toHaveLength(1); expect(result.diagnostics[0].type).toBe("info"); expect(result.diagnostics[0].message).toContain("Requested skill 'review/absent' not found"); @@ -710,7 +710,11 @@ describe("createSkillsOverrideFromSelection", () => { diagnostics: [], }; - expect(createSkillsOverrideFromSelection(selection)(base)).toBe(base); + expect(createSkillsOverrideFromSelection(selection)(base)).toEqual({ + ...base, + resolvedForcedSkills: [], + unresolvedForcedSkills: [], + }); }); it("preserves base diagnostics alongside new diagnostics", () => { @@ -1055,7 +1059,7 @@ describe("createSkillsOverrideFromSelection", () => { override({ skills: [], diagnostics: [] }); expect(mockPiLog.debug).toHaveBeenCalled(); - expect(mockPiLog.log).not.toHaveBeenCalledWith(expect.stringContaining("[skills]")); + expect(mockPiLog.log).toHaveBeenCalledWith(expect.stringContaining("[skills] [executor] 0 skill(s) available")); expect(consoleErrorSpy).not.toHaveBeenCalled(); expect(consoleWarnSpy).not.toHaveBeenCalled(); expect(consoleLogSpy).not.toHaveBeenCalled(); @@ -1265,3 +1269,29 @@ describe("createSkillsOverrideFromSelection", () => { }); }); }); + +describe("FN-9114 forced-vs-filter semantics (GitHub #1422)", () => { + beforeEach(() => { mockFiles.clear(); mockDirs.clear(); mockDirCounter.value = 0; mockPiLog.log.mockClear(); }); + it("keeps all enabled skills for forced and default agents while resolving only available forced intent", () => { + const dir = createMockProjectDir({ skills: ["+alpha/SKILL.md", "+beta/SKILL.md", "+gamma/SKILL.md", "-delta/SKILL.md"] }); + const selection = resolveSessionSkills({ projectRootDir: dir, forcedSkillNames: ["alpha", "delta", "missing"], sessionPurpose: "executor" }); + const result = createSkillsOverrideFromSelection(selection, { forcedSkillNames: ["alpha", "delta", "missing"], sessionPurpose: "executor" })({ + skills: ["alpha", "beta", "gamma", "delta"].map((name) => ({ name, filePath: `/tmp/skills/${name}/SKILL.md` })) as never[], diagnostics: [], + }); + expect(result.skills.map((skill) => skill.name)).toEqual(["alpha", "beta", "gamma"]); + expect(result.resolvedForcedSkills).toEqual([{ requestedName: "alpha", skillName: "alpha" }]); + expect(result.unresolvedForcedSkills).toEqual([{ requestedName: "delta", reason: "disabled-by-settings" }, { requestedName: "missing", reason: "not-found" }]); + expect(mockPiLog.log).toHaveBeenCalledWith(expect.stringContaining("forced: [alpha]")); + }); + + it("returns empty resolution channels for an unconfigured heartbeat session", () => { + const result = createSkillsOverrideFromSelection( + resolveSessionSkills({ projectRootDir: "/no-settings", sessionPurpose: "heartbeat" }), + { sessionPurpose: "heartbeat" }, + )({ skills: [{ name: "alpha", filePath: "/tmp/skills/alpha/SKILL.md" }] as never[], diagnostics: [] }); + + expect(result.skills.map((skill) => skill.name)).toEqual(["alpha"]); + expect(result.resolvedForcedSkills).toEqual([]); + expect(result.unresolvedForcedSkills).toEqual([]); + }); +}); diff --git a/packages/engine/src/__tests__/step-execute-skill-loading.test.ts b/packages/engine/src/__tests__/step-execute-skill-loading.test.ts index c889af6815..2873355cc6 100644 --- a/packages/engine/src/__tests__/step-execute-skill-loading.test.ts +++ b/packages/engine/src/__tests__/step-execute-skill-loading.test.ts @@ -107,6 +107,11 @@ describe("foreach step-execute skill session selection", () => { expect(options.skillSelection.requestedSkillNames).toEqual(expect.arrayContaining([ "compound-engineering:verify", "verify", ])); + // FNXC:SkillResolution 2026-08-16-03:42: A workflow's named skill is + // required reading when available, but remains additive to enabled skills. + expect(options.skillSelection.forcedSkillNames).toEqual(expect.arrayContaining([ + "compound-engineering:verify", "verify", + ])); expect(options.additionalSkillPaths).toContain("/opt/ce/.fusion-ce-skills"); expect(store.logEntry).not.toHaveBeenCalledWith(expect.anything(), expect.stringContaining("[skill-load]")); }); @@ -123,6 +128,7 @@ describe("foreach step-execute skill session selection", () => { const options = mockedStepSessionExecutor.mock.calls.at(-1)?.[0] as any; expect(options.skillSelection.requestedSkillNames).toContain("missing-verify"); + expect(options.skillSelection.forcedSkillNames).toContain("missing-verify"); expect(store.logEntry).toHaveBeenCalledWith(taskDetail.id, expect.stringContaining("[skill-load] Foreach step-execute")); }); }); diff --git a/packages/engine/src/agents/agent-runtime.ts b/packages/engine/src/agents/agent-runtime.ts index 1e1e0a94a8..9f31635c4d 100644 --- a/packages/engine/src/agents/agent-runtime.ts +++ b/packages/engine/src/agents/agent-runtime.ts @@ -134,6 +134,8 @@ export interface AgentRuntimeOptions { sessionManager?: SessionManager; /** Optional skill selection context */ skillSelection?: SkillSelectionContext; + /** Receives resolved skill availability after the runtime loader applies settings. */ + onSkillSummary?: (summary: { availableCount: number; forcedSkillNames: string[]; unresolvedForcedSkills: Array<{ requestedName: string; reason: string }> }) => void | Promise; /** Convenience: skill names to include in the session */ skills?: string[]; /** Extra directories to scan for skills (each holding `/SKILL.md`), in diff --git a/packages/engine/src/agents/agent-session-helpers.ts b/packages/engine/src/agents/agent-session-helpers.ts index e2c196a2a3..57ff4461ac 100644 --- a/packages/engine/src/agents/agent-session-helpers.ts +++ b/packages/engine/src/agents/agent-session-helpers.ts @@ -8,9 +8,14 @@ */ import type { AgentRuntimeOptions } from "./agent-runtime.js"; -import type { SkillSelectionContext } from "../cli-runtime/skill-resolver.js"; +import { + createSkillsOverrideFromSelection, + resolveProjectRoot, + resolveSessionSkills, + type SkillSelectionContext, +} from "../cli-runtime/skill-resolver.js"; import type { PluginRunner } from "../plugins/plugin-runner.js"; -import type { AgentSession, ToolDefinition } from "@earendil-works/pi-coding-agent"; +import { getAgentDir, loadSkills, type AgentSession, type ToolDefinition } from "@earendil-works/pi-coding-agent"; import { GROK_CLI_PROVIDER_ID, isGrokApiKeyFusionVisible, @@ -145,6 +150,55 @@ function extractSkillNamesFromSelection(skillSelection: SkillSelectionContext | .filter((name) => name.length > 0); } +type SkillSummary = NonNullable extends (summary: infer Summary) => unknown ? Summary : never; + +function appendForcedSkillInstruction(systemPrompt: string, forcedSkillNames: string[]): string { + if (forcedSkillNames.length === 0) return systemPrompt; + return `${systemPrompt}\n\nBefore starting work, you are REQUIRED to read these available skills: ${forcedSkillNames.join(", ")}. All other available skills may be consulted on demand when relevant.`; +} + +/* +FNXC:SkillResolution 2026-08-16-03:52: +Non-PI runtimes bypass pi.ts's DefaultResourceLoader, so this common runtime seam +resolves the same final skill set before their adapters start. Only settings-approved, +discovered forced skills are placed in the read-first instruction; unavailable intent is +reported through the summary rather than becoming an unfulfillable prompt order. +*/ +function resolveNonPiSkillDelivery(options: AgentRuntimeOptions): { + skills: string[]; + summary: SkillSummary; +} | undefined { + if (!options.skillSelection) return undefined; + + const projectRootDir = resolveProjectRoot(options.skillSelection.projectRootDir); + const discovered = loadSkills({ + cwd: projectRootDir, + agentDir: getAgentDir(), + skillPaths: options.additionalSkillPaths ?? [], + includeDefaults: true, + }); + const selection = resolveSessionSkills(options.skillSelection); + const override = createSkillsOverrideFromSelection(selection, { + requestedSkillNames: options.skillSelection.requestedSkillNames, + forcedSkillNames: options.skillSelection.forcedSkillNames, + sessionPurpose: options.skillSelection.sessionPurpose, + }); + const resolved = override(discovered); + return { + // FNXC:SkillResolution 2026-08-16-03:52: Ensure-present role fallback/plugin + // requests remain adapter-visible even if this process cannot discover their private body. + skills: Array.from(new Set([ + ...resolved.skills.map((skill) => skill.name), + ...(options.skillSelection.requestedSkillNames ?? []), + ])), + summary: { + availableCount: resolved.skills.length, + forcedSkillNames: resolved.resolvedForcedSkills.map((skill) => skill.skillName), + unresolvedForcedSkills: resolved.unresolvedForcedSkills, + }, + }; +} + /** * Options for creating an agent session with runtime resolution. */ @@ -890,15 +944,39 @@ export async function createResolvedAgentSession( FNXC:MergeQueue 2026-07-15-11:08: Always forward sessionPurpose into runtime.createSession so pi host-extension policy can suppress dual-store fn_* tools on merger sessions (FN-7956 hang: wedged fn_task_show). */ + const nonPiSkillDelivery = resolved.runtimeId === "pi" + ? undefined + : resolveNonPiSkillDelivery(effectiveRuntimeOptionsWithModel); + const baseSessionCreateOptions: AgentRuntimeOptions = nonPiSkillDelivery + ? { + ...effectiveRuntimeOptionsWithModel, + // Non-PI adapters receive the final availability set, never raw forced intent. + skills: nonPiSkillDelivery.skills, + systemPrompt: appendForcedSkillInstruction( + effectiveRuntimeOptionsWithModel.systemPrompt, + nonPiSkillDelivery.summary.forcedSkillNames, + ), + ...(effectiveRuntimeOptionsWithModel.systemPromptLayers ? { + systemPromptLayers: { + ...effectiveRuntimeOptionsWithModel.systemPromptLayers, + dynamic: appendForcedSkillInstruction( + effectiveRuntimeOptionsWithModel.systemPromptLayers.dynamic, + nonPiSkillDelivery.summary.forcedSkillNames, + ), + }, + } : {}), + } + : effectiveRuntimeOptionsWithModel; + if (nonPiSkillDelivery) await baseSessionCreateOptions.onSkillSummary?.(nonPiSkillDelivery.summary); const sessionCreateOptions: AgentRuntimeOptions = shouldWrapCustomToolsForRuntime(resolved.runtimeId) ? { - ...effectiveRuntimeOptionsWithModel, + ...baseSessionCreateOptions, sessionPurpose, - ...wrapPluginRuntimeToolOptions(effectiveRuntimeOptionsWithModel, { runtimeId: resolved.runtimeId, sessionPurpose }), + ...wrapPluginRuntimeToolOptions(baseSessionCreateOptions, { runtimeId: resolved.runtimeId, sessionPurpose }), } : { - ...effectiveRuntimeOptionsWithModel, + ...baseSessionCreateOptions, sessionPurpose, }; const result = await resolved.runtime.createSession(sessionCreateOptions); diff --git a/packages/engine/src/cli-runtime/session-skill-context.ts b/packages/engine/src/cli-runtime/session-skill-context.ts index 7d83d06913..bcde5d387f 100644 --- a/packages/engine/src/cli-runtime/session-skill-context.ts +++ b/packages/engine/src/cli-runtime/session-skill-context.ts @@ -67,7 +67,9 @@ export interface SessionSkillContextInput { export interface SessionSkillContextResult { /** Context to pass to createFnAgent's skillSelection option */ skillSelectionContext: SkillSelectionContext | undefined; - /** Normalized skill names that were resolved (for logging/debugging) */ + /** Forced names are intent only; resolver confirms availability downstream. */ + forcedSkillNames: string[]; + /** Ensure-present names (role fallback and plugin contributions). */ resolvedSkillNames: string[]; /** Source of the skills: 'assigned-agent', 'role-fallback', or 'none' */ skillSource: "assigned-agent" | "role-fallback" | "none"; @@ -266,49 +268,22 @@ export const SKILL_DIAGNOSTIC_MESSAGES = { export async function buildSessionSkillContext( input: SessionSkillContextInput, ): Promise { - const { agentStore, task, sessionPurpose, projectRootDir } = input; - const { assignedAgentId } = task; - - // Rule 1: Check assigned agent - if (assignedAgentId) { + let forcedSkillNames: string[] = []; + if (input.task.assignedAgentId) { try { - const agent = await agentStore.getAgent(assignedAgentId); - if (agent) { - const agentSkills = normalizeAgentSkills( - (agent.metadata as Record | undefined)?.skills, - ); - - if (agentSkills.length > 0) { - return mergePluginSkills( - { - skillSelectionContext: { - projectRootDir, - requestedSkillNames: agentSkills, - sessionPurpose, - }, - resolvedSkillNames: agentSkills, - skillSource: "assigned-agent", - additionalSkillPaths: [], - }, - sessionPurpose, - projectRootDir, - input.pluginRunner, - ); - } - } - } catch { - // Agent lookup failed - fall through to role fallback - } + const agent = await input.agentStore.getAgent(input.task.assignedAgentId); + forcedSkillNames = normalizeAgentSkills((agent?.metadata as Record | undefined)?.skills); + } catch { /* role fallback still applies */ } } - - return mergePluginSkills( - resolveRoleFallback(sessionPurpose, projectRootDir), - sessionPurpose, - projectRootDir, - input.pluginRunner, - ); + const base = resolveRoleFallback(input.sessionPurpose, input.projectRootDir); + const skillSelectionContext: SkillSelectionContext = { + projectRootDir: input.projectRootDir, + requestedSkillNames: base.resolvedSkillNames, + ...(forcedSkillNames.length ? { forcedSkillNames } : {}), + sessionPurpose: input.sessionPurpose, + }; + return mergePluginSkills({ ...base, skillSelectionContext, forcedSkillNames, skillSource: forcedSkillNames.length ? "assigned-agent" : base.skillSource }, input.sessionPurpose, input.projectRootDir, input.pluginRunner); } - function resolveRoleFallback( sessionPurpose: SessionPurpose, projectRootDir: string, @@ -322,14 +297,26 @@ function resolveRoleFallback( requestedSkillNames: roleFallbackSkills, sessionPurpose, }, + forcedSkillNames: [], resolvedSkillNames: roleFallbackSkills, skillSource: "role-fallback", additionalSkillPaths: [], }; } + /* + FNXC:SkillResolution 2026-08-16-03:52: + GitHub #1422 requires project settings to govern every session, including a + heartbeat with neither agent nor role skills. Keep an empty context so `-` + exclusions reach the resolver instead of letting the loader expose disabled skills. + */ return { - skillSelectionContext: undefined, + skillSelectionContext: { + projectRootDir, + requestedSkillNames: [], + sessionPurpose, + }, + forcedSkillNames: [], resolvedSkillNames: [], skillSource: "none", additionalSkillPaths: [], @@ -381,8 +368,10 @@ function mergePluginSkills( skillSelectionContext: { projectRootDir, requestedSkillNames: mergedNames, + ...(baseResult.forcedSkillNames.length ? { forcedSkillNames: baseResult.forcedSkillNames } : {}), sessionPurpose, }, + forcedSkillNames: baseResult.forcedSkillNames, resolvedSkillNames: mergedNames, skillSource: baseResult.skillSource === "none" ? "role-fallback" : baseResult.skillSource, additionalSkillPaths: mergeAdditionalSkillPaths(baseResult.additionalSkillPaths, additionalSkillPaths), @@ -403,35 +392,17 @@ export function buildSessionSkillContextSync( projectRootDir: string, pluginRunner?: PluginRunner, ): SessionSkillContextResult { - // Rule 1: Check assigned agent skills - if (agent) { - const agentSkills = normalizeAgentSkills( - (agent.metadata as Record | undefined)?.skills, - ); - - if (agentSkills.length > 0) { - return mergePluginSkills( - { - skillSelectionContext: { - projectRootDir, - requestedSkillNames: agentSkills, - sessionPurpose, - }, - resolvedSkillNames: agentSkills, - skillSource: "assigned-agent", - additionalSkillPaths: [], - }, - sessionPurpose, - projectRootDir, - pluginRunner, - ); - } - } - - return mergePluginSkills( - resolveRoleFallback(sessionPurpose, projectRootDir), - sessionPurpose, - projectRootDir, - pluginRunner, - ); + const forcedSkillNames = normalizeAgentSkills((agent?.metadata as Record | undefined)?.skills); + const base = resolveRoleFallback(sessionPurpose, projectRootDir); + return mergePluginSkills({ + ...base, + forcedSkillNames, + skillSelectionContext: { + projectRootDir, + requestedSkillNames: base.resolvedSkillNames, + ...(forcedSkillNames.length ? { forcedSkillNames } : {}), + sessionPurpose, + }, + skillSource: forcedSkillNames.length ? "assigned-agent" : base.skillSource, + }, sessionPurpose, projectRootDir, pluginRunner); } diff --git a/packages/engine/src/cli-runtime/skill-resolver.ts b/packages/engine/src/cli-runtime/skill-resolver.ts index e405a15ef6..bbcc13c488 100644 --- a/packages/engine/src/cli-runtime/skill-resolver.ts +++ b/packages/engine/src/cli-runtime/skill-resolver.ts @@ -78,6 +78,12 @@ export interface SkillSelectionContext { */ requestedSkillNames?: string[]; + /** + * Names that must be read before work when they survive project settings. + * Unlike requestedSkillNames, these are intent only and never restrict discovery. + */ + forcedSkillNames?: string[]; + /** * Diagnostic label for log messages (e.g., "executor", "triage", "reviewer"). */ @@ -277,7 +283,7 @@ function requestedSkillMatchKey(name: string): string { * - Requested names not matching any discovered skill (warning) */ export function resolveSessionSkills(context: SkillSelectionContext): SkillSelectionResult { - const { requestedSkillNames } = context; + const { requestedSkillNames, forcedSkillNames } = context; // Resolve project root from the given projectRootDir — it may be a // worktree path (e.g., /project/.worktrees/task-branch) which doesn't @@ -314,9 +320,10 @@ export function resolveSessionSkills(context: SkillSelectionContext): SkillSelec const hasPatterns = skillPatterns.length > 0; const hasRequestedNames = Boolean(requestedSkillNames && requestedSkillNames.length > 0); + const hasForcedNames = Boolean(forcedSkillNames && forcedSkillNames.length > 0); // If no patterns and no requested names, no filtering needed - if (!hasPatterns && !hasRequestedNames) { + if (!hasPatterns && !hasRequestedNames && !hasForcedNames) { return { allowedSkillPaths: new Set(), excludedSkillPaths: new Set(), @@ -350,7 +357,7 @@ export function resolveSessionSkills(context: SkillSelectionContext): SkillSelec // filterActive is true when: // - Patterns exist (some skills are explicitly configured) // - OR only requested names are provided (filter to those names) - const filterActive = hasPatterns || hasRequestedNames; + const filterActive = hasPatterns || hasRequestedNames || hasForcedNames; // Produce diagnostics for patterns (we can't check against actual discovered skills here, // so we note which patterns are configured) @@ -391,29 +398,6 @@ export function resolveSessionSkills(context: SkillSelectionContext): SkillSelec // ── Skills Override Factory ───────────────────────────────────────────────── -/* -FNXC:SkillResolution 2026-06-29-12:30: -Configured allow-list misses such as ce-optimize/SKILL.md are common when optional skills are absent from a checkout. -Keep the ResourceDiagnostic for programmatic visibility, but classify it separately so override application never mirrors this non-actionable info into piLog or console output. -*/ -function isMissingConfiguredPatternDiagnostic(diag: ResourceDiagnostic): boolean { - const diagnosticType = diag.type as string; - return diagnosticType === "info" - && diag.message.startsWith("Configured skill pattern '") - && diag.message.includes("' not found in discovered skills"); -} - -/* -FNXC:EngineDiagnostics 2026-07-26-08:01: -"Requested skill: " is a per-session listing diagnostic (not a miss). Emitting it at info filled the TUI with one line per skill on every session start. Keep the ResourceDiagnostic for programmatic consumers; mirror only to piLog.debug (FUSION_DEBUG=pi). - -FNXC:EngineDiagnostics 2026-07-26-09:40: -Also demote `Requested skill '…' not found…` and every other type=info skill diagnostic to debug. Operators still see warnings/errors; the TUI no longer fills with `[skills] info: Requested skill…` on every session start. -*/ -function isSkillInfoDiagnostic(diag: ResourceDiagnostic): boolean { - return (diag.type as string) === "info"; -} - /** * Options for skills override filtering. * We track requested names here so we can validate against base.skills. @@ -425,8 +409,10 @@ export interface SkillsOverrideOptions { excludedSkillPaths?: Set; /** Whether filtering is active */ filterActive: boolean; - /** Requested skill names for diagnostic purposes */ + /** Ensure-present names; they never narrow the discovered set. */ requestedSkillNames?: string[]; + /** Forced read-first names, resolved only after final filtering. */ + forcedSkillNames?: string[]; /** Session purpose for log messages */ sessionPurpose?: string; } @@ -438,146 +424,112 @@ export interface SkillsOverrideOptions { * @param options - Additional options for the override * @returns A skillsOverride callback for DefaultResourceLoader */ +export interface ResolvedForcedSkill { + requestedName: string; + skillName: string; +} + +export interface UnresolvedForcedSkill { + requestedName: string; + reason: "disabled-by-settings" | "not-found"; +} + +export interface SkillsOverrideResult { + skills: Skill[]; + diagnostics: ResourceDiagnostic[]; + resolvedForcedSkills: ResolvedForcedSkill[]; + unresolvedForcedSkills: UnresolvedForcedSkill[]; +} + +/* +FNXC:SkillResolution 2026-08-16-03:19: +GitHub #1422 requires project enablement to decide availability for every agent. +Per-agent and workflow names are additive forced-read intent: exclusions win, and +only names resolved in the final session set may be ordered in a prompt. +*/ export function createSkillsOverrideFromSelection( selection: SkillSelectionResult, options: Omit = {}, -): (base: { skills: Skill[]; diagnostics: ResourceDiagnostic[] }) => { skills: Skill[]; diagnostics: ResourceDiagnostic[] } { +): (base: { skills: Skill[]; diagnostics: ResourceDiagnostic[] }) => SkillsOverrideResult { const { allowedSkillPaths, excludedSkillPaths, filterActive } = selection; - const { requestedSkillNames, sessionPurpose } = options; + const { requestedSkillNames, forcedSkillNames, sessionPurpose } = options; + const isBuiltInFallbackRequest = (name: string): boolean => + ROLE_FALLBACK_SESSION_PURPOSES.has(sessionPurpose ?? "") && name.toLowerCase() === "fusion"; - const isBuiltInFallbackRequest = (name: string): boolean => { - const purposeUsesRoleFallback = ROLE_FALLBACK_SESSION_PURPOSES.has(sessionPurpose ?? ""); - return purposeUsesRoleFallback - && requestedSkillNames?.length === 1 - && name.toLowerCase() === "fusion"; - }; - - return (base: { skills: Skill[]; diagnostics: ResourceDiagnostic[] }) => { - // If filtering is not active, return base unchanged - if (!filterActive) { - return base; + return (base) => { + // FNXC:SkillResolution 2026-08-16-03:42: Even an unfiltered session must + // return explicit forced-resolution channels. The shared prompt seam and + // session summary consume this result for every lane, including heartbeat. + if (!filterActive && !(forcedSkillNames?.length)) { + return { + skills: base.skills, + diagnostics: base.diagnostics, + resolvedForcedSkills: [], + unresolvedForcedSkills: [], + }; } - - // Determine the effective filter criteria - // When requestedSkillNames is provided without patterns, filter by name - // When patterns are provided, filter by file path - const hasRequestedNames = Boolean(requestedSkillNames && requestedSkillNames.length > 0); - - // A stale flat enable must not turn into an empty allow-list after a body - // moved to a category. Keep truly missing configured patterns intact so - // their existing missing-pattern diagnostic and filtering semantics remain. - const effectiveAllowedSkillPaths = new Set( - [...allowedSkillPaths].filter((pattern) => !base.skills.some( - (skill) => isLegacyFlatPatternForNestedSkill(skill, pattern), - )), - ); - const hasPatterns = effectiveAllowedSkillPaths.size > 0; - - // Filter skills - // Skills must match the inclusion criteria AND not be in the exclusion list + const effectiveAllowed = new Set([...allowedSkillPaths].filter((pattern) => !base.skills.some( + (skill) => isLegacyFlatPatternForNestedSkill(skill, pattern), + ))); + const isExcluded = (skill: Skill) => [...excludedSkillPaths].some((path) => skillMatchesExecutionPattern(skill, path)); + const isAllowed = (skill: Skill) => [...effectiveAllowed].some((path) => skillMatchesExecutionPattern(skill, path)); const hasExcluded = excludedSkillPaths.size > 0; - let filteredSkills: Skill[]; - const isExcluded = (skill: Skill): boolean => { - for (const excludedPath of excludedSkillPaths) { - if (skillMatchesExecutionPattern(skill, excludedPath)) return true; - } - return false; - }; - const isAllowed = (skill: Skill): boolean => { - for (const allowedPath of effectiveAllowedSkillPaths) { - if (skillMatchesExecutionPattern(skill, allowedPath)) return true; - } - return false; - }; - if (hasRequestedNames) { - // Filter by requested names using the chat/dashboard bare-token convention only for requested-name matching. - const requestedMatchKeys = new Set(requestedSkillNames!.map(requestedSkillMatchKey)); - filteredSkills = base.skills.filter( - (skill) => requestedMatchKeys.has(requestedSkillMatchKey(skill.name)) && !isExcluded(skill) - ); - } else if (hasPatterns) { - // Filter by pattern (allowed AND not excluded) - filteredSkills = base.skills.filter( - (skill) => isAllowed(skill) && !isExcluded(skill) - ); - } else if (hasExcluded) { - // Only exclusions set - filter out excluded skills - filteredSkills = base.skills.filter((skill) => !isExcluded(skill)); - } else { - // No filter criteria - this shouldn't happen if filterActive is true - filteredSkills = base.skills; + // Patterns establish the base availability. Requested names only ensure a + // discovered skill is included; they are no longer an allow-list. + const skills = !filterActive ? base.skills : effectiveAllowed.size > 0 + ? base.skills.filter((skill) => isAllowed(skill) && !isExcluded(skill)) + : hasExcluded ? base.skills.filter((skill) => !isExcluded(skill)) : base.skills; + const ensureNames = [...(requestedSkillNames ?? []), ...(forcedSkillNames ?? [])]; + for (const name of ensureNames) { + const matching = base.skills.filter((skill) => requestedSkillMatchKey(skill.name) === requestedSkillMatchKey(name)); + for (const skill of matching) if (!isExcluded(skill) && !skills.includes(skill)) skills.push(skill); } - // Build diagnostics for missing and disabled skills - const newDiagnostics: ResourceDiagnostic[] = []; - - // Check for excluded paths that DO match a discovered skill (disabled) - const purpose = sessionPurpose ? ` [${sessionPurpose}]` : ""; - const hasDiscoveredMatch = (pattern: string): boolean => - base.skills.some((skill) => skillMatchesExecutionPattern(skill, pattern)); - - for (const excludedPath of excludedSkillPaths) { - if (hasDiscoveredMatch(excludedPath)) { - /* - FNXC:EngineDiagnostics 2026-08-01-18:11: - Intentional project skill exclusions are expected config, not operator degradation. - Use type=info so emission paths route to piLog.debug (FUSION_DEBUG=pi) instead of - warn-flooding the TUI on every session that rediscovers the same disabled skill. - */ - newDiagnostics.push({ - type: "info" as ResourceDiagnostic["type"], - message: `Skill at '${excludedPath}' exists but is disabled by project execution settings${purpose}`, - path: excludedPath, - }); + const diagnostics: ResourceDiagnostic[] = []; + const unresolvedForcedSkills: UnresolvedForcedSkill[] = []; + const resolvedForcedSkills: ResolvedForcedSkill[] = []; + const seenForced = new Set(); + for (const requestedName of forcedSkillNames ?? []) { + const key = requestedSkillMatchKey(requestedName); + if (!key || seenForced.has(key)) continue; + seenForced.add(key); + const matches = base.skills.filter((skill) => requestedSkillMatchKey(skill.name) === key); + const resolved = matches.find((skill) => skills.includes(skill)); + if (resolved) resolvedForcedSkills.push({ requestedName, skillName: resolved.name }); + else if (matches.some(isExcluded)) { + unresolvedForcedSkills.push({ requestedName, reason: "disabled-by-settings" }); + diagnostics.push({ type: "warning" as ResourceDiagnostic["type"], message: `Forced skill '${requestedName}' stays disabled by project settings`, path: requestedName }); + } else { + unresolvedForcedSkills.push({ requestedName, reason: "not-found" }); + diagnostics.push({ type: "info" as ResourceDiagnostic["type"], message: `Requested skill '${requestedName}' not found in discovered skills` }); } } - - // Check for configured patterns (allowed paths) that don't match any discovered skill - for (const allowedPath of effectiveAllowedSkillPaths) { - if (!hasDiscoveredMatch(allowedPath)) { - newDiagnostics.push({ - type: "info" as ResourceDiagnostic["type"], - message: `Configured skill pattern '${allowedPath}' not found in discovered skills${purpose}`, - path: allowedPath, - }); + for (const path of effectiveAllowed) { + if (!base.skills.some((skill) => skillMatchesExecutionPattern(skill, path))) { + diagnostics.push({ type: "info" as ResourceDiagnostic["type"], message: `Configured skill pattern '${path}' not found in discovered skills${sessionPurpose ? ` [${sessionPurpose}]` : ""}`, path }); } } - - // Check for requested names that don't match any discovered skill - if (requestedSkillNames) { - const discoveredRequestedMatchKeys = new Set(base.skills.map((s) => requestedSkillMatchKey(s.name))); - for (const requestedName of requestedSkillNames) { - if ( - !discoveredRequestedMatchKeys.has(requestedSkillMatchKey(requestedName)) - && !isBuiltInFallbackRequest(requestedName) - ) { - const purpose = sessionPurpose ? ` [${sessionPurpose}]` : ""; - newDiagnostics.push({ - type: "info" as ResourceDiagnostic["type"], - message: `Requested skill '${requestedName}' not found in discovered skills${purpose}`, - }); - } + for (const path of excludedSkillPaths) { + if (base.skills.some((skill) => skillMatchesExecutionPattern(skill, path))) { + diagnostics.push({ type: "info" as ResourceDiagnostic["type"], message: `Skill at '${path}' exists but is disabled by project execution settings`, path }); } } - - // Log diagnostics if any - if (newDiagnostics.length > 0) { - const _purpose = sessionPurpose ? `[${sessionPurpose}]` : "skills"; - for (const diag of newDiagnostics) { - if (isMissingConfiguredPatternDiagnostic(diag)) continue; - const msg = `[skills] ${diag.type}: ${diag.message}`; - if (diag.type === "error") piLog.error(msg); - else if (diag.type === "warning") piLog.warn(msg); - else if (isSkillInfoDiagnostic(diag)) piLog.debug(msg); - else piLog.log(msg); + for (const name of requestedSkillNames ?? []) { + if (!base.skills.some((skill) => requestedSkillMatchKey(skill.name) === requestedSkillMatchKey(name)) && !isBuiltInFallbackRequest(name)) { + diagnostics.push({ type: "info" as ResourceDiagnostic["type"], message: `Requested skill '${name}' not found in discovered skills${sessionPurpose ? ` [${sessionPurpose}]` : ""}` }); } } - - return { - skills: filteredSkills, - diagnostics: [...base.diagnostics, ...newDiagnostics], - }; + for (const diagnostic of diagnostics) { + const message = `[skills] ${diagnostic.type}: ${diagnostic.message}`; + if (diagnostic.type === "warning") piLog.warn(message); + else piLog.debug(message); + } + const purpose = sessionPurpose ?? "skills"; + const unavailable = unresolvedForcedSkills.length + ? `; forced-unavailable: [${unresolvedForcedSkills.map((entry) => `${entry.requestedName} (${entry.reason})`).join(", ")}]` : ""; + piLog.log(`[skills] [${purpose}] ${skills.length} skill(s) available; forced: ${resolvedForcedSkills.length ? `[${resolvedForcedSkills.map((entry) => entry.skillName).join(", ")}]` : "none"}${unavailable}`); + return { skills, diagnostics: [...base.diagnostics, ...diagnostics], resolvedForcedSkills, unresolvedForcedSkills }; }; } diff --git a/packages/engine/src/executor/execute-workflow-step.ts b/packages/engine/src/executor/execute-workflow-step.ts index ab3c2e9c45..57bdd3795a 100644 --- a/packages/engine/src/executor/execute-workflow-step.ts +++ b/packages/engine/src/executor/execute-workflow-step.ts @@ -590,6 +590,7 @@ CRITICAL SCOPING RULES — read before doing anything else: projectRootDir: effectiveSkillSelection?.projectRootDir ?? deps.rootDir, ...(effectiveSkillSelection?.sessionPurpose ? { sessionPurpose: effectiveSkillSelection.sessionPurpose } : { sessionPurpose: "executor" }), requestedSkillNames: mergedNames, + forcedSkillNames: [...new Set([...(effectiveSkillSelection?.forcedSkillNames ?? []), namespaced, bare])], }; } const additionalSkillPaths = mergeAdditionalSkillPaths(skillContext.additionalSkillPaths, ceSkillsDir ? [ceSkillsDir] : undefined); @@ -706,6 +707,19 @@ CRITICAL SCOPING RULES — read before doing anything else: // Skill selection: assigned-agent / role-fallback skills, plus the step's own named skill (U1) made discoverable via additionalSkillPaths. ...(effectiveSkillSelection ? { skillSelection: effectiveSkillSelection } : {}), ...(additionalSkillPaths ? { additionalSkillPaths } : {}), + // FNXC:SkillResolution 2026-08-16-03:19: Workflow steps are task-bound + // sessions too, so mirror the shared resolver's one-session summary into + // the task log; unresolved forced requests remain observable but are never + // presented to the model as required reading. + onSkillSummary: async (summary) => { + const unavailable = summary.unresolvedForcedSkills.length + ? `; forced-unavailable: [${summary.unresolvedForcedSkills.map((entry) => `${entry.requestedName} (${entry.reason})`).join(", ")}]` + : ""; + await deps.store.logEntry( + task.id, + `[skills] [executor] ${summary.availableCount} skill(s) available; forced: ${summary.forcedSkillNames.length ? `[${summary.forcedSkillNames.join(", ")}]` : "none"}${unavailable}`, + ); + }, ...(readonlyCustomTools.allowed.length > 0 ? { customTools: readonlyCustomTools.allowed, fusionTools: readonlyCustomTools.allowed } : {}), diff --git a/packages/engine/src/executor/run-implementation.ts b/packages/engine/src/executor/run-implementation.ts index 9b9f829ad8..b336322e96 100644 --- a/packages/engine/src/executor/run-implementation.ts +++ b/packages/engine/src/executor/run-implementation.ts @@ -993,6 +993,7 @@ export async function runImplementation( ? { sessionPurpose: stepSessionSkillSelection.sessionPurpose } : { sessionPurpose: "executor" }), requestedSkillNames: [...new Set([...existing, graphSeamSkillName, bare])], + forcedSkillNames: [...new Set([...(stepSessionSkillSelection?.forcedSkillNames ?? []), graphSeamSkillName, bare])], }; } const stepSessionAdditionalSkillPaths = mergeAdditionalSkillPaths( @@ -2168,6 +2169,10 @@ export async function runImplementation( // 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 } : {}), + onSkillSummary: async (summary) => { + const unavailable = summary.unresolvedForcedSkills.length ? `; forced-unavailable: [${summary.unresolvedForcedSkills.map((entry) => `${entry.requestedName} (${entry.reason})`).join(", ")}]` : ""; + await deps.store.logEntry(task.id, `[skills] [executor] ${summary.availableCount} skill(s) available; forced: ${summary.forcedSkillNames.length ? `[${summary.forcedSkillNames.join(", ")}]` : "none"}${unavailable}`); + }, // 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, diff --git a/packages/engine/src/pi.ts b/packages/engine/src/pi.ts index e31b426425..693c05ef7f 100644 --- a/packages/engine/src/pi.ts +++ b/packages/engine/src/pi.ts @@ -1100,6 +1100,8 @@ export interface AgentOptions { * (and `skillSelection` is not), auto-constructs a SkillSelectionContext * from the cwd and these names. Ignored when `skillSelection` is set. */ skills?: string[]; + /** Reports the one resolved session-skill summary to task-bound callers. */ + onSkillSummary?: (summary: { availableCount: number; forcedSkillNames: string[]; unresolvedForcedSkills: Array<{ requestedName: string; reason: string }> }) => void | Promise; /** Extra directories to scan for skills (each holding `/SKILL.md`), in * addition to the default cwd/agent-dir roots. Forwarded to the resource * loader so callers (e.g. plugins that install skills to a private dir) can @@ -2481,8 +2483,10 @@ export async function createFnAgent(options: AgentOptions): Promise }; } - // Resolve skill selection if provided + // Resolve skill selection if provided. The override is also the only point + // that knows which forced requests survived discovery and project exclusions. let skillsOverrideFn: ReturnType | undefined; + let skillSummary: { availableCount: number; forcedSkillNames: string[]; unresolvedForcedSkills: Array<{ requestedName: string; reason: string }> } | undefined; if (effectiveSkillSelection) { const selectionResult = resolveSessionSkills(effectiveSkillSelection); if (selectionResult.diagnostics.length > 0) { @@ -2505,8 +2509,19 @@ export async function createFnAgent(options: AgentOptions): Promise } skillsOverrideFn = createSkillsOverrideFromSelection(selectionResult, { requestedSkillNames: effectiveSkillSelection.requestedSkillNames, + forcedSkillNames: effectiveSkillSelection.forcedSkillNames, sessionPurpose: effectiveSkillSelection.sessionPurpose, }); + const rawOverride = skillsOverrideFn; + skillsOverrideFn = (base) => { + const result = rawOverride(base); + skillSummary = { + availableCount: result.skills.length, + forcedSkillNames: result.resolvedForcedSkills.map((entry) => entry.skillName), + unresolvedForcedSkills: result.unresolvedForcedSkills, + }; + return result; + }; } /* @@ -2548,10 +2563,12 @@ export async function createFnAgent(options: AgentOptions): Promise agentDir: getFusionAgentDir(), settingsManager, systemPromptOverride: () => options.systemPromptLayers?.stable ?? options.systemPrompt, - appendSystemPromptOverride: () => - options.systemPromptLayers?.dynamic - ? [options.systemPromptLayers.dynamic] - : [], + appendSystemPromptOverride: () => { + const dynamic = options.systemPromptLayers?.dynamic ? [options.systemPromptLayers.dynamic] : []; + const forced = skillSummary?.forcedSkillNames ?? []; + if (forced.length === 0) return dynamic; + return [...dynamic, `Before starting work, you are REQUIRED to read these available skills: ${forced.join(", ")}. All other available skills may be consulted on demand when relevant.`]; + }, ...(effectiveExtensionPaths.length > 0 ? { additionalExtensionPaths: [...effectiveExtensionPaths] } : {}), ...(normalizedAdditionalSkillPaths.length > 0 ? { additionalSkillPaths: normalizedAdditionalSkillPaths } @@ -2559,6 +2576,7 @@ export async function createFnAgent(options: AgentOptions): Promise ...(skillsOverrideFn ? { skillsOverride: skillsOverrideFn } : {}), }); await resourceLoader.reload(); + if (skillSummary) await options.onSkillSummary?.(skillSummary); const sessionManager = options.sessionManager ?? SessionManager.inMemory(); normalizeSessionHistoryEntries(sessionManager as unknown as SessionManagerLike);