Skip to content
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ Candidates are inert until you promote them into durable instructions — consol

## Roles

Markdown role definitions in `~/.agents/agents/`, rendered to each harness's native format (Claude Markdown, Codex TOML, Qwen Markdown, Droid). Generic `model` tiers (`haiku`/`sonnet`/`opus`) render natively per family; per-harness overrides pin exact ids. Six starter roles ship with the tool; yours win on name collision. Details in [docs/roles.md](docs/roles.md).
Markdown role definitions in `~/.agents/agents/`, rendered to each harness's native format (Claude Markdown, Codex TOML, Qwen Markdown, Droid). Generic `model` tiers (`haiku`/`sonnet`/`opus`) render natively for Claude and Droid; Codex omits them and uses its own default unless a per-harness override pins an exact id. Six starter roles ship with the tool; yours win on name collision. Details in [docs/roles.md](docs/roles.md).

## Commands

Expand Down
5 changes: 0 additions & 5 deletions agents/architect.md
Original file line number Diff line number Diff line change
@@ -1,13 +1,8 @@
---
name: architect
description: Designs system architecture, telemetry schemas, and technical plans. Use for design docs, architecture reviews, and API surface decisions. Delegates implementation to builders.
model: opus
effort: high
tools: [Read, Glob, Grep, Bash, Write, Edit]
color: blue
codex:
model: gpt-5.6-sol
model_reasoning_effort: high
---

You are a senior software architect. Your job is to design, not build.
Expand Down
5 changes: 0 additions & 5 deletions agents/builder.md
Original file line number Diff line number Diff line change
@@ -1,13 +1,8 @@
---
name: builder
description: Implements code changes following specs or architect designs. Use for feature implementation, bug fixes, and script writing. Focused on writing correct, minimal code.
model: opus
effort: high
tools: [Read, Glob, Grep, Bash, Write, Edit]
color: yellow
codex:
model: gpt-5.6-sol
model_reasoning_effort: high
---

You are a senior developer. Your job is to implement exactly what was specified.
Expand Down
5 changes: 0 additions & 5 deletions agents/reviewer.md
Original file line number Diff line number Diff line change
@@ -1,13 +1,8 @@
---
name: reviewer
description: Reviews code changes, PRs, and implementations against specs and best practices. Use for code review, quality gates, and pre-merge checks. Read-only.
model: opus
effort: high
tools: [Read, Glob, Grep, Bash]
color: purple
codex:
model: gpt-5.6-sol
model_reasoning_effort: high
---

