diff --git a/README.md b/README.md index 79469b3..24ed555 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/cmd/dotagents/harness.go b/cmd/dotagents/harness.go index ff29a31..3a48bb7 100644 --- a/cmd/dotagents/harness.go +++ b/cmd/dotagents/harness.go @@ -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] ", }, @@ -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] ", }, diff --git a/cmd/dotagents/root_instructions_test.go b/cmd/dotagents/root_instructions_test.go index 00422cb..4b2c435 100644 --- a/cmd/dotagents/root_instructions_test.go +++ b/cmd/dotagents/root_instructions_test.go @@ -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) + } + } +} diff --git a/cmd/dotagents/sync_test.go b/cmd/dotagents/sync_test.go index ed0eb98..ddebe66 100644 --- a/cmd/dotagents/sync_test.go +++ b/cmd/dotagents/sync_test.go @@ -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")