Skip to content

Commit 1dde0fe

Browse files
release v0.14.0: native Codex agent rendering, promotion collision detection, OpenCode disabled tools fix
Pack agents with harness.codex frontmatter overrides now render as self-contained TOML files in .codex/agents/ with registration entries in config.toml, matching Codex's native multi-agent format. General frontmatter stripping ensures harness-specific fields are removed when content is rendered as markdown for harnesses that don't support them. Cline harness now detects promotion name collisions between skills, workflows, and agents during sync. OpenCode capture correctly parses disabled tools from settings.
1 parent 436442f commit 1dde0fe

22 files changed

+1710
-118
lines changed

CHANGELOG.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,23 @@ The format is based on Keep a Changelog, and releases use semantic versioning ta
66

77
## Unreleased
88

9+
## [0.14.0]
10+
11+
### Added
12+
13+
- Native Codex agent rendering. Pack agents with `harness.codex` frontmatter overrides now render as self-contained TOML files in `.codex/agents/` with registration entries in `config.toml`, matching Codex's native multi-agent format. Agents can declare per-harness config (model, reasoning effort, service tier) under a `harness:` namespace in frontmatter.
14+
- General frontmatter stripping for cross-harness rendering. Harness-specific frontmatter fields (e.g., `harness:` on agents, `paths:` on rules) are automatically stripped when content is rendered as markdown for harnesses that don't support them.
15+
- Promotion collision detection for Cline harness. Skills, workflows, and agents that would collide in the same skill directory are now caught during sync with a descriptive error.
16+
17+
### Changed
18+
19+
- Codex Layout now manages `.codex/agents/` alongside `.agents/skills/` for validation, cleanup, and stale-file detection.
20+
- Codex `config.toml` Strip/Reset functions now handle both `mcp_servers` and `agents` managed keys.
21+
22+
### Fixed
23+
24+
- OpenCode capture now correctly parses disabled tools from settings, populating `DisabledTools` on captured MCP servers instead of silently dropping them.
25+
926
## [0.13.1]
1027

1128
### Changed

VERSION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
0.13.1
1+
0.14.0

internal/domain/content.go

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,18 @@ type RuleFrontmatter struct {
99
}
1010

1111
// AgentFrontmatter is the harness-neutral agent frontmatter schema.
12+
// The Harness field carries per-harness configuration overrides (e.g. model,
13+
// reasoning_effort) keyed by harness ID. Harness adapters read their own key
14+
// during native rendering and the field is stripped when agents are rendered
15+
// as markdown for model consumption.
1216
type AgentFrontmatter struct {
13-
Name string `yaml:"name,omitempty"`
14-
Description string `yaml:"description,omitempty"`
15-
Tools []string `yaml:"tools,omitempty"`
16-
DisallowedTools []string `yaml:"disallowed_tools,omitempty"`
17-
Skills []string `yaml:"skills,omitempty"`
18-
MCPServers []string `yaml:"mcp_servers,omitempty"`
17+
Name string `yaml:"name,omitempty"`
18+
Description string `yaml:"description,omitempty"`
19+
Tools []string `yaml:"tools,omitempty"`
20+
DisallowedTools []string `yaml:"disallowed_tools,omitempty"`
21+
Skills []string `yaml:"skills,omitempty"`
22+
MCPServers []string `yaml:"mcp_servers,omitempty"`
23+
Harness map[string]map[string]any `yaml:"harness,omitempty"`
1924
}
2025

2126
// WorkflowFrontmatter is the harness-neutral workflow frontmatter schema.

internal/domain/domain_test.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import (
66
"sort"
77
"testing"
88
"time"
9+
10+
"gopkg.in/yaml.v3"
911
)
1012

1113
func TestSplitFrontmatter_WithFrontmatter(t *testing.T) {
@@ -519,6 +521,48 @@ func TestLedger_PrevManagedOverlay_CleanedPath(t *testing.T) {
519521
}
520522
}
521523

