feat(launch): force-load fleet-critical Pi skills + reconcile skill docs (#555)
This commit was merged in pull request #555.
This commit is contained in:
@@ -1,6 +1,11 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach, type MockInstance } from 'vitest';
|
||||
import { Command } from 'commander';
|
||||
import { buildPiSkillArgs, registerRuntimeLaunchers, type RuntimeLaunchHandler } from './launch.js';
|
||||
import {
|
||||
buildPiSkillArgs,
|
||||
piForceSkillNames,
|
||||
registerRuntimeLaunchers,
|
||||
type RuntimeLaunchHandler,
|
||||
} from './launch.js';
|
||||
|
||||
/**
|
||||
* Tests for the commander wiring between `mosaic <runtime>` / `mosaic yolo <runtime>`
|
||||
@@ -23,6 +28,7 @@ function buildProgram(handler: RuntimeLaunchHandler): Command {
|
||||
}
|
||||
|
||||
const fakeSkills = ['--skill', '/skills/test-driven-development', '--skill', '/skills/pdf'];
|
||||
const fakeForced = ['--skill', '/skills/mosaic-tools'];
|
||||
|
||||
// `process.exit` returns `never`, so vi.spyOn demands a replacement with the
|
||||
// same signature. We throw from the mock to short-circuit into test-land.
|
||||
@@ -66,16 +72,42 @@ describe('registerRuntimeLaunchers — non-yolo subcommands', () => {
|
||||
});
|
||||
|
||||
describe('buildPiSkillArgs', () => {
|
||||
it('defaults to disabling Pi skill discovery to keep startup context small', () => {
|
||||
expect(buildPiSkillArgs([], {}, fakeSkills)).toEqual(['--no-skills']);
|
||||
it('disables auto-discovery but force-loads fleet-critical skills by default', () => {
|
||||
expect(buildPiSkillArgs([], {}, fakeSkills, fakeForced)).toEqual([
|
||||
'--no-skills',
|
||||
'--skill',
|
||||
'/skills/mosaic-tools',
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps explicit user skills while disabling automatic discovery', () => {
|
||||
expect(buildPiSkillArgs(['--skill', '/tmp/custom'], {}, fakeSkills)).toEqual(['--no-skills']);
|
||||
it('ignores _runtimeArgs (user --skill flags reach Pi via the launch handler, not here)', () => {
|
||||
expect(buildPiSkillArgs(['--skill', '/tmp/custom'], {}, fakeSkills, fakeForced)).toEqual([
|
||||
'--no-skills',
|
||||
'--skill',
|
||||
'/skills/mosaic-tools',
|
||||
]);
|
||||
});
|
||||
|
||||
it('supports legacy all-skills mode without double-loading settings skills', () => {
|
||||
expect(buildPiSkillArgs([], { MOSAIC_PI_SKILL_MODE: 'all' }, fakeSkills)).toEqual([
|
||||
it('emits only --no-skills when no forced skills are present on disk', () => {
|
||||
expect(buildPiSkillArgs([], {}, fakeSkills, [])).toEqual(['--no-skills']);
|
||||
});
|
||||
|
||||
it('all-skills mode merges the forced set in without duplicating discovered skills', () => {
|
||||
expect(buildPiSkillArgs([], { MOSAIC_PI_SKILL_MODE: 'all' }, fakeSkills, fakeForced)).toEqual([
|
||||
'--no-skills',
|
||||
'--skill',
|
||||
'/skills/test-driven-development',
|
||||
'--skill',
|
||||
'/skills/pdf',
|
||||
'--skill',
|
||||
'/skills/mosaic-tools',
|
||||
]);
|
||||
});
|
||||
|
||||
it('all-skills mode does not double-load a forced skill already discovered', () => {
|
||||
expect(
|
||||
buildPiSkillArgs([], { MOSAIC_PI_SKILL_MODE: 'all' }, fakeSkills, ['--skill', '/skills/pdf']),
|
||||
).toEqual([
|
||||
'--no-skills',
|
||||
'--skill',
|
||||
'/skills/test-driven-development',
|
||||
@@ -84,8 +116,27 @@ describe('buildPiSkillArgs', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('supports native Pi discovery when explicitly requested', () => {
|
||||
expect(buildPiSkillArgs([], { MOSAIC_PI_SKILL_MODE: 'discover' }, fakeSkills)).toEqual([]);
|
||||
it('force-loads fleet skills even under native Pi discovery', () => {
|
||||
expect(
|
||||
buildPiSkillArgs([], { MOSAIC_PI_SKILL_MODE: 'discover' }, fakeSkills, fakeForced),
|
||||
).toEqual(['--skill', '/skills/mosaic-tools']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('piForceSkillNames', () => {
|
||||
it('defaults to mosaic-tools when MOSAIC_PI_FORCE_SKILLS is unset', () => {
|
||||
expect(piForceSkillNames({})).toEqual(['mosaic-tools']);
|
||||
});
|
||||
|
||||
it('treats an empty string as "disable force-loading" (distinct from unset)', () => {
|
||||
expect(piForceSkillNames({ MOSAIC_PI_FORCE_SKILLS: '' })).toEqual([]);
|
||||
});
|
||||
|
||||
it('parses a colon list, trimming blanks and whitespace', () => {
|
||||
expect(piForceSkillNames({ MOSAIC_PI_FORCE_SKILLS: 'mosaic-tools: mosaic-gitea ::' })).toEqual([
|
||||
'mosaic-tools',
|
||||
'mosaic-gitea',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -455,22 +455,78 @@ function normalizePiSkillMode(env: NodeJS.ProcessEnv): PiSkillMode {
|
||||
return 'none';
|
||||
}
|
||||
|
||||
/**
|
||||
* Fleet-critical Pi skills that are force-loaded on every Pi launch regardless
|
||||
* of MOSAIC_PI_SKILL_MODE. They cover the highest-frequency cross-agent and
|
||||
* git-provider operations where Pi workers historically improvised raw CLIs
|
||||
* (raw `tmux send-keys`, raw `tea`/`gh`/`glab`) instead of the maintained
|
||||
* `~/.config/mosaic/tools/` wrappers.
|
||||
*
|
||||
* An explicit `--skill <dir>` overrides `--no-skills` for that path, so forcing
|
||||
* a single targeted skill surfaces the must-use toolkit without loading the full
|
||||
* ~100-skill catalog (context bloat). Missing skills are skipped silently, so
|
||||
* this is a no-op until the named skill is synced into ~/.config/mosaic/skills/.
|
||||
*
|
||||
* Override with MOSAIC_PI_FORCE_SKILLS (colon-separated skill dir names; set to
|
||||
* an empty string to disable force-loading entirely).
|
||||
*/
|
||||
const DEFAULT_PI_FORCE_SKILLS = ['mosaic-tools'];
|
||||
|
||||
export function piForceSkillNames(env: NodeJS.ProcessEnv): string[] {
|
||||
const override = env['MOSAIC_PI_FORCE_SKILLS'];
|
||||
if (override === undefined) return DEFAULT_PI_FORCE_SKILLS;
|
||||
return override
|
||||
.split(':')
|
||||
.map((name) => name.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function forcedPiSkillArgs(env: NodeJS.ProcessEnv = process.env): string[] {
|
||||
const args: string[] = [];
|
||||
for (const name of piForceSkillNames(env)) {
|
||||
const skillDir = join(MOSAIC_HOME, 'skills', name);
|
||||
if (existsSync(join(skillDir, 'SKILL.md'))) {
|
||||
args.push('--skill', skillDir);
|
||||
}
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
/** Concatenate `--skill <dir>` arg groups, dropping any directory already seen. */
|
||||
function mergeSkillArgs(...groups: string[][]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const group of groups) {
|
||||
for (let i = 0; i < group.length; i += 2) {
|
||||
const dir = group[i + 1];
|
||||
if (group[i] !== '--skill' || dir === undefined || seen.has(dir)) continue;
|
||||
seen.add(dir);
|
||||
out.push('--skill', dir);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function buildPiSkillArgs(
|
||||
_runtimeArgs: string[],
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
discoveredSkillArgs: string[] = discoverPiSkills(),
|
||||
forcedSkillArgs: string[] = forcedPiSkillArgs(env),
|
||||
): string[] {
|
||||
const mode = normalizePiSkillMode(env);
|
||||
|
||||
if (mode === 'discover') {
|
||||
return [];
|
||||
// Native Pi discovery handles the rest; still force-load the fleet skills.
|
||||
return [...forcedSkillArgs];
|
||||
}
|
||||
|
||||
if (mode === 'all') {
|
||||
return ['--no-skills', ...discoveredSkillArgs];
|
||||
// 'all' links the full catalog; merge in the forced set so fleet-critical
|
||||
// skills are guaranteed present even if they live only under skills-local/.
|
||||
return ['--no-skills', ...mergeSkillArgs(discoveredSkillArgs, forcedSkillArgs)];
|
||||
}
|
||||
|
||||
return ['--no-skills'];
|
||||
return ['--no-skills', ...forcedSkillArgs];
|
||||
}
|
||||
|
||||
function discoverPiExtension(): string[] {
|
||||
|
||||
Reference in New Issue
Block a user