feat(FN-1510): merge fusion/fn-1510
This commit is contained in:
@@ -18,6 +18,13 @@ export { MissionExecutionLoop, type MissionExecutionLoopOptions, type Validation
|
||||
export { aiMergeTask, type MergerOptions } from "./merger.js";
|
||||
export { reviewStep, type ReviewType, type ReviewVerdict, type ReviewResult, type ReviewOptions } from "./reviewer.js";
|
||||
export { createKbAgent, type AgentOptions, type AgentResult } from "./pi.js";
|
||||
export {
|
||||
resolveSessionSkills,
|
||||
createSkillsOverrideFromSelection,
|
||||
type SkillSelectionContext,
|
||||
type SkillSelectionResult,
|
||||
type SkillDiagnostic,
|
||||
} from "./skill-resolver.js";
|
||||
export { AgentReflectionService, type AgentReflectionServiceOptions } from "./agent-reflection.js";
|
||||
export { WorktreePool, scanIdleWorktrees, cleanupOrphanedWorktrees } from "./worktree-pool.js";
|
||||
export { createLogger, type Logger } from "./logger.js";
|
||||
|
||||
@@ -20,7 +20,7 @@ const setFallbackResolverMock = vi.fn();
|
||||
const reloadMock = vi.fn(async () => {});
|
||||
const execSyncMock = vi.fn((_cmd?: any, _opts?: any) => "");
|
||||
const existsSyncMock = vi.fn((_path: PathLike) => false);
|
||||
const readFileSyncMock = vi.fn(() => "{}");
|
||||
const readFileSyncMock = vi.fn((_path?: any) => "{}");
|
||||
|
||||
// Route async `exec` through the `execSync` mock so the promisify bridge works.
|
||||
vi.mock("node:child_process", async () => {
|
||||
@@ -538,4 +538,284 @@ describe("createKbAgent", () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
describe("skill selection", () => {
|
||||
beforeEach(() => {
|
||||
// Reset modules to ensure fresh imports for each test
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it("without skillSelection does not pass skillsOverride to resource loader", async () => {
|
||||
let capturedResourceLoaderOptions: any;
|
||||
vi.doMock("@mariozechner/pi-coding-agent", () => ({
|
||||
AuthStorage: {
|
||||
create: () => ({
|
||||
setFallbackResolver: setFallbackResolverMock,
|
||||
}),
|
||||
},
|
||||
createAgentSession: createAgentSessionMock,
|
||||
createCodingTools: createCodingToolsMock,
|
||||
createExtensionRuntime: createExtensionRuntimeMock,
|
||||
createReadOnlyTools: createReadOnlyToolsMock,
|
||||
DefaultResourceLoader: class {
|
||||
constructor(options: any) {
|
||||
capturedResourceLoaderOptions = options;
|
||||
}
|
||||
async reload() {
|
||||
await reloadMock();
|
||||
}
|
||||
},
|
||||
DefaultPackageManager: class {
|
||||
async resolve() {
|
||||
return packageManagerResolveMock();
|
||||
}
|
||||
},
|
||||
discoverAndLoadExtensions: discoverAndLoadExtensionsMock,
|
||||
getAgentDir: () => "/mock-agent-dir",
|
||||
ModelRegistry: class {
|
||||
find(provider: string, modelId: string) {
|
||||
return findMock(provider, modelId);
|
||||
}
|
||||
getAll() {
|
||||
return getAllMock();
|
||||
}
|
||||
registerProvider(name: string, config: unknown) {
|
||||
return registerProviderMock(name, config);
|
||||
}
|
||||
refresh() {
|
||||
return refreshMock();
|
||||
}
|
||||
},
|
||||
SessionManager: {
|
||||
inMemory: () => ({ kind: "session-manager" }),
|
||||
},
|
||||
SettingsManager: {
|
||||
create: settingsManagerCreateMock,
|
||||
inMemory: settingsManagerInMemoryMock,
|
||||
},
|
||||
}));
|
||||
|
||||
const { createKbAgent: freshCreateKbAgent } = await import("./pi.js");
|
||||
|
||||
await freshCreateKbAgent({
|
||||
cwd: "/tmp",
|
||||
systemPrompt: "test",
|
||||
tools: "coding",
|
||||
});
|
||||
|
||||
// skillsOverride should not be present when skillSelection is not provided
|
||||
expect(capturedResourceLoaderOptions.skillsOverride).toBeUndefined();
|
||||
});
|
||||
|
||||
it("with skillSelection (empty patterns, no requested names) passes through all skills (filter not active)", async () => {
|
||||
// Mock existsSync to return true for settings file
|
||||
existsSyncMock.mockImplementation((path) => {
|
||||
const value = String(path);
|
||||
return value.includes(".fusion/settings.json");
|
||||
});
|
||||
readFileSyncMock.mockImplementation((path) => {
|
||||
const value = String(path);
|
||||
if (value.includes(".fusion/settings.json")) {
|
||||
return JSON.stringify({});
|
||||
}
|
||||
return "{}";
|
||||
});
|
||||
|
||||
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
let capturedResourceLoaderOptions: any;
|
||||
vi.doMock("@mariozechner/pi-coding-agent", () => ({
|
||||
AuthStorage: {
|
||||
create: () => ({
|
||||
setFallbackResolver: setFallbackResolverMock,
|
||||
}),
|
||||
},
|
||||
createAgentSession: createAgentSessionMock,
|
||||
createCodingTools: createCodingToolsMock,
|
||||
createExtensionRuntime: createExtensionRuntimeMock,
|
||||
createReadOnlyTools: createReadOnlyToolsMock,
|
||||
DefaultResourceLoader: class {
|
||||
constructor(options: any) {
|
||||
capturedResourceLoaderOptions = options;
|
||||
}
|
||||
async reload() {
|
||||
await reloadMock();
|
||||
}
|
||||
},
|
||||
DefaultPackageManager: class {
|
||||
async resolve() {
|
||||
return packageManagerResolveMock();
|
||||
}
|
||||
},
|
||||
discoverAndLoadExtensions: discoverAndLoadExtensionsMock,
|
||||
getAgentDir: () => "/mock-agent-dir",
|
||||
ModelRegistry: class {
|
||||
find(provider: string, modelId: string) {
|
||||
return findMock(provider, modelId);
|
||||
}
|
||||
getAll() {
|
||||
return getAllMock();
|
||||
}
|
||||
registerProvider(name: string, config: unknown) {
|
||||
return registerProviderMock(name, config);
|
||||
}
|
||||
refresh() {
|
||||
return refreshMock();
|
||||
}
|
||||
},
|
||||
SessionManager: {
|
||||
inMemory: () => ({ kind: "session-manager" }),
|
||||
},
|
||||
SettingsManager: {
|
||||
create: settingsManagerCreateMock,
|
||||
inMemory: settingsManagerInMemoryMock,
|
||||
},
|
||||
}));
|
||||
|
||||
const { createKbAgent: freshCreateKbAgent } = await import("./pi.js");
|
||||
|
||||
await freshCreateKbAgent({
|
||||
cwd: "/tmp",
|
||||
systemPrompt: "test",
|
||||
tools: "coding",
|
||||
skillSelection: {
|
||||
projectRootDir: "/tmp",
|
||||
},
|
||||
});
|
||||
|
||||
// When filterActive is false, skillsOverride returns base unchanged
|
||||
// The callback should exist but simply return the base skills
|
||||
if (capturedResourceLoaderOptions.skillsOverride) {
|
||||
const result = capturedResourceLoaderOptions.skillsOverride({
|
||||
skills: [{ name: "test", filePath: "/path", description: "", baseDir: "", sourceInfo: {} as any, disableModelInvocation: false }],
|
||||
diagnostics: [],
|
||||
});
|
||||
expect(result.skills).toHaveLength(1); // All skills pass through
|
||||
}
|
||||
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("with skillSelection (specific requested names) activates skill filtering", async () => {
|
||||
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
let capturedResourceLoaderOptions: any;
|
||||
vi.doMock("@mariozechner/pi-coding-agent", () => ({
|
||||
AuthStorage: {
|
||||
create: () => ({
|
||||
setFallbackResolver: setFallbackResolverMock,
|
||||
}),
|
||||
},
|
||||
createAgentSession: createAgentSessionMock,
|
||||
createCodingTools: createCodingToolsMock,
|
||||
createExtensionRuntime: createExtensionRuntimeMock,
|
||||
createReadOnlyTools: createReadOnlyToolsMock,
|
||||
DefaultResourceLoader: class {
|
||||
constructor(options: any) {
|
||||
capturedResourceLoaderOptions = options;
|
||||
}
|
||||
async reload() {
|
||||
await reloadMock();
|
||||
}
|
||||
},
|
||||
DefaultPackageManager: class {
|
||||
async resolve() {
|
||||
return packageManagerResolveMock();
|
||||
}
|
||||
},
|
||||
discoverAndLoadExtensions: discoverAndLoadExtensionsMock,
|
||||
getAgentDir: () => "/mock-agent-dir",
|
||||
ModelRegistry: class {
|
||||
find(provider: string, modelId: string) {
|
||||
return findMock(provider, modelId);
|
||||
}
|
||||
getAll() {
|
||||
return getAllMock();
|
||||
}
|
||||
registerProvider(name: string, config: unknown) {
|
||||
return registerProviderMock(name, config);
|
||||
}
|
||||
refresh() {
|
||||
return refreshMock();
|
||||
}
|
||||
},
|
||||
SessionManager: {
|
||||
inMemory: () => ({ kind: "session-manager" }),
|
||||
},
|
||||
SettingsManager: {
|
||||
create: settingsManagerCreateMock,
|
||||
inMemory: settingsManagerInMemoryMock,
|
||||
},
|
||||
}));
|
||||
|
||||
const { createKbAgent: freshCreateKbAgent } = await import("./pi.js");
|
||||
|
||||
await freshCreateKbAgent({
|
||||
cwd: "/tmp",
|
||||
systemPrompt: "test",
|
||||
tools: "coding",
|
||||
skillSelection: {
|
||||
projectRootDir: "/tmp",
|
||||
requestedSkillNames: ["paperclip"],
|
||||
sessionPurpose: "executor",
|
||||
},
|
||||
});
|
||||
|
||||
// skillsOverride should be present
|
||||
expect(capturedResourceLoaderOptions.skillsOverride).toBeDefined();
|
||||
|
||||
// The override should filter skills
|
||||
const result = capturedResourceLoaderOptions.skillsOverride({
|
||||
skills: [
|
||||
{ name: "paperclip", filePath: "/path/paperclip", description: "", baseDir: "", sourceInfo: {} as any, disableModelInvocation: false },
|
||||
{ name: "lint", filePath: "/path/lint", description: "", baseDir: "", sourceInfo: {} as any, disableModelInvocation: false },
|
||||
],
|
||||
diagnostics: [],
|
||||
});
|
||||
|
||||
// Only paperclip should pass through (matching requested name)
|
||||
expect(result.skills).toHaveLength(1);
|
||||
expect(result.skills[0].name).toBe("paperclip");
|
||||
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("diagnostics are logged via console.error with [pi] [skills] prefix", async () => {
|
||||
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
// Test diagnostics logging by directly calling createSkillsOverrideFromSelection
|
||||
const { createSkillsOverrideFromSelection } = await import("./skill-resolver.js");
|
||||
|
||||
const selection = {
|
||||
allowedSkillPaths: new Set(["/path/nonexistent"]),
|
||||
diagnostics: [],
|
||||
filterActive: true,
|
||||
};
|
||||
|
||||
const override = createSkillsOverrideFromSelection(selection, {
|
||||
sessionPurpose: "executor",
|
||||
});
|
||||
|
||||
// Invoke the override to trigger diagnostics
|
||||
const result = override({
|
||||
skills: [],
|
||||
diagnostics: [],
|
||||
});
|
||||
|
||||
// Check that diagnostics were produced
|
||||
expect(result.diagnostics.length).toBeGreaterThan(0);
|
||||
|
||||
// Check that diagnostics were logged with correct prefix
|
||||
const skillLogs = consoleErrorSpy.mock.calls.filter(call =>
|
||||
String(call[0]).includes("[pi] [skills]")
|
||||
);
|
||||
expect(skillLogs.length).toBeGreaterThan(0);
|
||||
|
||||
// Should include the session purpose
|
||||
const lastLog = skillLogs[skillLogs.length - 1][0] as string;
|
||||
expect(lastLog).toContain("[executor]");
|
||||
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,6 +27,11 @@ import {
|
||||
type AgentSession,
|
||||
type ToolDefinition,
|
||||
} from "@mariozechner/pi-coding-agent";
|
||||
import {
|
||||
resolveSessionSkills,
|
||||
createSkillsOverrideFromSelection,
|
||||
type SkillSelectionContext,
|
||||
} from "./skill-resolver.js";
|
||||
|
||||
export interface AgentResult {
|
||||
session: AgentSession;
|
||||
@@ -141,6 +146,11 @@ export interface AgentOptions {
|
||||
* uses this instead of creating an in-memory session. Pass a file-based
|
||||
* SessionManager to enable session persistence and pause/resume. */
|
||||
sessionManager?: SessionManager;
|
||||
/** Optional skill selection context. When provided, the agent session's
|
||||
* skills are filtered according to project execution settings and any
|
||||
* caller-requested skill names. Omit to use default skill discovery
|
||||
* (all discovered skills included). */
|
||||
skillSelection?: SkillSelectionContext;
|
||||
}
|
||||
|
||||
function resolveConfiguredModel(
|
||||
@@ -472,11 +482,28 @@ export async function createKbAgent(options: AgentOptions): Promise<AgentResult>
|
||||
options.fallbackModelId,
|
||||
);
|
||||
|
||||
// Resolve skill selection if provided
|
||||
let skillsOverrideFn: ReturnType<typeof createSkillsOverrideFromSelection> | undefined;
|
||||
if (options.skillSelection) {
|
||||
const selectionResult = resolveSessionSkills(options.skillSelection);
|
||||
if (selectionResult.diagnostics.length > 0) {
|
||||
const purpose = options.skillSelection.sessionPurpose ?? "skills";
|
||||
for (const diag of selectionResult.diagnostics) {
|
||||
console.error(`[pi] [skills] [${purpose}] ${diag.type}: ${diag.message}`);
|
||||
}
|
||||
}
|
||||
skillsOverrideFn = createSkillsOverrideFromSelection(selectionResult, {
|
||||
requestedSkillNames: options.skillSelection.requestedSkillNames,
|
||||
sessionPurpose: options.skillSelection.sessionPurpose,
|
||||
});
|
||||
}
|
||||
|
||||
const resourceLoader = new DefaultResourceLoader({
|
||||
cwd: options.cwd,
|
||||
settingsManager,
|
||||
systemPromptOverride: () => options.systemPrompt,
|
||||
appendSystemPromptOverride: () => [],
|
||||
...(skillsOverrideFn ? { skillsOverride: skillsOverrideFn } : {}),
|
||||
});
|
||||
await resourceLoader.reload();
|
||||
|
||||
|
||||
544
packages/engine/src/skill-resolver.test.ts
Normal file
544
packages/engine/src/skill-resolver.test.ts
Normal file
@@ -0,0 +1,544 @@
|
||||
/**
|
||||
* Unit tests for skill resolver.
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
resolveSessionSkills,
|
||||
createSkillsOverrideFromSelection,
|
||||
type SkillSelectionResult,
|
||||
} from "./skill-resolver.js";
|
||||
|
||||
// ── Mock Setup ───────────────────────────────────────────────────────────────
|
||||
|
||||
// In-memory file system for tests - using a proxy to intercept fs calls
|
||||
const mockFiles = new Map<string, string>();
|
||||
let mockDirCounter = 0;
|
||||
|
||||
vi.mock("node:fs", async () => {
|
||||
const actual = await vi.importActual<typeof import("node:fs")>("node:fs");
|
||||
return {
|
||||
...actual,
|
||||
existsSync: (path: unknown) => mockFiles.has(String(path)),
|
||||
readFileSync: (path: unknown) => mockFiles.get(String(path)) ?? "{}",
|
||||
mkdtempSync: () => `/tmp/skill-resolver-mock-${++mockDirCounter}`,
|
||||
writeFileSync: (path: unknown, content: unknown) => mockFiles.set(String(path), String(content)),
|
||||
rmSync: (path: unknown) => {
|
||||
const pathStr = String(path);
|
||||
for (const key of mockFiles.keys()) {
|
||||
if (key.startsWith(pathStr)) mockFiles.delete(key);
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// ── Test Helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
function createMockProjectDir(settings: Record<string, unknown> | null): string {
|
||||
const dir = `/tmp/skill-resolver-mock-${++mockDirCounter}`;
|
||||
if (settings !== null) {
|
||||
mockFiles.set(`${dir}/.fusion/settings.json`, JSON.stringify(settings));
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
|
||||
// ── Tests ───────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("resolveSessionSkills", () => {
|
||||
beforeEach(() => {
|
||||
mockFiles.clear();
|
||||
mockDirCounter = 0;
|
||||
});
|
||||
|
||||
describe("returns filterActive: false when no patterns and no requested names", () => {
|
||||
it("returns filterActive: false when no settings file exists", () => {
|
||||
const result = resolveSessionSkills({
|
||||
projectRootDir: "/nonexistent",
|
||||
});
|
||||
|
||||
expect(result.filterActive).toBe(false);
|
||||
expect(result.allowedSkillPaths.size).toBe(0);
|
||||
expect(result.diagnostics).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("returns filterActive: false when settings file is empty", () => {
|
||||
const dir = createMockProjectDir({});
|
||||
const result = resolveSessionSkills({
|
||||
projectRootDir: dir,
|
||||
});
|
||||
|
||||
expect(result.filterActive).toBe(false);
|
||||
expect(result.allowedSkillPaths.size).toBe(0);
|
||||
expect(result.diagnostics).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("returns filterActive: false when settings has no skill configuration", () => {
|
||||
const dir = createMockProjectDir({
|
||||
defaultProvider: "anthropic",
|
||||
defaultModel: "claude-sonnet-4-5",
|
||||
});
|
||||
const result = resolveSessionSkills({
|
||||
projectRootDir: dir,
|
||||
});
|
||||
|
||||
expect(result.filterActive).toBe(false);
|
||||
expect(result.allowedSkillPaths.size).toBe(0);
|
||||
expect(result.diagnostics).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("returns filterActive: true with + patterns", () => {
|
||||
it("adds skill paths to allowed set with + prefix", () => {
|
||||
const dir = createMockProjectDir({
|
||||
skills: ["+skills/paperclip/SKILL.md", "+skills/lint/SKILL.md"],
|
||||
});
|
||||
|
||||
const result = resolveSessionSkills({
|
||||
projectRootDir: dir,
|
||||
});
|
||||
|
||||
expect(result.filterActive).toBe(true);
|
||||
expect(result.allowedSkillPaths.size).toBe(2);
|
||||
expect(result.allowedSkillPaths.has("skills/paperclip/SKILL.md")).toBe(true);
|
||||
expect(result.allowedSkillPaths.has("skills/lint/SKILL.md")).toBe(true);
|
||||
});
|
||||
|
||||
it("adds skill paths to allowed set without prefix (implicit +)", () => {
|
||||
const dir = createMockProjectDir({
|
||||
skills: ["skills/paperclip/SKILL.md", "skills/lint/SKILL.md"],
|
||||
});
|
||||
|
||||
const result = resolveSessionSkills({
|
||||
projectRootDir: dir,
|
||||
});
|
||||
|
||||
expect(result.filterActive).toBe(true);
|
||||
expect(result.allowedSkillPaths.size).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("excludes - pattern skills from allowed set", () => {
|
||||
it("removes skill from allowed set with - prefix", () => {
|
||||
const dir = createMockProjectDir({
|
||||
skills: ["+skills/foo/SKILL.md", "+skills/bar/SKILL.md", "-skills/foo/SKILL.md"],
|
||||
});
|
||||
|
||||
const result = resolveSessionSkills({
|
||||
projectRootDir: dir,
|
||||
});
|
||||
|
||||
expect(result.filterActive).toBe(true);
|
||||
expect(result.allowedSkillPaths.size).toBe(1);
|
||||
expect(result.allowedSkillPaths.has("skills/foo/SKILL.md")).toBe(false);
|
||||
expect(result.allowedSkillPaths.has("skills/bar/SKILL.md")).toBe(true);
|
||||
});
|
||||
|
||||
it("exclusion pattern removes previously added entry", () => {
|
||||
const dir = createMockProjectDir({
|
||||
skills: ["skills/foo/SKILL.md", "-skills/foo/SKILL.md"],
|
||||
});
|
||||
|
||||
const result = resolveSessionSkills({
|
||||
projectRootDir: dir,
|
||||
});
|
||||
|
||||
expect(result.allowedSkillPaths.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("handles mixed + / - patterns correctly", () => {
|
||||
it("last entry wins for duplicate paths", () => {
|
||||
const dir = createMockProjectDir({
|
||||
skills: ["+skills/foo/SKILL.md", "-skills/foo/SKILL.md", "+skills/foo/SKILL.md"],
|
||||
});
|
||||
|
||||
const result = resolveSessionSkills({
|
||||
projectRootDir: dir,
|
||||
});
|
||||
|
||||
// Last + wins
|
||||
expect(result.allowedSkillPaths.has("skills/foo/SKILL.md")).toBe(true);
|
||||
});
|
||||
|
||||
it("last entry wins (exclusion after inclusion)", () => {
|
||||
const dir = createMockProjectDir({
|
||||
skills: ["+skills/foo/SKILL.md", "+skills/foo/SKILL.md", "-skills/foo/SKILL.md"],
|
||||
});
|
||||
|
||||
const result = resolveSessionSkills({
|
||||
projectRootDir: dir,
|
||||
});
|
||||
|
||||
// Last - wins
|
||||
expect(result.allowedSkillPaths.has("skills/foo/SKILL.md")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("handles requestedSkillNames", () => {
|
||||
it("with no patterns, only requested names marks filterActive: true", () => {
|
||||
const result = resolveSessionSkills({
|
||||
projectRootDir: "/nonexistent",
|
||||
requestedSkillNames: ["paperclip", "lint"],
|
||||
});
|
||||
|
||||
expect(result.filterActive).toBe(true);
|
||||
expect(result.diagnostics).toHaveLength(2);
|
||||
expect(result.diagnostics.some(d => d.skillName === "paperclip")).toBe(true);
|
||||
expect(result.diagnostics.some(d => d.skillName === "lint")).toBe(true);
|
||||
});
|
||||
|
||||
it("requestedSkillNames act as info diagnostics when no patterns exist", () => {
|
||||
const result = resolveSessionSkills({
|
||||
projectRootDir: "/nonexistent",
|
||||
requestedSkillNames: ["custom-skill"],
|
||||
});
|
||||
|
||||
expect(result.filterActive).toBe(true);
|
||||
const nameDiags = result.diagnostics.filter(d => d.skillName === "custom-skill");
|
||||
expect(nameDiags).toHaveLength(1);
|
||||
expect(nameDiags[0].type).toBe("info");
|
||||
});
|
||||
});
|
||||
|
||||
describe("package-scoped skill patterns", () => {
|
||||
it("extracts skills from package objects with skills array", () => {
|
||||
const dir = createMockProjectDir({
|
||||
packages: [
|
||||
{
|
||||
source: "@myorg/ai-kit",
|
||||
skills: ["+skills/custom/SKILL.md"],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = resolveSessionSkills({
|
||||
projectRootDir: dir,
|
||||
});
|
||||
|
||||
expect(result.filterActive).toBe(true);
|
||||
expect(result.allowedSkillPaths.has("skills/custom/SKILL.md")).toBe(true);
|
||||
});
|
||||
|
||||
it("handles mixed top-level and package-scoped patterns", () => {
|
||||
const dir = createMockProjectDir({
|
||||
skills: ["+skills/shared/SKILL.md"],
|
||||
packages: [
|
||||
{
|
||||
source: "@myorg/ai-kit",
|
||||
skills: ["+skills/package-skill/SKILL.md"],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = resolveSessionSkills({
|
||||
projectRootDir: dir,
|
||||
});
|
||||
|
||||
expect(result.filterActive).toBe(true);
|
||||
expect(result.allowedSkillPaths.size).toBe(2);
|
||||
expect(result.allowedSkillPaths.has("skills/shared/SKILL.md")).toBe(true);
|
||||
expect(result.allowedSkillPaths.has("skills/package-skill/SKILL.md")).toBe(true);
|
||||
});
|
||||
|
||||
it("handles string package entries without crashing", () => {
|
||||
const dir = createMockProjectDir({
|
||||
packages: ["@myorg/ai-kit"],
|
||||
});
|
||||
|
||||
const result = resolveSessionSkills({
|
||||
projectRootDir: dir,
|
||||
});
|
||||
|
||||
// Should not crash, patterns array is undefined for string entries
|
||||
expect(result.filterActive).toBe(false);
|
||||
expect(result.allowedSkillPaths.size).toBe(0);
|
||||
});
|
||||
|
||||
it("handles package objects without skills array", () => {
|
||||
const dir = createMockProjectDir({
|
||||
packages: [
|
||||
{
|
||||
source: "@myorg/ai-kit",
|
||||
extensions: ["dist/index.js"],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = resolveSessionSkills({
|
||||
projectRootDir: dir,
|
||||
});
|
||||
|
||||
// No skill patterns exist (package has extensions, not skills)
|
||||
expect(result.filterActive).toBe(false);
|
||||
expect(result.allowedSkillPaths.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reads from .fusion/settings.json primary and .pi/settings.json fallback", () => {
|
||||
it("prefers .fusion/settings.json over .pi/settings.json", () => {
|
||||
const dir = createMockProjectDir(null);
|
||||
|
||||
// Create .pi/settings.json (legacy)
|
||||
mockFiles.set(`${dir}/.pi/settings.json`, JSON.stringify({
|
||||
skills: ["+skills/legacy/SKILL.md"],
|
||||
}));
|
||||
|
||||
// Create .fusion/settings.json (newer, should win)
|
||||
mockFiles.set(`${dir}/.fusion/settings.json`, JSON.stringify({
|
||||
skills: ["+skills/fusion/SKILL.md"],
|
||||
}));
|
||||
|
||||
const result = resolveSessionSkills({
|
||||
projectRootDir: dir,
|
||||
});
|
||||
|
||||
expect(result.allowedSkillPaths.has("skills/fusion/SKILL.md")).toBe(true);
|
||||
expect(result.allowedSkillPaths.has("skills/legacy/SKILL.md")).toBe(false);
|
||||
});
|
||||
|
||||
it("falls back to .pi/settings.json when .fusion/settings.json doesn't exist", () => {
|
||||
const dir = createMockProjectDir(null);
|
||||
|
||||
// Create only .pi/settings.json (legacy)
|
||||
mockFiles.set(`${dir}/.pi/settings.json`, JSON.stringify({
|
||||
skills: ["+skills/legacy/SKILL.md"],
|
||||
}));
|
||||
|
||||
const result = resolveSessionSkills({
|
||||
projectRootDir: dir,
|
||||
});
|
||||
|
||||
expect(result.allowedSkillPaths.has("skills/legacy/SKILL.md")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("handles missing/empty settings files gracefully", () => {
|
||||
it("handles invalid JSON gracefully", () => {
|
||||
const dir = createMockProjectDir(null);
|
||||
|
||||
// Set invalid JSON
|
||||
mockFiles.set(`${dir}/.fusion/settings.json`, "not valid json {{{");
|
||||
|
||||
// Should not throw
|
||||
const result = resolveSessionSkills({
|
||||
projectRootDir: dir,
|
||||
});
|
||||
|
||||
expect(result.filterActive).toBe(false);
|
||||
});
|
||||
|
||||
it("handles malformed settings object gracefully", () => {
|
||||
const dir = createMockProjectDir(null);
|
||||
|
||||
mockFiles.set(`${dir}/.fusion/settings.json`, JSON.stringify({
|
||||
skills: "not an array",
|
||||
packages: "also not an array",
|
||||
}));
|
||||
|
||||
// Should not throw - malformed data treated as no patterns
|
||||
const result = resolveSessionSkills({
|
||||
projectRootDir: dir,
|
||||
});
|
||||
|
||||
expect(result.filterActive).toBe(false);
|
||||
expect(result.allowedSkillPaths.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("produces info diagnostics for patterns", () => {
|
||||
it("produces info diagnostic for each + pattern", () => {
|
||||
const dir = createMockProjectDir({
|
||||
skills: ["+skills/foo/SKILL.md", "-skills/bar/SKILL.md"],
|
||||
});
|
||||
|
||||
const result = resolveSessionSkills({
|
||||
projectRootDir: dir,
|
||||
});
|
||||
|
||||
const infoDiags = result.diagnostics.filter(d => d.type === "info");
|
||||
expect(infoDiags).toHaveLength(1); // Only + pattern gets info diag
|
||||
expect(infoDiags[0].skillPath).toBe("skills/foo/SKILL.md");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("createSkillsOverrideFromSelection", () => {
|
||||
describe("with filterActive: false", () => {
|
||||
it("returns base unchanged", () => {
|
||||
const selection: SkillSelectionResult = {
|
||||
allowedSkillPaths: new Set(),
|
||||
diagnostics: [],
|
||||
filterActive: false,
|
||||
};
|
||||
|
||||
const override = createSkillsOverrideFromSelection(selection);
|
||||
const base = {
|
||||
skills: [
|
||||
{ name: "foo", filePath: "/path/foo", description: "", baseDir: "", sourceInfo: {} as any, disableModelInvocation: false },
|
||||
{ name: "bar", filePath: "/path/bar", description: "", baseDir: "", sourceInfo: {} as any, disableModelInvocation: false },
|
||||
],
|
||||
diagnostics: [],
|
||||
};
|
||||
|
||||
const result = override(base);
|
||||
|
||||
expect(result.skills).toHaveLength(2);
|
||||
expect(result.diagnostics).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("with filterActive: true", () => {
|
||||
it("filters skills by allowedSkillPaths", () => {
|
||||
const selection: SkillSelectionResult = {
|
||||
allowedSkillPaths: new Set(["/path/foo"]),
|
||||
diagnostics: [],
|
||||
filterActive: true,
|
||||
};
|
||||
|
||||
const override = createSkillsOverrideFromSelection(selection);
|
||||
const base = {
|
||||
skills: [
|
||||
{ name: "foo", filePath: "/path/foo", description: "", baseDir: "", sourceInfo: {} as any, disableModelInvocation: false },
|
||||
{ name: "bar", filePath: "/path/bar", description: "", baseDir: "", sourceInfo: {} as any, disableModelInvocation: false },
|
||||
],
|
||||
diagnostics: [],
|
||||
};
|
||||
|
||||
const result = override(base);
|
||||
|
||||
expect(result.skills).toHaveLength(1);
|
||||
expect(result.skills[0].name).toBe("foo");
|
||||
});
|
||||
|
||||
it("appends warning diagnostic for allowed paths not matching any skill", () => {
|
||||
const selection: SkillSelectionResult = {
|
||||
allowedSkillPaths: new Set(["/path/nonexistent"]),
|
||||
diagnostics: [],
|
||||
filterActive: true,
|
||||
};
|
||||
|
||||
const override = createSkillsOverrideFromSelection(selection);
|
||||
const base = {
|
||||
skills: [
|
||||
{ name: "foo", filePath: "/path/foo", description: "", baseDir: "", sourceInfo: {} as any, disableModelInvocation: false },
|
||||
],
|
||||
diagnostics: [],
|
||||
};
|
||||
|
||||
const result = override(base);
|
||||
|
||||
expect(result.skills).toHaveLength(0);
|
||||
expect(result.diagnostics).toHaveLength(1);
|
||||
expect(result.diagnostics[0].type).toBe("warning");
|
||||
expect(result.diagnostics[0].message).toContain("not found in discovered skills");
|
||||
});
|
||||
|
||||
it("checks requested names against discovered skills (case-insensitive)", () => {
|
||||
const selection: SkillSelectionResult = {
|
||||
allowedSkillPaths: new Set(),
|
||||
diagnostics: [],
|
||||
filterActive: true,
|
||||
};
|
||||
|
||||
const override = createSkillsOverrideFromSelection(selection, {
|
||||
requestedSkillNames: ["PAPERCLIP", "CustomSkill"],
|
||||
sessionPurpose: "test",
|
||||
});
|
||||
|
||||
const base = {
|
||||
skills: [
|
||||
{ name: "paperclip", filePath: "/path/paperclip", description: "", baseDir: "", sourceInfo: {} as any, disableModelInvocation: false },
|
||||
{ name: "lint", filePath: "/path/lint", description: "", baseDir: "", sourceInfo: {} as any, disableModelInvocation: false },
|
||||
],
|
||||
diagnostics: [],
|
||||
};
|
||||
|
||||
const result = override(base);
|
||||
|
||||
expect(result.diagnostics).toHaveLength(1); // Only CustomSkill not found
|
||||
expect(result.diagnostics[0].type).toBe("warning");
|
||||
expect(result.diagnostics[0].message).toContain("CustomSkill");
|
||||
});
|
||||
|
||||
it("preserves base diagnostics alongside new diagnostics", () => {
|
||||
const selection: SkillSelectionResult = {
|
||||
allowedSkillPaths: new Set(["/path/foo"]),
|
||||
diagnostics: [],
|
||||
filterActive: true,
|
||||
};
|
||||
|
||||
const override = createSkillsOverrideFromSelection(selection);
|
||||
const base = {
|
||||
skills: [
|
||||
{ name: "foo", filePath: "/path/foo", description: "", baseDir: "", sourceInfo: {} as any, disableModelInvocation: false },
|
||||
],
|
||||
diagnostics: [
|
||||
{ type: "warning" as const, message: "base warning" },
|
||||
],
|
||||
};
|
||||
|
||||
const result = override(base);
|
||||
|
||||
expect(result.diagnostics).toHaveLength(1);
|
||||
expect(result.diagnostics[0].message).toBe("base warning");
|
||||
});
|
||||
|
||||
it("logs diagnostics via console.error", () => {
|
||||
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
const selection: SkillSelectionResult = {
|
||||
allowedSkillPaths: new Set(["/path/nonexistent"]),
|
||||
diagnostics: [],
|
||||
filterActive: true,
|
||||
};
|
||||
|
||||
const override = createSkillsOverrideFromSelection(selection, {
|
||||
sessionPurpose: "executor",
|
||||
});
|
||||
|
||||
const base = {
|
||||
skills: [],
|
||||
diagnostics: [],
|
||||
};
|
||||
|
||||
override(base);
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalled();
|
||||
const lastCall = consoleErrorSpy.mock.calls[consoleErrorSpy.mock.calls.length - 1][0] as string;
|
||||
expect(lastCall).toContain("[pi] [skills]");
|
||||
expect(lastCall).toContain("nonexistent");
|
||||
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("includes sessionPurpose in log messages when provided", () => {
|
||||
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
const selection: SkillSelectionResult = {
|
||||
allowedSkillPaths: new Set(["/path/foo"]),
|
||||
diagnostics: [],
|
||||
filterActive: true,
|
||||
};
|
||||
|
||||
const override = createSkillsOverrideFromSelection(selection, {
|
||||
requestedSkillNames: ["missing-skill"],
|
||||
sessionPurpose: "reviewer",
|
||||
});
|
||||
|
||||
const base = {
|
||||
skills: [
|
||||
{ name: "foo", filePath: "/path/foo", description: "", baseDir: "", sourceInfo: {} as any, disableModelInvocation: false },
|
||||
],
|
||||
diagnostics: [],
|
||||
};
|
||||
|
||||
override(base);
|
||||
|
||||
const lastCall = consoleErrorSpy.mock.calls[consoleErrorSpy.mock.calls.length - 1][0] as string;
|
||||
expect(lastCall).toContain("[reviewer]");
|
||||
expect(lastCall).toContain("missing-skill");
|
||||
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
});
|
||||
369
packages/engine/src/skill-resolver.ts
Normal file
369
packages/engine/src/skill-resolver.ts
Normal file
@@ -0,0 +1,369 @@
|
||||
/**
|
||||
* Skill selection resolver for deterministic session skill sets.
|
||||
*
|
||||
* Computes which skills should be available in agent sessions based on:
|
||||
* 1. Project execution-enabled skill patterns from settings
|
||||
* 2. Optional caller-requested skill names (for per-task overrides)
|
||||
*
|
||||
* The resolver reads project settings files directly (read-only) and produces
|
||||
* a filter set used by createKbAgent's DefaultResourceLoader.skillsOverride.
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import type { ResourceDiagnostic, Skill } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
// ── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Context for skill selection resolution.
|
||||
*/
|
||||
export interface SkillSelectionContext {
|
||||
/**
|
||||
* Absolute path to the project root for reading settings.
|
||||
*/
|
||||
projectRootDir: string;
|
||||
|
||||
/**
|
||||
* Optional explicit skill names the caller wants (e.g., from task config).
|
||||
* These are skill names (not IDs), matched case-insensitively against Skill.name.
|
||||
*/
|
||||
requestedSkillNames?: string[];
|
||||
|
||||
/**
|
||||
* Diagnostic label for log messages (e.g., "executor", "triage", "reviewer").
|
||||
*/
|
||||
sessionPurpose?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Diagnostic about a configured or requested skill.
|
||||
*/
|
||||
export interface SkillDiagnostic {
|
||||
type: "info" | "warning" | "error";
|
||||
message: string;
|
||||
skillName?: string;
|
||||
skillPath?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of skill selection resolution.
|
||||
*/
|
||||
export interface SkillSelectionResult {
|
||||
/**
|
||||
* Set of skill file paths to include in the session.
|
||||
* Used by skillsOverride to filter discovered skills.
|
||||
*/
|
||||
allowedSkillPaths: Set<string>;
|
||||
|
||||
/**
|
||||
* Diagnostics about configured/requested skills.
|
||||
*/
|
||||
diagnostics: SkillDiagnostic[];
|
||||
|
||||
/**
|
||||
* Whether filtering should be applied.
|
||||
* false = all discovered skills pass through (no patterns configured, no requested names)
|
||||
* true = skills are filtered according to allowedSkillPaths
|
||||
*/
|
||||
filterActive: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Project settings structure relevant to skill selection.
|
||||
*/
|
||||
interface ProjectSkillSettings {
|
||||
skills?: string[];
|
||||
packages?: Array<string | { source: string; skills?: string[] }>;
|
||||
}
|
||||
|
||||
// ── Settings Reading ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Read a JSON object from a file path.
|
||||
* Returns empty object if file doesn't exist or is invalid.
|
||||
*/
|
||||
function readJsonObject(path: string): Record<string, unknown> {
|
||||
if (!existsSync(path)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(readFileSync(path, "utf-8"));
|
||||
return parsed && typeof parsed === "object" ? parsed as Record<string, unknown> : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read project settings from .fusion/settings.json with .pi/settings.json fallback.
|
||||
*/
|
||||
function readProjectSettings(projectRootDir: string): ProjectSkillSettings {
|
||||
const fusionSettings = join(projectRootDir, ".fusion", "settings.json");
|
||||
const legacySettings = join(projectRootDir, ".pi", "settings.json");
|
||||
|
||||
// Try .fusion first, then .pi
|
||||
if (existsSync(fusionSettings)) {
|
||||
const parsed = readJsonObject(fusionSettings);
|
||||
// Only return skill-relevant fields
|
||||
return {
|
||||
skills: Array.isArray(parsed.skills) ? (parsed.skills as string[]) : undefined,
|
||||
packages: Array.isArray(parsed.packages) ? (parsed.packages as Array<string | { source: string; skills?: string[] }>) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
if (existsSync(legacySettings)) {
|
||||
const parsed = readJsonObject(legacySettings);
|
||||
return {
|
||||
skills: Array.isArray(parsed.skills) ? (parsed.skills as string[]) : undefined,
|
||||
packages: Array.isArray(parsed.packages) ? (parsed.packages as Array<string | { source: string; skills?: string[] }>) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
// ── Pattern Normalization ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Normalize a skill pattern by removing the + prefix (enabled by default).
|
||||
* Returns the path portion of the pattern.
|
||||
*/
|
||||
function normalizePattern(pattern: string): string {
|
||||
if (pattern.startsWith("+") || pattern.startsWith("-")) {
|
||||
return pattern.slice(1);
|
||||
}
|
||||
return pattern;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a pattern is an exclusion pattern (-prefixed).
|
||||
*/
|
||||
function isExclusionPattern(pattern: string): boolean {
|
||||
return pattern.startsWith("-");
|
||||
}
|
||||
|
||||
// ── Main Resolution Logic ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Compute deterministic skill selection from project settings and optional requested names.
|
||||
*
|
||||
* Resolution rules:
|
||||
* 1. If NO skill patterns exist AND no requestedSkillNames → filterActive: false (all pass through)
|
||||
* 2. If skill patterns exist:
|
||||
* - + prefix or no prefix = add to allowed set
|
||||
* - - prefix = exclude from allowed set
|
||||
* - Last entry wins for duplicate paths
|
||||
* 3. If requestedSkillNames provided:
|
||||
* - Acts as additional intersection filter (skills must match name AND be in allowed set)
|
||||
* - Case-insensitive matching against Skill.name
|
||||
* 4. Diagnostics produced for:
|
||||
* - Patterns that don't match discovered skills (warning)
|
||||
* - Requested names not matching any discovered skill (warning)
|
||||
*/
|
||||
export function resolveSessionSkills(context: SkillSelectionContext): SkillSelectionResult {
|
||||
const { projectRootDir, requestedSkillNames } = context;
|
||||
|
||||
// Read project settings
|
||||
const settings = readProjectSettings(projectRootDir);
|
||||
|
||||
// Collect all skill patterns from settings
|
||||
const skillPatterns: string[] = [];
|
||||
|
||||
// Top-level skills patterns
|
||||
if (settings.skills) {
|
||||
for (const pattern of settings.skills) {
|
||||
if (typeof pattern === "string") {
|
||||
skillPatterns.push(pattern);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Package-scoped skill patterns
|
||||
if (settings.packages) {
|
||||
for (const pkg of settings.packages) {
|
||||
if (typeof pkg === "object" && pkg !== null && "skills" in pkg && Array.isArray(pkg.skills)) {
|
||||
for (const pattern of pkg.skills) {
|
||||
if (typeof pattern === "string") {
|
||||
skillPatterns.push(pattern);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const hasPatterns = skillPatterns.length > 0;
|
||||
const hasRequestedNames = Boolean(requestedSkillNames && requestedSkillNames.length > 0);
|
||||
|
||||
// If no patterns and no requested names, no filtering needed
|
||||
if (!hasPatterns && !hasRequestedNames) {
|
||||
return {
|
||||
allowedSkillPaths: new Set<string>(),
|
||||
diagnostics: [],
|
||||
filterActive: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Build allowed set from patterns
|
||||
// Last entry wins for duplicate paths: we track the "final decision" per path
|
||||
const finalDecisions = new Map<string, boolean>(); // true = allowed, false = excluded
|
||||
|
||||
for (const pattern of skillPatterns) {
|
||||
const path = normalizePattern(pattern);
|
||||
const isExclusion = isExclusionPattern(pattern);
|
||||
finalDecisions.set(path, !isExclusion);
|
||||
}
|
||||
|
||||
// Build allowed set from final decisions
|
||||
const allowedSet = new Set<string>();
|
||||
for (const [path, allowed] of finalDecisions) {
|
||||
if (allowed) {
|
||||
allowedSet.add(path);
|
||||
}
|
||||
}
|
||||
|
||||
// Determine if filtering is active
|
||||
// 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;
|
||||
|
||||
// Produce diagnostics for patterns (we can't check against actual discovered skills here,
|
||||
// so we note which patterns are configured)
|
||||
const diagnostics: SkillDiagnostic[] = [];
|
||||
|
||||
if (hasPatterns) {
|
||||
for (const pattern of skillPatterns) {
|
||||
if (!isExclusionPattern(pattern)) {
|
||||
// Note: We don't have access to discovered skills here to check if pattern matches
|
||||
// The actual validation happens in createSkillsOverrideFromSelection when base.skills is available
|
||||
const path = normalizePattern(pattern);
|
||||
diagnostics.push({
|
||||
type: "info",
|
||||
message: `Configured skill pattern: ${pattern}`,
|
||||
skillPath: path,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (hasRequestedNames) {
|
||||
for (const name of requestedSkillNames!) {
|
||||
diagnostics.push({
|
||||
type: "info",
|
||||
message: `Requested skill: ${name}`,
|
||||
skillName: name,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
allowedSkillPaths: allowedSet,
|
||||
diagnostics,
|
||||
filterActive,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Skills Override Factory ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Options for skills override filtering.
|
||||
* We track requested names here so we can validate against base.skills.
|
||||
*/
|
||||
export interface SkillsOverrideOptions {
|
||||
/** Set of allowed skill paths */
|
||||
allowedSkillPaths: Set<string>;
|
||||
/** Whether filtering is active */
|
||||
filterActive: boolean;
|
||||
/** Requested skill names for diagnostic purposes */
|
||||
requestedSkillNames?: string[];
|
||||
/** Session purpose for log messages */
|
||||
sessionPurpose?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a skillsOverride callback compatible with DefaultResourceLoaderOptions.skillsOverride.
|
||||
*
|
||||
* @param selection - The skill selection result from resolveSessionSkills
|
||||
* @param options - Additional options for the override
|
||||
* @returns A skillsOverride callback for DefaultResourceLoader
|
||||
*/
|
||||
export function createSkillsOverrideFromSelection(
|
||||
selection: SkillSelectionResult,
|
||||
options: Omit<SkillsOverrideOptions, "allowedSkillPaths" | "filterActive"> = {},
|
||||
): (base: { skills: Skill[]; diagnostics: ResourceDiagnostic[] }) => { skills: Skill[]; diagnostics: ResourceDiagnostic[] } {
|
||||
const { allowedSkillPaths, filterActive } = selection;
|
||||
const { requestedSkillNames, sessionPurpose } = options;
|
||||
|
||||
return (base: { skills: Skill[]; diagnostics: ResourceDiagnostic[] }) => {
|
||||
// If filtering is not active, return base unchanged
|
||||
if (!filterActive) {
|
||||
return base;
|
||||
}
|
||||
|
||||
// Determine the effective filter criteria
|
||||
// When requestedSkillNames is provided without patterns, filter by name
|
||||
// When patterns are provided, filter by file path
|
||||
const hasPatterns = allowedSkillPaths.size > 0;
|
||||
const hasRequestedNames = Boolean(requestedSkillNames && requestedSkillNames.length > 0);
|
||||
|
||||
// Filter skills
|
||||
let filteredSkills: Skill[];
|
||||
if (hasRequestedNames) {
|
||||
// Filter by requested names (case-insensitive match)
|
||||
const requestedNamesLower = new Set(requestedSkillNames!.map((n) => n.toLowerCase()));
|
||||
filteredSkills = base.skills.filter((skill) => requestedNamesLower.has(skill.name.toLowerCase()));
|
||||
} else if (hasPatterns) {
|
||||
// Filter by file path
|
||||
filteredSkills = base.skills.filter((skill) => allowedSkillPaths.has(skill.filePath));
|
||||
} else {
|
||||
// No filter criteria - this shouldn't happen if filterActive is true
|
||||
filteredSkills = base.skills;
|
||||
}
|
||||
|
||||
// Build diagnostics for missing skills
|
||||
const newDiagnostics: ResourceDiagnostic[] = [];
|
||||
|
||||
// Check for configured patterns that don't match any discovered skill
|
||||
// Note: At this point, we have access to base.skills for validation
|
||||
const purpose = sessionPurpose ? ` [${sessionPurpose}]` : "";
|
||||
for (const allowedPath of allowedSkillPaths) {
|
||||
const hasMatch = base.skills.some((skill) => skill.filePath === allowedPath);
|
||||
if (!hasMatch) {
|
||||
newDiagnostics.push({
|
||||
type: "warning",
|
||||
message: `Configured skill pattern '${allowedPath}' not found in discovered skills${purpose}`,
|
||||
path: allowedPath,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Check for requested names that don't match any discovered skill
|
||||
if (requestedSkillNames) {
|
||||
const discoveredNamesLower = new Set(base.skills.map((s) => s.name.toLowerCase()));
|
||||
for (const requestedName of requestedSkillNames) {
|
||||
if (!discoveredNamesLower.has(requestedName.toLowerCase())) {
|
||||
const purpose = sessionPurpose ? ` [${sessionPurpose}]` : "";
|
||||
newDiagnostics.push({
|
||||
type: "warning",
|
||||
message: `Requested skill '${requestedName}' not found in discovered skills${purpose}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Log diagnostics if any
|
||||
if (newDiagnostics.length > 0) {
|
||||
const purpose = sessionPurpose ? `[${sessionPurpose}]` : "skills";
|
||||
for (const diag of newDiagnostics) {
|
||||
console.error(`[pi] [skills] ${diag.type}: ${diag.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
skills: filteredSkills,
|
||||
diagnostics: [...base.diagnostics, ...newDiagnostics],
|
||||
};
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user