You are a senior code reviewer. Your job is to find bugs, security issues, and spec violations.
Expand Down
2 changes: 0 additions & 2 deletions agents/tester.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
---
name: tester
description: Runs end-to-end tests against a prepared environment. Executes golden routes and user scenarios, records logic/UX/behavior problems. Never fixes code or builds — report only.
model: opus
effort: medium
tools: [Read, Glob, Grep, Bash, Write]
color: green
---
Expand Down
40 changes: 26 additions & 14 deletions cmd/dotagents/agents.go
Original file line number Diff line number Diff line change
Expand Up @@ -402,6 +402,9 @@ func renderAgentRole(role agentRole, agent agentConfig) (string, string, bool) {
if h == nil || h.Roles == nil {
return "", "", false
}
if role.Model == "" && agent.RoleModel != "" {
role.Model = agent.RoleModel
Comment thread
yourconscience marked this conversation as resolved.
Comment thread
yourconscience marked this conversation as resolved.
}
target := filepath.Join(agent.AgentRoot, role.Name+h.Roles.Extension)
content := h.Roles.Render(role)
return target, content, true
Expand Down Expand Up @@ -460,6 +463,18 @@ func renderCodexAgentRole(role agentRole) string {
return b.String()
}

// codexModelFor resolves legacy canonical model aliases. Claude-family names
// (opus/sonnet/haiku) are not valid Codex identifiers, so they render without a
// model and Codex uses its own default; anything else passes through verbatim.
func codexModelFor(model string) string {
switch strings.ToLower(strings.TrimSpace(model)) {
case "", "haiku", "sonnet", "opus":
return ""
default:
return strings.TrimSpace(model)
}
}

func renderDroidAgentRole(role agentRole) string {
model := strings.TrimSpace(role.Droid.Model)
if model == "" {
Expand Down Expand Up @@ -497,6 +512,17 @@ func renderDroidAgentRole(role agentRole) string {
return b.String()
}

// canonicalModelTier reports whether the value is a legacy tier alias rather
// than an exact model identifier. Harnesses without a tier concept must not
// emit these values verbatim.
func canonicalModelTier(model string) bool {
switch strings.ToLower(strings.TrimSpace(model)) {
case "haiku", "sonnet", "opus":
return true
}
return false
}

func agentRoleSourceLabel(role agentRole) string {
if role.Source == "" {
return "agents/" + role.Name + agentRoleMarkdownExt
Expand Down Expand Up @@ -543,20 +569,6 @@ func writeTOMLMultiline(b *strings.Builder, key string, value string) {
b.WriteString("\n")
}

func codexModelFor(model string) string {
switch strings.ToLower(strings.TrimSpace(model)) {
case "haiku":
return "gpt-5.4-mini"
case "sonnet", "opus":
return "gpt-5.4"
default:
if strings.TrimSpace(model) == "" {
return "gpt-5.4"
}
return strings.TrimSpace(model)
}
}

func droidModelFor(model string) string {
model = strings.TrimSpace(model)
switch strings.ToLower(model) {
Expand Down
43 changes: 35 additions & 8 deletions cmd/dotagents/agents_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,16 +84,15 @@ func TestRenderCodexAgentRoleEscapesControlCharacters(t *testing.T) {
Effort: "high",
Instructions: "Line one\nLine two\tTabbed\rReturn",
Codex: codexRoleOptions{
Model: "gpt-5.4-mini",
Model: "test-model-mini",
ModelReasoningEffort: "medium",
},
}

got := renderCodexAgentRole(role)
for _, want := range []string{
`name = "researcher"`,
`description = "Find \"facts\""`,
`model = "gpt-5.4-mini"`,
`model = "test-model-mini"`,
`model_reasoning_effort = "medium"`,
`developer_instructions = "Line one\nLine two\tTabbed\rReturn"`,
generatedAgentMarker,
Expand Down Expand Up @@ -184,12 +183,26 @@ func TestRenderDroidAgentRoleMapsModelAndTools(t *testing.T) {
}
}

func TestCodexModelFor(t *testing.T) {
func TestRenderCodexAgentRoleOmitsMissingModel(t *testing.T) {
role := agentRole{
Name: "builder",
Description: "Builds features",
Instructions: "Implement the change.",
}

got := renderCodexAgentRole(role)
for _, absent := range []string{"model =", "model_reasoning_effort ="} {
if strings.Contains(got, absent) {
t.Fatalf("model-neutral codex role should omit %q:\n%s", absent, got)
}
}
}
func TestCodexModelForNeutralizesAliases(t *testing.T) {
tests := map[string]string{
"": "gpt-5.4",
"sonnet": "gpt-5.4",
"opus": "gpt-5.4",
"haiku": "gpt-5.4-mini",
"": "",
"sonnet": "",
"opus": "",
"haiku": "",
"gpt-custom": "gpt-custom",
}

Expand All @@ -200,6 +213,20 @@ func TestCodexModelFor(t *testing.T) {
}
}

func TestRenderAgentRolePrefillsConfiguredModel(t *testing.T) {
role := agentRole{Name: "builder", Description: "Builds features", Instructions: "Implement."}
path, content, ok := renderAgentRole(role, agentConfig{Name: agentClaudeCode, AgentRoot: t.TempDir(), RoleModel: "configured-model"})
if !ok {
t.Fatal("claude role was not rendered")
}
if !strings.Contains(content, `model: "configured-model"`) {
t.Fatalf("role missing configured role_model:\n%s", content)
}
if path == "" {
t.Fatal("empty target path")
}
}

func TestDroidModelFor(t *testing.T) {
tests := map[string]string{
"": "inherit",
Expand Down
1 change: 1 addition & 0 deletions cmd/dotagents/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ type agentConfig struct {
SkillRoot string `yaml:"skill_root"`
AgentRoot string `yaml:"agent_root,omitempty"`
Detect string `yaml:"detect,omitempty"`
RoleModel string `yaml:"role_model,omitempty"`
}

type repoLinkReport struct {
Expand Down
2 changes: 2 additions & 0 deletions cmd/dotagents/opencode.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ func renderOpenCodeAgentRole(role agentRole) string {
b.WriteString("\n")
if model := strings.TrimSpace(role.Opencode.Model); model != "" {
writeYAMLScalar(&b, "model", model)
} else if model := strings.TrimSpace(role.Model); model != "" && !canonicalModelTier(model) {
writeYAMLScalar(&b, "model", model)
}
if temperature := strings.TrimSpace(role.Opencode.Temperature); temperature != "" {
b.WriteString("temperature: ")
Expand Down
13 changes: 13 additions & 0 deletions cmd/dotagents/opencode_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -387,3 +387,16 @@ Be helpful.
t.Fatalf("converted opencode role dropped body:\n%s", data)
}
}

func TestOpenCodeRoleRenderUsesConfiguredModel(t *testing.T) {
role := agentRole{
Name: "builder",
Description: "Builds features",
Model: "openai/gpt-5.6",
Instructions: "Implement.",
}
content := renderOpenCodeAgentRole(role)
if !strings.Contains(content, "model: \"openai/gpt-5.6\"") {
t.Fatalf("configured role_model not applied to OpenCode role:\n%s", content)
}
}
6 changes: 5 additions & 1 deletion cmd/dotagents/qwen.go
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,11 @@ var qwenToolMapping = map[string]string{
func renderQwenAgentRole(role agentRole) string {
model := strings.TrimSpace(role.Qwen.Model)
if model == "" {
model = "inherit"
if generic := strings.TrimSpace(role.Model); generic != "" && !canonicalModelTier(generic) {
model = generic
} else {
model = "inherit"
}
}
tools := role.Qwen.Tools
if len(tools) == 0 {
Expand Down
14 changes: 14 additions & 0 deletions cmd/dotagents/qwen_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -247,3 +247,17 @@ hooks:
t.Fatalf("config-driven skills should not create a Qwen mirror: %v", err)
}
}

func TestQwenRoleRenderUsesConfiguredModel(t *testing.T) {
role := agentRole{Name: "builder", Description: "Builds features", Model: "qwen3-coder-plus", Instructions: "Implement."}
content := renderQwenAgentRole(role)
if !strings.Contains(content, "model: \"qwen3-coder-plus\"") {
t.Fatalf("configured role_model not applied to Qwen role:\n%s", content)
}

alias := agentRole{Name: "reviewer", Description: "Reviews code", Model: "opus", Instructions: "Review."}
content = renderQwenAgentRole(alias)
if strings.Contains(content, "model: opus") {
t.Fatalf("tier alias leaked into Qwen role:\n%s", content)
}
}
6 changes: 5 additions & 1 deletion docs/roles.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ A role is a Markdown file in `~/.agents/agents/` with frontmatter (`name`, `desc

## Model tiers and overrides

The generic `model` value is a capability tier (`haiku`, `sonnet`, or `opus`). Claude Code, Codex, and Droid render it natively in their own model family. Harnesses without a native tier concept use native inheritance or an exact per-harness override:
Claude Code and Droid render the tier natively in their own model family. Codex neutralizes the tier and uses its own default unless an exact per-harness `codex.model` override is set. Harnesses without a tier concept use native inheritance or an exact per-harness override:
Comment thread
yourconscience marked this conversation as resolved.

```yaml
model: opus
Expand All @@ -21,6 +21,10 @@ qwen:
approval_mode: plan
```

### Centralized model pin
Comment thread
yourconscience marked this conversation as resolved.

To pin one model for all rendered roles that have no explicit model, set `role_model` on the agent entry in `dotagents.yaml`. Roles (or per-harness overrides) that declare a model always win over `role_model`. Exact model names belong in role frontmatter or `role_model` — never in tool code.

## Rendering targets

| Harness | Format | Path |
Expand Down
Loading