FN-9114: expose enabled skills and enforce agent skill reads

Make project-enabled skills available across agent sessions while preserving explicit per-agent skills as observable read-first requirements.

- resolve all enabled discovered skills without using agent metadata as an availability filter
- carry forced skill intent through PI, plugin, chat, workflow, merger, and heartbeat session paths
- report resolved and unavailable forced skills in session summaries and diagnostics
- document the updated skill model and add release notes and regression coverage

Files changed:
 .changeset/fn-9114-forced-skills.md                |   7 +
 docs/PLUGIN_AUTHORING.md                           |   2 +-
 docs/agents.md                                     |  10 +-
 docs/diagnostics.md                                |   2 +-
 docs/settings-reference.md                         |   2 +-
 packages/core/src/__tests__/skill-settings.test.ts |   9 +
 .../dashboard/src/__tests__/chat-manager.test.ts   |  26 ++-
 packages/dashboard/src/chat.ts                     |  10 +
 .../engine/src/__tests__/agent-skills-flow.test.ts |  14 +-
 .../compound-engineering-skill-resolution.test.ts  |   2 +-
 .../engine/src/__tests__/heartbeat-skills.test.ts  |  41 ++++
 .../__tests__/hermes-runtime-integration.test.ts   |  63 +++++-
 .../engine/src/__tests__/merger-skills.test.ts     |  33 ++-
 packages/engine/src/__tests__/pi.test.ts           |  35 ++-
 .../__tests__/plugin-skill-body-delivery.test.ts   |   8 +-
 .../src/__tests__/plugin-skill-integration.test.ts |   3 +-
 .../src/__tests__/session-skill-context.test.ts    |  40 ++--
 .../engine/src/__tests__/skill-resolver.test.ts    |  38 +++-
 .../__tests__/step-execute-skill-loading.test.ts   |   6 +
 packages/engine/src/agents/agent-runtime.ts        |   2 +
 .../engine/src/agents/agent-session-helpers.ts     |  88 ++++++-
 .../src/cli-runtime/session-skill-context.ts       | 117 ++++------
 packages/engine/src/cli-runtime/skill-resolver.ts  | 252 +++++++++------------
 .../engine/src/executor/execute-workflow-step.ts   |  14 ++
 packages/engine/src/executor/run-implementation.ts |   5 +
 packages/engine/src/pi.ts                          |  28 ++-
 26 files changed, 576 insertions(+), 281 deletions(-)

Fusion-Task-Id: FN-9114

Fusion-Task-Lineage: fe63ebd1-f947-40d7-be7b-c13d7f1c35c9

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-08-15 21:54:51 -07:00
parent aedcb681b9
commit 9f5f981e33
26 changed files with 575 additions and 280 deletions

View File

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

View File

@@ -1641,7 +1641,7 @@ const skills: PluginSkillContribution[] = [
Plugin skills are discovered per requesting project: the Skills view and workflow editor surface `plugin:<id>` skills only when that plugin is enabled for that project's plugin state, even if the daemon was started from a different directory.
<!-- FNXC:PluginSkills 2026-07-12-00:00: GitHub #2017 requires plugin skill bodies to be available anywhere native skills are available. The engine threads enabled plugin skill body discovery paths into agent sessions, and the dashboard reads the resolved plugin-package SKILL.md/reference files instead of showing a runtime placeholder. -->
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

View File

@@ -37,9 +37,9 @@ fn chat <agent-id> [message…] [--once] [--non-interactive] [--poll-ms <n>] [--
- 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:<agent-id>`; use `--conversation-id <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.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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(),

View File

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

View File

@@ -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 () => {

View File

@@ -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<string, unknown> | 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<string, unknown>) {
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",

View File

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

View File

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

View File

@@ -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 () => {

View File

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

View File

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

View File

@@ -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<void>;
/** Convenience: skill names to include in the session */
skills?: string[];
/** Extra directories to scan for skills (each holding `<id>/SKILL.md`), in

View File

@@ -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<AgentRuntimeOptions["onSkillSummary"]> 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);

View File

@@ -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<SessionSkillContextResult> {
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<string, unknown> | 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<string, unknown> | 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<string, unknown> | 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<string, unknown> | 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);
}

View File

@@ -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<string>(),
excludedSkillPaths: new Set<string>(),
@@ -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: <name>" 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<string>;
/** 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<SkillsOverrideOptions, "allowedSkillPaths" | "filterActive"> = {},
): (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<string>();
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 };
};
}

View File

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

View File

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

View File

@@ -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<void>;
/** Extra directories to scan for skills (each holding `<id>/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<AgentResult>
};
}
// 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<typeof createSkillsOverrideFromSelection> | 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<AgentResult>
}
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<AgentResult>
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<AgentResult>
...(skillsOverrideFn ? { skillsOverride: skillsOverrideFn } : {}),
});
await resourceLoader.reload();
if (skillSummary) await options.onSkillSummary?.(skillSummary);
const sessionManager = options.sessionManager ?? SessionManager.inMemory();
normalizeSessionHistoryEntries(sessionManager as unknown as SessionManagerLike);