diff --git a/src/__tests__/agent.test.ts b/src/__tests__/agent.test.ts index a189680..742499b 100644 --- a/src/__tests__/agent.test.ts +++ b/src/__tests__/agent.test.ts @@ -33,13 +33,25 @@ describe('convertAgent', () => { expect(result.content).not.toContain('sonnet'); }); - it('defaults to gpt-5.6-sol when no model specified', () => { + it('omits model when none specified so the platform default applies', () => { const noModelAgent: Agent = { ...sampleAgent, frontmatter: { ...sampleAgent.frontmatter, model: undefined }, }; const result = convertAgent(noModelAgent, 'codex'); - expect(result.content).toContain('model = "gpt-5.6-sol"'); + expect(result.content).not.toContain('model ='); + expect(result.content).toContain('model_reasoning_effort'); + }); + + it('omits model for model: inherit on every platform', () => { + const inheritAgent: Agent = { + ...sampleAgent, + frontmatter: { ...sampleAgent.frontmatter, model: 'inherit' }, + }; + expect(convertAgent(inheritAgent, 'codex').content).not.toContain('model ='); + expect(convertAgent(inheritAgent, 'opencode').content).not.toContain('model:'); + expect(convertAgent(inheritAgent, 'cursor').content).not.toContain('model:'); + expect(convertAgent(inheritAgent, 'antigravity').content).not.toContain('model:'); }); it('maps tools to sandbox_mode for codex', () => { diff --git a/src/__tests__/hooks.test.ts b/src/__tests__/hooks.test.ts index 935c353..3563e22 100644 --- a/src/__tests__/hooks.test.ts +++ b/src/__tests__/hooks.test.ts @@ -22,6 +22,25 @@ const sampleHooks: Hooks = { }; describe('convertHooks', () => { + it('degrades PreCompact to a codex note instead of skipping it', () => { + const hooks: Hooks = { + PreCompact: [{ hooks: [{ type: 'command', command: 'npx --no-install llmdoc hook compact' }] }], + }; + const result = convertHooks(hooks, 'codex'); + const note = result.converted.find(f => f.content.includes('PreCompact')); + expect(note).toBeDefined(); + expect(note!.content).toContain('Right before context compaction'); + expect(note!.content).toContain('npx --no-install llmdoc hook compact'); + expect(result.warnings.find(w => w.includes('PreCompact'))).toBeUndefined(); + }); + + it('uses event-appropriate timing phrases in degraded notes', () => { + const result = convertHooks(sampleHooks, 'codex'); + const sessionStart = result.converted.find(f => f.content.includes('SessionStart')); + expect(sessionStart!.content).toContain('At the start of every session'); + expect(sessionStart!.content).not.toContain('Run after SessionStart'); + }); + it('converts portable command hooks to codex notes', () => { const result = convertHooks(sampleHooks, 'codex'); expect(result.converted.length).toBeGreaterThan(0); diff --git a/src/converter/agent.ts b/src/converter/agent.ts index 7d47ca0..a78028c 100644 --- a/src/converter/agent.ts +++ b/src/converter/agent.ts @@ -3,6 +3,18 @@ import { stringifyFrontmatter } from '../utils/frontmatter.js'; import { toToml } from '../utils/toml.js'; import { mapModel } from '../utils/model.js'; +// 'inherit' (and an absent model field) means "use the session's default +// model". Hardcoding a mapped model name here would freeze today's default +// into the output and rot; omitting the field lets the target platform pick +// its own current default. +function resolvedModel(agent: Agent, platform: Platform): string | undefined { + const model = agent.frontmatter.model; + if (!model || model === 'inherit') { + return undefined; + } + return mapModel(model, platform); +} + export function convertAgent(agent: Agent, platform: Platform): ConvertedFile { switch (platform) { case 'codex': @@ -30,10 +42,9 @@ function convertToCodex(agent: Agent): ConvertedFile { developer_instructions: agent.body.trim(), }; - if (agent.frontmatter.model) { - tomlData.model = mapModel(agent.frontmatter.model, 'codex'); - } else { - tomlData.model = mapModel('inherit', 'codex'); + const model = resolvedModel(agent, 'codex'); + if (model) { + tomlData.model = model; } if (agent.frontmatter.tools) { @@ -62,8 +73,9 @@ function convertToOpenCode(agent: Agent): ConvertedFile { mode: 'subagent', }; - if (agent.frontmatter.model) { - fm.model = mapModel(agent.frontmatter.model, 'opencode'); + const model = resolvedModel(agent, 'opencode'); + if (model) { + fm.model = model; } if (agent.frontmatter.maxTurns) { @@ -98,8 +110,9 @@ function convertToCursor(agent: Agent): ConvertedFile { description: agent.frontmatter.description || `Agent: ${name}`, }; - if (agent.frontmatter.model) { - fm.model = mapModel(agent.frontmatter.model, 'cursor'); + const model = resolvedModel(agent, 'cursor'); + if (model) { + fm.model = model; } // Map tools to readonly @@ -123,8 +136,9 @@ function convertToAntigravity(agent: Agent): ConvertedFile { description: agent.frontmatter.description || `Agent: ${name}`, }; - if (agent.frontmatter.model) { - fm.model = mapModel(agent.frontmatter.model, 'antigravity'); + const model = resolvedModel(agent, 'antigravity'); + if (model) { + fm.model = model; } // Antigravity's internal tool identifiers are not published, so we cannot diff --git a/src/converter/hooks.ts b/src/converter/hooks.ts index 030ff4e..917a8bf 100644 --- a/src/converter/hooks.ts +++ b/src/converter/hooks.ts @@ -1,7 +1,17 @@ import type { Hooks, Platform, ConvertedFile } from '../types.js'; // Events that have reasonable mapping across platforms -const PORTABLE_EVENTS = ['PostToolUse', 'PreToolUse', 'Stop', 'SessionStart']; +const PORTABLE_EVENTS = ['PostToolUse', 'PreToolUse', 'Stop', 'SessionStart', 'PreCompact']; + +// Human-readable timing phrase per event, used when degrading a hook to an +// AGENTS.md note. "Run after PreCompact" would be semantically wrong. +const EVENT_TIMING: Record = { + 'SessionStart': 'At the start of every session, run', + 'Stop': 'When the session stops, run', + 'PreCompact': 'Right before context compaction, run', + 'PreToolUse': 'Before each tool use, run', + 'PostToolUse': 'After each tool use, run', +}; // Claude Code PascalCase → Cursor camelCase event name mapping const CURSOR_EVENT_MAP: Record = { @@ -113,14 +123,14 @@ function convertCommandHook( // Codex doesn't have hooks — add as a note in AGENTS.md return { path: `AGENTS.md.hook-${event}`, - content: `## Hook: ${event}${matcher ? ` (${matcher})` : ''}\n\nRun after ${event}: \`${command}\`\n`, + content: `## Hook: ${event}${matcher ? ` (${matcher})` : ''}\n\n${EVENT_TIMING[event] || `On ${event}, run`}: \`${command}\`\n`, type: 'hook', }; case 'opencode': // OpenCode doesn't have a public hooks system — add as a note return { path: `AGENTS.md.hook-${event}`, - content: `## Hook: ${event}${matcher ? ` (${matcher})` : ''}\n\nRun after ${event}: \`${command}\`\n`, + content: `## Hook: ${event}${matcher ? ` (${matcher})` : ''}\n\n${EVENT_TIMING[event] || `On ${event}, run`}: \`${command}\`\n`, type: 'hook', }; case 'antigravity': diff --git a/src/writer/codex.ts b/src/writer/codex.ts index ead3603..b4db38d 100644 --- a/src/writer/codex.ts +++ b/src/writer/codex.ts @@ -45,13 +45,16 @@ export function generateCodex(scan: ScanResult): ConvertResult { // Merge hook notes into AGENTS.md if (hookResult.converted.length > 0) { - const hookContent = '\n\n---\n\n# Hooks (from Claude Code)\n\n' + + const hookSection = '# Hooks (from Claude Code)\n\n' + hookResult.converted.map(f => f.content).join('\n\n'); const existingAgentsMd = files.find(f => f.path === 'AGENTS.md'); if (existingAgentsMd) { - existingAgentsMd.content += hookContent; + // Separate from prior instructions with a thematic break. + existingAgentsMd.content = existingAgentsMd.content.trimEnd() + '\n\n---\n\n' + hookSection + '\n'; } else { - files.push({ path: 'AGENTS.md', content: hookContent.trim(), type: 'hook' }); + // A fresh AGENTS.md must not open with "---": that reads as a + // frontmatter fence / stray horizontal rule. + files.push({ path: 'AGENTS.md', content: hookSection + '\n', type: 'hook' }); } } } diff --git a/src/writer/opencode.ts b/src/writer/opencode.ts index b996b67..f238209 100644 --- a/src/writer/opencode.ts +++ b/src/writer/opencode.ts @@ -40,13 +40,16 @@ export function generateOpenCode(scan: ScanResult): ConvertResult { warnings.push(...hookResult.warnings); if (hookResult.converted.length > 0) { - const hookContent = '\n\n---\n\n# Hooks (from Claude Code)\n\n' + + const hookSection = '# Hooks (from Claude Code)\n\n' + hookResult.converted.map(f => f.content).join('\n\n'); const existingAgentsMd = files.find(f => f.path === 'AGENTS.md'); if (existingAgentsMd) { - existingAgentsMd.content += hookContent; + // Separate from prior instructions with a thematic break. + existingAgentsMd.content = existingAgentsMd.content.trimEnd() + '\n\n---\n\n' + hookSection + '\n'; } else { - files.push({ path: 'AGENTS.md', content: hookContent.trim(), type: 'hook' }); + // A fresh AGENTS.md must not open with "---": that reads as a + // frontmatter fence / stray horizontal rule. + files.push({ path: 'AGENTS.md', content: hookSection + '\n', type: 'hook' }); } } }