feat(FN-1510): merge fusion/fn-1510

This commit is contained in:
gsxdsm
2026-04-14 08:13:53 -07:00
parent 271f3f7c16
commit ad363d8441
7 changed files with 1257 additions and 1 deletions

View File

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

View File

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

View File

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

View 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();
});
});
});

View 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],
};
};
}