Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/prove-composite-discovery.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"agent-bundle": patch
---

Make `AB4103` name the colliding component and native-precedence risk in composite roots. (#653)
2 changes: 1 addition & 1 deletion docs/diagnostics.md
Original file line number Diff line number Diff line change
Expand Up @@ -829,7 +829,7 @@ exactly where `build` would refuse.

| Code | Severity | Trigger | Recovery |
| --- | --- | --- | --- |
| `AB4103` | error | Two selected projections plan the same artifact path with different bytes, so one root cannot hold both. The common case is a Skill whose frontmatter carries a host extension (`targets: { claude: … }`): it lowers to different `skills/<name>/SKILL.md` bytes for Claude Code than for the other hosts. Projections are compared in host-name order and paths in path order, so the same selection reports the same collision however `targets` is written. | Make the component identical for every selected host, or build the conflicting hosts into separate artifacts (one `targets` entry per build). |
| `AB4103` | error | Two selected projections plan the same component path with different bytes, so one root cannot hold both without changing native precedence. The diagnostic names the component location, path, and hosts. Common cases are a Skill whose host extension lowers to different `skills/<name>/SKILL.md` bytes, or a command whose Claude and Cursor dialects lower differently. Projections are compared in host-name order and paths in path order, so the same selection reports the same collision however `targets` is written. | Make the component identical for every selected host, or build the conflicting hosts into separate artifacts (one `targets` entry per build). |
| `AB4105` | error | A component scoped to a subset of the selected hosts (a command or rule with frontmatter `targets`) would be discovered by another selected host that scans the same conventional directory (`commands/` for Claude Code and Cursor, `rules/` for Cursor). Inside one root the file cannot be hidden from that host, so the build refuses rather than leaking it. Skills are never host-scoped — every skill ships to every selected host, and a per-host frontmatter extension that changes its bytes is an `AB4103` collision instead. | Extend the component's `targets` to every selected host that discovers its directory, or build those hosts into separate artifacts. |
| `AB4106` | error | The selection mixes an adapter registered on an advanced `TargetRegistry` — any target whose adapter is not one of the shipped `claude`, `codex`, `cursor`, `portable` adapters, judged by adapter identity, so a custom adapter registered under one of those names counts as advanced — with one or more other targets. The built-in hosts agree on where the files they cannot share live, which conventional directories each discovers, and one install surface; a third-party adapter has made none of those agreements, so it cannot share a root. Judged on the normalized model, so `validate`, `inspect`, and `build` all report it, on the non-built-in target with its config provenance. A selection of one target never triggers it, whatever the target; unknown names are `AB4100`'s and do not count. | Build that target alone — `targets: ['<name>']` — into its own `--output`, and the remaining targets into another. |

Expand Down
17 changes: 10 additions & 7 deletions packages/agent-bundle/src/build/compose.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,13 +59,16 @@ const sameBytes = (left: TargetArtifactEntry, right: TargetArtifactEntry): boole
return false;
};

const collisionDiagnostic = (relativePath: string, owners: readonly string[]): Diagnostic => ({
code: 'AB4103',
generatedPath: relativePath,
message: `Artifact path ${JSON.stringify(relativePath)} is planned with different contents by the ${owners.join(' and ')} projections; one composite root cannot hold both.`,
recovery: 'Build the conflicting hosts into separate artifacts (one `targets` entry per build), or make the component identical for every selected host.',
severity: 'error',
});
const collisionDiagnostic = (relativePath: string, owners: readonly string[]): Diagnostic => {
const component = relativePath.includes('/') ? relativePath.slice(0, relativePath.indexOf('/')) : 'root';
return {
code: 'AB4103',
generatedPath: relativePath,
message: `${component} component path ${JSON.stringify(relativePath)} is planned with different contents by the ${owners.join(' and ')} projections; deterministic projection ordering cannot choose one without changing native precedence.`,
recovery: 'Build the conflicting hosts into separate artifacts (one `targets` entry per build), or make the component identical for every selected host.',
severity: 'error',
};
};

