refactor: unify skill system on library.yaml, disable pi --skill flag

Switch to Option B: pi's built-in skill discovery disabled (--no-skills),
agent-scoped skills injected via library.yaml + skills_search extension tool.

Removes triple-loading where skills appeared in pi's system prompt, our
--append-system-prompt, and the extension tool simultaneously. Agents now
get only their assigned skills from library.yaml.

- Remove --skill flag from controlplane-runner, jail-exec-runner, agent-runner
- Add --no-skills to all pi invocations
- Remove skillsDir from ControlplaneHeartbeatConfig and all plumbing
- Simplify loadSkillsCatalog() to library.yaml only (remove fs fallback)
- Remove dead: PI_TUI_SKILLS_PATH, PI_TUI_PROFILE_LABEL, countSkillsInDir
- Remove 214 lines net across 17 files

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Mevy Assistant 2026-04-18 08:48:20 +00:00
parent 2027d9870a
commit dba0b691f8
17 changed files with 55 additions and 269 deletions

View file

@ -19,9 +19,7 @@ import {
PI_TUI_MODEL,
PI_TUI_NO_SESSION,
PI_TUI_OFFLINE,
PI_TUI_PROFILE,
PI_TUI_PROVIDER,
PI_TUI_SKILLS_PATH,
PI_TUI_THINKING,
PI_TUI_TOOLS,
TMP_DIR,
@ -171,16 +169,9 @@ export async function runJailAgent(
if (PI_TUI_MODEL) args.push('--model', PI_TUI_MODEL);
if (PI_TUI_TOOLS) args.push('--tools', PI_TUI_TOOLS);
if (PI_TUI_THINKING) args.push('--thinking', PI_TUI_THINKING);
// Inject skills only for task-oriented profiles — not for operator/status
// chat mode where the model gets distracted by the skills directory listing.
const SKILL_AWARE_PROFILES = ['setup', 'docs', 'cms', 'git', 'memory', 'coding'];
if (
SKILL_AWARE_PROFILES.includes(PI_TUI_PROFILE) &&
PI_TUI_SKILLS_PATH &&
fs.existsSync(PI_TUI_SKILLS_PATH)
) {
args.push('--skill', PI_TUI_SKILLS_PATH);
}
// Disable pi's built-in skill discovery — skills are scoped per-agent via
// library.yaml and served on-demand through the skills_search extension tool.
args.push('--no-skills');
// Append identity + per-call context (memory, skills) to system prompt.
// This keeps ephemeral context out of the session file — it is never stored
// in the JSONL history, so it cannot compound across turns.

View file

@ -234,7 +234,6 @@ const resolvedPiTuiProfile = resolvePiTuiProfile({
export const PI_TUI_PROFILE = resolvedPiTuiProfile.name;
export const PI_TUI_PROVIDER = resolvedPiTuiProfile.provider;
export const PI_TUI_MODEL = resolvedPiTuiProfile.model;
export const PI_TUI_PROFILE_LABEL = resolvedPiTuiProfile.label;
export const PI_TUI_DISPLAY_INTENT = resolvedPiTuiProfile.displayIntent;
export const PI_TUI_SESSION_POLICY = resolvedPiTuiProfile.sessionPolicy;
export const PI_TUI_TOOLS = resolvedPiTuiProfile.tools || '';
@ -243,8 +242,6 @@ export const PI_TUI_NO_SESSION = resolvedPiTuiProfile.noSession === true;
export const PI_TUI_OFFLINE = resolvedPiTuiProfile.offline === true;
export const PI_TUI_APPEND_SYSTEM_PROMPT =
resolvedPiTuiProfile.appendSystemPrompt;
// Skill files in .agent/skills/ — exposed to pi via --skill flag
export const PI_TUI_SKILLS_PATH = path.join(process.cwd(), '.agent', 'skills');
export const POLL_INTERVAL = 2000;
export const SCHEDULER_POLL_INTERVAL = 60000;

View file

@ -20,7 +20,6 @@ import {
verifyPassword,
generatePassword,
copySkills,
countSkillsInDir,
CONTROLPLANE_SCHEMA_SQL,
VALID_EVENT_TYPES,
} from './controlplane-db.js';
@ -170,31 +169,6 @@ describe('generatePassword', () => {
});
});
describe('countSkillsInDir', () => {
it('returns 0 for non-existent directory', () => {
expect(countSkillsInDir(path.join(TMP, 'no-such-dir'))).toBe(0);
});
it('returns 0 for empty directory', () => {
const emptyDir = path.join(TMP, 'empty');
fs.mkdirSync(emptyDir);
expect(countSkillsInDir(emptyDir)).toBe(0);
});
it('counts only directories with SKILL.md', () => {
const skillsDir = path.join(TMP, 'skills');
fs.mkdirSync(skillsDir, { recursive: true });
fs.mkdirSync(path.join(skillsDir, 'skill-a'));
fs.writeFileSync(path.join(skillsDir, 'skill-a', 'SKILL.md'), '# A');
fs.mkdirSync(path.join(skillsDir, 'skill-b'));
fs.writeFileSync(path.join(skillsDir, 'skill-b', 'SKILL.md'), '# B');
fs.mkdirSync(path.join(skillsDir, 'not-a-skill'));
fs.writeFileSync(path.join(skillsDir, 'readme.md'), 'ignore');
expect(countSkillsInDir(skillsDir)).toBe(2);
});
});
describe('copySkills', () => {
it('throws if source directory does not exist', () => {
expect(() =>

View file

@ -583,16 +583,6 @@ export function copySkills(sourceDir: string, destDir: string): void {
}
}
export function countSkillsInDir(dir: string): number {
if (!fs.existsSync(dir)) return 0;
return fs
.readdirSync(dir, { withFileTypes: true })
.filter(
(e) =>
e.isDirectory() && fs.existsSync(path.join(dir, e.name, 'SKILL.md')),
).length;
}
// ── Task queries ────────────────────────────────────────────────────────────
export async function createTask(

View file

@ -32,7 +32,6 @@ function makeConfig(
pool: mockPool.pool as unknown as Pool,
workspaceCwd: process.cwd(),
sessionCwd: path.join(TMP, 'sessions'),
skillsDir: path.join(TMP, 'skills'),
apiKey: 'test-key',
agentName: 'clawdie',
tickIntervalMs: 60000,

View file

@ -59,7 +59,6 @@ export interface ControlplaneHeartbeatConfig {
pool: Pool;
workspaceCwd: string;
sessionCwd: string;
skillsDir: string;
apiKey: string;
agentName: string;
tickIntervalMs: number;
@ -201,7 +200,7 @@ export async function runAgentHeartbeat(
wakeReason: WakeReason,
taskId?: string,
): Promise<HeartbeatResult> {
const { pool, workspaceCwd, sessionCwd, skillsDir, apiKey } = config;
const { pool, workspaceCwd, sessionCwd, apiKey } = config;
// Resolve alias IDs (db-admin → db_admin_agent) for budget + identity lookups.
const canonicalId = resolveCanonicalAgentId(agentId);
@ -230,7 +229,7 @@ export async function runAgentHeartbeat(
}
const taskDescription = task?.title ?? `Heartbeat check for ${agentId}`;
const skills = loadSkillsCatalog(skillsDir);
const skills = loadSkillsCatalog();
const match = matchTaskToSkill(taskDescription, skills);
const prompt =
@ -268,8 +267,6 @@ export async function runAgentHeartbeat(
if (jailName) {
// ── Jail execution path ────────────────────────────────────────────
// Skills are mounted at /opt/skills inside every agent jail (nullfs ro)
const jailSkillsDir = '/opt/skills';
const jailEnv = {
CONTROLPLANE_AGENT_ID: agentId,
CONTROLPLANE_TASK_ID: effectiveTaskId,
@ -298,7 +295,6 @@ export async function runAgentHeartbeat(
jailName,
prompt: promptWithContext,
systemPrompt: identityContent ?? undefined,
skillsDir: jailSkillsDir,
extensionPath: jailExtPath,
env: jailEnv,
});

View file

@ -122,11 +122,10 @@ describe('Control Plane Runner — env injection', () => {
expect(result.args[idx + 1]).toContain('sysadmin_agent.jsonl');
});
it('args include --skill pointing to data/skills/', () => {
it('args include --no-skills to disable pi skill discovery', () => {
const result = buildControlplaneRunCommand(makeOpts());
const idx = result.args.indexOf('--skill');
expect(idx).toBeGreaterThanOrEqual(0);
expect(result.args[idx + 1]).toContain('data/skills');
expect(result.args).toContain('--no-skills');
expect(result.args).not.toContain('--skill');
});
it('args include --append-system-prompt with identity content', () => {

View file

@ -127,19 +127,21 @@ export function buildControlplaneRunCommand(
: path.resolve(workspaceCwd, sessionCwd);
const sessionFile = path.join(absSessionCwd, `${agentId}.jsonl`);
const skillsDir = path.join(workspaceCwd, 'data', 'skills');
const identityFile =
opts.identityFile ?? resolveIdentityFile(agentId, agentName, workspaceCwd);
const prompt = opts.prompt ?? `Control plane wake: ${wakeReason}`;
// Build system prompt: identity + skill index (lazy load via --skill flag)
// Build system prompt: identity + scoped skill index from library.yaml.
// Pi's built-in skill discovery is disabled (--no-skills) — we inject only
// the agent's assigned skills via system prompt. Full content is available
// on-demand through the skills_search extension tool.
const identityContent = fs.existsSync(identityFile)
? fs.readFileSync(identityFile, 'utf-8')
: '';
const skillIndex = getAgentSkillIndex(agentId);
const skillContext =
skillIndex.length > 0
? '\n\n---\n\n## Available Skills (use --skill to load full content)\n\n' +
? '\n\n---\n\n## Available Skills (use skills_search tool for full content)\n\n' +
skillIndex.join('\n')
: '';
const systemPrompt = identityContent + skillContext;
@ -147,10 +149,9 @@ export function buildControlplaneRunCommand(
const args: string[] = [
'--mode',
'json',
'--no-skills',
'--session',
sessionFile,
'--skill',
skillsDir,
];
// Load the harness extension so agents get tools (hostd, skills_search, etc.)

View file

@ -14,7 +14,6 @@ import path from 'path';
import {
DEFAULT_AGENTS,
copySkills,
countSkillsInDir,
generatePassword,
hashPassword,
verifyPassword,
@ -225,22 +224,7 @@ describe('Control Plane Provisioning', () => {
expect(stat.isFile()).toBe(true);
});
it('countSkillsInDir returns correct count', () => {
const base = path.join(tmpDir, 'count-skills');
for (const name of ['skill-a', 'skill-b', 'skill-c']) {
fs.mkdirSync(path.join(base, name), { recursive: true });
fs.writeFileSync(path.join(base, name, 'SKILL.md'), `---\nname: ${name}\n---`);
}
// add dir without SKILL.md
fs.mkdirSync(path.join(base, 'not-a-skill'), { recursive: true });
expect(countSkillsInDir(base)).toBe(3);
});
it('countSkillsInDir returns 0 for missing directory', () => {
expect(countSkillsInDir(path.join(tmpDir, 'does-not-exist'))).toBe(0);
});
});
});
describe('idempotency (via upsert semantics)', () => {
it('DEFAULT_AGENTS have unique ids (no duplicate roles)', () => {

View file

@ -32,7 +32,6 @@ import { bridgeTelegramMessage } from './controlplane-telegram.js';
import type { ControlplaneHeartbeatConfig } from './controlplane-heartbeat.js';
let tmpDir: string;
let skillsDir: string;
let sessionDir: string;
const insertedTasks: Array<{
@ -65,7 +64,6 @@ function makeConfig(pool: Pool): ControlplaneHeartbeatConfig {
pool,
workspaceCwd: tmpDir,
sessionCwd: sessionDir,
skillsDir,
apiKey: 'test-key',
agentName: 'clawdie',
tickIntervalMs: 30000,
@ -73,22 +71,9 @@ function makeConfig(pool: Pool): ControlplaneHeartbeatConfig {
};
}
function writeSkill(name: string, invokePatterns: string[]): void {
const dir = path.join(skillsDir, name);
fs.mkdirSync(dir, { recursive: true });
const patterns = invokePatterns.map((p) => ` - "${p}"`).join('\n');
fs.writeFileSync(
path.join(dir, 'SKILL.md'),
`---\nname: ${name}\ndescription: Test skill\ninvoke_patterns:\n${patterns}\n---\n\n# ${name}\n`,
'utf-8',
);
}
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tg-bridge-test-'));
skillsDir = path.join(tmpDir, 'skills');
sessionDir = path.join(tmpDir, 'sessions');
fs.mkdirSync(skillsDir, { recursive: true });
fs.mkdirSync(sessionDir, { recursive: true });
});
@ -111,7 +96,6 @@ describe('Telegram → Control Plane Bridge', () => {
});
it('routes to orchestrator when message does not match any skill', async () => {
writeSkill('jail-status', ['Is * jail running', 'Check jail *']);
const pool = makePool();
const result = await bridgeTelegramMessage(
pool,
@ -124,7 +108,6 @@ describe('Telegram → Control Plane Bridge', () => {
});
it('returns task result when message matches a skill', async () => {
writeSkill('jail-status', ['Is * jail running', 'Check jail *']);
const pool = makePool();
const result = await bridgeTelegramMessage(
pool,
@ -136,7 +119,6 @@ describe('Telegram → Control Plane Bridge', () => {
});
it('result includes skillName from matched skill', async () => {
writeSkill('jail-status', ['Is * jail running', 'Check * jail']);
const pool = makePool();
const result = await bridgeTelegramMessage(
pool,
@ -150,19 +132,17 @@ describe('Telegram → Control Plane Bridge', () => {
describe('agent routing', () => {
it('routes jail messages to sysadmin_agent', async () => {
writeSkill('jail-status', ['Check jail *', 'Is * jail running']);
const pool = makePool();
const result = await bridgeTelegramMessage(
pool,
makeConfig(pool),
'tg:test',
'Check jail db',
'Check jail status',
);
expect(result.assignedTo).toBe('sysadmin_agent');
});
it('routes database messages to db_admin_agent', async () => {
writeSkill('backup-db', ['backup * database', 'run database backup']);
const pool = makePool();
const result = await bridgeTelegramMessage(
pool,
@ -174,7 +154,6 @@ describe('Telegram → Control Plane Bridge', () => {
});
it('routes git messages to git_admin_agent', async () => {
writeSkill('git-status', ['check git repo', 'git status *']);
const pool = makePool();
const result = await bridgeTelegramMessage(
pool,
@ -186,7 +165,6 @@ describe('Telegram → Control Plane Bridge', () => {
});
it('routes unrecognised agent messages to orchestrator', async () => {
writeSkill('general', ['review system status', 'system overview']);
const pool = makePool();
const result = await bridgeTelegramMessage(
pool,
@ -222,7 +200,6 @@ describe('Telegram → Control Plane Bridge', () => {
describe('task creation', () => {
it('inserts a task into the database', async () => {
writeSkill('jail-status', ['Check jail *']);
const pool = makePool();
await bridgeTelegramMessage(
pool,
@ -234,7 +211,6 @@ describe('Telegram → Control Plane Bridge', () => {
});
it('task title is the message text (truncated to 200 chars)', async () => {
writeSkill('jail-status', ['Check jail *']);
const pool = makePool();
await bridgeTelegramMessage(
pool,
@ -246,7 +222,6 @@ describe('Telegram → Control Plane Bridge', () => {
});
it('task id starts with TG-', async () => {
writeSkill('jail-status', ['Check jail *']);
const pool = makePool();
await bridgeTelegramMessage(
pool,
@ -258,13 +233,12 @@ describe('Telegram → Control Plane Bridge', () => {
});
it('task assigned_to matches inferred role', async () => {
writeSkill('jail-status', ['Check jail *']);
const pool = makePool();
await bridgeTelegramMessage(
pool,
makeConfig(pool),
'tg:test',
'Check jail db',
'Check jail status',
);
expect(insertedTasks[0].assigned_to).toBe('sysadmin_agent');
});
@ -284,7 +258,6 @@ describe('Telegram → Control Plane Bridge', () => {
describe('long messages', () => {
it('truncates title to 200 characters', async () => {
writeSkill('jail-status', ['Check jail *']);
const pool = makePool();
const long = 'Check jail db ' + 'x'.repeat(300);
const result = await bridgeTelegramMessage(

View file

@ -102,7 +102,7 @@ export async function bridgeTelegramMessage(
chatJid: string,
message: string,
): Promise<TelegramBridgeResult> {
const skills = loadSkillsCatalog(config.skillsDir);
const skills = loadSkillsCatalog();
const match = matchTaskToSkill(message, skills);
const skillName = match.confidence !== 'none' ? match.skill?.name : undefined;

View file

@ -37,14 +37,11 @@ import { type Agent, type Budget, type Task } from './controlplane-db.js';
let tmpDir: string;
let sessionDir: string;
let skillsDir: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cp-integration-'));
sessionDir = path.join(tmpDir, 'sessions');
skillsDir = path.join(tmpDir, 'skills');
fs.mkdirSync(sessionDir, { recursive: true });
fs.mkdirSync(skillsDir, { recursive: true });
});
afterEach(() => {
@ -97,7 +94,6 @@ function makeConfig(pool: Pool): ControlplaneHeartbeatConfig {
pool,
workspaceCwd: tmpDir,
sessionCwd: sessionDir,
skillsDir,
apiKey: 'test-key',
agentName: 'clawdie',
tickIntervalMs: 30000,
@ -209,7 +205,6 @@ describe('Control Plane Integration — Full Heartbeat Cycle', () => {
it('pi spawn failure posts error event to control plane', async () => {
const pool = makePool();
const config = makeConfig(pool);
config.skillsDir = '/nonexistent/skills/path';
const result = await runAgentHeartbeat(config, 'sysadmin_agent', 'interval_elapsed');
expect(result.agentId).toBe('sysadmin_agent');
});
@ -217,7 +212,6 @@ describe('Control Plane Integration — Full Heartbeat Cycle', () => {
it('error event includes action_taken field', async () => {
const pool = makePool();
const config = makeConfig(pool);
config.skillsDir = '/nonexistent/skills/path';
await runAgentHeartbeat(config, 'sysadmin_agent', 'interval_elapsed');
if (activityLog.length > 0) {
const hasActionTaken = activityLog.some((e) => {
@ -231,7 +225,6 @@ describe('Control Plane Integration — Full Heartbeat Cycle', () => {
it('scheduler does not crash on agent error (continues to next tick)', async () => {
const pool = makePool();
const config = makeConfig(pool);
config.skillsDir = '/nonexistent';
const result = await runAgentHeartbeat(config, 'sysadmin_agent', 'interval_elapsed');
expect(result).toBeDefined();
expect(result).toHaveProperty('agentId');

View file

@ -10,7 +10,6 @@ import {
MAIN_GROUP_FOLDER,
METRICS_PORT,
OPENAI_API_KEY,
PI_TUI_SKILLS_PATH,
PROJECT_ROOT,
TMP_DIR,
TRIGGER_PATTERN,
@ -712,7 +711,6 @@ async function main(): Promise<void> {
pool: getMemoryPool(),
workspaceCwd: PROJECT_ROOT,
sessionCwd: sessionDir,
skillsDir: PI_TUI_SKILLS_PATH,
apiKey: CONTROLPLANE_SHARED_SECRET || OPENAI_API_KEY,
agentName: AGENT_NAME,
tickIntervalMs: 30000,

View file

@ -400,18 +400,18 @@ describe('jailPi', () => {
);
});
it('passes --skill to pi when skillsDir provided', async () => {
it('passes --no-skills to disable pi skill discovery', async () => {
mockSpawn.mockReturnValue(
makeMockProc({ stdout: '{}', exitCode: 0 }) as any,
);
await jailPi({
jailName: 'test-jail',
prompt: 'hello',
skillsDir: '/opt/skills',
});
const args = mockSpawn.mock.calls[0][1] as string[];
const shellCmd = args[args.length - 1] as string;
expect(shellCmd).toContain('--skill /opt/skills');
expect(shellCmd).toContain('--no-skills');
expect(shellCmd).not.toContain('--skill ');
});
});

View file

@ -273,7 +273,6 @@ export interface JailPiOptions {
systemPrompt?: string;
sessionId?: string;
sessionDir?: string;
skillsDir?: string;
extensionPath?: string;
env?: Record<string, string>;
timeoutMs?: number;
@ -290,9 +289,9 @@ export async function jailPi(opts: JailPiOptions): Promise<JailExecResult> {
} else {
args.push('--continue');
}
if (opts.skillsDir) {
args.push('--skill', opts.skillsDir);
}
// Disable pi's built-in skill discovery — skills are scoped per-agent via
// library.yaml and served on-demand through the skills_search extension tool.
args.push('--no-skills');
// Load harness extension for tools (hostd, skills_search, etc.)
if (opts.extensionPath) {
args.push('-e', opts.extensionPath);

View file

@ -3,10 +3,7 @@
*
* Run on FreeBSD with: npx vitest run src/skills-discovery.test.ts
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import fs from 'fs';
import path from 'path';
import os from 'os';
import { describe, it, expect } from 'vitest';
import {
loadSkillsCatalog,
@ -15,39 +12,6 @@ import {
type Skill,
} from './skills-discovery.js';
let tmpDir: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'skills-test-'));
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
function writeSkill(
dir: string,
name: string,
patterns: string[] = [],
desc = `${name} skill`,
) {
const skillDir = path.join(dir, name);
fs.mkdirSync(skillDir, { recursive: true });
const fm = [
'---',
`name: ${name}`,
`description: ${desc}`,
'compatibility: FreeBSD 15.0+',
'invoke_patterns:',
...patterns.map((p) => ` - "${p}"`),
`estimated_tokens: 300-500`,
'---',
'',
`# ${name}`,
].join('\n');
fs.writeFileSync(path.join(skillDir, 'SKILL.md'), fm, 'utf-8');
}
function makeSkills(names: string[]): Skill[] {
return names.map((name) => ({
name,
@ -59,51 +23,24 @@ function makeSkills(names: string[]): Skill[] {
describe('Skills Discovery', () => {
describe('catalog loading', () => {
it('discovers all SKILL.md files from skills directory', () => {
writeSkill(tmpDir, 'jail-status', ['Check if * jail is running']);
writeSkill(tmpDir, 'disk-usage', ['How much free disk']);
const skills = loadSkillsCatalog(tmpDir);
it('loads skills from library.yaml', () => {
const skills = loadSkillsCatalog();
const names = skills.map((s) => s.name);
expect(names).toContain('jail-status');
expect(names).toContain('disk-usage');
});
it('each skill has name, description, compatibility fields', () => {
writeSkill(tmpDir, 'jail-status', ['Jail status']);
const skills = loadSkillsCatalog(tmpDir);
it('each skill has name, description, invoke_patterns', () => {
const skills = loadSkillsCatalog();
const skill = skills[0];
expect(skill).toHaveProperty('name');
expect(skill).toHaveProperty('description');
expect(skill).toHaveProperty('compatibility');
expect(skill).toHaveProperty('invoke_patterns');
});
it('filesystem skills have compatibility field', () => {
writeSkill(tmpDir, 'backup-db', ['Back up the database']);
const skills = loadSkillsCatalog(tmpDir);
const fsSkill = skills.find((s) => s.name === 'backup-db');
expect(fsSkill).toBeDefined();
expect(fsSkill!.compatibility).toContain('FreeBSD 15.0+');
});
it('returns at least 2 skills from test directory', () => {
writeSkill(tmpDir, 'a', []);
writeSkill(tmpDir, 'b', []);
writeSkill(tmpDir, 'c', []);
const skills = loadSkillsCatalog(tmpDir);
expect(skills.length).toBeGreaterThanOrEqual(2);
});
it('handles missing directory gracefully — returns library skills', () => {
const skills = loadSkillsCatalog('/nonexistent/path/skills');
expect(skills.length).toBeGreaterThan(0);
});
it('skips subdirectories with no SKILL.md', () => {
writeSkill(tmpDir, 'real-skill-xyz', ['test']);
fs.mkdirSync(path.join(tmpDir, 'no-skill-md'), { recursive: true });
const skills = loadSkillsCatalog(tmpDir);
const fsSkill = skills.find((s) => s.name === 'real-skill-xyz');
expect(fsSkill).toBeDefined();
it('returns all skills from library.yaml', () => {
const skills = loadSkillsCatalog();
expect(skills.length).toBeGreaterThanOrEqual(10);
});
});

View file

@ -2,11 +2,8 @@
* src/skills-discovery.ts
*
* Skill discovery and pattern matching for task routing.
*
* Primary source: agent/library.yaml via skill-library.ts
* Fallback: data/skills/ directories with SKILL.md frontmatter
* Source of truth: agent/library.yaml via skill-library.ts
*/
import fs from 'fs';
import path from 'path';
import { loadLibrary } from './skill-library.js';
@ -89,69 +86,27 @@ export function parseSkillFrontmatter(
// ── Catalog loading ────────────────────────────────────────────────────────
export function loadSkillsCatalog(skillsDir?: string): Skill[] {
const skills: Skill[] = [];
// Primary source: agent/library.yaml
try {
const lib = loadLibrary();
for (const s of lib.skills) {
const localPath = s.source.startsWith('local:')
? s.source.slice('local:'.length)
: null;
const dirName = localPath
? path.basename(localPath.replace(/\/SKILL\.md$/i, ''))
: undefined;
const skillMdPath = localPath
? path.resolve(path.dirname(LIBRARY_PATH), localPath)
: undefined;
skills.push({
name: s.id,
description: s.description,
compatibility: '',
invoke_patterns: s.tags,
dirName,
path: skillMdPath,
});
}
} catch {
// Library not available — fall through to filesystem scan
}
// Fallback: data/skills/ directories (override library entries with same dirName)
if (skillsDir && fs.existsSync(skillsDir)) {
const entries = fs.readdirSync(skillsDir, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const skillMd = path.join(skillsDir, entry.name, 'SKILL.md');
if (!fs.existsSync(skillMd)) continue;
const content = fs.readFileSync(skillMd, 'utf-8');
const fm = parseSkillFrontmatter(content);
const fsSkill: Skill = {
name: (fm.name as string) ?? entry.name,
description: (fm.description as string) ?? '',
compatibility: (fm.compatibility as string) ?? '',
invoke_patterns: Array.isArray(fm.invoke_patterns)
? (fm.invoke_patterns as string[])
: [],
estimated_tokens: fm.estimated_tokens as string | undefined,
dirName: entry.name,
path: skillMd,
};
// Replace library entry if same dirName, otherwise append
const existingIdx = skills.findIndex((s) => s.dirName === entry.name);
if (existingIdx >= 0) {
skills[existingIdx] = fsSkill;
} else {
skills.push(fsSkill);
}
}
}
return skills;
export function loadSkillsCatalog(): Skill[] {
const lib = loadLibrary();
return lib.skills.map((s) => {
const localPath = s.source.startsWith('local:')
? s.source.slice('local:'.length)
: null;
const dirName = localPath
? path.basename(localPath.replace(/\/SKILL\.md$/i, ''))
: undefined;
const skillMdPath = localPath
? path.resolve(path.dirname(LIBRARY_PATH), localPath)
: undefined;
return {
name: s.id,
description: s.description,
compatibility: '',
invoke_patterns: s.tags,
dirName,
path: skillMdPath,
};
});
}
// ── Pattern matching ───────────────────────────────────────────────────────