524+
func TestAgentFrontmatter_HarnessMapParsed(t *testing.T) {
525+
t.Parallel()
526+
raw := []byte("---\nname: explorer\ndescription: Fast exploration\nharness:\n codex:\n model: o3\n model_reasoning_effort: high\n---\n\nYou explore code.\n")
527+
fmBytes, _, err := SplitFrontmatter(raw)
528+
if err != nil {
529+
t.Fatal(err)
530+
}
531+
var fm AgentFrontmatter
532+
if err := yaml.Unmarshal(fmBytes, &fm); err != nil {
533+
t.Fatal(err)
534+
}
535+
codex, ok := fm.Harness["codex"]
536+
if !ok {
537+
t.Fatal("expected harness.codex key")
538+
}
539+
if codex["model"] != "o3" {
540+
t.Fatalf("model = %v, want o3", codex["model"])
541+
}
542+
if codex["model_reasoning_effort"] != "high" {
543+
t.Fatalf("reasoning = %v, want high", codex["model_reasoning_effort"])
544+
}
545+
}
546+
547+
func TestAgentFrontmatter_HarnessOmittedWhenEmpty(t *testing.T) {
548+
t.Parallel()
549+
fm := AgentFrontmatter{
550+
Name: "simple",
551+
Description: "No harness config",
552+
}
553+
out, err := yaml.Marshal(fm)
554+
if err != nil {
555+
t.Fatal(err)
556+
}
557+
var m map[string]any
558+
if err := yaml.Unmarshal(out, &m); err != nil {
559+
t.Fatal(err)
560+
}
561+
if _, ok := m["harness"]; ok {
562+
t.Fatal("harness key should be omitted when nil")
563+
}
564+
}
565+
522566
func TestProfile_SettingsPackName(t *testing.T) {
523567
t.Parallel()
524568
p := NewProfile()
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
package engine
2+
3+
import (
4+
"gopkg.in/yaml.v3"
5+
)
6+
7+
// StripFrontmatterKeys marshals a frontmatter struct to a map, removes the
8+
// named keys, and returns clean YAML bytes. Used by harness adapters to strip
9+
// fields the target harness doesn't support before rendering content as
10+
// markdown for model consumption.
11+
//
12+
// Examples: "harness" on agents (only meaningful to the native Codex adapter),
13+
// "paths" on rules (only Claude Code supports conditional path loading).
14+
func StripFrontmatterKeys(fm any, keys ...string) ([]byte, error) {
15+
// Marshal the struct to a generic map so we can remove arbitrary keys.
16+
raw, err := yaml.Marshal(fm)
17+
if err != nil {
18+
return nil, err
19+
}
20+
var m map[string]any
21+
if err := yaml.Unmarshal(raw, &m); err != nil {
22+
return nil, err
23+
}
24+
for _, k := range keys {
25+
delete(m, k)
26+
}
27+
return yaml.Marshal(m)
28+
}
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
package engine
2+
3+
import (
4+
"strings"
5+
"testing"
6+
7+
"github.com/shrug-labs/aipack/internal/domain"
8+
)
9+
10+
func TestStripFrontmatterKeys_Agent_RemovesHarness(t *testing.T) {
11+
t.Parallel()
12+
fm := domain.AgentFrontmatter{
13+
Name: "explorer",
14+
Description: "Fast exploration",
15+
Tools: []string{"Read", "Grep"},
16+
Harness: map[string]map[string]any{
17+
"codex": {"model": "o3"},
18+
},
19+
}
20+
out, err := StripFrontmatterKeys(fm, "harness")
21+
if err != nil {
22+
t.Fatal(err)
23+
}
24+
s := string(out)
25+
if strings.Contains(s, "harness") {
26+
t.Fatalf("should strip harness key, got:\n%s", s)
27+
}
28+
if !strings.Contains(s, "explorer") {
29+
t.Fatal("should preserve name")
30+
}
31+
if !strings.Contains(s, "Read") {
32+
t.Fatal("should preserve tools")
33+
}
34+
}
35+
36+
func TestStripFrontmatterKeys_Rule_RemovesPaths(t *testing.T) {
37+
t.Parallel()
38+
fm := domain.RuleFrontmatter{
39+
Name: "my-rule",
40+
Description: "Does things",
41+
Paths: []string{"src/**/*.go"},
42+
}
43+
out, err := StripFrontmatterKeys(fm, "paths")
44+
if err != nil {
45+
t.Fatal(err)
46+
}
47+
s := string(out)
48+
if strings.Contains(s, "paths") {
49+
t.Fatalf("should strip paths key, got:\n%s", s)
50+
}
51+
if !strings.Contains(s, "my-rule") {
52+
t.Fatal("should preserve name")
53+
}
54+
}
55+
56+
func TestStripFrontmatterKeys_NoKeysToStrip(t *testing.T) {
57+
t.Parallel()
58+
fm := domain.AgentFrontmatter{
59+
Name: "simple",
60+
Description: "No harness-specific fields",
61+
}
62+
out, err := StripFrontmatterKeys(fm, "harness")
63+
if err != nil {
64+
t.Fatal(err)
65+
}
66+
if !strings.Contains(string(out), "simple") {
67+
t.Fatal("should preserve content when nothing to strip")
68+
}
69+
}
70+
71+
func TestStripFrontmatterKeys_MultipleKeys(t *testing.T) {
72+
t.Parallel()
73+
fm := domain.AgentFrontmatter{
74+
Name: "test",
75+
Description: "test agent",
76+
Tools: []string{"Read"},
77+
Harness: map[string]map[string]any{
78+
"codex": {"model": "o3"},
79+
},
80+
}
81+
out, err := StripFrontmatterKeys(fm, "harness", "tools")
82+
if err != nil {
83+
t.Fatal(err)
84+
}
85+
s := string(out)
86+
if strings.Contains(s, "harness") {
87+
t.Fatal("should strip harness")
88+
}
89+
if strings.Contains(s, "tools") {
90+
t.Fatal("should strip tools")
91+
}
92+
if !strings.Contains(s, "test") {
93+
t.Fatal("should preserve name")
94+
}
95+
}

internal/engine/parse.go

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,14 @@ func RenderRuleBytes(rule domain.Rule) ([]byte, error) {
162162
}
163163

164164
func RenderAgentBytes(agent domain.Agent) ([]byte, error) {
165-
return renderTypedContent(agent.Frontmatter, agent.Body)
165+
// Strip harness-specific keys before rendering to markdown — they are
166+
// only meaningful to native harness adapters (e.g. Codex), not to models
167+
// consuming the rendered markdown.
168+
fm, err := StripFrontmatterKeys(agent.Frontmatter, "harness")
169+
if err != nil {
170+
return nil, err
171+
}
172+
return renderFrontmatterBytes(fm, agent.Body)
166173
}
167174

168175
func RenderWorkflowBytes(workflow domain.Workflow) ([]byte, error) {
@@ -177,7 +184,16 @@ func renderTypedContent(frontmatter any, body []byte) ([]byte, error) {
177184
if err != nil {
178185
return nil, err
179186
}
187+
return renderFrontmatterBytes(fm, body)
188+
}
189+
190+
// renderFrontmatterBytes combines pre-marshaled YAML frontmatter bytes with a
191+
// markdown body into a complete frontmatter document.
192+
func renderFrontmatterBytes(fm []byte, body []byte) ([]byte, error) {
180193
fm = bytes.TrimRight(fm, "\n")
194+
if len(fm) == 0 {
195+
return append([]byte(nil), body...), nil
196+
}
181197
var out bytes.Buffer
182198
out.WriteString("---\n")
183199
out.Write(fm)

internal/engine/parse_test.go

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -370,6 +370,69 @@ func TestRenderAgentBytes_NeutralSchema(t *testing.T) {
370370
}
371371
}
372372

373+
func TestRenderAgentBytes_StripsHarness(t *testing.T) {
374+
t.Parallel()
375+
agent := domain.Agent{
376+
Frontmatter: domain.AgentFrontmatter{
377+
Name: "explorer",
378+
Description: "Fast exploration",
379+
Tools: []string{"Read"},
380+
Harness: map[string]map[string]any{
381+
"codex": {"model": "o3", "model_reasoning_effort": "high"},
382+
},
383+
},
384+
Body: []byte("You explore.\n"),
385+
}
386+
out, err := RenderAgentBytes(agent)
387+
if err != nil {
388+
t.Fatal(err)
389+
}
390+
s := string(out)
391+
if strings.Contains(s, "harness") {
392+
t.Fatalf("rendered markdown should not contain harness block:\n%s", s)
393+
}
394+
if strings.Contains(s, "codex") {
395+
t.Fatalf("rendered markdown should not contain harness-specific keys:\n%s", s)
396+
}
397+
if !strings.Contains(s, "explorer") {
398+
t.Fatal("should preserve name")
399+
}
400+
if !strings.Contains(s, "You explore.") {
401+
t.Fatal("should preserve body")
402+
}
403+
}
404+
405+
func TestRenderAgentBytes_RoundTripsWithHarness(t *testing.T) {
406+
t.Parallel()
407+
agent := domain.Agent{
408+
Frontmatter: domain.AgentFrontmatter{
409+
Name: "reviewer",
410+
Description: "Reviews changes",
411+
Tools: []string{"bash", "read"},
412+
Harness: map[string]map[string]any{
413+
"codex": {"model": "o3"},
414+
},
415+
},
416+
Body: []byte("Review\n"),
417+
}
418+
raw, err := RenderAgentBytes(agent)
419+
if err != nil {
420+
t.Fatal(err)
421+
}
422+
// Parse should succeed — harness is stripped, so no round-trip of that field.
423+
parsed, err := ParseAgentBytes(raw, "reviewer", "")
424+
if err != nil {
425+
t.Fatal(err)
426+
}
427+
if parsed.Frontmatter.Description != "Reviews changes" {
428+
t.Fatalf("Description = %q", parsed.Frontmatter.Description)
429+
}
430+
// Harness should NOT round-trip through markdown rendering.
431+
if parsed.Frontmatter.Harness != nil {
432+
t.Fatalf("Harness should be nil after round-trip, got %v", parsed.Frontmatter.Harness)
433+
}
434+
}
435+
373436
func TestParseRules_MissingFile(t *testing.T) {
374437
t.Parallel()
375438
dir := t.TempDir()

internal/harness/cline/harness.go

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -95,10 +95,17 @@ func planProject(f *domain.Fragment, ctx engine.SyncContext) error {
9595
rulesDir := filepath.Join(pd, paths.RulesDir)
9696
skillsDir := filepath.Join(pd, paths.SkillsDir)
9797

98+
skills := ctx.Profile.AllSkills()
99+
agents := ctx.Profile.AllAgents()
100+
101+
if err := harness.CheckPromotionCollisions(skills, nil, agents); err != nil {
102+
return fmt.Errorf("cline: %w", err)
103+
}
104+
98105
f.AddRuleWrites(rulesDir, "", ctx.Profile.AllRules())
99106
f.AddWorkflowWrites(filepath.Join(pd, paths.WorkflowsDir), "", ctx.Profile.AllWorkflows())
100-
f.AddSkillCopies(skillsDir, "", ctx.Profile.AllSkills())
101-
addPromotedAgents(f, skillsDir, ctx.Profile.AllAgents())
107+
f.AddSkillCopies(skillsDir, "", skills)
108+
addPromotedAgents(f, skillsDir, agents)
102109

103110
// Cline MCP settings are always global — sync them even in project scope.
104111
return planGlobalMCP(f, ctx)
@@ -107,10 +114,17 @@ func planProject(f *domain.Fragment, ctx engine.SyncContext) error {
107114
func planGlobal(f *domain.Fragment, ctx engine.SyncContext) error {
108115
paths := GlobalPathsFor(ctx.TargetDir)
109116

117+
skills := ctx.Profile.AllSkills()
118+
agents := ctx.Profile.AllAgents()
119+
120+
if err := harness.CheckPromotionCollisions(skills, nil, agents); err != nil {
121+
return fmt.Errorf("cline: %w", err)
122+
}
123+
110124
f.AddRuleWrites(paths.RulesDir, "", ctx.Profile.AllRules())
111125
f.AddWorkflowWrites(paths.WorkflowsDir, "", ctx.Profile.AllWorkflows())
112-
f.AddSkillCopies(paths.SkillsDir, "", ctx.Profile.AllSkills())
113-
addPromotedAgents(f, paths.SkillsDir, ctx.Profile.AllAgents())
126+
f.AddSkillCopies(paths.SkillsDir, "", skills)
127+
addPromotedAgents(f, paths.SkillsDir, agents)
114128

115129
return planGlobalMCP(f, ctx)
116130
}

internal/harness/cline/promote.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ func addPromotedAgents(f *domain.Fragment, skillsDir string, agents []domain.Age
2626
if desc == "" {
2727
desc = fmt.Sprintf("Agent: %s", name)
2828
}
29+
if !strings.HasPrefix(desc, harness.DescPrefixAgent) {
30+
desc = harness.DescPrefixAgent + desc
31+
}
2932
fm := harness.PromotedFrontmatter{
3033
Name: name,
3134
Description: desc,

0 commit comments

Comments
 (0)