interface MergedEntries {
readonly diagnostics: readonly Diagnostic[];
Expand Down
180 changes: 179 additions & 1 deletion packages/agent-bundle/tests/build-compose.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { codexAdapter, codexArtifactPaths } from '../src/adapters/codex.ts';
import { cursorAdapter, cursorArtifactPaths } from '../src/adapters/cursor.ts';
import { portableAdapter } from '../src/adapters/portable.ts';
import type { TargetAdapter } from '../src/adapters/types.ts';
import { build, type BuildProjectResult, createDefaultRegistry, runMcp, TargetRegistry, validate } from '../src/api.ts';
import { build, type BuildProjectResult, createDefaultRegistry, inspect, runMcp, TargetRegistry, validate } from '../src/api.ts';
import { parseArtifactManifest } from '../src/build/manifest.ts';
import { sha256Hex } from '../src/core/digest.ts';
import { DiagnosticError } from '../src/core/diagnostics.ts';
Expand Down Expand Up @@ -88,6 +88,12 @@ const writeProject = async (root: string, options: FixtureOptions = {}): Promise
'---', 'name: review', 'description: Review changes', ...(options.skillFrontmatter ?? []), '---', '# Review', '',
].join('\n')),
writeProjectFile(root, 'src/commands/summarize.md', `---\ndescription: Summarize the diff\n${commandTargets}---\nSummarize the current diff.\n`),
...(options.targets?.includes('cursor') === true
? [writeProjectFile(root, 'src/rules/cursor-only.mdc', [
'---', 'description: Cursor-only instruction', 'targets:', ' - cursor', '---',
'Apply only the Cursor instruction.',
].join('\n'))]
: []),
]);
};

