When Claude sessions were terminated using KillSession(), bash subprocesses spawned by Claude's Bash tool could survive because they ignore SIGHUP. This caused zombie processes to accumulate over time. Changed all critical session termination paths to use KillSessionWithProcesses() which explicitly kills all descendant processes before terminating the session. Fixes: gt-ew3tk Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
542 lines
18 KiB
Go
542 lines
18 KiB
Go
package doctor
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/steveyegge/gastown/internal/claude"
|
|
"github.com/steveyegge/gastown/internal/session"
|
|
"github.com/steveyegge/gastown/internal/style"
|
|
"github.com/steveyegge/gastown/internal/templates"
|
|
"github.com/steveyegge/gastown/internal/tmux"
|
|
"github.com/steveyegge/gastown/internal/workspace"
|
|
)
|
|
|
|
// gitFileStatus represents the git status of a file.
|
|
type gitFileStatus string
|
|
|
|
const (
|
|
gitStatusUntracked gitFileStatus = "untracked" // File not tracked by git
|
|
gitStatusTrackedClean gitFileStatus = "tracked-clean" // Tracked, no local modifications
|
|
gitStatusTrackedModified gitFileStatus = "tracked-modified" // Tracked with local modifications
|
|
gitStatusUnknown gitFileStatus = "unknown" // Not in a git repo or error
|
|
)
|
|
|
|
// 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)
|
|
gitStatus gitFileStatus // Git status for wrong-location files (for safe deletion)
|
|
}
|
|
|
|
// 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",
|
|
CheckCategory: CategoryConfig,
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
// Run checks all Claude settings.json files for staleness.
|
|
func (c *ClaudeSettingsCheck) Run(ctx *CheckContext) *CheckResult {
|
|
c.staleSettings = nil
|
|
|
|
var details []string
|
|
var hasModifiedFiles bool
|
|
|
|
// 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 {
|
|
// Check git status to determine safe deletion strategy
|
|
sf.gitStatus = c.getGitFileStatus(sf.path)
|
|
c.staleSettings = append(c.staleSettings, sf)
|
|
|
|
// Provide detailed message based on git status
|
|
var statusMsg string
|
|
switch sf.gitStatus {
|
|
case gitStatusUntracked:
|
|
statusMsg = "wrong location, untracked (safe to delete)"
|
|
case gitStatusTrackedClean:
|
|
statusMsg = "wrong location, tracked but unmodified (safe to delete)"
|
|
case gitStatusTrackedModified:
|
|
statusMsg = "wrong location, tracked with local modifications (manual review needed)"
|
|
hasModifiedFiles = true
|
|
default:
|
|
statusMsg = "wrong location (inside source repo)"
|
|
}
|
|
details = append(details, fmt.Sprintf("%s: %s", sf.path, statusMsg))
|
|
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",
|
|
}
|
|
}
|
|
|
|
fixHint := "Run 'gt doctor --fix' to update settings and restart affected agents"
|
|
if hasModifiedFiles {
|
|
fixHint = "Run 'gt doctor --fix' to fix safe issues. Files with local modifications require manual review."
|
|
}
|
|
|
|
return &CheckResult{
|
|
Name: c.Name(),
|
|
Status: StatusError,
|
|
Message: fmt.Sprintf("Found %d stale Claude config file(s) in wrong location", len(c.staleSettings)),
|
|
Details: details,
|
|
FixHint: fixHint,
|
|
}
|
|
}
|
|
|
|
// findSettingsFiles locates all .claude/settings.json files and identifies their agent type.
|
|
func (c *ClaudeSettingsCheck) findSettingsFiles(townRoot string) []staleSettingsInfo {
|
|
var files []staleSettingsInfo
|
|
|
|
// Check for STALE settings at town root (~/gt/.claude/settings.json)
|
|
// This is WRONG - settings here pollute ALL child workspaces via directory traversal.
|
|
// Mayor settings should be at ~/gt/mayor/.claude/ instead.
|
|
staleTownRootSettings := filepath.Join(townRoot, ".claude", "settings.json")
|
|
if fileExists(staleTownRootSettings) {
|
|
files = append(files, staleSettingsInfo{
|
|
path: staleTownRootSettings,
|
|
agentType: "mayor",
|
|
sessionName: "hq-mayor",
|
|
wrongLocation: true,
|
|
gitStatus: c.getGitFileStatus(staleTownRootSettings),
|
|
missing: []string{"should be at mayor/.claude/settings.json, not town root"},
|
|
})
|
|
}
|
|
|
|
// Check for STALE CLAUDE.md at town root (~/gt/CLAUDE.md)
|
|
// This is WRONG - CLAUDE.md here is inherited by ALL agents via directory traversal,
|
|
// causing crew/polecat/etc to receive Mayor-specific instructions.
|
|
// Mayor's CLAUDE.md should be at ~/gt/mayor/CLAUDE.md instead.
|
|
staleTownRootCLAUDEmd := filepath.Join(townRoot, "CLAUDE.md")
|
|
if fileExists(staleTownRootCLAUDEmd) {
|
|
files = append(files, staleSettingsInfo{
|
|
path: staleTownRootCLAUDEmd,
|
|
agentType: "mayor",
|
|
sessionName: "hq-mayor",
|
|
wrongLocation: true,
|
|
gitStatus: c.getGitFileStatus(staleTownRootCLAUDEmd),
|
|
missing: []string{"should be at mayor/CLAUDE.md, not town root"},
|
|
})
|
|
}
|
|
|
|
// Town-level: mayor (~/gt/mayor/.claude/settings.json) - CORRECT location
|
|
mayorSettings := filepath.Join(townRoot, "mayor", ".claude", "settings.json")
|
|
if fileExists(mayorSettings) {
|
|
files = append(files, staleSettingsInfo{
|
|
path: mayorSettings,
|
|
agentType: "mayor",
|
|
sessionName: "hq-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: "hq-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 - witness/.claude/ is correct (outside git repo)
|
|
// Settings in witness/rig/.claude/ are wrong (inside source repo)
|
|
witnessSettings := filepath.Join(rigPath, "witness", ".claude", "settings.json")
|
|
if fileExists(witnessSettings) {
|
|
files = append(files, staleSettingsInfo{
|
|
path: witnessSettings,
|
|
agentType: "witness",
|
|
rigName: rigName,
|
|
sessionName: fmt.Sprintf("gt-%s-witness", rigName),
|
|
})
|
|
}
|
|
witnessWrongSettings := filepath.Join(rigPath, "witness", "rig", ".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 - refinery/.claude/ is correct (outside git repo)
|
|
// Settings in refinery/rig/.claude/ are wrong (inside source repo)
|
|
refinerySettings := filepath.Join(rigPath, "refinery", ".claude", "settings.json")
|
|
if fileExists(refinerySettings) {
|
|
files = append(files, staleSettingsInfo{
|
|
path: refinerySettings,
|
|
agentType: "refinery",
|
|
rigName: rigName,
|
|
sessionName: fmt.Sprintf("gt-%s-refinery", rigName),
|
|
})
|
|
}
|
|
refineryWrongSettings := filepath.Join(rigPath, "refinery", "rig", ".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/ is correct (shared by all crew, outside git repos)
|
|
// Settings in crew/<name>/.claude/ are wrong (inside git repos)
|
|
crewDir := filepath.Join(rigPath, "crew")
|
|
crewSettings := filepath.Join(crewDir, ".claude", "settings.json")
|
|
if fileExists(crewSettings) {
|
|
files = append(files, staleSettingsInfo{
|
|
path: crewSettings,
|
|
agentType: "crew",
|
|
rigName: rigName,
|
|
sessionName: "", // Shared settings, no single session
|
|
})
|
|
}
|
|
if dirExists(crewDir) {
|
|
crewEntries, _ := os.ReadDir(crewDir)
|
|
for _, crewEntry := range crewEntries {
|
|
if !crewEntry.IsDir() || crewEntry.Name() == ".claude" {
|
|
continue
|
|
}
|
|
crewWrongSettings := filepath.Join(crewDir, crewEntry.Name(), ".claude", "settings.json")
|
|
if fileExists(crewWrongSettings) {
|
|
files = append(files, staleSettingsInfo{
|
|
path: crewWrongSettings,
|
|
agentType: "crew",
|
|
rigName: rigName,
|
|
sessionName: fmt.Sprintf("gt-%s-crew-%s", rigName, crewEntry.Name()),
|
|
wrongLocation: true,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check for polecat settings - polecats/.claude/ is correct (shared by all polecats, outside git repos)
|
|
// Settings in polecats/<name>/.claude/ are wrong (inside git repos)
|
|
polecatsDir := filepath.Join(rigPath, "polecats")
|
|
polecatsSettings := filepath.Join(polecatsDir, ".claude", "settings.json")
|
|
if fileExists(polecatsSettings) {
|
|
files = append(files, staleSettingsInfo{
|
|
path: polecatsSettings,
|
|
agentType: "polecat",
|
|
rigName: rigName,
|
|
sessionName: "", // Shared settings, no single session
|
|
})
|
|
}
|
|
if dirExists(polecatsDir) {
|
|
polecatEntries, _ := os.ReadDir(polecatsDir)
|
|
for _, pcEntry := range polecatEntries {
|
|
if !pcEntry.IsDir() || pcEntry.Name() == ".claude" {
|
|
continue
|
|
}
|
|
// Check for wrong settings in both structures:
|
|
// Old structure: polecats/<name>/.claude/settings.json
|
|
// New structure: polecats/<name>/<rigname>/.claude/settings.json
|
|
wrongPaths := []string{
|
|
filepath.Join(polecatsDir, pcEntry.Name(), ".claude", "settings.json"),
|
|
filepath.Join(polecatsDir, pcEntry.Name(), rigName, ".claude", "settings.json"),
|
|
}
|
|
for _, pcWrongSettings := range wrongPaths {
|
|
if fileExists(pcWrongSettings) {
|
|
files = append(files, staleSettingsInfo{
|
|
path: pcWrongSettings,
|
|
agentType: "polecat",
|
|
rigName: rigName,
|
|
sessionName: fmt.Sprintf("gt-%s-%s", rigName, pcEntry.Name()),
|
|
wrongLocation: true,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return files
|
|
}
|
|
|
|
// checkSettings compares a settings file against the expected template.
|
|
// Returns a list of what's missing.
|
|
// agentType is reserved for future role-specific validation.
|
|
func (c *ClaudeSettingsCheck) checkSettings(path, _ 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
|
|
}
|
|
|
|
// getGitFileStatus determines the git status of a file.
|
|
// Returns untracked, tracked-clean, tracked-modified, or unknown.
|
|
func (c *ClaudeSettingsCheck) getGitFileStatus(filePath string) gitFileStatus {
|
|
dir := filepath.Dir(filePath)
|
|
fileName := filepath.Base(filePath)
|
|
|
|
// Check if we're in a git repo
|
|
cmd := exec.Command("git", "-C", dir, "rev-parse", "--git-dir")
|
|
if err := cmd.Run(); err != nil {
|
|
return gitStatusUnknown
|
|
}
|
|
|
|
// Check if file is tracked
|
|
cmd = exec.Command("git", "-C", dir, "ls-files", fileName)
|
|
output, err := cmd.Output()
|
|
if err != nil {
|
|
return gitStatusUnknown
|
|
}
|
|
|
|
if len(strings.TrimSpace(string(output))) == 0 {
|
|
// File is not tracked
|
|
return gitStatusUntracked
|
|
}
|
|
|
|
// File is tracked - check if modified
|
|
cmd = exec.Command("git", "-C", dir, "diff", "--quiet", fileName)
|
|
if err := cmd.Run(); err != nil {
|
|
// Non-zero exit means file has changes
|
|
return gitStatusTrackedModified
|
|
}
|
|
|
|
// Also check for staged changes
|
|
cmd = exec.Command("git", "-C", dir, "diff", "--cached", "--quiet", fileName)
|
|
if err := cmd.Run(); err != nil {
|
|
return gitStatusTrackedModified
|
|
}
|
|
|
|
return gitStatusTrackedClean
|
|
}
|
|
|
|
// 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.
|
|
// Files with local modifications are skipped to avoid losing user changes.
|
|
func (c *ClaudeSettingsCheck) Fix(ctx *CheckContext) error {
|
|
var errors []string
|
|
var skipped []string
|
|
t := tmux.NewTmux()
|
|
|
|
for _, sf := range c.staleSettings {
|
|
// Skip files with local modifications - require manual review
|
|
if sf.wrongLocation && sf.gitStatus == gitStatusTrackedModified {
|
|
skipped = append(skipped, fmt.Sprintf("%s: has local modifications, skipping", sf.path))
|
|
continue
|
|
}
|
|
|
|
// 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, delete and create at correct location
|
|
if sf.wrongLocation {
|
|
mayorDir := filepath.Join(ctx.TownRoot, "mayor")
|
|
|
|
// For mayor settings.json at town root, create at mayor/.claude/
|
|
if sf.agentType == "mayor" && strings.HasSuffix(claudeDir, ".claude") && !strings.Contains(sf.path, "/mayor/") {
|
|
if err := os.MkdirAll(mayorDir, 0755); err == nil {
|
|
_ = claude.EnsureSettingsForRole(mayorDir, "mayor")
|
|
}
|
|
}
|
|
|
|
// For mayor CLAUDE.md at town root, create at mayor/
|
|
if sf.agentType == "mayor" && strings.HasSuffix(sf.path, "CLAUDE.md") && !strings.Contains(sf.path, "/mayor/") {
|
|
townName, _ := workspace.GetTownName(ctx.TownRoot)
|
|
if err := templates.CreateMayorCLAUDEmd(
|
|
mayorDir,
|
|
ctx.TownRoot,
|
|
townName,
|
|
session.MayorSessionName(),
|
|
session.DeaconSessionName(),
|
|
); err != nil {
|
|
errors = append(errors, fmt.Sprintf("failed to create mayor/CLAUDE.md: %v", err))
|
|
}
|
|
}
|
|
|
|
// Town-root files were inherited by ALL agents via directory traversal.
|
|
// Warn user to restart agents - don't auto-kill sessions as that's too disruptive,
|
|
// especially since deacon runs gt doctor automatically which would create a loop.
|
|
// Settings are only read at startup, so running agents already have config loaded.
|
|
fmt.Printf("\n %s Town-root settings were moved. Restart agents to pick up new config:\n", style.Warning.Render("⚠"))
|
|
fmt.Printf(" gt up --restart\n\n")
|
|
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
|
|
}
|
|
|
|
// Only cycle patrol roles if --restart-sessions was explicitly passed.
|
|
// This prevents unexpected session restarts during routine --fix operations.
|
|
// Crew and polecats are spawned on-demand and won't auto-restart anyway.
|
|
if ctx.RestartSessions {
|
|
if sf.agentType == "witness" || sf.agentType == "refinery" ||
|
|
sf.agentType == "deacon" || sf.agentType == "mayor" {
|
|
running, _ := t.HasSession(sf.sessionName)
|
|
if running {
|
|
// Cycle the agent by killing and letting gt up restart it.
|
|
// Use KillSessionWithProcesses to ensure all descendant processes are killed.
|
|
_ = t.KillSessionWithProcesses(sf.sessionName)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Report skipped files as warnings, not errors
|
|
if len(skipped) > 0 {
|
|
for _, s := range skipped {
|
|
fmt.Printf(" Warning: %s\n", s)
|
|
}
|
|
}
|
|
|
|
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()
|
|
}
|