Ship native subagent definitions for GitHub Copilot and Cursor

The github and cursor providers previously received only the generated
degraded/ inline fallbacks. Both harnesses support real custom subagents,
so the build now emits them from the same skill/agents/ source:

- GitHub Copilot: .github/agents/impeccable-<role>.agent.md with portable
  frontmatter only (name + description; omitting tools grants all tools,
  and Copilot has no documented model/effort/max-turns equivalents).
- Cursor: .cursor/agents/impeccable-<role>.md with name, description,
  model: inherit, is_background: false, and readonly derived from the
  agent's tool list (true only for the finish reviewer, which declares
  neither Write nor Edit). effort/max-turns are skipped because Cursor's
  effort option requires an explicit model id.

Agent bodies now also resolve {{scripts_path}} and strip rule markers in
the shared agentFormat pipeline, which fixes the previously unresolved
placeholder in the emitted Claude asset-producer agent.

The CLI installer places agents per scope: project installs write
<repo>/.github/agents/ and <repo>/.cursor/agents/; user-level installs
write ~/.copilot/agents/ (Copilot's user dir, not ~/.github/) and
~/.cursor/agents/, overwriting stale impeccable-* copies. Because
Copilot lets user-level agents shadow same-named project ones, a project
install warns when shadowing copies exist; Cursor gives project agents
precedence, so no warning there.

new-work.md and visualize.md extend their harness-naming clauses with
the Cursor and Copilot invocations. The degraded/ fallbacks keep
shipping for surfaces where the model still fails to delegate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Paul Bakaus
2026-07-28 16:16:57 -07:00
co-authored by Claude Fable 5
parent dedb8a1df2
commit fa1177ed9c
7 changed files with 397 additions and 3 deletions
+71
View File
@@ -1189,6 +1189,71 @@ function copyProviderSkills(bundleDir, root, targets, { scope } = {}) {
return written;
}
// Native subagent definitions that ship in the bundle next to a provider's
// skills. GitHub Copilot's live at `.github/agents/impeccable-*.agent.md`:
// project installs commit them at `<repo>/.github/agents/`, user-level
// installs go to `~/.copilot/agents/` (Copilot's user-scope dir, NOT
// `~/.github/`). On a name conflict Copilot lets the user-level file shadow
// the project one, so both paths overwrite existing impeccable-* copies and a
// project install reports any same-named user-level agents that would shadow
// it. Cursor's live at `.cursor/agents/impeccable-*.md`, user scope
// `~/.cursor/agents/`; project agents take precedence there, so no shadow
// warning is needed.
const PROVIDER_AGENT_ARTIFACTS = {
'.github': {
ext: '.agent.md',
userDir: home => join(home, '.copilot', 'agents'),
userShadowsProject: true,
},
'.cursor': {
ext: '.md',
userDir: home => join(home, '.cursor', 'agents'),
userShadowsProject: false,
},
};
function copyProviderAgents(bundleDir, root, providers, { scope, home = homedir() } = {}) {
const targets = Array.isArray(providers) ? providers : [providers];
const results = [];
for (const provider of targets) {
const artifact = PROVIDER_AGENT_ARTIFACTS[provider];
if (!artifact) continue;
const srcDir = join(bundleDir, provider, 'agents');
if (!existsSync(srcDir)) continue;
const agentFiles = readdirSync(srcDir).filter(name => name.endsWith(artifact.ext));
if (agentFiles.length === 0) continue;
const destDir = scope === 'user'
? artifact.userDir(root)
: join(root, provider, 'agents');
mkdirSync(destDir, { recursive: true });
for (const name of agentFiles) {
writeFileSync(join(destDir, name), readFileSync(join(srcDir, name)));
}
// A project install can be shadowed by same-named agents in the user-level
// dir; surface them so the freshly installed project agents actually apply.
const userDir = artifact.userDir(home);
const shadowed = artifact.userShadowsProject && scope !== 'user'
? agentFiles.filter(name => existsSync(join(userDir, name)))
: [];
results.push({ provider, written: agentFiles.length, destDir, userDir, shadowed });
}
return results;
}
function reportProviderAgents(results) {
for (const result of results || []) {
if (result.written === 0) continue;
console.log(`Installed ${providerDisplayName(result.provider)} agents into: ${formatPathForDisplay(result.destDir)}`);
if (result.shadowed.length > 0) {
console.warn(`Warning: user-level agents in ${formatPathForDisplay(result.userDir)} shadow the project copies just installed: ${result.shadowed.join(', ')}.`);
console.warn('Run `npx impeccable update --user` to refresh them, or remove them so the project agents apply.');
}
}
}
function refreshProviderSkills(bundleDir, root, providers, scope) {
const unique = deduplicateProviders(root, providers, scope);
let updated = 0;
@@ -1735,6 +1800,7 @@ async function install(flags) {
if (!updateCheckSkipped && copyTargets.length > 0 && !isUpToDate(installRoot, copyTargets, bundleDir, scope)) {
migrateUnprefixImpeccable(installRoot, scope);
updated = refreshProviderSkills(bundleDir, installRoot, copyTargets, scope);
reportProviderAgents(copyProviderAgents(bundleDir, installRoot, copyTargets, { scope }));
const v = getSkillsVersion(installRoot, scope);
console.log(`Updated ${updated} skill(s)${v ? ` to v${v}` : ''}.`);
}
@@ -1791,8 +1857,10 @@ async function install(flags) {
let written = 0;
let hookTargets = [];
let agentResults = [];
try {
written = copyProviderSkills(bundleDir, installRoot, targets, { scope });
agentResults = copyProviderAgents(bundleDir, installRoot, targets, { scope });
hookTargets = wantHooks ? copyProviderHooks(bundleDir, hookRoot, targets, { force, skillRoot: installRoot }) : [];
} catch (e) {
rmSync(bundleDir, { recursive: true, force: true });
@@ -1806,6 +1874,7 @@ async function install(flags) {
process.exit(1);
}
console.log(`Installed impeccable into: ${targets.join(', ')} (${scope === 'user' ? 'global' : 'project'})`);
reportProviderAgents(agentResults);
if (hookTargets.length > 0) console.log(`Installed hooks into: ${hookTargets.join(', ')}`);
console.log('\nDone! Run /impeccable init in your AI harness to set up design context.\n');
@@ -2062,6 +2131,7 @@ async function update(flags = []) {
if (migrated > 0) console.log('Migrated a prefixed install back to /impeccable (the i- prefix is no longer used).');
const updated = refreshProviderSkills(tmpDir, root, copyProviders, scope);
reportProviderAgents(copyProviderAgents(tmpDir, root, copyProviders, { scope }));
const wantHooks = installHooks && await decideHookInstall(root, providers, { yes });
const hookTargets = wantHooks ? copyProviderHooks(tmpDir, root, providers, { force }) : [];
@@ -2096,6 +2166,7 @@ function copyDirSync(src, dest) {
// reimplementation in a helper script (which is how bugs slip through).
export {
collectInstallDetections,
copyProviderAgents,
copyProviderHooks,
copyProviderSkills,
decideHookInstall,
+57
View File
@@ -136,6 +136,46 @@ function buildClaudeAgent(agent, body) {
return `${generateYamlFrontmatter(frontmatter)}\n${body.trim()}\n`;
}
// GitHub Copilot custom agents are markdown files named `<name>.agent.md`
// (project scope: `.github/agents/`; user scope: `~/.copilot/agents/`). Only
// the portable frontmatter fields are emitted: `name` and `description`.
// `tools` is omitted deliberately -- omitting it grants access to all tools,
// and Copilot's tool vocabulary differs from ours -- and Copilot has no
// documented model/effort/max-turns equivalents. VS Code-specific fields
// (handoffs, argument-hint) are ignored elsewhere, so none are emitted.
function buildCopilotAgent(agent, body) {
const frontmatter = {
name: agent.name,
description: agent.description,
};
return `${generateYamlFrontmatter(frontmatter)}\n${body.trim()}\n`;
}
// Cursor subagents are plain markdown files with YAML frontmatter (project
// scope: `.cursor/agents/`; user scope: `~/.cursor/agents/`). Fields: name,
// description (drives auto-delegation), model (`inherit` maps directly to our
// value), readonly, is_background. `readonly` is derived from the agent's own
// tool list: a role that declares tools but neither Write nor Edit is a
// reader, and Cursor can enforce that. effort/max-turns are skipped: Cursor's
// effort option requires an explicit model id, incompatible with `inherit`.
function buildCursorAgent(agent, body) {
const frontmatter = {
name: agent.name,
description: agent.description,
model: agent.model || 'inherit',
};
const tools = String(agent.tools || '').split(',').map(t => t.trim()).filter(Boolean);
if (tools.length > 0 && !tools.includes('Write') && !tools.includes('Edit')) {
frontmatter.readonly = true;
}
// The parent thread waits on each role's return; none of these run detached.
frontmatter.is_background = false;
return `${generateYamlFrontmatter(frontmatter)}\n${body.trim()}\n`;
}
function buildAgentFile(config, agent, body) {
if (config.agentFormat === 'codex-toml') {
return {
@@ -151,6 +191,20 @@ function buildAgentFile(config, agent, body) {
};
}
if (config.agentFormat === 'copilot-agent-md') {
return {
filename: `${agent.name}.agent.md`,
content: buildCopilotAgent(agent, body),
};
}
if (config.agentFormat === 'cursor-md') {
return {
filename: `${agent.name}.md`,
content: buildCursorAgent(agent, body),
};
}
return null;
}
@@ -316,12 +370,15 @@ export function createTransformer(config) {
if (config.agentFormat) {
const agentsDir = path.join(providerDir, `${configDir}/agents`);
for (const skill of skills) {
const scriptsPath = `${configDir}/skills/${skill.name}/scripts`;
for (const agent of skill.agents || []) {
// Agents can declare `providers: <list>` to limit which harnesses
// they emit to. Default (no field) ships everywhere with agentFormat.
if (agent.providers && !agent.providers.includes(provider)) continue;
let body = compileProviderBlocks(agent.body, providerTags);
body = replacePlaceholders(body, placeholderKey, [], allSkillNames);
body = stripRuleMarkers(body);
body = body.replace(/\{\{scripts_path\}\}/g, scriptsPath);
const agentFile = buildAgentFile(config, agent, body);
if (!agentFile) continue;
ensureDir(agentsDir);
+9
View File
@@ -16,6 +16,10 @@ export const PROVIDERS = {
configDir: '.cursor',
displayName: 'Cursor',
frontmatterFields: ['license', 'compatibility', 'metadata'],
// Cursor subagents: `.cursor/agents/<name>.md` at repo level,
// `~/.cursor/agents/` at user level. Project agents take precedence over
// user ones, so installs simply overwrite on update.
agentFormat: 'cursor-md',
emitHooks: 'cursor',
// Cursor reads `.cursor/hooks.json`, not `.cursor/hooks/hooks.json`.
hooksManifestRel: 'hooks.json',
@@ -68,6 +72,11 @@ export const PROVIDERS = {
displayName: 'GitHub Copilot',
placeholderProvider: 'agents',
frontmatterFields: ['user-invocable', 'argument-hint', 'license', 'compatibility', 'metadata'],
// Copilot custom agents: `.github/agents/<name>.agent.md` at repo level,
// `~/.copilot/agents/` at user level (the CLI installer handles placement).
// The degraded/ fallbacks still ship for Copilot surfaces where the model
// fails to delegate; the .agent.md files are the real subagent path.
agentFormat: 'copilot-agent-md',
emitHooks: 'github',
// GitHub Copilot discovers repo-level hooks under `.github/hooks/*.json`.
hooksManifestRel: 'hooks/impeccable.json',
+1 -1
View File
@@ -104,6 +104,6 @@ Preserve semantics, accessibility, performance, responsiveness, project conventi
Inspect desktop and mobile in one batched screenshot round, critique the render against the user's request and the direction contract, fix material gaps, and confirm with one final round; two rounds is the ceiling, and fixes batch between them rather than earning per-tweak screenshots. On a Persuade surface, verify the mode did its job: a first-time visitor should know what this is, why it matters, and what to do within seconds, in the form's own vocabulary.
After the second inspection round the build thread's polishing is over: no further defect hunts, micro-edit scripts, or rebuilds here; whatever remains ships through the handoffs, where a fresh context does the finding better and cheaper. Capture desktop and mobile screenshots to files, then spawn the shipped finish reviewer, `impeccable-finish-reviewer` (`impeccable_finish_reviewer` in codex), with the original request, confirmed answers, the artifact path, the screenshot paths, its direction contract, existing hook findings, and the QUALITY BAR card and approved comp paths. The reviewer has no browser; screenshots you fail to pass are checks it cannot run. Verify its return carries the five contract sections; on an empty or thrashed return, respawn once with the same inputs before doing anything else. This review never runs inside the build thread. Only a harness whose tool surface has no subagent capability at all substitutes a fresh in-thread pass after stepping fully out of the build context, run from [degraded/finish-reviewer.md](degraded/finish-reviewer.md), and a substituted or failed-and-replaced review is disclosed in one line at finish, never silently. Apply the material fixes in one batch, rebuild once, and recapture the same viewports. A recapture measures positions, loading, and overflow; it cannot measure whether a fix reached the quality the finding named, so send the recaptured screenshots back to the same reviewer for a verdict scoring every material fix resolved, partial, or unresolved (through the harness's agent continuation; without one, run the scoring fresh from [degraded/finish-reviewer.md](degraded/finish-reviewer.md)'s Verdict Pass). Fixes scored partial or unresolved get exactly one more batch, recapture, and verdict; two correction rounds is the ceiling, the second verdict ends the work whatever it says, and the reviewer's findings are the only list you work from, never your own re-opened hunt. Report the final verdict table to the user as it stands, open items included: presenting mechanical confirmation as artistic success is how a failed build gets announced as a finished one. Do not run a second detector. <!-- rule:skill-verdict-bounds-the-finish --> <!-- rule:skill-finish-separate-reviewer -->
After the second inspection round the build thread's polishing is over: no further defect hunts, micro-edit scripts, or rebuilds here; whatever remains ships through the handoffs, where a fresh context does the finding better and cheaper. Capture desktop and mobile screenshots to files, then spawn the shipped finish reviewer, `impeccable-finish-reviewer` (`impeccable_finish_reviewer` in codex; `/impeccable-finish-reviewer` in Cursor; on GitHub Copilot say "Use the impeccable-finish-reviewer agent"), with the original request, confirmed answers, the artifact path, the screenshot paths, its direction contract, existing hook findings, and the QUALITY BAR card and approved comp paths. The reviewer has no browser; screenshots you fail to pass are checks it cannot run. Verify its return carries the five contract sections; on an empty or thrashed return, respawn once with the same inputs before doing anything else. This review never runs inside the build thread. Only a harness whose tool surface has no subagent capability at all substitutes a fresh in-thread pass after stepping fully out of the build context, run from [degraded/finish-reviewer.md](degraded/finish-reviewer.md), and a substituted or failed-and-replaced review is disclosed in one line at finish, never silently. Apply the material fixes in one batch, rebuild once, and recapture the same viewports. A recapture measures positions, loading, and overflow; it cannot measure whether a fix reached the quality the finding named, so send the recaptured screenshots back to the same reviewer for a verdict scoring every material fix resolved, partial, or unresolved (through the harness's agent continuation; without one, run the scoring fresh from [degraded/finish-reviewer.md](degraded/finish-reviewer.md)'s Verdict Pass). Fixes scored partial or unresolved get exactly one more batch, recapture, and verdict; two correction rounds is the ceiling, the second verdict ends the work whatever it says, and the reviewer's findings are the only list you work from, never your own re-opened hunt. Report the final verdict table to the user as it stands, open items included: presenting mechanical confirmation as artistic success is how a failed build gets announced as a finished one. Do not run a second detector. <!-- rule:skill-verdict-bounds-the-finish --> <!-- rule:skill-finish-separate-reviewer -->
Then spawn the shipped documenter, `impeccable-documenter` (`impeccable_documenter` in codex), with the project root, the artifact path, the direction contract, PRODUCT.md, the [document.md](document.md) reference path, and the boundary to write at; it records DESIGN.md and the sidecar from the built world, ground truth over intention; without subagents the pass runs from [degraded/documenter.md](degraded/documenter.md). A clean detector pass is not finished; finished is the contract kept, the comp honored, the review closed, and the system recorded. <!-- rule:skill-documenter-records-the-world -->
+1 -1
View File
@@ -36,6 +36,6 @@ Treat the comp as a north star, not something to trace, and know what that allow
Generation context is part of the asset: the thread that wrote a prompt knows what the image contains, why, and how it is meant to sit in the layout, and a build composed by a thread without that knowledge places assets it does not understand. So prefer generating build-critical imagery in the build thread when the budget allows, and when a subagent produces assets instead, every asset must carry its prompt, and the builder reads those prompts before composing a single one of them. The carrier is uniform across harnesses: after generating any image with any tool, native or `generate-image.mjs` (which does it automatically), run `node {{scripts_path}}/embed-prompt.mjs <image> --prompt "<the prompt used>"` so the intent lives inside the file itself and survives copies between machines and harnesses; `--read` recovers it from any impeccable-generated image.
When clean raster ingredients are required and the harness runs subagents, use the shipped asset producer, `impeccable-asset-producer` (`impeccable_asset_producer` in codex): give it the approved comp, output paths, required dimensions and formats, transparency needs, crop notes, and what must remain semantic code. Otherwise produce the minimum required assets in the current thread by the book: load [degraded/asset-producer.md](degraded/asset-producer.md) and follow it inline, with whatever generation exists, the native tool or generate-image.mjs.
When clean raster ingredients are required and the harness runs subagents, use the shipped asset producer, `impeccable-asset-producer` (`impeccable_asset_producer` in codex; `/impeccable-asset-producer` in Cursor; on GitHub Copilot say "Use the impeccable-asset-producer agent"): give it the approved comp, output paths, required dimensions and formats, transparency needs, crop notes, and what must remain semantic code. Otherwise produce the minimum required assets in the current thread by the book: load [degraded/asset-producer.md](degraded/asset-producer.md) and follow it inline, with whatever generation exists, the native tool or generate-image.mjs.
Return to [new-work.md](new-work.md) for the direction contract, implementation, and the finishing pass.
+166 -1
View File
@@ -162,7 +162,7 @@ This is a test skill body.`;
expect(fs.existsSync(path.join(DIST_DIR, 'codex/.codex/skills/test-skill/SKILL.md'))).toBe(true);
});
test('integration: emits native subagent files for Codex and Claude Code', () => {
test('integration: emits native subagent files for Codex, Claude Code, GitHub Copilot, and Cursor', () => {
const skillContent = `---
name: test-skill
description: A test skill
@@ -195,14 +195,22 @@ Do not redesign the approved crop.`;
transformers.transformClaudeCode(skills, DIST_DIR, patterns);
transformers.transformCodex(skills, DIST_DIR, patterns);
transformers.transformGitHub(skills, DIST_DIR, patterns);
transformers.transformCursor(skills, DIST_DIR, patterns);
const claudeAgentPath = path.join(DIST_DIR, 'claude-code/.claude/agents/asset-producer.md');
// Codex auto-discovers agents nested inside an installed skill, so the .toml
// ships in the skill's own agents/ folder rather than a top-level .codex/agents/.
const codexAgentPath = path.join(DIST_DIR, 'codex/.codex/skills/test-skill/agents/asset_producer.toml');
// GitHub Copilot discovers repo-level custom agents at .github/agents/<name>.agent.md.
const copilotAgentPath = path.join(DIST_DIR, 'github/.github/agents/asset-producer.agent.md');
// Cursor discovers repo-level subagents at .cursor/agents/<name>.md.
const cursorAgentPath = path.join(DIST_DIR, 'cursor/.cursor/agents/asset-producer.md');
expect(fs.existsSync(claudeAgentPath)).toBe(true);
expect(fs.existsSync(codexAgentPath)).toBe(true);
expect(fs.existsSync(copilotAgentPath)).toBe(true);
expect(fs.existsSync(cursorAgentPath)).toBe(true);
const claudeAgent = fs.readFileSync(claudeAgentPath, 'utf-8');
expect(claudeAgent).toContain('name: asset-producer');
@@ -214,6 +222,31 @@ Do not redesign the approved crop.`;
expect(codexAgent).toContain('model_reasoning_effort = "medium"');
expect(codexAgent).toContain('nickname_candidates = ["Asset Plate"]');
expect(codexAgent).toContain('developer_instructions =');
// Copilot's portable frontmatter is name + description only: omitting
// `tools` grants access to all tools, and there are no documented
// model/effort/max-turns equivalents.
const copilotAgent = fs.readFileSync(copilotAgentPath, 'utf-8');
expect(copilotAgent).toContain('name: asset-producer');
expect(copilotAgent).toContain('description: Produces assets from approved crops');
expect(copilotAgent).toContain('Do not redesign the approved crop.');
expect(copilotAgent).not.toContain('tools:');
expect(copilotAgent).not.toContain('model:');
expect(copilotAgent).not.toContain('effort:');
expect(copilotAgent).not.toContain('maxTurns:');
// Cursor keeps model (inherit maps directly) and derives readonly from the
// tool list; this agent carries Write, so no readonly field is emitted.
const cursorAgent = fs.readFileSync(cursorAgentPath, 'utf-8');
expect(cursorAgent).toContain('name: asset-producer');
expect(cursorAgent).toContain('description: Produces assets from approved crops');
expect(cursorAgent).toContain('model: inherit');
expect(cursorAgent).toContain('is_background: false');
expect(cursorAgent).toContain('Do not redesign the approved crop.');
expect(cursorAgent).not.toContain('readonly:');
expect(cursorAgent).not.toContain('tools:');
expect(cursorAgent).not.toContain('effort:');
expect(cursorAgent).not.toContain('maxTurns:');
});
test('integration: verify transformations are correct', () => {
@@ -466,3 +499,135 @@ describe('degraded-mode fallback reference generation', () => {
expect(fs.existsSync(path.join(ROOT, 'skill', 'reference', 'degraded'))).toBe(false);
});
});
describe('GitHub Copilot custom agent generation', () => {
const ROOT = process.cwd();
const COPILOT_TEST_DIR = path.join(ROOT, 'test-tmp-copilot-agents');
const DIST = path.join(COPILOT_TEST_DIR, 'dist');
const AGENTS_DIR = path.join(DIST, 'github', '.github', 'agents');
beforeEach(() => {
if (fs.existsSync(COPILOT_TEST_DIR)) fs.rmSync(COPILOT_TEST_DIR, { recursive: true, force: true });
fs.mkdirSync(COPILOT_TEST_DIR, { recursive: true });
const { skills } = utils.readSourceFiles(ROOT);
transformers.transformGitHub(skills, DIST);
});
afterEach(() => {
if (fs.existsSync(COPILOT_TEST_DIR)) fs.rmSync(COPILOT_TEST_DIR, { recursive: true, force: true });
});
test('emits .github/agents/<name>.agent.md for every shipped agent', () => {
const files = fs.readdirSync(AGENTS_DIR).sort();
expect(files).toEqual([
'impeccable-asset-producer.agent.md',
'impeccable-documenter.agent.md',
'impeccable-finish-reviewer.agent.md',
'impeccable-manual-edit-applier.agent.md',
]);
});
test('frontmatter carries only name and description, description verbatim from the source', () => {
const source = fs.readFileSync(path.join(ROOT, 'skill', 'agents', 'impeccable-finish-reviewer.md'), 'utf-8');
const sourceDescription = source.match(/^description:\s*(.+)$/m)[1].trim();
const content = fs.readFileSync(path.join(AGENTS_DIR, 'impeccable-finish-reviewer.agent.md'), 'utf-8');
const frontmatter = content.split('---')[1];
expect(frontmatter).toContain('name: impeccable-finish-reviewer');
expect(frontmatter).toContain(`description: ${sourceDescription}`);
// Copilot has no documented equivalents for these, and omitting `tools`
// grants access to all tools; only portable fields are emitted.
expect(frontmatter).not.toContain('tools:');
expect(frontmatter).not.toContain('model:');
expect(frontmatter).not.toContain('effort:');
expect(frontmatter).not.toContain('maxTurns:');
expect(frontmatter).not.toContain('nickname');
});
test('bodies are compiled: placeholders resolved, rule markers stripped', () => {
for (const name of fs.readdirSync(AGENTS_DIR)) {
const content = fs.readFileSync(path.join(AGENTS_DIR, name), 'utf-8');
expect(content).not.toContain('{{');
expect(content).not.toMatch(/<!--\s*rule:/);
}
// The asset producer's body references the skill's scripts dir; the
// placeholder resolves to the provider-aware path.
const assetProducer = fs.readFileSync(path.join(AGENTS_DIR, 'impeccable-asset-producer.agent.md'), 'utf-8');
expect(assetProducer).toContain('.github/skills/impeccable/scripts');
// A distinctive body phrase proves the agent body itself was inlined.
const reviewer = fs.readFileSync(path.join(AGENTS_DIR, 'impeccable-finish-reviewer.agent.md'), 'utf-8');
expect(reviewer).toContain('material_fixes');
});
test('degraded fallbacks still ship for the github provider alongside the real agents', () => {
const degradedDir = path.join(DIST, 'github', '.github', 'skills', 'impeccable', 'reference', 'degraded');
const files = fs.readdirSync(degradedDir).sort();
expect(files).toEqual([
'asset-producer.md',
'documenter.md',
'finish-reviewer.md',
'manual-edit-applier.md',
]);
});
});
describe('Cursor subagent generation', () => {
const ROOT = process.cwd();
const CURSOR_TEST_DIR = path.join(ROOT, 'test-tmp-cursor-agents');
const DIST = path.join(CURSOR_TEST_DIR, 'dist');
const AGENTS_DIR = path.join(DIST, 'cursor', '.cursor', 'agents');
beforeEach(() => {
if (fs.existsSync(CURSOR_TEST_DIR)) fs.rmSync(CURSOR_TEST_DIR, { recursive: true, force: true });
fs.mkdirSync(CURSOR_TEST_DIR, { recursive: true });
const { skills } = utils.readSourceFiles(ROOT);
transformers.transformCursor(skills, DIST);
});
afterEach(() => {
if (fs.existsSync(CURSOR_TEST_DIR)) fs.rmSync(CURSOR_TEST_DIR, { recursive: true, force: true });
});
test('emits .cursor/agents/<name>.md for every shipped agent', () => {
const files = fs.readdirSync(AGENTS_DIR).sort();
expect(files).toEqual([
'impeccable-asset-producer.md',
'impeccable-documenter.md',
'impeccable-finish-reviewer.md',
'impeccable-manual-edit-applier.md',
]);
});
test('frontmatter maps name, description, model inherit, is_background false; readonly only on the reviewer', () => {
for (const name of fs.readdirSync(AGENTS_DIR)) {
const content = fs.readFileSync(path.join(AGENTS_DIR, name), 'utf-8');
const frontmatter = content.split('---')[1];
expect(frontmatter).toContain(`name: ${name.replace(/\.md$/, '')}`);
expect(frontmatter).toContain('description: ');
expect(frontmatter).toContain('model: inherit');
expect(frontmatter).toContain('is_background: false');
// Cursor's effort option requires an explicit model id, incompatible
// with inherit, and our tool names are not Cursor's vocabulary.
expect(frontmatter).not.toContain('tools:');
expect(frontmatter).not.toContain('effort:');
expect(frontmatter).not.toContain('maxTurns:');
// The finish reviewer is the only role whose tool list has no Write or
// Edit; it reviews, the other three write.
if (name === 'impeccable-finish-reviewer.md') {
expect(frontmatter).toContain('readonly: true');
} else {
expect(frontmatter).not.toContain('readonly:');
}
}
});
test('bodies are compiled: placeholders resolved, rule markers stripped', () => {
for (const name of fs.readdirSync(AGENTS_DIR)) {
const content = fs.readFileSync(path.join(AGENTS_DIR, name), 'utf-8');
expect(content).not.toContain('{{');
expect(content).not.toMatch(/<!--\s*rule:/);
}
const assetProducer = fs.readFileSync(path.join(AGENTS_DIR, 'impeccable-asset-producer.md'), 'utf-8');
expect(assetProducer).toContain('.cursor/skills/impeccable/scripts');
});
});
+92
View File
@@ -15,6 +15,7 @@ import { mkdtempSync, existsSync, readdirSync, readFileSync, mkdirSync, writeFil
import { join } from 'path';
import { tmpdir } from 'os';
import {
copyProviderAgents,
copyProviderHooks,
copyProviderSkills,
decideHookInstall,
@@ -105,6 +106,19 @@ function createFakeUniversalBundle(root, providers = ['.claude', '.agents', '.cu
hooks: { PostToolUse: [{ matcher: 'apply_patch', hooks: [{ type: 'command', command: 'node ".codex/skills/impeccable/scripts/hook.mjs"' }] }] },
}, null, 2));
}
// Native subagent definitions, mirroring the build's provider agents output.
if (providers.includes('.github')) {
mkdirSync(join(bundleRoot, '.github', 'agents'), { recursive: true });
writeFileSync(join(bundleRoot, '.github', 'agents', 'impeccable-finish-reviewer.agent.md'),
'---\nname: impeccable-finish-reviewer\ndescription: Reviews a finished build.\n---\nCopilot reviewer body.\n');
writeFileSync(join(bundleRoot, '.github', 'agents', 'impeccable-asset-producer.agent.md'),
'---\nname: impeccable-asset-producer\ndescription: Produces assets.\n---\nCopilot producer body.\n');
}
if (providers.includes('.cursor')) {
mkdirSync(join(bundleRoot, '.cursor', 'agents'), { recursive: true });
writeFileSync(join(bundleRoot, '.cursor', 'agents', 'impeccable-finish-reviewer.md'),
'---\nname: impeccable-finish-reviewer\ndescription: Reviews a finished build.\nmodel: inherit\nreadonly: true\nis_background: false\n---\nCursor reviewer body.\n');
}
return bundleRoot;
}
@@ -214,6 +228,84 @@ describe('copyProviderSkills: symlink handling', () => {
});
});
describe('copyProviderAgents: Copilot and Cursor subagents', () => {
test('project scope places agents at .github/agents/ and .cursor/agents/', () => {
const tmp = mkdtempSync(join(tmpdir(), 'imp-agents-project-'));
const bundle = createFakeUniversalBundle(tmp, ['.github', '.cursor']);
const results = copyProviderAgents(bundle, tmp, ['.github', '.cursor'], { scope: 'project' });
expect(existsSync(join(tmp, '.github', 'agents', 'impeccable-finish-reviewer.agent.md'))).toBe(true);
expect(existsSync(join(tmp, '.github', 'agents', 'impeccable-asset-producer.agent.md'))).toBe(true);
expect(existsSync(join(tmp, '.cursor', 'agents', 'impeccable-finish-reviewer.md'))).toBe(true);
expect(results.map(r => r.provider).sort()).toEqual(['.cursor', '.github']);
rmSync(tmp, { recursive: true, force: true });
});
test('user scope places Copilot agents at ~/.copilot/agents (not ~/.github) and Cursor agents at ~/.cursor/agents, overwriting stale copies', () => {
const tmp = mkdtempSync(join(tmpdir(), 'imp-agents-user-'));
const home = mkdtempSync(join(tmpdir(), 'imp-agents-user-home-'));
const bundle = createFakeUniversalBundle(tmp, ['.github', '.cursor']);
// A stale user-level copy from an older release must be overwritten.
mkdirSync(join(home, '.copilot', 'agents'), { recursive: true });
writeFileSync(join(home, '.copilot', 'agents', 'impeccable-finish-reviewer.agent.md'), 'stale copy\n');
copyProviderAgents(bundle, home, ['.github', '.cursor'], { scope: 'user' });
const copilotAgent = readFileSync(join(home, '.copilot', 'agents', 'impeccable-finish-reviewer.agent.md'), 'utf8');
expect(copilotAgent).toContain('Copilot reviewer body.');
expect(existsSync(join(home, '.cursor', 'agents', 'impeccable-finish-reviewer.md'))).toBe(true);
expect(existsSync(join(home, '.github', 'agents'))).toBe(false);
rmSync(tmp, { recursive: true, force: true });
rmSync(home, { recursive: true, force: true });
});
test('project scope reports user-level Copilot agents that shadow the installed ones; Cursor never does (project wins there)', () => {
const tmp = mkdtempSync(join(tmpdir(), 'imp-agents-shadow-'));
const home = mkdtempSync(join(tmpdir(), 'imp-agents-shadow-home-'));
const bundle = createFakeUniversalBundle(tmp, ['.github', '.cursor']);
mkdirSync(join(home, '.copilot', 'agents'), { recursive: true });
writeFileSync(join(home, '.copilot', 'agents', 'impeccable-finish-reviewer.agent.md'), 'user-level copy\n');
mkdirSync(join(home, '.cursor', 'agents'), { recursive: true });
writeFileSync(join(home, '.cursor', 'agents', 'impeccable-finish-reviewer.md'), 'user-level copy\n');
const results = copyProviderAgents(bundle, tmp, ['.github', '.cursor'], { scope: 'project', home });
const github = results.find(r => r.provider === '.github');
const cursor = results.find(r => r.provider === '.cursor');
expect(github.shadowed).toEqual(['impeccable-finish-reviewer.agent.md']);
expect(cursor.shadowed).toEqual([]);
// The project copies still land; the shadow report is a warning, not a block.
expect(existsSync(join(tmp, '.github', 'agents', 'impeccable-finish-reviewer.agent.md'))).toBe(true);
rmSync(tmp, { recursive: true, force: true });
rmSync(home, { recursive: true, force: true });
});
test('fresh install lays agents down alongside skills and reports them', () => {
const tmp = mkdtempSync(join(tmpdir(), 'imp-agents-install-'));
const home = mkdtempSync(join(tmpdir(), 'imp-agents-install-home-'));
execSync('git init', { cwd: tmp });
const bundleRoot = createFakeUniversalBundle(tmp, ['.github', '.cursor']);
const output = run('skills install -y --no-hooks --providers=github,cursor', {
cwd: tmp,
env: { ...process.env, HOME: home, IMPECCABLE_BUNDLE_PATH: bundleRoot },
});
expect(output).toContain('Installed impeccable into: .github, .cursor (project)');
expect(output).toContain('Installed GitHub Copilot agents into:');
expect(output).toContain('Installed Cursor agents into:');
expect(existsSync(join(tmp, '.github', 'agents', 'impeccable-finish-reviewer.agent.md'))).toBe(true);
expect(existsSync(join(tmp, '.cursor', 'agents', 'impeccable-finish-reviewer.md'))).toBe(true);
rmSync(tmp, { recursive: true, force: true });
rmSync(home, { recursive: true, force: true });
}, 15000);
});
describe('skills install: already-installed detection', () => {
test('detects impeccable sentinel and bails', () => {
const tmp = mkdtempSync(join(tmpdir(), 'imp-test-'));