Expand Down Expand Up @@ -174,6 +180,102 @@ const readJson = async (path: string): Promise<unknown> => JSON.parse(await read

const topLevel = async (root: string): Promise<readonly string[]> => (await readdir(root)).sort();

type BuiltInHost = 'claude' | 'codex' | 'cursor' | 'portable';

const nonCollidingFixturePairs: readonly (readonly [BuiltInHost, BuiltInHost])[] = Object.freeze([
['claude', 'codex'],
['claude', 'portable'],
['codex', 'cursor'],
['codex', 'portable'],
['cursor', 'portable'],
]);

const expectedDocuments = Object.freeze({
claude: Object.freeze({ hooks: 'hooks/hooks.json', mcp: '.mcp.json', plugin: '.claude-plugin/plugin.json' }),
codex: Object.freeze({ hooks: '.codex-plugin/hooks.json', mcp: '.codex-plugin/mcp.json', plugin: '.codex-plugin/plugin.json' }),
cursor: Object.freeze({ hooks: '.cursor-plugin/hooks.json', mcp: '.cursor-plugin/mcp.json', plugin: '.cursor-plugin/plugin.json' }),
portable: Object.freeze({ mcp: 'mcp.json', plugin: 'plugin.json' }),
});

const assertEffectiveSurface = async (
root: string,
selected: readonly BuiltInHost[],
): Promise<void> => {
const manifest = parseArtifactManifest(await readFile(join(root, 'agent-bundle.manifest.json'), 'utf8'));
expect(manifest.projections.map(({ host }) => host)).toEqual([...selected].sort());
expect('discovery' in manifest).toBe(false);

for (const projection of manifest.projections) {
const host = projection.host as BuiltInHost;
expect(projection.documents).toMatchObject(expectedDocuments[host]);
const pluginPath = projection.documents.plugin;
if (pluginPath === undefined) throw new TypeError(`${host} projection did not record its plugin document.`);
const plugin = await readJson(join(root, pluginPath)) as Record<string, unknown>;

switch (host) {
case 'claude':
expect(plugin).not.toHaveProperty('hooks');
expect(projection.documents).toMatchObject({ hooks: 'hooks/hooks.json', mcp: '.mcp.json' });
break;
case 'codex':
expect(plugin).toMatchObject({
hooks: './.codex-plugin/hooks.json',
mcpServers: './.codex-plugin/mcp.json',
skills: './skills/',
});
break;
case 'cursor':
expect(plugin).toMatchObject({
commands: './commands/',
hooks: './.cursor-plugin/hooks.json',
mcpServers: './.cursor-plugin/mcp.json',
rules: './rules/',
skills: './skills/',
});
break;
case 'portable':
expect(plugin).not.toHaveProperty('hooks');
expect(plugin).not.toHaveProperty('rules');
expect(projection.documents).not.toHaveProperty('hooks');
break;
default: {
const exhaustive: never = host;
throw new TypeError(`Unknown built-in host ${String(exhaustive)}.`);
}
}

const hookPath = projection.documents.hooks;
if (hookPath === undefined) continue;
const hookText = await readFile(join(root, hookPath), 'utf8');
const ownHooks = manifest.executables.hooks.filter((hook) => hook.host === host);
const foreignHooks = manifest.executables.hooks.filter((hook) => hook.host !== host);
for (const hook of ownHooks) expect(hookText).toContain(hook.path);
for (const hook of foreignHooks) expect(hookText).not.toContain(hook.path);
if (host === 'claude') {
expect(hookText).toContain('echo claude-native');
expect(hookText).not.toContain('echo codex-native');
} else if (host === 'codex') {
expect(hookText).toContain('echo codex-native');
expect(hookText).not.toContain('echo claude-native');
} else {
expect(hookText).not.toMatch(/echo (?:claude|codex)-native/u);
}
}

if (selected.includes('cursor')) {
expect(await readFile(join(root, 'rules', 'cursor-only.mdc'), 'utf8')).toContain('Apply only the Cursor instruction.');
}

const inspected = await inspect({ root: dirname(root) });
if (inspected.state !== 'ready') throw new TypeError(`inspect returned ${inspected.state}.`);
const summary = inspected.output.manifest;
if (summary === undefined || 'status' in summary) throw new TypeError('inspect did not return a valid artifact manifest.');
expect(summary.projections.map((projection) => projection.host)).toEqual(
manifest.projections.map((projection) => projection.host),
);
expect(summary.executables.hooks).toBe(manifest.executables.hooks.length);
};

describe('composite plugin root (#555)', () => {
it('emits one root whose top-level entries are exactly the selected projections and shared surfaces (acceptance 1)', { timeout: 120_000 }, async () => {
const { output, result } = await buildFixture(['claude', 'codex']);
Expand Down Expand Up @@ -345,15 +447,91 @@ describe('composite plugin root (#555)', () => {
'hooks',
'install.mjs',
'mcp',
'rules',
'scripts',
'skills',
]);
expect(await topLevel(join(cursorOnly.output, '.cursor-plugin'))).toEqual(['hooks.json', 'mcp.json', 'plugin.json']);
expect(await topLevel(join(cursorOnly.output, 'hooks'))).toEqual(['session-start-session-start-9781e2c5.mjs']);
expect(await topLevel(join(cursorOnly.output, 'rules'))).toEqual(['cursor-only.mdc']);
const cursor = await readJson(join(cursorOnly.output, cursorArtifactPaths.hooks)) as { hooks: Record<string, { command: string }[]> };
expect(cursor.hooks['sessionStart']).toEqual([{ command: 'node "${CURSOR_PLUGIN_ROOT}/hooks/session-start-session-start-9781e2c5.mjs"' }]);
});

it('preserves each supported pair effective discovery surface in either target order (#651)', { timeout: 600_000 }, async () => {
for (const pair of nonCollidingFixturePairs) {
const root = await mkdtemp(join(tmpdir(), 'agent-bundle-composite-discovery-'));
roots.push(root);
await writeProject(root, { targets: pair });
const output = join(root, 'artifact');

await build({ output, root, targets: pair });
const forward = await digestTree(output);
await assertEffectiveSurface(output, pair);

const reversed = [pair[1], pair[0]] as const;
await build({ output, root, targets: reversed });
expect([...await digestTree(output)], `${pair.join('+')} reversed order`).toEqual([...forward]);
await assertEffectiveSurface(output, pair);
}
});

it('rejects both target orders when native command dialects cannot share one path (#651, AB4103)', { timeout: 120_000 }, async () => {
for (const targets of [['claude', 'cursor'], ['cursor', 'claude']] as const) {
const root = await mkdtemp(join(tmpdir(), 'agent-bundle-composite-command-dialect-'));
roots.push(root);
await writeProject(root, { targets });
const failure = await build({ output: join(root, 'artifact'), root }).catch((error: unknown) => error);
expect(failure).toBeInstanceOf(DiagnosticError);
expect((failure as DiagnosticError).diagnostics).toEqual([expect.objectContaining({
code: 'AB4103',
generatedPath: 'commands/summarize.md',
message: expect.stringContaining('claude and cursor projections'),
})]);
}
});

it('labels the Ponytail-shaped fixture synthetic and models explicit versus fallback hook discovery (#651)', () => {
type SyntheticHookContract =
| { readonly manifestPointer: string; readonly rule: 'explicit-replaces-fallback' }
| { readonly fallback: string; readonly rule: 'conventional-fallback' };
const discover = (
contract: SyntheticHookContract,
files: Readonly<Record<string, string>>,
): readonly string[] => {
switch (contract.rule) {
case 'explicit-replaces-fallback':
return files[contract.manifestPointer] === undefined ? [] : [contract.manifestPointer];
case 'conventional-fallback':
return files[contract.fallback] === undefined ? [] : [contract.fallback];
default: {
const exhaustive: never = contract;
throw new TypeError(`Unknown synthetic discovery rule ${JSON.stringify(exhaustive)}.`);
}
}
};
const syntheticFixture = Object.freeze({
label: 'synthetic adapter fixture — Ponytail pattern, not native-host evidence',
hostA: Object.freeze({
hooks: Object.freeze({
manifestPointer: 'hooks/claude-codex-hooks.json',
rule: 'explicit-replaces-fallback' as const,
}),
}),
hostB: Object.freeze({
hooks: Object.freeze({ fallback: 'hooks/hooks.json', rule: 'conventional-fallback' as const }),
}),
});
const explicitOnly = Object.freeze({ 'hooks/claude-codex-hooks.json': 'A hooks' });
const withFallback = Object.freeze({ ...explicitOnly, 'hooks/hooks.json': 'B hooks' });

expect(syntheticFixture.label).toContain('synthetic');
expect(discover(syntheticFixture.hostA.hooks, explicitOnly)).toEqual(['hooks/claude-codex-hooks.json']);
expect(discover(syntheticFixture.hostA.hooks, withFallback)).toEqual(['hooks/claude-codex-hooks.json']);
expect(discover(syntheticFixture.hostB.hooks, explicitOnly)).toEqual([]);
expect(discover(syntheticFixture.hostB.hooks, withFallback)).toEqual(['hooks/hooks.json']);
});

it('records only the selected projections in the artifact manifest and hook index (acceptance 8)', { timeout: 120_000 }, async () => {
const { output } = await buildFixture(['codex', 'claude']);
const manifest = parseArtifactManifest(await readFile(join(output, 'agent-bundle.manifest.json'), 'utf8'));
Expand Down
24 changes: 23 additions & 1 deletion packages/agent-bundle/tests/support/host-install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -988,6 +988,10 @@ export const runClaudeHostInstallProof = async (

const skillPath = join(expectedInstallPath, 'skills', 'probe', 'SKILL.md');
await access(skillPath).catch(() => fail('Claude cache did not contain skills/probe/SKILL.md.'));
const claudeHooks = await readText(join(expectedInstallPath, 'hooks', 'hooks.json'), 'Claude installed hooks');
const codexHooks = await readText(join(expectedInstallPath, codexArtifactPaths.hooksManifest), 'Codex hooks beside Claude install');
assertProof(claudeHooks !== codexHooks, 'Claude and Codex installed hook bindings unexpectedly resolved to the same document.');
assertProof(!claudeHooks.includes('.codex.mjs'), 'Claude auto-discovered a Codex-only hook wrapper.');
const sameVersionRebuild = await proveSameVersionRebuild({
bundle: fixture.bundles.claude,
host: 'claude',
Expand Down Expand Up @@ -1085,6 +1089,18 @@ export const runCodexHostInstallProof = async (
const manifestDocument = parseJson<unknown>(installedManifestText, 'Codex installed plugin manifest');
const manifest = record(manifestDocument);
assertProof(manifest !== undefined, 'Codex installed plugin manifest was not a JSON object.');
assertProof(
manifest.hooks === `./${codexArtifactPaths.hooksManifest}`,
'Codex installed plugin manifest did not replace hooks/hooks.json fallback discovery.',
);
assertProof(
manifest.mcpServers === `./${codexArtifactPaths.mcp}`,
'Codex installed plugin manifest did not replace the root .mcp.json fallback.',
);
const codexHooks = await readText(join(cachePath, codexArtifactPaths.hooksManifest), 'Codex installed hooks');
const claudeHooks = await readText(join(cachePath, 'hooks', 'hooks.json'), 'Claude hooks beside Codex install');
assertProof(codexHooks !== claudeHooks, 'Codex and Claude installed hook bindings unexpectedly resolved to the same document.');
assertProof(!codexHooks.includes('.claude.mjs'), 'Codex auto-discovered a Claude-only hook wrapper.');
const manifestIssues = validateCodexPluginManifest(manifestDocument);
assertProof(
manifestIssues.length === 0,
Expand Down Expand Up @@ -1390,7 +1406,10 @@ export const runCursorHostInstallProof = async (

const pluginDocument = await readJson(join(destination, '.cursor-plugin', 'plugin.json'), 'Cursor plugin manifest');
assertProof(cursorPluginValidator(pluginDocument), `Cursor plugin manifest failed its pinned schema: ${JSON.stringify(cursorPluginValidator.errors)}`);
const logo = record(pluginDocument)?.logo;
const cursorPlugin = record(pluginDocument);
assertProof(cursorPlugin?.hooks === './.cursor-plugin/hooks.json', 'Cursor installed manifest did not replace hooks/hooks.json fallback discovery.');
assertProof(cursorPlugin.mcpServers === './.cursor-plugin/mcp.json', 'Cursor installed manifest did not replace root mcp.json fallback discovery.');
const logo = cursorPlugin.logo;
assertProof(typeof logo === 'string' && logo.length > 0, 'Cursor plugin manifest did not emit a logo path.');
assertProof(!logo.includes('..'), `Cursor plugin logo ${JSON.stringify(logo)} escapes the deploy tree.`);
const logoRelative = logo.replace(/^\.\//u, '');
Expand All @@ -1403,6 +1422,9 @@ export const runCursorHostInstallProof = async (
const hooksText = await readText(join(destination, '.cursor-plugin', 'hooks.json'), 'Cursor hooks document');
const hooksDocument = parseJson<unknown>(hooksText, 'Cursor hooks document');
assertProof(cursorHooksValidator(hooksDocument), `Cursor hooks document failed its pinned schema: ${JSON.stringify(cursorHooksValidator.errors)}`);
const claudeHooks = await readText(join(destination, 'hooks', 'hooks.json'), 'Claude hooks beside Cursor install');
assertProof(hooksText !== claudeHooks, 'Cursor and Claude installed hook bindings unexpectedly resolved to the same document.');
assertProof(!hooksText.includes('.claude.mjs'), 'Cursor auto-discovered a Claude-only hook wrapper.');
const mcpText = await readText(join(destination, '.cursor-plugin', 'mcp.json'), 'Cursor MCP document');
const mcpDocument = parseJson<unknown>(mcpText, 'Cursor MCP document');
assertProof(cursorMcpValidator(mcpDocument), `Cursor MCP document failed its pinned schema: ${JSON.stringify(cursorMcpValidator.errors)}`);
Expand Down
Loading