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
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,10 @@ mcp_servers:

A role is a Markdown file in `~/.agents/agents/` with frontmatter (`name`, `description`, `model`, `effort`, `tools`, optional per-harness overrides) and the system prompt as body. dotagents renders it into each harness's native format — e.g. TOML for Codex. Five generic starter roles ship with the tool: `architect` `builder` `researcher` `reviewer` `tester`. A same-name file in your `~/.agents/agents/` always wins over the starter.

### Root instructions

`~/.agents/AGENTS.md` is your single root instruction file. During sync, dotagents links it into each harness's native memory path — `~/.claude/CLAUDE.md` for Claude Code, `~/.codex/AGENTS.md` for Codex, `~/.factory/AGENTS.md` for Droid — so an edit in one place reaches every agent. `dotagents status` reports drift, and a file that exists but is not a symlink is never touched without your confirmation.

## Hooks and memory

Hooks are lifecycle commands (session start/end, stop) registered per harness in `dotagents.yaml`. dotagents ships a memory integration built on them — pick a tier during setup:
Expand Down
8 changes: 8 additions & 0 deletions cmd/dotagents/harness.go
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,10 @@ func initHarnesses() {
inspect: inspectClaudeHook,
patch: patchClaudeHook,
},
RootInstructions: &RootInstructionsCapability{
Path: func(home string) string { return filepath.Join(home, ".claude", "CLAUDE.md") },
Expected: func(repoRoot string) string { return filepath.Join(repoRoot, "AGENTS.md") },
},
TrailerExample: "Co-authored-by: claude[bot] <claude[bot]@users.noreply.github.com>",
},

Expand All @@ -151,6 +155,10 @@ func initHarnesses() {
inspect: inspectCodexHook,
patch: patchCodexHook,
},
RootInstructions: &RootInstructionsCapability{
Path: func(home string) string { return filepath.Join(home, ".codex", "AGENTS.md") },
Expected: func(repoRoot string) string { return filepath.Join(repoRoot, "AGENTS.md") },
},
TrailerExample: "Co-Authored-By: codex[bot] <codex[bot]@users.noreply.github.com>",
},

Expand Down
140 changes: 140 additions & 0 deletions cmd/dotagents/root_instructions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -110,3 +110,143 @@ func TestApplyAgentRootInstructionSyncCreatesMissingLink(t *testing.T) {
t.Fatalf("link target = %q, want %q", rawTarget, report.RootExpected)
}
}

func claudeCodeRootInstructions() *RootInstructionsCapability {
h := harnessFor(agentClaudeCode)
if h == nil || h.RootInstructions == nil {
panic("claude-code harness missing RootInstructions")
}
return h.RootInstructions
}

func codexRootInstructions() *RootInstructionsCapability {
h := harnessFor(agentCodex)
if h == nil || h.RootInstructions == nil {
panic("codex harness missing RootInstructions")
}
return h.RootInstructions
}

func TestRootInstructionsNativePaths(t *testing.T) {
home := t.TempDir()
repoRoot := filepath.Join(home, ".agents")
for _, tc := range []struct {
agent string
cap *RootInstructionsCapability
wantLink string
}{
{agentClaudeCode, claudeCodeRootInstructions(), filepath.Join(home, ".claude", "CLAUDE.md")},
{agentCodex, codexRootInstructions(), filepath.Join(home, ".codex", "AGENTS.md")},
} {
if got := tc.cap.Path(home); got != tc.wantLink {
t.Fatalf("%s Path = %q, want %q", tc.agent, got, tc.wantLink)
}
if got := tc.cap.Expected(repoRoot); got != filepath.Join(repoRoot, "AGENTS.md") {
t.Fatalf("%s Expected = %q, want %q", tc.agent, got, filepath.Join(repoRoot, "AGENTS.md"))
}
}
}

