diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..8cc128aa --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,42 @@ +name: CI + +on: + pull_request: + branches: [main] + push: + branches: [main] + +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + check: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: '22.14.0' + cache: 'pnpm' + + - name: Install deps + run: pnpm install --frozen-lockfile + + - name: Lint + run: pnpm run lint + + - name: Typecheck + run: pnpm run typecheck + + - name: Test + run: pnpm run test diff --git a/packages/workload-router/routing-profiles/default.json b/packages/workload-router/routing-profiles/default.json index fc8c4b26..996e379c 100644 --- a/packages/workload-router/routing-profiles/default.json +++ b/packages/workload-router/routing-profiles/default.json @@ -74,6 +74,10 @@ "sage-cloud-e2e-conduction": { "tier": "best-value", "rationale": "End-to-end conduction is orchestration-heavy work where strong reasoning is useful without requiring the top tier by default." + }, + "capability-discovery": { + "tier": "best-value", + "rationale": "Searching skill.sh and prpm.dev for existing skills, agents, and hooks is lightweight research; the balanced default is sufficient when guided by the skill.sh/find-skills and @prpm/self-improving skills." } } } diff --git a/packages/workload-router/scripts/generate-personas.mjs b/packages/workload-router/scripts/generate-personas.mjs index cc5f51e3..c7216e81 100644 --- a/packages/workload-router/scripts/generate-personas.mjs +++ b/packages/workload-router/scripts/generate-personas.mjs @@ -26,7 +26,8 @@ const exportNameMap = new Map([ ['sage-slack-egress-migrator', 'sageSlackEgressMigrator'], ['sage-proactive-rewirer', 'sageProactiveRewirer'], ['cloud-slack-proxy-guard', 'cloudSlackProxyGuard'], - ['agent-relay-e2e-conductor', 'agentRelayE2eConductor'] + ['agent-relay-e2e-conductor', 'agentRelayE2eConductor'], + ['capability-discoverer', 'capabilityDiscoverer'] ]); const files = (await fs.readdir(personasDir)).filter((n) => n.endsWith('.json')).sort(); @@ -38,7 +39,10 @@ const lines = [ for (const file of files) { const basename = file.replace(/\.json$/, ''); const exportName = exportNameMap.get(basename); - if (!exportName) throw new Error(`No export name mapping for ${basename}`); + if (!exportName) { + console.warn(`[generate-personas] skipping unwired persona: ${basename} (no export name mapping)`); + continue; + } const json = await fs.readFile(path.join(personasDir, file), 'utf8'); lines.push(`export const ${exportName} = ${json.trim()} as const;`); lines.push(''); diff --git a/packages/workload-router/src/generated/personas.ts b/packages/workload-router/src/generated/personas.ts index 548807d5..14f6ac5f 100644 --- a/packages/workload-router/src/generated/personas.ts +++ b/packages/workload-router/src/generated/personas.ts @@ -53,6 +53,44 @@ export const architecturePlanner = { } } as const; +export const capabilityDiscoverer = { + "id": "capability-discoverer", + "intent": "capability-discovery", + "description": "Finds existing skills, agents, and hooks for a project by searching both the skills.sh ecosystem and prpm.dev instead of hand-rolling new logic. Picks the best fit across providers and emits the exact install command.", + "skills": [ + { + "id": "skill.sh/find-skills", + "source": "https://github.com/vercel-labs/skills#find-skills", + "description": "skill.sh find-skills guide for searching skills.sh, proposing matches, and driving `npx skills add` installs." + }, + { + "id": "prpm/self-improving", + "source": "https://prpm.dev/packages/@prpm/self-improving", + "description": "prpm skill that teaches an agent to search prpm.dev for skills, agents, and hooks and install them with the right --as flag for the active harness." + } + ], + "tiers": { + "best": { + "harness": "codex", + "model": "openai-codex/gpt-5.3-codex", + "systemPrompt": "You are a capability discovery specialist. Your job is to close capability gaps by finding existing skills, agents, or hooks from either the skills.sh ecosystem or prpm.dev, rather than hand-rolling new logic. Process: (1) restate the capability gap in one sentence, (2) classify whether the gap is best filled by a skill (reusable knowledge), an agent (a harness persona), or a hook (lifecycle automation), (3) search BOTH ecosystems — skill.sh via `npx skills find ` and prpm.dev — and inspect candidate manifests/SKILL.md before recommending anything, (4) recommend at most two packages total across providers with explicit fit rationale (what each covers, what it does NOT, which provider it comes from), (5) produce the exact install command for the chosen provider: `npx -y skills add --skill -y` for skill.sh or `npx -y prpm install --as ` for prpm (using the currently active harness flag), and (6) flag any security/permission notes surfaced by skills.sh assessments and any conflicts with already-installed packages. Never recommend a package you have not verified exists. If no candidate fits in either ecosystem, say so plainly and suggest the closest adjacent capability instead of inventing one. Apply the skill.sh/find-skills and prpm/self-improving skills for canonical discovery and install workflow. Output contract: gap summary, type classification, top candidates with provider + fit rationale, exact install command, security/conflict notes, open questions for the user.", + "harnessSettings": { "reasoning": "high", "timeoutSeconds": 600 } + }, + "best-value": { + "harness": "opencode", + "model": "opencode/gpt-5-nano", + "systemPrompt": "You are a capability discovery specialist in efficient mode. Same quality bar as top tier; reduce only verbosity. Process: restate the gap, classify it as skill/agent/hook, search BOTH skill.sh (`npx skills find `) and prpm.dev, verify candidate manifests before recommending, recommend at most two packages total across providers with provider-labeled fit rationale, produce the exact install command for the chosen provider (`npx -y skills add --skill -y` for skill.sh or `npx -y prpm install --as ` for prpm using the active harness), flag security/permission notes and install conflicts. Never recommend unverified packages. If nothing fits in either ecosystem, say so directly. Apply the skill.sh/find-skills and prpm/self-improving skills. Output contract: gap summary, classification, candidates with provider + fit rationale, install command, security/conflict notes, open questions.", + "harnessSettings": { "reasoning": "medium", "timeoutSeconds": 450 } + }, + "minimum": { + "harness": "opencode", + "model": "opencode/minimax-m2.5-free", + "systemPrompt": "You are a concise capability discovery specialist. Same quality bar; only limit depth. Required: classify the gap as skill/agent/hook; search BOTH skill.sh via `npx skills find ` and prpm.dev; verify candidate manifests before recommending; never fabricate packages; recommend at most two with provider-labeled fit rationale; produce the exact install command for the chosen provider (`npx -y skills add --skill -y` for skill.sh or `npx -y prpm install --as ` for prpm); call out security notes and install conflicts. If nothing fits, say so. Apply the skill.sh/find-skills and prpm/self-improving skills. Output contract: gap summary, classification, candidates, install command, notes, open questions.", + "harnessSettings": { "reasoning": "low", "timeoutSeconds": 300 } + } + } +} as const; + export const cloudSandboxInfra = { "id": "cloud-sandbox-infra", "intent": "cloud-sandbox-infra", diff --git a/packages/workload-router/src/index.test.ts b/packages/workload-router/src/index.test.ts index a5cc31bd..461b2a14 100644 --- a/packages/workload-router/src/index.test.ts +++ b/packages/workload-router/src/index.test.ts @@ -1,6 +1,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { @@ -114,6 +114,10 @@ test('resolves review from custom routing profile rule', () => { 'sage-cloud-e2e-conduction': { tier: 'best-value', rationale: 'e2e conduction benefits from strong reasoning without the highest-cost default' + }, + 'capability-discovery': { + tier: 'best-value', + rationale: 'lightweight discovery work' } } }); @@ -264,6 +268,145 @@ test('materializeSkillsFor derives an install plan from a resolved persona', () assert.match(cmd, /prpm install @prpm\/npm-trusted-publishing --as /); }); +test('materializeSkills emits a skill.sh install for a github#skill source', () => { + const plan = materializeSkills( + [ + { + id: 'skill.sh/find-skills', + source: 'https://github.com/vercel-labs/skills#find-skills', + description: 'skill.sh discovery skill' + } + ], + 'claude' + ); + + assert.equal(plan.installs.length, 1); + const [install] = plan.installs; + assert.equal(install.sourceKind, 'skill.sh'); + assert.equal(install.packageRef, 'https://github.com/vercel-labs/skills#find-skills'); + assert.deepEqual( + [...install.installCommand], + ['npx', '-y', 'skills', 'add', 'https://github.com/vercel-labs/skills', '--skill', 'find-skills', '-y'] + ); + // skill.sh uses a single universal content dir regardless of harness. + assert.equal(install.installedDir, '.agents/skills/find-skills'); + assert.equal(install.installedManifest, '.agents/skills/find-skills/SKILL.md'); + // Cleanup should target every harness symlink + the universal dir, but + // never the lockfile itself. + assert.deepEqual( + [...install.cleanupPaths], + [ + '.agents/skills/find-skills', + '.claude/skills/find-skills', + '.factory/skills/find-skills', + '.kiro/skills/find-skills', + 'skills/find-skills' + ] + ); + assert.ok(!install.cleanupPaths.includes('skills-lock.json')); +}); + +test('prpm installs carry a harness-scoped cleanup path (not the lockfile)', () => { + const plan = materializeSkills( + [ + { + id: 'prpm/npm-trusted-publishing', + source: '@prpm/npm-trusted-publishing', + description: 'bare ref form' + } + ], + 'codex' + ); + const [install] = plan.installs; + assert.deepEqual([...install.cleanupPaths], ['.agents/skills/npm-trusted-publishing']); + assert.ok(!install.cleanupPaths.includes('prpm.lock')); +}); + +test('usePersona install command never embeds cleanup (agent must read skills first)', () => { + // Regression guard: previously buildInstallArtifacts inlined `&& rm -rf` into + // the install step, which ran BEFORE the agent step and deleted skill files + // the agent needed to read. Cleanup now lives on a separate post-agent step + // and on install.cleanupCommandString for Mode B callers. + const context = usePersona('npm-provenance'); + assert.doesNotMatch(context.install.commandString, /rm -rf/); + assert.match( + context.install.commandString, + /prpm install @prpm\/npm-trusted-publishing --as [a-z]+/ + ); +}); + +test('usePersona exposes a post-run cleanupCommandString targeting skill artifact paths', () => { + const context = usePersona('npm-provenance'); + assert.ok(Array.isArray(context.install.cleanupCommand)); + assert.equal(context.install.cleanupCommand[0], 'sh'); + assert.match(context.install.cleanupCommandString, /^rm -rf /); + assert.match(context.install.cleanupCommandString, /npm-trusted-publishing/); + // The provider lockfile must never be cleaned — repeat runs depend on it. + assert.doesNotMatch(context.install.cleanupCommandString, /prpm\.lock|skills-lock\.json/); +}); + +test('usePersona cleanupCommandString chains paths from every install in the plan', () => { + const context = usePersona('capability-discovery'); + const cleanup = context.install.cleanupCommandString; + // Both the skill.sh symlink set and the prpm per-harness dir should appear + // in a single rm -rf chain. + assert.match(cleanup, /^rm -rf /); + assert.match(cleanup, /find-skills/); + assert.match(cleanup, /self-improving/); + // Cover every skill.sh harness symlink, not just the universal dir. + assert.match(cleanup, /\.agents\/skills\/find-skills/); + assert.match(cleanup, /\.claude\/skills\/find-skills/); + assert.match(cleanup, /\.factory\/skills\/find-skills/); + assert.match(cleanup, /\.kiro\/skills\/find-skills/); +}); + +test('usePersona cleanupCommandString is a shell no-op when the persona declares no skills', () => { + const context = usePersona('architecture-plan'); + assert.equal(context.install.cleanupCommandString, ':'); +}); + +test('resolves capability-discovery persona carrying both skill.sh and prpm skills', () => { + const selection = resolvePersona('capability-discovery'); + assert.equal(selection.personaId, 'capability-discoverer'); + assert.equal(selection.tier, 'best-value'); + assert.equal(selection.skills.length, 2); + + const byId = new Map(selection.skills.map((s) => [s.id, s])); + const skillSh = byId.get('skill.sh/find-skills'); + assert.ok(skillSh, 'missing skill.sh/find-skills skill'); + assert.equal(skillSh!.source, 'https://github.com/vercel-labs/skills#find-skills'); + const prpm = byId.get('prpm/self-improving'); + assert.ok(prpm, 'missing prpm/self-improving skill'); + assert.match(prpm!.source, /prpm\.dev\/packages\/@prpm\/self-improving/); +}); + +test('materializeSkillsFor capability-discovery plans both installs under one shell chain with cleanup', () => { + const selection = resolvePersona('capability-discovery'); + const plan = materializeSkillsFor(selection); + assert.equal(plan.installs.length, 2); + + const byKind = new Map(plan.installs.map((i) => [i.sourceKind, i])); + const skillShInstall = byKind.get('skill.sh'); + const prpmInstall = byKind.get('prpm'); + assert.ok(skillShInstall, 'missing skill.sh install'); + assert.ok(prpmInstall, 'missing prpm install'); + assert.deepEqual( + [...skillShInstall!.installCommand], + ['npx', '-y', 'skills', 'add', 'https://github.com/vercel-labs/skills', '--skill', 'find-skills', '-y'] + ); + assert.equal(prpmInstall!.packageRef, '@prpm/self-improving'); + + const context = usePersona('capability-discovery'); + const cmd = context.install.commandString; + // Both installs should be chained back-to-back with `&&`, with NO inline + // cleanup — cleanup lives on a separate post-agent step. + assert.match( + cmd, + /skills add https:\/\/github\.com\/vercel-labs\/skills --skill find-skills -y && npx -y prpm install @prpm\/self-improving/ + ); + assert.doesNotMatch(cmd, /rm -rf/); +}); + test('materializeSkills rejects unknown skill sources', () => { assert.throws( () => @@ -390,6 +533,73 @@ process.stdout.write('agent-after-install'); } }); +test('usePersona.sendMessage keeps skill files on disk for the agent, then cleans them up after', async () => { + // Regression guard for the Devin-flagged P1: cleanup used to be chained + // into the install step with `&& rm -rf`, so by the time the agent step + // ran the skill manifest was already gone. This test: + // 1. Uses a fake `npx` that writes a stub SKILL.md into the exact path + // listed in the install plan's cleanupPaths (simulating prpm install). + // 2. Uses a fake `codex` that asserts the SKILL.md IS present and + // captures its location, then writes an agent-ran sentinel. + // 3. Asserts that after sendMessage settles, the cleanupPaths have been + // removed — proving the post-agent cleanup step ran. + const dir = mkdtempSync(join(tmpdir(), 'use-persona-cleanup-')); + try { + // The install plan cleanupPaths for npm-provenance under codex is + // `.agents/skills/npm-trusted-publishing`. The fake npx must materialize + // a SKILL.md inside that dir so the agent can verify it. + writeNodeExecutable( + dir, + 'npx', + ` +const { mkdirSync, writeFileSync } = require('node:fs'); +const path = require('node:path'); +const skillDir = path.join(process.cwd(), '.agents/skills/npm-trusted-publishing'); +mkdirSync(skillDir, { recursive: true }); +writeFileSync(path.join(skillDir, 'SKILL.md'), '# npm trusted publishing', 'utf8'); +` + ); + writeNodeExecutable( + dir, + 'codex', + ` +const { existsSync, writeFileSync } = require('node:fs'); +const skillPath = '.agents/skills/npm-trusted-publishing/SKILL.md'; +if (!existsSync(skillPath)) { + process.stderr.write('SKILL.md missing during agent step'); + process.exit(9); +} +writeFileSync('agent-saw-skill.txt', 'yes', 'utf8'); +process.stdout.write('agent-read-skill'); +` + ); + + const context = usePersona('npm-provenance', { harness: 'codex' }); + // Sanity check: plan must say this is the path we expect to verify. + assert.deepEqual( + context.install.plan.installs[0]?.cleanupPaths, + ['.agents/skills/npm-trusted-publishing'] + ); + + const result = await context.sendMessage('Configure publishing', { + workingDirectory: dir, + env: buildEnv(dir) + }); + + assert.equal(result.status, 'completed'); + // Agent must have seen the skill during its run. + assert.equal(readFileSync(join(dir, 'agent-saw-skill.txt'), 'utf8'), 'yes'); + // Post-agent cleanup must have removed the skill artifact path. + assert.equal( + existsSync(join(dir, '.agents/skills/npm-trusted-publishing')), + false, + 'cleanup step should have removed the skill artifact dir after the agent ran' + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + test('usePersona.sendMessage maps non-zero exits to PersonaExecutionError with captured stderr', async () => { const dir = mkdtempSync(join(tmpdir(), 'use-persona-fail-')); try { diff --git a/packages/workload-router/src/index.ts b/packages/workload-router/src/index.ts index 1fe641f5..57e20de7 100644 --- a/packages/workload-router/src/index.ts +++ b/packages/workload-router/src/index.ts @@ -2,7 +2,7 @@ import { spawn } from 'node:child_process'; import { createHash } from 'node:crypto'; import { resolve as resolvePath } from 'node:path'; import type { RunnerStepExecutor, WorkflowRunRow } from '@agent-relay/sdk/workflows'; -import { frontendImplementer, codeReviewer, architecturePlanner, requirementsAnalyst, debuggerPersona, securityReviewer, technicalWriter, verifierPersona, testStrategist, tddGuard, flakeHunter, opencodeWorkflowSpecialist, npmProvenancePublisher, cloudSandboxInfra, sageSlackEgressMigrator, sageProactiveRewirer, cloudSlackProxyGuard, agentRelayE2eConductor } from './generated/personas.js'; +import { frontendImplementer, codeReviewer, architecturePlanner, requirementsAnalyst, debuggerPersona, securityReviewer, technicalWriter, verifierPersona, testStrategist, tddGuard, flakeHunter, opencodeWorkflowSpecialist, npmProvenancePublisher, cloudSandboxInfra, sageSlackEgressMigrator, sageProactiveRewirer, cloudSlackProxyGuard, agentRelayE2eConductor, capabilityDiscoverer } from './generated/personas.js'; import defaultRoutingProfileJson from '../routing-profiles/default.json' with { type: 'json' }; export const HARNESS_VALUES = ['opencode', 'codex', 'claude'] as const; @@ -25,7 +25,8 @@ export const PERSONA_INTENTS = [ 'sage-slack-egress-migration', 'sage-proactive-rewire', 'cloud-slack-proxy-guard', - 'sage-cloud-e2e-conduction' + 'sage-cloud-e2e-conduction', + 'capability-discovery' ] as const; export type Harness = (typeof HARNESS_VALUES)[number]; @@ -99,7 +100,7 @@ export interface PersonaSelection { // never touches the filesystem or spawns processes. Callers (relay workflows, // the OpenClaw spawner, ad-hoc scripts) decide how to execute it. -export const SKILL_SOURCE_KINDS = ['prpm'] as const; +export const SKILL_SOURCE_KINDS = ['prpm', 'skill.sh'] as const; export type SkillSourceKind = (typeof SKILL_SOURCE_KINDS)[number]; /** Per-harness rules for where skills land on disk and how to ask prpm for them. */ @@ -121,7 +122,7 @@ export interface SkillInstall { /** Original `source` string from the persona JSON. */ source: string; sourceKind: SkillSourceKind; - /** Normalized package reference used by prpm (e.g. `prpm/npm-trusted-publishing`). */ + /** Normalized package reference (e.g. `@prpm/npm-trusted-publishing`, `vercel-labs/skills#find-skills`). */ packageRef: string; harness: Harness; /** argv-style command — safer than a shell string for execFile/spawn callers. */ @@ -130,6 +131,13 @@ export interface SkillInstall { installedDir: string; /** Path to the installed SKILL.md manifest (for prompt injection fallback). */ installedManifest: string; + /** + * Paths the installer scatters outside of a durable lockfile — safe to + * `rm -rf` once the persona run has read what it needs from them. The + * provider's lockfile (`prpm.lock`, `skills-lock.json`, etc.) is deliberately + * omitted so repeat runs can stay fast and reproducible. + */ + cleanupPaths: readonly string[]; } export interface SkillMaterializationPlan { @@ -144,6 +152,17 @@ export interface PersonaInstallContext { readonly command: readonly string[]; /** Shell-escaped form of the full install command, convenient for `spawn(..., { shell: true })`. */ readonly commandString: string; + /** + * Post-run cleanup command (argv form) that removes the ephemeral artifact + * paths the provider scatters during install, leaving the provider lockfile + * in place. Callers running the install themselves (Mode B) should run this + * **after** the agent step consumes the skills, never before. For empty + * plans this is a shell no-op (`:`). `sendMessage()` wires this into a + * post-agent workflow step automatically in Mode A. + */ + readonly cleanupCommand: readonly string[]; + /** Shell-escaped form of {@link cleanupCommand}. */ + readonly cleanupCommandString: string; } /** @@ -355,40 +374,171 @@ class CapturedCommandError extends Error { } } -const PRPM_URL_RE = - /^https?:\/\/prpm\.dev\/packages\/([^/\s?#]+)\/([^/\s?#]+)\/?(?:[?#].*)?$/i; -const PRPM_BARE_REF_RE = /^([^/\s]+)\/([^/\s]+)$/; +// --------------------------------------------------------------------------- +// Skill providers +// --------------------------------------------------------------------------- +// +// Each provider (prpm, skill.sh, ...) owns three concerns: +// 1. How to parse its source-string shape out of a persona JSON entry. +// 2. How to build the concrete `npx ...` install command for a given harness. +// 3. Which ephemeral paths the installer writes that should be cleaned up +// after the persona run finishes. The provider's *lockfile* is NOT in +// that list — it stays on disk so repeat runs reuse resolved versions. +// +// Adding a new skill source kind = add one provider entry here; everything +// else (materializeSkills, buildInstallArtifacts, tests) picks it up via the +// common interface. interface ResolvedSkillSource { kind: SkillSourceKind; packageRef: string; + /** Directory name used for the installed skill (e.g. `npm-trusted-publishing`). */ + installedName: string; } -function resolveSkillSource(source: string): ResolvedSkillSource { - const urlMatch = source.match(PRPM_URL_RE); - if (urlMatch) { - return { kind: 'prpm', packageRef: `${urlMatch[1]}/${urlMatch[2]}` }; +interface SkillProvider { + readonly kind: SkillSourceKind; + /** Parse a persona `source` string; return null if this provider does not claim it. */ + parse(source: string): ResolvedSkillSource | null; + /** Build the argv-style install command for `materializeSkills`. */ + buildInstallCommand(ref: ResolvedSkillSource, harness: Harness): readonly string[]; + /** + * Ephemeral paths the installer scatters for this skill under `harness` that are + * safe to remove once the persona has finished reading them. Does not include + * the provider's lockfile. + */ + cleanupPaths(ref: ResolvedSkillSource, harness: Harness): readonly string[]; +} + +const PRPM_URL_RE = + /^https?:\/\/prpm\.dev\/packages\/([^/\s?#]+)\/([^/\s?#]+)\/?(?:[?#].*)?$/i; +const PRPM_BARE_REF_RE = /^([^/\s]+)\/([^/\s]+)$/; + +function lastSegment(ref: string): string { + const slash = ref.lastIndexOf('/'); + return slash >= 0 ? ref.slice(slash + 1) : ref; +} + +const prpmProvider: SkillProvider = { + kind: 'prpm', + parse(source) { + const urlMatch = source.match(PRPM_URL_RE); + if (urlMatch) { + const ref = `${urlMatch[1]}/${urlMatch[2]}`; + return { kind: 'prpm', packageRef: ref, installedName: lastSegment(ref) }; + } + const bareMatch = source.match(PRPM_BARE_REF_RE); + if (bareMatch) { + return { kind: 'prpm', packageRef: source, installedName: lastSegment(source) }; + } + return null; + }, + buildInstallCommand(ref, harness) { + const target = HARNESS_SKILL_TARGETS[harness]; + return Object.freeze([ + 'npx', + '-y', + 'prpm', + 'install', + ref.packageRef, + '--as', + target.asFlag + ]) as readonly string[]; + }, + cleanupPaths(ref, harness) { + const target = HARNESS_SKILL_TARGETS[harness]; + return Object.freeze([`${target.dir}/${ref.installedName}`]) as readonly string[]; } - const bareMatch = source.match(PRPM_BARE_REF_RE); - if (bareMatch) { - return { kind: 'prpm', packageRef: source }; +}; + +// skill.sh source form: `#` +// Example: `https://github.com/vercel-labs/skills#find-skills` +const SKILL_SH_URL_RE = + /^(https?:\/\/github\.com\/[^/\s?#]+\/[^/\s?#]+?)(?:\.git)?#([^\s?#]+)$/i; + +/** + * Paths `npx skills add` writes per install. Mirrors the on-disk layout from + * a live `npx -y skills add ... -y` run (universal dir + harness-side + * symlinks). `skills-lock.json` is deliberately excluded so repeat runs can + * re-resolve from the lock instead of refetching sources. + */ +function skillShArtifactPaths(installedName: string): readonly string[] { + return Object.freeze([ + `.agents/skills/${installedName}`, + `.claude/skills/${installedName}`, + `.factory/skills/${installedName}`, + `.kiro/skills/${installedName}`, + `skills/${installedName}` + ]) as readonly string[]; +} + +const skillShProvider: SkillProvider = { + kind: 'skill.sh', + parse(source) { + const match = source.match(SKILL_SH_URL_RE); + if (!match) { + return null; + } + const [, repoUrl, skillName] = match; + return { + kind: 'skill.sh', + // packageRef preserves the full `#` shape so the command builder + // can reconstruct both halves without re-parsing the original source. + packageRef: `${repoUrl}#${skillName}`, + installedName: skillName + }; + }, + buildInstallCommand(ref) { + const [repoUrl, skillName] = ref.packageRef.split('#'); + return Object.freeze([ + 'npx', + '-y', + 'skills', + 'add', + repoUrl, + '--skill', + skillName, + '-y' + ]) as readonly string[]; + }, + cleanupPaths(ref) { + // skill.sh installs the same universal dir + harness symlinks regardless + // of which agent is the "host" — clean all of them so nothing leaks into + // `.claude/`, `.agents/`, etc. after the persona run. + return skillShArtifactPaths(ref.installedName); + } +}; + +const SKILL_PROVIDERS: readonly SkillProvider[] = Object.freeze([prpmProvider, skillShProvider]); + +function resolveSkillSource(source: string): ResolvedSkillSource { + for (const provider of SKILL_PROVIDERS) { + const parsed = provider.parse(source); + if (parsed) { + return parsed; + } } throw new Error( `Unsupported skill source: ${source}. ` + - `Supported forms: prpm.dev package URL (https://prpm.dev/packages//) ` + - `or a bare "/" reference.` + `Supported forms: prpm.dev package URL (https://prpm.dev/packages//), ` + + `bare "/" prpm reference, ` + + `or skill.sh github URL with skill fragment (https://github.com//#).` ); } -function deriveInstalledName(packageRef: string): string { - const slash = packageRef.lastIndexOf('/'); - return slash >= 0 ? packageRef.slice(slash + 1) : packageRef; +function providerFor(kind: SkillSourceKind): SkillProvider { + const provider = SKILL_PROVIDERS.find((p) => p.kind === kind); + if (!provider) { + throw new Error(`No skill provider registered for kind: ${kind}`); + } + return provider; } /** * Given a set of persona skills and the harness the persona will run under, - * produce the concrete install plan: which `prpm install` invocations to run - * and where the skill will land on disk once installed. + * produce the concrete install plan: which install invocations to run, where + * the skill will land on disk, and which artifact paths should be cleaned up + * after the persona run to keep the workspace tidy. * * Pure function — does not execute commands or touch the filesystem. */ @@ -402,26 +552,28 @@ export function materializeSkills( } const installs = skills.map((skill): SkillInstall => { - const { kind, packageRef } = resolveSkillSource(skill.source); - const installedName = deriveInstalledName(packageRef); - const installedDir = `${target.dir}/${installedName}`; + const resolved = resolveSkillSource(skill.source); + const provider = providerFor(resolved.kind); + const installCommand = provider.buildInstallCommand(resolved, harness); + const cleanupPaths = provider.cleanupPaths(resolved, harness); + // For prompt-injection fallback we still want a single canonical manifest + // path. prpm installs into the harness target dir; skill.sh installs into + // its universal `.agents/skills` dir regardless of harness, so key off + // whichever cleanup path ends in the installed name. + const installedDir = + resolved.kind === 'skill.sh' + ? `.agents/skills/${resolved.installedName}` + : `${target.dir}/${resolved.installedName}`; return { skillId: skill.id, source: skill.source, - sourceKind: kind, - packageRef, + sourceKind: resolved.kind, + packageRef: resolved.packageRef, harness, - installCommand: Object.freeze([ - 'npx', - '-y', - 'prpm', - 'install', - packageRef, - '--as', - target.asFlag - ]) as readonly string[], + installCommand, installedDir, - installedManifest: `${installedDir}/SKILL.md` + installedManifest: `${installedDir}/SKILL.md`, + cleanupPaths }; }); @@ -462,6 +614,28 @@ function buildInstallArtifacts(plan: SkillMaterializationPlan): { }; } +/** + * Post-run cleanup: one shell command that removes every ephemeral artifact + * path declared across all installs in the plan. Runs AFTER the agent step so + * the agent can still read skill manifests off disk during execution. The + * provider lockfile is deliberately not in the path set, so repeat runs keep + * cached resolution. + * + * Empty plans return `:` (shell no-op) to keep the post-step shape uniform. + */ +function buildCleanupArtifacts(plan: SkillMaterializationPlan): { + cleanupCommand: readonly string[]; + cleanupCommandString: string; +} { + const allPaths = plan.installs.flatMap((install) => [...install.cleanupPaths]); + const cleanupCommandString = + allPaths.length === 0 ? ':' : `rm -rf ${allPaths.map(shellEscape).join(' ')}`; + return { + cleanupCommand: Object.freeze(['sh', '-c', cleanupCommandString]) as readonly string[], + cleanupCommandString + }; +} + function buildExecutionTask( systemPrompt: string, task: string, @@ -969,7 +1143,8 @@ export const personaCatalog: Record = { 'sage-cloud-e2e-conduction': parsePersonaSpec( agentRelayE2eConductor, 'sage-cloud-e2e-conduction' - ) + ), + 'capability-discovery': parsePersonaSpec(capabilityDiscoverer, 'capability-discovery') }; export const routingProfiles = { @@ -1109,12 +1284,15 @@ export function usePersona( : materializeSkills(selection.skills, effectiveHarness); const { installCommand, installCommandString } = buildInstallArtifacts(installPlan); + const { cleanupCommand, cleanupCommandString } = buildCleanupArtifacts(installPlan); const frozenSelection = deepFreeze(selection); const frozenInstallPlan = deepFreeze(installPlan); - const frozenInstall = Object.freeze({ + const frozenInstall: PersonaInstallContext = Object.freeze({ plan: frozenInstallPlan, command: installCommand, - commandString: installCommandString + commandString: installCommandString, + cleanupCommand, + cleanupCommandString }); const sendMessage = (task: string, sendMessageOptions: ExecuteOptions = {}): PersonaExecution => { @@ -1135,6 +1313,7 @@ export function usePersona( ); const workflowName = `use-persona-${stepName}`; const installStepName = `${stepName}-install-skills`; + const cleanupStepName = `${stepName}-cleanup-skills`; const workingDirectory = resolvePath(sendMessageOptions.workingDirectory ?? process.cwd()); const timeoutMs = Math.max( 1, @@ -1246,6 +1425,26 @@ export function usePersona( ...(shouldInstallSkills ? { dependsOn: [installStepName] } : {}) }); + // Post-agent cleanup: removes the ephemeral skill artifact paths the + // provider scattered during the install step. Only runs when this + // sendMessage owns the install (Mode A) AND the agent step completed + // — if the agent step fails or is skipped, the dag runner will skip + // this step too, which is fine because (a) failure diagnostics stay + // on disk for the user to inspect, and (b) `rm -rf` is idempotent so + // a follow-up run can re-clean. The lockfile is deliberately not in + // cleanupPaths, so repeat runs still benefit from cached resolution. + if (shouldInstallSkills && frozenInstall.cleanupCommandString !== ':') { + builder.step(cleanupStepName, { + type: 'deterministic', + command: frozenInstall.cleanupCommandString, + cwd: workingDirectory, + timeoutMs, + captureOutput: true, + failOnError: false, + dependsOn: [stepName] + }); + } + if (abortController.signal.aborted) { runner.abort(); } else { diff --git a/personas/capability-discoverer.json b/personas/capability-discoverer.json new file mode 100644 index 00000000..9c91724c --- /dev/null +++ b/personas/capability-discoverer.json @@ -0,0 +1,37 @@ +{ + "id": "capability-discoverer", + "intent": "capability-discovery", + "description": "Finds existing skills, agents, and hooks for a project by searching both the skills.sh ecosystem and prpm.dev instead of hand-rolling new logic. Picks the best fit across providers and emits the exact install command.", + "skills": [ + { + "id": "skill.sh/find-skills", + "source": "https://github.com/vercel-labs/skills#find-skills", + "description": "skill.sh find-skills guide for searching skills.sh, proposing matches, and driving `npx skills add` installs." + }, + { + "id": "prpm/self-improving", + "source": "https://prpm.dev/packages/@prpm/self-improving", + "description": "prpm skill that teaches an agent to search prpm.dev for skills, agents, and hooks and install them with the right --as flag for the active harness." + } + ], + "tiers": { + "best": { + "harness": "codex", + "model": "openai-codex/gpt-5.3-codex", + "systemPrompt": "You are a capability discovery specialist. Your job is to close capability gaps by finding existing skills, agents, or hooks from either the skills.sh ecosystem or prpm.dev, rather than hand-rolling new logic. Process: (1) restate the capability gap in one sentence, (2) classify whether the gap is best filled by a skill (reusable knowledge), an agent (a harness persona), or a hook (lifecycle automation), (3) search BOTH ecosystems — skill.sh via `npx skills find ` and prpm.dev — and inspect candidate manifests/SKILL.md before recommending anything, (4) recommend at most two packages total across providers with explicit fit rationale (what each covers, what it does NOT, which provider it comes from), (5) produce the exact install command for the chosen provider: `npx -y skills add --skill -y` for skill.sh or `npx -y prpm install --as ` for prpm (using the currently active harness flag), and (6) flag any security/permission notes surfaced by skills.sh assessments and any conflicts with already-installed packages. Never recommend a package you have not verified exists. If no candidate fits in either ecosystem, say so plainly and suggest the closest adjacent capability instead of inventing one. Apply the skill.sh/find-skills and prpm/self-improving skills for canonical discovery and install workflow. Output contract: gap summary, type classification, top candidates with provider + fit rationale, exact install command, security/conflict notes, open questions for the user.", + "harnessSettings": { "reasoning": "high", "timeoutSeconds": 600 } + }, + "best-value": { + "harness": "opencode", + "model": "opencode/gpt-5-nano", + "systemPrompt": "You are a capability discovery specialist in efficient mode. Same quality bar as top tier; reduce only verbosity. Process: restate the gap, classify it as skill/agent/hook, search BOTH skill.sh (`npx skills find `) and prpm.dev, verify candidate manifests before recommending, recommend at most two packages total across providers with provider-labeled fit rationale, produce the exact install command for the chosen provider (`npx -y skills add --skill -y` for skill.sh or `npx -y prpm install --as ` for prpm using the active harness), flag security/permission notes and install conflicts. Never recommend unverified packages. If nothing fits in either ecosystem, say so directly. Apply the skill.sh/find-skills and prpm/self-improving skills. Output contract: gap summary, classification, candidates with provider + fit rationale, install command, security/conflict notes, open questions.", + "harnessSettings": { "reasoning": "medium", "timeoutSeconds": 450 } + }, + "minimum": { + "harness": "opencode", + "model": "opencode/minimax-m2.5-free", + "systemPrompt": "You are a concise capability discovery specialist. Same quality bar; only limit depth. Required: classify the gap as skill/agent/hook; search BOTH skill.sh via `npx skills find ` and prpm.dev; verify candidate manifests before recommending; never fabricate packages; recommend at most two with provider-labeled fit rationale; produce the exact install command for the chosen provider (`npx -y skills add --skill -y` for skill.sh or `npx -y prpm install --as ` for prpm); call out security notes and install conflicts. If nothing fits, say so. Apply the skill.sh/find-skills and prpm/self-improving skills. Output contract: gap summary, classification, candidates, install command, notes, open questions.", + "harnessSettings": { "reasoning": "low", "timeoutSeconds": 300 } + } + } +}