Skip to content

Commit 1fa45dd

Browse files
committed
fix: degrade PreCompact hooks instead of skipping, omit model for inherit, clean AGENTS.md hook section
Found while converting the llmdoc plugin surface: - PreCompact hooks were dropped entirely with a skip warning; for platforms where hooks already degrade to AGENTS.md notes (codex/opencode) there is no reason to treat PreCompact worse than SessionStart/Stop. Degraded notes now also use event-appropriate timing phrases ('Right before context compaction, run …' instead of the semantically wrong 'Run after PreCompact'). - 'model: inherit' (and an absent model) was hardcoded to a mapped model name (e.g. gpt-5.6-sol), freezing today's default into the output. All four platform branches now omit the model field so the target platform's own session default applies. - A freshly created AGENTS.md opened with a stray '---' (reads as a frontmatter fence); the hook section is now assembled without a leading separator, and appending to an existing AGENTS.md trims before adding the thematic break.
1 parent 9320905 commit 1fa45dd

6 files changed

Lines changed: 82 additions & 21 deletions

File tree

src/__tests__/agent.test.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,13 +33,25 @@ describe('convertAgent', () => {
3333
expect(result.content).not.toContain('sonnet');
3434
});
3535

36-
it('defaults to gpt-5.6-sol when no model specified', () => {
36+
it('omits model when none specified so the platform default applies', () => {
3737
const noModelAgent: Agent = {
3838
...sampleAgent,
3939
frontmatter: { ...sampleAgent.frontmatter, model: undefined },
4040
};
4141
const result = convertAgent(noModelAgent, 'codex');
42-
expect(result.content).toContain('model = "gpt-5.6-sol"');
42+
expect(result.content).not.toContain('model =');
43+
expect(result.content).toContain('model_reasoning_effort');
44+
});
45+
46+
it('omits model for model: inherit on every platform', () => {
47+
const inheritAgent: Agent = {
48+
...sampleAgent,
49+
frontmatter: { ...sampleAgent.frontmatter, model: 'inherit' },
50+
};
51+
expect(convertAgent(inheritAgent, 'codex').content).not.toContain('model =');
52+
expect(convertAgent(inheritAgent, 'opencode').content).not.toContain('model:');
53+
expect(convertAgent(inheritAgent, 'cursor').content).not.toContain('model:');
54+
expect(convertAgent(inheritAgent, 'antigravity').content).not.toContain('model:');
4355
});
4456

4557
it('maps tools to sandbox_mode for codex', () => {

src/__tests__/hooks.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,25 @@ const sampleHooks: Hooks = {
2222
};
2323

2424
describe('convertHooks', () => {
25+
it('degrades PreCompact to a codex note instead of skipping it', () => {
26+
const hooks: Hooks = {
27+
PreCompact: [{ hooks: [{ type: 'command', command: 'npx --no-install llmdoc hook compact' }] }],
28+
};
29+
const result = convertHooks(hooks, 'codex');
30+
const note = result.converted.find(f => f.content.includes('PreCompact'));
31+
expect(note).toBeDefined();
32+
expect(note!.content).toContain('Right before context compaction');
33+
expect(note!.content).toContain('npx --no-install llmdoc hook compact');
34+
expect(result.warnings.find(w => w.includes('PreCompact'))).toBeUndefined();
35+
});
36+
37+
it('uses event-appropriate timing phrases in degraded notes', () => {
38+
const result = convertHooks(sampleHooks, 'codex');
39+
const sessionStart = result.converted.find(f => f.content.includes('SessionStart'));
40+
expect(sessionStart!.content).toContain('At the start of every session');
41+
expect(sessionStart!.content).not.toContain('Run after SessionStart');
42+
});
43+
2544
it('converts portable command hooks to codex notes', () => {
2645
const result = convertHooks(sampleHooks, 'codex');
2746
expect(result.converted.length).toBeGreaterThan(0);

src/converter/agent.ts

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,18 @@ import { stringifyFrontmatter } from '../utils/frontmatter.js';
33
import { toToml } from '../utils/toml.js';
44
import { mapModel } from '../utils/model.js';
55

6+
// 'inherit' (and an absent model field) means "use the session's default
7+
// model". Hardcoding a mapped model name here would freeze today's default
8+
// into the output and rot; omitting the field lets the target platform pick
9+
// its own current default.
10+
function resolvedModel(agent: Agent, platform: Platform): string | undefined {
11+
const model = agent.frontmatter.model;
12+
if (!model || model === 'inherit') {
13+
return undefined;
14+
}
15+
return mapModel(model, platform);
16+
}
17+
618
export function convertAgent(agent: Agent, platform: Platform): ConvertedFile {
719
switch (platform) {
820
case 'codex':
@@ -30,10 +42,9 @@ function convertToCodex(agent: Agent): ConvertedFile {
3042
developer_instructions: agent.body.trim(),
3143
};
3244

33-
if (agent.frontmatter.model) {
34-
tomlData.model = mapModel(agent.frontmatter.model, 'codex');
35-
} else {
36-
tomlData.model = mapModel('inherit', 'codex');
45+
const model = resolvedModel(agent, 'codex');
46+
if (model) {
47+
tomlData.model = model;
3748
}
3849

3950
if (agent.frontmatter.tools) {
@@ -62,8 +73,9 @@ function convertToOpenCode(agent: Agent): ConvertedFile {
6273
mode: 'subagent',
6374
};
6475

65-
if (agent.frontmatter.model) {
66-
fm.model = mapModel(agent.frontmatter.model, 'opencode');
76+
const model = resolvedModel(agent, 'opencode');
77+
if (model) {
78+
fm.model = model;
6779
}
6880

6981
if (agent.frontmatter.maxTurns) {
@@ -98,8 +110,9 @@ function convertToCursor(agent: Agent): ConvertedFile {
98110
description: agent.frontmatter.description || `Agent: ${name}`,
99111
};
100112

101-
if (agent.frontmatter.model) {
102-
fm.model = mapModel(agent.frontmatter.model, 'cursor');
113+
const model = resolvedModel(agent, 'cursor');
114+
if (model) {
115+
fm.model = model;
103116
}
104117

105118
// Map tools to readonly
@@ -123,8 +136,9 @@ function convertToAntigravity(agent: Agent): ConvertedFile {
123136
description: agent.frontmatter.description || `Agent: ${name}`,
124137
};
125138

126-
if (agent.frontmatter.model) {
127-
fm.model = mapModel(agent.frontmatter.model, 'antigravity');
139+
const model = resolvedModel(agent, 'antigravity');
140+
if (model) {
141+
fm.model = model;
128142
}
129143

130144
// Antigravity's internal tool identifiers are not published, so we cannot

src/converter/hooks.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,17 @@
11
import type { Hooks, Platform, ConvertedFile } from '../types.js';
22

33
// Events that have reasonable mapping across platforms
4-
const PORTABLE_EVENTS = ['PostToolUse', 'PreToolUse', 'Stop', 'SessionStart'];
4+
const PORTABLE_EVENTS = ['PostToolUse', 'PreToolUse', 'Stop', 'SessionStart', 'PreCompact'];
5+
6+
// Human-readable timing phrase per event, used when degrading a hook to an
7+
// AGENTS.md note. "Run after PreCompact" would be semantically wrong.
8+
const EVENT_TIMING: Record<string, string> = {
9+
'SessionStart': 'At the start of every session, run',
10+
'Stop': 'When the session stops, run',
11+
'PreCompact': 'Right before context compaction, run',
12+
'PreToolUse': 'Before each tool use, run',
13+
'PostToolUse': 'After each tool use, run',
14+
};
515

616
// Claude Code PascalCase → Cursor camelCase event name mapping
717
const CURSOR_EVENT_MAP: Record<string, string> = {
@@ -113,14 +123,14 @@ function convertCommandHook(
113123
// Codex doesn't have hooks — add as a note in AGENTS.md
114124
return {
115125
path: `AGENTS.md.hook-${event}`,
116-
content: `## Hook: ${event}${matcher ? ` (${matcher})` : ''}\n\nRun after ${event}: \`${command}\`\n`,
126+
content: `## Hook: ${event}${matcher ? ` (${matcher})` : ''}\n\n${EVENT_TIMING[event] || `On ${event}, run`}: \`${command}\`\n`,
117127
type: 'hook',
118128
};
119129
case 'opencode':
120130
// OpenCode doesn't have a public hooks system — add as a note
121131
return {
122132
path: `AGENTS.md.hook-${event}`,
123-
content: `## Hook: ${event}${matcher ? ` (${matcher})` : ''}\n\nRun after ${event}: \`${command}\`\n`,
133+
content: `## Hook: ${event}${matcher ? ` (${matcher})` : ''}\n\n${EVENT_TIMING[event] || `On ${event}, run`}: \`${command}\`\n`,
124134
type: 'hook',
125135
};
126136
case 'antigravity':

src/writer/codex.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,13 +45,16 @@ export function generateCodex(scan: ScanResult): ConvertResult {
4545

4646
// Merge hook notes into AGENTS.md
4747
if (hookResult.converted.length > 0) {
48-
const hookContent = '\n\n---\n\n# Hooks (from Claude Code)\n\n' +
48+
const hookSection = '# Hooks (from Claude Code)\n\n' +
4949
hookResult.converted.map(f => f.content).join('\n\n');
5050
const existingAgentsMd = files.find(f => f.path === 'AGENTS.md');
5151
if (existingAgentsMd) {
52-
existingAgentsMd.content += hookContent;
52+
// Separate from prior instructions with a thematic break.
53+
existingAgentsMd.content = existingAgentsMd.content.trimEnd() + '\n\n---\n\n' + hookSection + '\n';
5354
} else {
54-
files.push({ path: 'AGENTS.md', content: hookContent.trim(), type: 'hook' });
55+
// A fresh AGENTS.md must not open with "---": that reads as a
56+
// frontmatter fence / stray horizontal rule.
57+
files.push({ path: 'AGENTS.md', content: hookSection + '\n', type: 'hook' });
5558
}
5659
}
5760
}

src/writer/opencode.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,13 +40,16 @@ export function generateOpenCode(scan: ScanResult): ConvertResult {
4040
warnings.push(...hookResult.warnings);
4141

4242
if (hookResult.converted.length > 0) {
43-
const hookContent = '\n\n---\n\n# Hooks (from Claude Code)\n\n' +
43+
const hookSection = '# Hooks (from Claude Code)\n\n' +
4444
hookResult.converted.map(f => f.content).join('\n\n');
4545
const existingAgentsMd = files.find(f => f.path === 'AGENTS.md');
4646
if (existingAgentsMd) {
47-
existingAgentsMd.content += hookContent;
47+
// Separate from prior instructions with a thematic break.
48+
existingAgentsMd.content = existingAgentsMd.content.trimEnd() + '\n\n---\n\n' + hookSection + '\n';
4849
} else {
49-
files.push({ path: 'AGENTS.md', content: hookContent.trim(), type: 'hook' });
50+
// A fresh AGENTS.md must not open with "---": that reads as a
51+
// frontmatter fence / stray horizontal rule.
52+
files.push({ path: 'AGENTS.md', content: hookSection + '\n', type: 'hook' });
5053
}
5154
}
5255
}

0 commit comments

Comments
 (0)