func TestInspectClaudeAndCodexRootInstructions(t *testing.T) {
for _, tc := range []struct {
agent string
cap func() *RootInstructionsCapability
dir string
file string
}{
{agentClaudeCode, claudeCodeRootInstructions, ".claude", "CLAUDE.md"},
{agentCodex, codexRootInstructions, ".codex", "AGENTS.md"},
} {
t.Run(tc.agent+"/missing", func(t *testing.T) {
home := t.TempDir()
report := agentReport{}
if err := inspectRootInstructions(&report, tc.cap(), filepath.Join(home, ".agents"), home); err != nil {
t.Fatal(err)
}
if report.RootState != stateMissing {
t.Fatalf("RootState = %q, want %q", report.RootState, stateMissing)
}
})
t.Run(tc.agent+"/synced", func(t *testing.T) {
home := t.TempDir()
expected := filepath.Join(home, ".agents", "AGENTS.md")
linkPath := filepath.Join(home, tc.dir, tc.file)
if err := os.MkdirAll(filepath.Dir(expected), 0o755); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Dir(linkPath), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(expected, []byte("# Shared\n"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.Symlink(expected, linkPath); err != nil {
t.Fatal(err)
}
report := agentReport{}
if err := inspectRootInstructions(&report, tc.cap(), filepath.Join(home, ".agents"), home); err != nil {
t.Fatal(err)
}
if report.RootState != stateSynced {
t.Fatalf("RootState = %q, want %q", report.RootState, stateSynced)
}
})
t.Run(tc.agent+"/conflict", func(t *testing.T) {
home := t.TempDir()
linkPath := filepath.Join(home, tc.dir, tc.file)
if err := os.MkdirAll(filepath.Dir(linkPath), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(linkPath, []byte("# Local\n"), 0o644); err != nil {
t.Fatal(err)
}
report := agentReport{}
if err := inspectRootInstructions(&report, tc.cap(), filepath.Join(home, ".agents"), home); err != nil {
t.Fatal(err)
}
if report.RootState != stateConflict {
t.Fatalf("RootState = %q, want %q", report.RootState, stateConflict)
}
if len(report.Conflicts) != 1 {
t.Fatalf("Conflicts = %#v, want one conflict", report.Conflicts)
}
})
}
}

func TestApplyAgentRootInstructionSyncClaudeAndCodex(t *testing.T) {
for _, tc := range []struct {
agent string
linkPath string
}{
{agentClaudeCode, ".claude/CLAUDE.md"},
{agentCodex, ".codex/AGENTS.md"},
} {
home := t.TempDir()
report := agentReport{
Name: tc.agent,
Detected: true,
RootPath: filepath.Join(home, filepath.FromSlash(tc.linkPath)),
RootExpected: filepath.Join(home, ".agents", "AGENTS.md"),
RootState: stateMissing,
}
if err := os.MkdirAll(filepath.Dir(report.RootExpected), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(report.RootExpected, []byte("# Shared\n"), 0o644); err != nil {
t.Fatal(err)
}

if err := applyAgentRootInstructionSync([]agentReport{report}); err != nil {
t.Fatal(err)
}

rawTarget, err := os.Readlink(report.RootPath)
if err != nil {
t.Fatal(err)
}
if rawTarget != report.RootExpected {
t.Fatalf("%s link target = %q, want %q", tc.agent, rawTarget, report.RootExpected)
}
}
}
7 changes: 7 additions & 0 deletions cmd/dotagents/sync_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,13 @@ func writeSyncTestFile(t *testing.T, path string, data []byte) {
func TestInspectAgentAcceptsMatchingNativeCopyAndRejectsDifferentContent(t *testing.T) {
home := t.TempDir()
repoRoot := t.TempDir()
writeSyncTestFile(t, filepath.Join(repoRoot, "AGENTS.md"), []byte("# Shared\n"))
if err := os.MkdirAll(filepath.Join(home, ".claude"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.Symlink(filepath.Join(repoRoot, "AGENTS.md"), filepath.Join(home, ".claude", "CLAUDE.md")); err != nil {
t.Fatal(err)
}
canonical := filepath.Join(repoRoot, "skills", "existing")
native := filepath.Join(home, ".claude", "skills", "existing")
content := []byte("---\nname: existing\n---\n")
Expand Down
Loading