diff --git a/internal/claude/settings.go b/internal/claude/settings.go index 5ac72935..b9807023 100644 --- a/internal/claude/settings.go +++ b/internal/claude/settings.go @@ -27,7 +27,7 @@ const ( // RoleTypeFor returns the RoleType for a given role name. func RoleTypeFor(role string) RoleType { switch role { - case "polecat", "witness", "refinery": + case "polecat", "witness", "refinery", "deacon": return Autonomous default: return Interactive @@ -35,8 +35,8 @@ func RoleTypeFor(role string) RoleType { } // EnsureSettings ensures .claude/settings.json exists in the given directory. -// If the file doesn't exist, it copies the appropriate template based on role type. -// If the file already exists, it's left unchanged. +// For worktrees, we use sparse checkout to exclude source repo's .claude/ directory, +// so our settings.json is the only one Claude Code sees. func EnsureSettings(workDir string, roleType RoleType) error { claudeDir := filepath.Join(workDir, ".claude") settingsPath := filepath.Join(claudeDir, "settings.json") diff --git a/internal/cmd/crew_lifecycle.go b/internal/cmd/crew_lifecycle.go index 200d7b21..7f613fa9 100644 --- a/internal/cmd/crew_lifecycle.go +++ b/internal/cmd/crew_lifecycle.go @@ -10,6 +10,7 @@ import ( "github.com/spf13/cobra" "github.com/steveyegge/gastown/internal/beads" + "github.com/steveyegge/gastown/internal/claude" "github.com/steveyegge/gastown/internal/config" "github.com/steveyegge/gastown/internal/constants" "github.com/steveyegge/gastown/internal/crew" @@ -577,6 +578,11 @@ func restartCrewSession(rigName, crewName, clonePath string) error { } } + // Ensure Claude settings exist (crew is interactive role) + if err := claude.EnsureSettingsForRole(clonePath, "crew"); err != nil { + return fmt.Errorf("ensuring Claude settings: %w", err) + } + // Start new session if err := t.NewSession(sessionID, clonePath); err != nil { return fmt.Errorf("creating session: %w", err) diff --git a/internal/cmd/deacon.go b/internal/cmd/deacon.go index 33d45b3c..112ad9a0 100644 --- a/internal/cmd/deacon.go +++ b/internal/cmd/deacon.go @@ -12,6 +12,7 @@ import ( "github.com/spf13/cobra" "github.com/steveyegge/gastown/internal/beads" + "github.com/steveyegge/gastown/internal/claude" "github.com/steveyegge/gastown/internal/config" "github.com/steveyegge/gastown/internal/constants" "github.com/steveyegge/gastown/internal/deacon" @@ -274,9 +275,9 @@ func startDeaconSession(t *tmux.Tmux, sessionName string) error { return fmt.Errorf("creating deacon directory: %w", err) } - // Ensure deacon has patrol hooks (idempotent) - if err := ensurePatrolHooks(deaconDir); err != nil { - style.PrintWarning("Could not create deacon hooks: %v", err) + // Ensure Claude settings exist (autonomous role needs mail in SessionStart) + if err := claude.EnsureSettingsForRole(deaconDir, "deacon"); err != nil { + style.PrintWarning("Could not create deacon settings: %v", err) } // Create session in deacon directory @@ -526,64 +527,6 @@ func runDeaconTriggerPending(cmd *cobra.Command, args []string) error { return nil } -// ensurePatrolHooks creates .claude/settings.json with hooks for patrol roles. -// This is idempotent - if hooks already exist, it does nothing. -func ensurePatrolHooks(workspacePath string) error { - settingsPath := filepath.Join(workspacePath, ".claude", "settings.json") - - // Check if already exists - if _, err := os.Stat(settingsPath); err == nil { - return nil // Already exists - } - - claudeDir := filepath.Join(workspacePath, ".claude") - if err := os.MkdirAll(claudeDir, 0755); err != nil { - return fmt.Errorf("creating .claude dir: %w", err) - } - - // Standard patrol hooks - // Note: SessionStart nudges Deacon for GUPP backstop (agent wake notification) - hooksJSON := `{ - "hooks": { - "SessionStart": [ - { - "matcher": "", - "hooks": [ - { - "type": "command", - "command": "gt prime && gt mail check --inject && gt nudge deacon session-started" - } - ] - } - ], - "PreCompact": [ - { - "matcher": "", - "hooks": [ - { - "type": "command", - "command": "gt prime" - } - ] - } - ], - "UserPromptSubmit": [ - { - "matcher": "", - "hooks": [ - { - "type": "command", - "command": "gt mail check --inject" - } - ] - } - ] - } -} -` - return os.WriteFile(settingsPath, []byte(hooksJSON), 0600) -} - // runDeaconHealthCheck implements the health-check command. // It sends a HEALTH_CHECK nudge to an agent, waits for response, and tracks state. func runDeaconHealthCheck(cmd *cobra.Command, args []string) error { diff --git a/internal/cmd/doctor.go b/internal/cmd/doctor.go index 4893af44..2409c5da 100644 --- a/internal/cmd/doctor.go +++ b/internal/cmd/doctor.go @@ -63,6 +63,7 @@ Routing checks (fixable): Session hook checks: - session-hooks Check settings.json use session-start.sh + - claude-settings Check Claude settings.json match templates (fixable) Patrol checks: - patrol-molecules-exist Verify patrol molecules exist @@ -137,6 +138,7 @@ func runDoctor(cmd *cobra.Command, args []string) error { d.Register(doctor.NewSessionHookCheck()) d.Register(doctor.NewRuntimeGitignoreCheck()) d.Register(doctor.NewLegacyGastownCheck()) + d.Register(doctor.NewClaudeSettingsCheck()) // Crew workspace checks d.Register(doctor.NewCrewStateCheck()) diff --git a/internal/doctor/claude_settings_check.go b/internal/doctor/claude_settings_check.go new file mode 100644 index 00000000..1613d117 --- /dev/null +++ b/internal/doctor/claude_settings_check.go @@ -0,0 +1,350 @@ +package doctor + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/steveyegge/gastown/internal/claude" + "github.com/steveyegge/gastown/internal/tmux" +) + +// ClaudeSettingsCheck verifies that Claude settings.json files match the expected templates. +// Detects stale settings files that are missing required hooks or configuration. +type ClaudeSettingsCheck struct { + FixableCheck + staleSettings []staleSettingsInfo +} + +type staleSettingsInfo struct { + path string // Full path to settings.json + agentType string // e.g., "witness", "refinery", "deacon", "mayor" + rigName string // Rig name (empty for town-level agents) + sessionName string // tmux session name for cycling + missing []string // What's missing from the settings + wrongLocation bool // True if file is in wrong location (should be deleted) +} + +// NewClaudeSettingsCheck creates a new Claude settings validation check. +func NewClaudeSettingsCheck() *ClaudeSettingsCheck { + return &ClaudeSettingsCheck{ + FixableCheck: FixableCheck{ + BaseCheck: BaseCheck{ + CheckName: "claude-settings", + CheckDescription: "Verify Claude settings.json files match expected templates", + }, + }, + } +} + +// Run checks all Claude settings.json files for staleness. +func (c *ClaudeSettingsCheck) Run(ctx *CheckContext) *CheckResult { + c.staleSettings = nil + + var details []string + + // Find all settings.json files + settingsFiles := c.findSettingsFiles(ctx.TownRoot) + + for _, sf := range settingsFiles { + // Files in wrong locations are always stale (should be deleted) + if sf.wrongLocation { + c.staleSettings = append(c.staleSettings, sf) + details = append(details, fmt.Sprintf("%s: wrong location (should be in rig/ subdirectory)", sf.path)) + continue + } + + // Check content of files in correct locations + missing := c.checkSettings(sf.path, sf.agentType) + if len(missing) > 0 { + sf.missing = missing + c.staleSettings = append(c.staleSettings, sf) + details = append(details, fmt.Sprintf("%s: missing %s", sf.path, strings.Join(missing, ", "))) + } + } + + if len(c.staleSettings) == 0 { + return &CheckResult{ + Name: c.Name(), + Status: StatusOK, + Message: "All Claude settings.json files are up to date", + } + } + + return &CheckResult{ + Name: c.Name(), + Status: StatusError, + Message: fmt.Sprintf("Found %d stale Claude settings.json file(s)", len(c.staleSettings)), + Details: details, + FixHint: "Run 'gt doctor --fix' to update settings and restart affected agents", + } +} + +// findSettingsFiles locates all .claude/settings.json files and identifies their agent type. +func (c *ClaudeSettingsCheck) findSettingsFiles(townRoot string) []staleSettingsInfo { + var files []staleSettingsInfo + + // Town-level: mayor (~/gt/.claude/settings.json) + mayorSettings := filepath.Join(townRoot, ".claude", "settings.json") + if fileExists(mayorSettings) { + files = append(files, staleSettingsInfo{ + path: mayorSettings, + agentType: "mayor", + sessionName: "gt-mayor", + }) + } + + // Town-level: deacon (~/gt/deacon/.claude/settings.json) + deaconSettings := filepath.Join(townRoot, "deacon", ".claude", "settings.json") + if fileExists(deaconSettings) { + files = append(files, staleSettingsInfo{ + path: deaconSettings, + agentType: "deacon", + sessionName: "gt-deacon", + }) + } + + // Find rig directories + entries, err := os.ReadDir(townRoot) + if err != nil { + return files + } + + for _, entry := range entries { + if !entry.IsDir() { + continue + } + + rigName := entry.Name() + rigPath := filepath.Join(townRoot, rigName) + + // Skip known non-rig directories + if rigName == "mayor" || rigName == "deacon" || rigName == "daemon" || + rigName == ".git" || rigName == "docs" || rigName[0] == '.' { + continue + } + + // Check for witness settings - rig/ is correct location, without rig/ is wrong + witnessRigSettings := filepath.Join(rigPath, "witness", "rig", ".claude", "settings.json") + if fileExists(witnessRigSettings) { + files = append(files, staleSettingsInfo{ + path: witnessRigSettings, + agentType: "witness", + rigName: rigName, + sessionName: fmt.Sprintf("gt-%s-witness", rigName), + }) + } + // Settings in witness/.claude/ (not witness/rig/.claude/) are in wrong location + witnessWrongSettings := filepath.Join(rigPath, "witness", ".claude", "settings.json") + if fileExists(witnessWrongSettings) { + files = append(files, staleSettingsInfo{ + path: witnessWrongSettings, + agentType: "witness", + rigName: rigName, + sessionName: fmt.Sprintf("gt-%s-witness", rigName), + wrongLocation: true, + }) + } + + // Check for refinery settings - rig/ is correct location, without rig/ is wrong + refineryRigSettings := filepath.Join(rigPath, "refinery", "rig", ".claude", "settings.json") + if fileExists(refineryRigSettings) { + files = append(files, staleSettingsInfo{ + path: refineryRigSettings, + agentType: "refinery", + rigName: rigName, + sessionName: fmt.Sprintf("gt-%s-refinery", rigName), + }) + } + // Settings in refinery/.claude/ (not refinery/rig/.claude/) are in wrong location + refineryWrongSettings := filepath.Join(rigPath, "refinery", ".claude", "settings.json") + if fileExists(refineryWrongSettings) { + files = append(files, staleSettingsInfo{ + path: refineryWrongSettings, + agentType: "refinery", + rigName: rigName, + sessionName: fmt.Sprintf("gt-%s-refinery", rigName), + wrongLocation: true, + }) + } + + // Check for crew settings (crew//.claude/) + crewDir := filepath.Join(rigPath, "crew") + if dirExists(crewDir) { + crewEntries, _ := os.ReadDir(crewDir) + for _, crewEntry := range crewEntries { + if !crewEntry.IsDir() { + continue + } + crewSettings := filepath.Join(crewDir, crewEntry.Name(), ".claude", "settings.json") + if fileExists(crewSettings) { + files = append(files, staleSettingsInfo{ + path: crewSettings, + agentType: "crew", + rigName: rigName, + sessionName: fmt.Sprintf("gt-%s-crew-%s", rigName, crewEntry.Name()), + }) + } + } + } + + // Check for polecat settings (polecats//.claude/) + polecatsDir := filepath.Join(rigPath, "polecats") + if dirExists(polecatsDir) { + polecatEntries, _ := os.ReadDir(polecatsDir) + for _, pcEntry := range polecatEntries { + if !pcEntry.IsDir() { + continue + } + pcSettings := filepath.Join(polecatsDir, pcEntry.Name(), ".claude", "settings.json") + if fileExists(pcSettings) { + files = append(files, staleSettingsInfo{ + path: pcSettings, + agentType: "polecat", + rigName: rigName, + sessionName: fmt.Sprintf("gt-%s-polecat-%s", rigName, pcEntry.Name()), + }) + } + } + } + } + + return files +} + +// checkSettings compares a settings file against the expected template. +// Returns a list of what's missing. +func (c *ClaudeSettingsCheck) checkSettings(path, agentType string) []string { + var missing []string + + // Read the actual settings + data, err := os.ReadFile(path) + if err != nil { + return []string{"unreadable"} + } + + var actual map[string]any + if err := json.Unmarshal(data, &actual); err != nil { + return []string{"invalid JSON"} + } + + // Check for required elements based on template + // All templates should have: + // 1. enabledPlugins + // 2. PATH export in hooks + // 3. Stop hook with gt costs record (for autonomous) + // 4. gt nudge deacon session-started in SessionStart + + // Check enabledPlugins + if _, ok := actual["enabledPlugins"]; !ok { + missing = append(missing, "enabledPlugins") + } + + // Check hooks + hooks, ok := actual["hooks"].(map[string]any) + if !ok { + return append(missing, "hooks") + } + + // Check SessionStart hook has PATH export + if !c.hookHasPattern(hooks, "SessionStart", "PATH=") { + missing = append(missing, "PATH export") + } + + // Check SessionStart hook has deacon nudge + if !c.hookHasPattern(hooks, "SessionStart", "gt nudge deacon session-started") { + missing = append(missing, "deacon nudge") + } + + // Check Stop hook exists with gt costs record (for all roles) + if !c.hookHasPattern(hooks, "Stop", "gt costs record") { + missing = append(missing, "Stop hook") + } + + return missing +} + +// hookHasPattern checks if a hook contains a specific pattern. +func (c *ClaudeSettingsCheck) hookHasPattern(hooks map[string]any, hookName, pattern string) bool { + hookList, ok := hooks[hookName].([]any) + if !ok { + return false + } + + for _, hook := range hookList { + hookMap, ok := hook.(map[string]any) + if !ok { + continue + } + innerHooks, ok := hookMap["hooks"].([]any) + if !ok { + continue + } + for _, inner := range innerHooks { + innerMap, ok := inner.(map[string]any) + if !ok { + continue + } + cmd, ok := innerMap["command"].(string) + if ok && strings.Contains(cmd, pattern) { + return true + } + } + } + return false +} + +// Fix deletes stale settings files and restarts affected agents. +func (c *ClaudeSettingsCheck) Fix(ctx *CheckContext) error { + var errors []string + t := tmux.NewTmux() + + for _, sf := range c.staleSettings { + // Delete the stale settings file + if err := os.Remove(sf.path); err != nil { + errors = append(errors, fmt.Sprintf("failed to delete %s: %v", sf.path, err)) + continue + } + + // Also delete parent .claude directory if empty + claudeDir := filepath.Dir(sf.path) + _ = os.Remove(claudeDir) // Best-effort, will fail if not empty + + // For files in wrong locations, just delete - don't recreate + // The correct location will get settings when the agent starts + if sf.wrongLocation { + continue + } + + // Recreate settings using EnsureSettingsForRole + workDir := filepath.Dir(claudeDir) // agent work directory + if err := claude.EnsureSettingsForRole(workDir, sf.agentType); err != nil { + errors = append(errors, fmt.Sprintf("failed to recreate settings for %s: %v", sf.path, err)) + continue + } + + // Check if agent has a running session + running, _ := t.HasSession(sf.sessionName) + if running { + // Cycle the agent by killing and letting gt up restart it + // (or the daemon will restart it) + _ = t.KillSession(sf.sessionName) + } + } + + if len(errors) > 0 { + return fmt.Errorf("%s", strings.Join(errors, "; ")) + } + return nil +} + +// fileExists checks if a file exists. +func fileExists(path string) bool { + info, err := os.Stat(path) + if err != nil { + return false + } + return !info.IsDir() +} diff --git a/internal/doctor/claude_settings_check_test.go b/internal/doctor/claude_settings_check_test.go new file mode 100644 index 00000000..135684ee --- /dev/null +++ b/internal/doctor/claude_settings_check_test.go @@ -0,0 +1,613 @@ +package doctor + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestNewClaudeSettingsCheck(t *testing.T) { + check := NewClaudeSettingsCheck() + + if check.Name() != "claude-settings" { + t.Errorf("expected name 'claude-settings', got %q", check.Name()) + } + + if !check.CanFix() { + t.Error("expected CanFix to return true") + } +} + +func TestClaudeSettingsCheck_NoSettingsFiles(t *testing.T) { + tmpDir := t.TempDir() + + check := NewClaudeSettingsCheck() + ctx := &CheckContext{TownRoot: tmpDir} + + result := check.Run(ctx) + + if result.Status != StatusOK { + t.Errorf("expected StatusOK when no settings files, got %v", result.Status) + } +} + +// createValidSettings creates a valid settings.json with all required elements. +func createValidSettings(t *testing.T, path string) { + t.Helper() + + settings := map[string]any{ + "enabledPlugins": []string{"plugin1"}, + "hooks": map[string]any{ + "SessionStart": []any{ + map[string]any{ + "matcher": "**", + "hooks": []any{ + map[string]any{ + "type": "command", + "command": "export PATH=/usr/local/bin:$PATH", + }, + map[string]any{ + "type": "command", + "command": "gt nudge deacon session-started", + }, + }, + }, + }, + "Stop": []any{ + map[string]any{ + "matcher": "**", + "hooks": []any{ + map[string]any{ + "type": "command", + "command": "gt costs record --session $CLAUDE_SESSION_ID", + }, + }, + }, + }, + }, + } + + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + t.Fatal(err) + } + + data, err := json.MarshalIndent(settings, "", " ") + if err != nil { + t.Fatal(err) + } + + if err := os.WriteFile(path, data, 0644); err != nil { + t.Fatal(err) + } +} + +// createStaleSettings creates a settings.json missing required elements. +func createStaleSettings(t *testing.T, path string, missingElements ...string) { + t.Helper() + + settings := map[string]any{ + "enabledPlugins": []string{"plugin1"}, + "hooks": map[string]any{ + "SessionStart": []any{ + map[string]any{ + "matcher": "**", + "hooks": []any{ + map[string]any{ + "type": "command", + "command": "export PATH=/usr/local/bin:$PATH", + }, + map[string]any{ + "type": "command", + "command": "gt nudge deacon session-started", + }, + }, + }, + }, + "Stop": []any{ + map[string]any{ + "matcher": "**", + "hooks": []any{ + map[string]any{ + "type": "command", + "command": "gt costs record --session $CLAUDE_SESSION_ID", + }, + }, + }, + }, + }, + } + + for _, missing := range missingElements { + switch missing { + case "enabledPlugins": + delete(settings, "enabledPlugins") + case "hooks": + delete(settings, "hooks") + case "PATH": + // Remove PATH from SessionStart hooks + hooks := settings["hooks"].(map[string]any) + sessionStart := hooks["SessionStart"].([]any) + hookObj := sessionStart[0].(map[string]any) + innerHooks := hookObj["hooks"].([]any) + // Filter out PATH command + var filtered []any + for _, h := range innerHooks { + hMap := h.(map[string]any) + if cmd, ok := hMap["command"].(string); ok && !strings.Contains(cmd, "PATH=") { + filtered = append(filtered, h) + } + } + hookObj["hooks"] = filtered + case "deacon-nudge": + // Remove deacon nudge from SessionStart hooks + hooks := settings["hooks"].(map[string]any) + sessionStart := hooks["SessionStart"].([]any) + hookObj := sessionStart[0].(map[string]any) + innerHooks := hookObj["hooks"].([]any) + // Filter out deacon nudge + var filtered []any + for _, h := range innerHooks { + hMap := h.(map[string]any) + if cmd, ok := hMap["command"].(string); ok && !strings.Contains(cmd, "gt nudge deacon") { + filtered = append(filtered, h) + } + } + hookObj["hooks"] = filtered + case "Stop": + hooks := settings["hooks"].(map[string]any) + delete(hooks, "Stop") + } + } + + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + t.Fatal(err) + } + + data, err := json.MarshalIndent(settings, "", " ") + if err != nil { + t.Fatal(err) + } + + if err := os.WriteFile(path, data, 0644); err != nil { + t.Fatal(err) + } +} + +func TestClaudeSettingsCheck_ValidMayorSettings(t *testing.T) { + tmpDir := t.TempDir() + + // Create valid mayor settings + mayorSettings := filepath.Join(tmpDir, ".claude", "settings.json") + createValidSettings(t, mayorSettings) + + check := NewClaudeSettingsCheck() + ctx := &CheckContext{TownRoot: tmpDir} + + result := check.Run(ctx) + + if result.Status != StatusOK { + t.Errorf("expected StatusOK for valid settings, got %v: %s", result.Status, result.Message) + } +} + +func TestClaudeSettingsCheck_ValidDeaconSettings(t *testing.T) { + tmpDir := t.TempDir() + + // Create valid deacon settings + deaconSettings := filepath.Join(tmpDir, "deacon", ".claude", "settings.json") + createValidSettings(t, deaconSettings) + + check := NewClaudeSettingsCheck() + ctx := &CheckContext{TownRoot: tmpDir} + + result := check.Run(ctx) + + if result.Status != StatusOK { + t.Errorf("expected StatusOK for valid deacon settings, got %v: %s", result.Status, result.Message) + } +} + +func TestClaudeSettingsCheck_ValidWitnessSettings(t *testing.T) { + tmpDir := t.TempDir() + rigName := "testrig" + + // Create valid witness settings in correct location (rig/.claude/) + witnessSettings := filepath.Join(tmpDir, rigName, "witness", "rig", ".claude", "settings.json") + createValidSettings(t, witnessSettings) + + check := NewClaudeSettingsCheck() + ctx := &CheckContext{TownRoot: tmpDir} + + result := check.Run(ctx) + + if result.Status != StatusOK { + t.Errorf("expected StatusOK for valid witness settings, got %v: %s", result.Status, result.Message) + } +} + +func TestClaudeSettingsCheck_ValidRefinerySettings(t *testing.T) { + tmpDir := t.TempDir() + rigName := "testrig" + + // Create valid refinery settings in correct location + refinerySettings := filepath.Join(tmpDir, rigName, "refinery", "rig", ".claude", "settings.json") + createValidSettings(t, refinerySettings) + + check := NewClaudeSettingsCheck() + ctx := &CheckContext{TownRoot: tmpDir} + + result := check.Run(ctx) + + if result.Status != StatusOK { + t.Errorf("expected StatusOK for valid refinery settings, got %v: %s", result.Status, result.Message) + } +} + +func TestClaudeSettingsCheck_ValidCrewSettings(t *testing.T) { + tmpDir := t.TempDir() + rigName := "testrig" + + // Create valid crew agent settings + crewSettings := filepath.Join(tmpDir, rigName, "crew", "agent1", ".claude", "settings.json") + createValidSettings(t, crewSettings) + + check := NewClaudeSettingsCheck() + ctx := &CheckContext{TownRoot: tmpDir} + + result := check.Run(ctx) + + if result.Status != StatusOK { + t.Errorf("expected StatusOK for valid crew settings, got %v: %s", result.Status, result.Message) + } +} + +func TestClaudeSettingsCheck_ValidPolecatSettings(t *testing.T) { + tmpDir := t.TempDir() + rigName := "testrig" + + // Create valid polecat settings + pcSettings := filepath.Join(tmpDir, rigName, "polecats", "pc1", ".claude", "settings.json") + createValidSettings(t, pcSettings) + + check := NewClaudeSettingsCheck() + ctx := &CheckContext{TownRoot: tmpDir} + + result := check.Run(ctx) + + if result.Status != StatusOK { + t.Errorf("expected StatusOK for valid polecat settings, got %v: %s", result.Status, result.Message) + } +} + +func TestClaudeSettingsCheck_MissingEnabledPlugins(t *testing.T) { + tmpDir := t.TempDir() + + // Create stale mayor settings missing enabledPlugins + mayorSettings := filepath.Join(tmpDir, ".claude", "settings.json") + createStaleSettings(t, mayorSettings, "enabledPlugins") + + check := NewClaudeSettingsCheck() + ctx := &CheckContext{TownRoot: tmpDir} + + result := check.Run(ctx) + + if result.Status != StatusError { + t.Errorf("expected StatusError for missing enabledPlugins, got %v", result.Status) + } + if !strings.Contains(result.Message, "1 stale") { + t.Errorf("expected message about stale settings, got %q", result.Message) + } +} + +func TestClaudeSettingsCheck_MissingHooks(t *testing.T) { + tmpDir := t.TempDir() + + // Create stale settings missing hooks entirely + mayorSettings := filepath.Join(tmpDir, ".claude", "settings.json") + createStaleSettings(t, mayorSettings, "hooks") + + check := NewClaudeSettingsCheck() + ctx := &CheckContext{TownRoot: tmpDir} + + result := check.Run(ctx) + + if result.Status != StatusError { + t.Errorf("expected StatusError for missing hooks, got %v", result.Status) + } +} + +func TestClaudeSettingsCheck_MissingPATH(t *testing.T) { + tmpDir := t.TempDir() + + // Create stale settings missing PATH export + mayorSettings := filepath.Join(tmpDir, ".claude", "settings.json") + createStaleSettings(t, mayorSettings, "PATH") + + check := NewClaudeSettingsCheck() + ctx := &CheckContext{TownRoot: tmpDir} + + result := check.Run(ctx) + + if result.Status != StatusError { + t.Errorf("expected StatusError for missing PATH, got %v", result.Status) + } + found := false + for _, d := range result.Details { + if strings.Contains(d, "PATH export") { + found = true + break + } + } + if !found { + t.Errorf("expected details to mention PATH export, got %v", result.Details) + } +} + +func TestClaudeSettingsCheck_MissingDeaconNudge(t *testing.T) { + tmpDir := t.TempDir() + + // Create stale settings missing deacon nudge + mayorSettings := filepath.Join(tmpDir, ".claude", "settings.json") + createStaleSettings(t, mayorSettings, "deacon-nudge") + + check := NewClaudeSettingsCheck() + ctx := &CheckContext{TownRoot: tmpDir} + + result := check.Run(ctx) + + if result.Status != StatusError { + t.Errorf("expected StatusError for missing deacon nudge, got %v", result.Status) + } + found := false + for _, d := range result.Details { + if strings.Contains(d, "deacon nudge") { + found = true + break + } + } + if !found { + t.Errorf("expected details to mention deacon nudge, got %v", result.Details) + } +} + +func TestClaudeSettingsCheck_MissingStopHook(t *testing.T) { + tmpDir := t.TempDir() + + // Create stale settings missing Stop hook + mayorSettings := filepath.Join(tmpDir, ".claude", "settings.json") + createStaleSettings(t, mayorSettings, "Stop") + + check := NewClaudeSettingsCheck() + ctx := &CheckContext{TownRoot: tmpDir} + + result := check.Run(ctx) + + if result.Status != StatusError { + t.Errorf("expected StatusError for missing Stop hook, got %v", result.Status) + } + found := false + for _, d := range result.Details { + if strings.Contains(d, "Stop hook") { + found = true + break + } + } + if !found { + t.Errorf("expected details to mention Stop hook, got %v", result.Details) + } +} + +func TestClaudeSettingsCheck_WrongLocationWitness(t *testing.T) { + tmpDir := t.TempDir() + rigName := "testrig" + + // Create settings in wrong location (witness/.claude/ instead of witness/rig/.claude/) + wrongSettings := filepath.Join(tmpDir, rigName, "witness", ".claude", "settings.json") + createValidSettings(t, wrongSettings) + + check := NewClaudeSettingsCheck() + ctx := &CheckContext{TownRoot: tmpDir} + + result := check.Run(ctx) + + if result.Status != StatusError { + t.Errorf("expected StatusError for wrong location, got %v", result.Status) + } + found := false + for _, d := range result.Details { + if strings.Contains(d, "wrong location") { + found = true + break + } + } + if !found { + t.Errorf("expected details to mention wrong location, got %v", result.Details) + } +} + +func TestClaudeSettingsCheck_WrongLocationRefinery(t *testing.T) { + tmpDir := t.TempDir() + rigName := "testrig" + + // Create settings in wrong location (refinery/.claude/ instead of refinery/rig/.claude/) + wrongSettings := filepath.Join(tmpDir, rigName, "refinery", ".claude", "settings.json") + createValidSettings(t, wrongSettings) + + check := NewClaudeSettingsCheck() + ctx := &CheckContext{TownRoot: tmpDir} + + result := check.Run(ctx) + + if result.Status != StatusError { + t.Errorf("expected StatusError for wrong location, got %v", result.Status) + } + found := false + for _, d := range result.Details { + if strings.Contains(d, "wrong location") { + found = true + break + } + } + if !found { + t.Errorf("expected details to mention wrong location, got %v", result.Details) + } +} + +func TestClaudeSettingsCheck_MultipleStaleFiles(t *testing.T) { + tmpDir := t.TempDir() + rigName := "testrig" + + // Create multiple stale settings files + mayorSettings := filepath.Join(tmpDir, ".claude", "settings.json") + createStaleSettings(t, mayorSettings, "PATH") + + deaconSettings := filepath.Join(tmpDir, "deacon", ".claude", "settings.json") + createStaleSettings(t, deaconSettings, "Stop") + + witnessWrong := filepath.Join(tmpDir, rigName, "witness", ".claude", "settings.json") + createValidSettings(t, witnessWrong) // Valid content but wrong location + + check := NewClaudeSettingsCheck() + ctx := &CheckContext{TownRoot: tmpDir} + + result := check.Run(ctx) + + if result.Status != StatusError { + t.Errorf("expected StatusError for multiple stale files, got %v", result.Status) + } + if !strings.Contains(result.Message, "3 stale") { + t.Errorf("expected message about 3 stale files, got %q", result.Message) + } +} + +func TestClaudeSettingsCheck_InvalidJSON(t *testing.T) { + tmpDir := t.TempDir() + + // Create invalid JSON file + mayorSettings := filepath.Join(tmpDir, ".claude", "settings.json") + if err := os.MkdirAll(filepath.Dir(mayorSettings), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(mayorSettings, []byte("not valid json {"), 0644); err != nil { + t.Fatal(err) + } + + check := NewClaudeSettingsCheck() + ctx := &CheckContext{TownRoot: tmpDir} + + result := check.Run(ctx) + + if result.Status != StatusError { + t.Errorf("expected StatusError for invalid JSON, got %v", result.Status) + } + found := false + for _, d := range result.Details { + if strings.Contains(d, "invalid JSON") { + found = true + break + } + } + if !found { + t.Errorf("expected details to mention invalid JSON, got %v", result.Details) + } +} + +func TestClaudeSettingsCheck_FixDeletesStaleFile(t *testing.T) { + tmpDir := t.TempDir() + + // Create stale settings in wrong location (easy to test - just delete, no recreate) + rigName := "testrig" + wrongSettings := filepath.Join(tmpDir, rigName, "witness", ".claude", "settings.json") + createValidSettings(t, wrongSettings) + + check := NewClaudeSettingsCheck() + ctx := &CheckContext{TownRoot: tmpDir} + + // Run to detect + result := check.Run(ctx) + if result.Status != StatusError { + t.Fatalf("expected StatusError before fix, got %v", result.Status) + } + + // Apply fix + if err := check.Fix(ctx); err != nil { + t.Fatalf("Fix failed: %v", err) + } + + // Verify file was deleted + if _, err := os.Stat(wrongSettings); !os.IsNotExist(err) { + t.Error("expected wrong location settings to be deleted") + } + + // Verify check passes (no settings files means OK) + result = check.Run(ctx) + if result.Status != StatusOK { + t.Errorf("expected StatusOK after fix, got %v", result.Status) + } +} + +func TestClaudeSettingsCheck_SkipsNonRigDirectories(t *testing.T) { + tmpDir := t.TempDir() + + // Create directories that should be skipped + for _, skipDir := range []string{"mayor", "deacon", "daemon", ".git", "docs", ".hidden"} { + dir := filepath.Join(tmpDir, skipDir, "witness", "rig", ".claude") + if err := os.MkdirAll(dir, 0755); err != nil { + t.Fatal(err) + } + // These should NOT be detected as rig witness settings + settingsPath := filepath.Join(dir, "settings.json") + createStaleSettings(t, settingsPath, "PATH") + } + + check := NewClaudeSettingsCheck() + ctx := &CheckContext{TownRoot: tmpDir} + + _ = check.Run(ctx) + + // Should only find mayor and deacon settings in their specific locations + // The witness settings in these dirs should be ignored + // Since we didn't create valid mayor/deacon settings, those will be stale + // But the ones in "mayor/witness/rig/.claude" should be ignored + + // Count how many stale files were found - should be 0 since none of the + // skipped directories have their settings detected + if len(check.staleSettings) != 0 { + t.Errorf("expected 0 stale files (skipped dirs), got %d", len(check.staleSettings)) + } +} + +func TestClaudeSettingsCheck_MixedValidAndStale(t *testing.T) { + tmpDir := t.TempDir() + rigName := "testrig" + + // Create valid mayor settings + mayorSettings := filepath.Join(tmpDir, ".claude", "settings.json") + createValidSettings(t, mayorSettings) + + // Create stale witness settings (missing PATH) + witnessSettings := filepath.Join(tmpDir, rigName, "witness", "rig", ".claude", "settings.json") + createStaleSettings(t, witnessSettings, "PATH") + + // Create valid refinery settings + refinerySettings := filepath.Join(tmpDir, rigName, "refinery", "rig", ".claude", "settings.json") + createValidSettings(t, refinerySettings) + + check := NewClaudeSettingsCheck() + ctx := &CheckContext{TownRoot: tmpDir} + + result := check.Run(ctx) + + if result.Status != StatusError { + t.Errorf("expected StatusError for mixed valid/stale, got %v", result.Status) + } + if !strings.Contains(result.Message, "1 stale") { + t.Errorf("expected message about 1 stale file, got %q", result.Message) + } + // Should only report the witness settings as stale + if len(result.Details) != 1 { + t.Errorf("expected 1 detail, got %d: %v", len(result.Details), result.Details) + } +} diff --git a/internal/rig/manager.go b/internal/rig/manager.go index 55a7edb0..7c49b7a3 100644 --- a/internal/rig/manager.go +++ b/internal/rig/manager.go @@ -383,11 +383,6 @@ func (m *Manager) AddRig(opts AddRigOptions) (*Rig, error) { if err := m.createRoleCLAUDEmd(refineryRigPath, "refinery", opts.Name, ""); err != nil { return nil, fmt.Errorf("creating refinery CLAUDE.md: %w", err) } - // Create refinery hooks for patrol triggering (at refinery/ level, not rig/) - refineryPath := filepath.Dir(refineryRigPath) - if err := m.createPatrolHooks(refineryPath); err != nil { - fmt.Printf(" Warning: Could not create refinery hooks: %v\n", err) - } // Create empty crew directory with README (crew members added via gt crew add) crewPath := filepath.Join(rigPath, "crew") @@ -422,10 +417,6 @@ Use crew for your own workspace. Polecats are for batch work dispatch. if err := os.MkdirAll(witnessPath, 0755); err != nil { return nil, fmt.Errorf("creating witness dir: %w", err) } - // Create witness hooks for patrol triggering - if err := m.createPatrolHooks(witnessPath); err != nil { - fmt.Printf(" Warning: Could not create witness hooks: %v\n", err) - } // Create polecats directory (empty) polecatsPath := filepath.Join(rigPath, "polecats") @@ -822,58 +813,6 @@ func (m *Manager) createRoleCLAUDEmd(workspacePath string, role string, rigName return os.WriteFile(claudePath, []byte(content), 0644) } -// createPatrolHooks creates .claude/settings.json with hooks for patrol roles. -// These hooks trigger gt prime on session start and inject mail, enabling -// autonomous patrol execution for Witness and Refinery roles. -func (m *Manager) createPatrolHooks(workspacePath string) error { - claudeDir := filepath.Join(workspacePath, ".claude") - if err := os.MkdirAll(claudeDir, 0755); err != nil { - return fmt.Errorf("creating .claude dir: %w", err) - } - - // Standard patrol hooks - same as deacon - hooksJSON := `{ - "hooks": { - "SessionStart": [ - { - "matcher": "", - "hooks": [ - { - "type": "command", - "command": "gt prime && gt mail check --inject" - } - ] - } - ], - "PreCompact": [ - { - "matcher": "", - "hooks": [ - { - "type": "command", - "command": "gt prime" - } - ] - } - ], - "UserPromptSubmit": [ - { - "matcher": "", - "hooks": [ - { - "type": "command", - "command": "gt mail check --inject" - } - ] - } - ] - } -} -` - settingsPath := filepath.Join(claudeDir, "settings.json") - return os.WriteFile(settingsPath, []byte(hooksJSON), 0600) -} - // seedPatrolMolecules creates patrol molecule prototypes in the rig's beads database. // These molecules define the work loops for Deacon, Witness, and Refinery roles. func (m *Manager) seedPatrolMolecules(rigPath string) error {