Unify agent startup with Manager pattern
Refactors all agent startup paths (witness, refinery, crew, polecat) to use a consistent Manager interface with Start(), Stop(), IsRunning(), and SessionName() methods. Includes: - Witness manager with GUPP propulsion nudge for startup - Refinery manager for engineer sessions - Crew manager for worker agents - Session/polecat manager updates - claude_settings_check doctor check for settings validation - Settings management consolidated from rig/manager.go - Settings location moved outside source repos to prevent conflicts 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -10,12 +10,9 @@ import (
|
|||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"github.com/steveyegge/gastown/internal/beads"
|
"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/constants"
|
||||||
"github.com/steveyegge/gastown/internal/crew"
|
"github.com/steveyegge/gastown/internal/crew"
|
||||||
"github.com/steveyegge/gastown/internal/mail"
|
"github.com/steveyegge/gastown/internal/mail"
|
||||||
"github.com/steveyegge/gastown/internal/session"
|
|
||||||
"github.com/steveyegge/gastown/internal/style"
|
"github.com/steveyegge/gastown/internal/style"
|
||||||
"github.com/steveyegge/gastown/internal/tmux"
|
"github.com/steveyegge/gastown/internal/tmux"
|
||||||
"github.com/steveyegge/gastown/internal/townlog"
|
"github.com/steveyegge/gastown/internal/townlog"
|
||||||
@@ -201,7 +198,7 @@ func runCrewRefresh(cmd *cobra.Command, args []string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the crew worker
|
// Get the crew worker (must exist for refresh)
|
||||||
worker, err := crewMgr.Get(name)
|
worker, err := crewMgr.Get(name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == crew.ErrCrewNotFound {
|
if err == crew.ErrCrewNotFound {
|
||||||
@@ -210,12 +207,6 @@ func runCrewRefresh(cmd *cobra.Command, args []string) error {
|
|||||||
return fmt.Errorf("getting crew worker: %w", err)
|
return fmt.Errorf("getting crew worker: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
t := tmux.NewTmux()
|
|
||||||
sessionID := crewSessionName(r.Name, name)
|
|
||||||
|
|
||||||
// Check if session exists
|
|
||||||
hasSession, _ := t.HasSession(sessionID)
|
|
||||||
|
|
||||||
// Create handoff message
|
// Create handoff message
|
||||||
handoffMsg := crewMessage
|
handoffMsg := crewMessage
|
||||||
if handoffMsg == "" {
|
if handoffMsg == "" {
|
||||||
@@ -243,47 +234,14 @@ func runCrewRefresh(cmd *cobra.Command, args []string) error {
|
|||||||
}
|
}
|
||||||
fmt.Printf("Sent handoff mail to %s/%s\n", r.Name, name)
|
fmt.Printf("Sent handoff mail to %s/%s\n", r.Name, name)
|
||||||
|
|
||||||
// Kill existing session if running
|
// Use manager's Start() with refresh options
|
||||||
if hasSession {
|
err = crewMgr.Start(name, crew.StartOptions{
|
||||||
if err := t.KillSession(sessionID); err != nil {
|
KillExisting: true, // Kill old session if running
|
||||||
return fmt.Errorf("killing old session: %w", err)
|
Topic: "refresh", // Startup nudge topic
|
||||||
}
|
Interactive: true, // No --dangerously-skip-permissions
|
||||||
fmt.Printf("Killed old session %s\n", sessionID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start new session
|
|
||||||
if err := t.NewSession(sessionID, worker.ClonePath); err != nil {
|
|
||||||
return fmt.Errorf("creating session: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wait for shell to be ready
|
|
||||||
if err := t.WaitForShellReady(sessionID, constants.ShellReadyTimeout); err != nil {
|
|
||||||
return fmt.Errorf("waiting for shell: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build the startup beacon for predecessor discovery via /resume
|
|
||||||
// Pass it as Claude's initial prompt - processed when Claude is ready
|
|
||||||
address := fmt.Sprintf("%s/crew/%s", r.Name, name)
|
|
||||||
beacon := session.FormatStartupNudge(session.StartupNudgeConfig{
|
|
||||||
Recipient: address,
|
|
||||||
Sender: "human",
|
|
||||||
Topic: "refresh",
|
|
||||||
})
|
})
|
||||||
|
if err != nil {
|
||||||
// Start claude with environment exports and beacon as initial prompt
|
return fmt.Errorf("starting crew session: %w", err)
|
||||||
// Refresh uses regular permissions (no --dangerously-skip-permissions)
|
|
||||||
// SessionStart hook handles context loading (gt prime --hook)
|
|
||||||
claudeCmd := config.BuildCrewStartupCommand(r.Name, name, r.Path, beacon)
|
|
||||||
// Remove --dangerously-skip-permissions for refresh (interactive mode)
|
|
||||||
claudeCmd = strings.Replace(claudeCmd, " --dangerously-skip-permissions", "", 1)
|
|
||||||
if err := t.SendKeys(sessionID, claudeCmd); err != nil {
|
|
||||||
return fmt.Errorf("starting claude: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wait for Claude to start (optional, for status feedback)
|
|
||||||
shells := constants.SupportedShells
|
|
||||||
if err := t.WaitForCommand(sessionID, shells, constants.ClaudeStartTimeout); err != nil {
|
|
||||||
// Non-fatal
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("%s Refreshed crew workspace: %s/%s\n",
|
fmt.Printf("%s Refreshed crew workspace: %s/%s\n",
|
||||||
@@ -385,81 +343,18 @@ func runCrewRestart(cmd *cobra.Command, args []string) error {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the crew worker, create if not exists (idempotent)
|
// Use manager's Start() with restart options
|
||||||
worker, err := crewMgr.Get(name)
|
// Start() will create workspace if needed (idempotent)
|
||||||
if err == crew.ErrCrewNotFound {
|
err = crewMgr.Start(name, crew.StartOptions{
|
||||||
fmt.Printf("Creating crew workspace %s in %s...\n", name, r.Name)
|
KillExisting: true, // Kill old session if running
|
||||||
worker, err = crewMgr.Add(name, false) // No feature branch for crew
|
Topic: "restart", // Startup nudge topic
|
||||||
if err != nil {
|
|
||||||
fmt.Printf("Error creating %s: %v\n", arg, err)
|
|
||||||
lastErr = err
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
fmt.Printf("Created crew workspace: %s/%s\n", r.Name, name)
|
|
||||||
} else if err != nil {
|
|
||||||
fmt.Printf("Error getting %s: %v\n", arg, err)
|
|
||||||
lastErr = err
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
t := tmux.NewTmux()
|
|
||||||
sessionID := crewSessionName(r.Name, name)
|
|
||||||
|
|
||||||
// Kill existing session if running
|
|
||||||
if hasSession, _ := t.HasSession(sessionID); hasSession {
|
|
||||||
if err := t.KillSession(sessionID); err != nil {
|
|
||||||
fmt.Printf("Error killing session for %s: %v\n", arg, err)
|
|
||||||
lastErr = err
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
fmt.Printf("Killed session %s\n", sessionID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start new session
|
|
||||||
if err := t.NewSession(sessionID, worker.ClonePath); err != nil {
|
|
||||||
fmt.Printf("Error creating session for %s: %v\n", arg, err)
|
|
||||||
lastErr = err
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set environment
|
|
||||||
_ = t.SetEnvironment(sessionID, "GT_ROLE", "crew")
|
|
||||||
// Apply rig-based theming (non-fatal: theming failure doesn't affect operation)
|
|
||||||
theme := getThemeForRig(r.Name)
|
|
||||||
_ = t.ConfigureGasTownSession(sessionID, theme, r.Name, name, "crew")
|
|
||||||
|
|
||||||
// Wait for shell to be ready
|
|
||||||
if err := t.WaitForShellReady(sessionID, constants.ShellReadyTimeout); err != nil {
|
|
||||||
fmt.Printf("Error waiting for shell for %s: %v\n", arg, err)
|
|
||||||
lastErr = err
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build the startup beacon for predecessor discovery via /resume
|
|
||||||
// Pass it as Claude's initial prompt - processed when Claude is ready
|
|
||||||
address := fmt.Sprintf("%s/crew/%s", r.Name, name)
|
|
||||||
beacon := session.FormatStartupNudge(session.StartupNudgeConfig{
|
|
||||||
Recipient: address,
|
|
||||||
Sender: "human",
|
|
||||||
Topic: "restart",
|
|
||||||
})
|
})
|
||||||
|
if err != nil {
|
||||||
// Start claude with environment exports and beacon as initial prompt
|
fmt.Printf("Error restarting %s: %v\n", arg, err)
|
||||||
// SessionStart hook handles context loading (gt prime --hook)
|
|
||||||
// The startup protocol tells agent to check mail/hook, no explicit prompt needed
|
|
||||||
claudeCmd := config.BuildCrewStartupCommand(r.Name, name, r.Path, beacon)
|
|
||||||
if err := t.SendKeys(sessionID, claudeCmd); err != nil {
|
|
||||||
fmt.Printf("Error starting claude for %s: %v\n", arg, err)
|
|
||||||
lastErr = err
|
lastErr = err
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for Claude to start (optional, for status feedback)
|
|
||||||
shells := constants.SupportedShells
|
|
||||||
if err := t.WaitForCommand(sessionID, shells, constants.ClaudeStartTimeout); err != nil {
|
|
||||||
style.PrintWarning("Timeout waiting for Claude to start for %s: %v", arg, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("%s Restarted crew workspace: %s/%s\n",
|
fmt.Printf("%s Restarted crew workspace: %s/%s\n",
|
||||||
style.Bold.Render("✓"), r.Name, name)
|
style.Bold.Render("✓"), r.Name, name)
|
||||||
fmt.Printf("Attach with: %s\n", style.Dim.Render(fmt.Sprintf("gt crew at %s", name)))
|
fmt.Printf("Attach with: %s\n", style.Dim.Render(fmt.Sprintf("gt crew at %s", name)))
|
||||||
@@ -519,7 +414,7 @@ func runCrewRestartAll() error {
|
|||||||
savedRig := crewRig
|
savedRig := crewRig
|
||||||
crewRig = agent.Rig
|
crewRig = agent.Rig
|
||||||
|
|
||||||
crewMgr, r, err := getCrewManager(crewRig)
|
crewMgr, _, err := getCrewManager(crewRig)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
failed++
|
failed++
|
||||||
failures = append(failures, fmt.Sprintf("%s: %v", agentName, err))
|
failures = append(failures, fmt.Sprintf("%s: %v", agentName, err))
|
||||||
@@ -528,20 +423,15 @@ func runCrewRestartAll() error {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
worker, err := crewMgr.Get(agent.AgentName)
|
// Use manager's Start() with restart options
|
||||||
|
err = crewMgr.Start(agent.AgentName, crew.StartOptions{
|
||||||
|
KillExisting: true, // Kill old session if running
|
||||||
|
Topic: "restart", // Startup nudge topic
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
failed++
|
failed++
|
||||||
failures = append(failures, fmt.Sprintf("%s: %v", agentName, err))
|
failures = append(failures, fmt.Sprintf("%s: %v", agentName, err))
|
||||||
fmt.Printf(" %s %s\n", style.ErrorPrefix, agentName)
|
fmt.Printf(" %s %s\n", style.ErrorPrefix, agentName)
|
||||||
crewRig = savedRig
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Restart the session
|
|
||||||
if err := restartCrewSession(r.Name, agent.AgentName, worker.ClonePath); err != nil {
|
|
||||||
failed++
|
|
||||||
failures = append(failures, fmt.Sprintf("%s: %v", agentName, err))
|
|
||||||
fmt.Printf(" %s %s\n", style.ErrorPrefix, agentName)
|
|
||||||
} else {
|
} else {
|
||||||
succeeded++
|
succeeded++
|
||||||
fmt.Printf(" %s %s\n", style.SuccessPrefix, agentName)
|
fmt.Printf(" %s %s\n", style.SuccessPrefix, agentName)
|
||||||
@@ -567,62 +457,6 @@ func runCrewRestartAll() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// restartCrewSession handles the core restart logic for a single crew session.
|
|
||||||
func restartCrewSession(rigName, crewName, clonePath string) error {
|
|
||||||
t := tmux.NewTmux()
|
|
||||||
sessionID := crewSessionName(rigName, crewName)
|
|
||||||
|
|
||||||
// Kill existing session if running
|
|
||||||
if hasSession, _ := t.HasSession(sessionID); hasSession {
|
|
||||||
if err := t.KillSession(sessionID); err != nil {
|
|
||||||
return fmt.Errorf("killing old session: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply rig-based theming
|
|
||||||
theme := getThemeForRig(rigName)
|
|
||||||
_ = t.ConfigureGasTownSession(sessionID, theme, rigName, crewName, "crew")
|
|
||||||
|
|
||||||
// Wait for shell to be ready
|
|
||||||
if err := t.WaitForShellReady(sessionID, constants.ShellReadyTimeout); err != nil {
|
|
||||||
return fmt.Errorf("waiting for shell: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build the startup beacon for predecessor discovery via /resume
|
|
||||||
// Pass it as Claude's initial prompt - processed when Claude is ready
|
|
||||||
address := fmt.Sprintf("%s/crew/%s", rigName, crewName)
|
|
||||||
beacon := session.FormatStartupNudge(session.StartupNudgeConfig{
|
|
||||||
Recipient: address,
|
|
||||||
Sender: "human",
|
|
||||||
Topic: "restart",
|
|
||||||
})
|
|
||||||
|
|
||||||
// Start claude with environment exports and beacon as initial prompt
|
|
||||||
// SessionStart hook handles context loading (gt prime --hook)
|
|
||||||
claudeCmd := config.BuildCrewStartupCommand(rigName, crewName, "", beacon)
|
|
||||||
if err := t.SendKeys(sessionID, claudeCmd); err != nil {
|
|
||||||
return fmt.Errorf("starting claude: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wait for Claude to start (optional, for status feedback)
|
|
||||||
shells := constants.SupportedShells
|
|
||||||
if err := t.WaitForCommand(sessionID, shells, constants.ClaudeStartTimeout); err != nil {
|
|
||||||
// Non-fatal warning
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// runCrewStop stops one or more crew workers.
|
// runCrewStop stops one or more crew workers.
|
||||||
// Supports: "name", "rig/name" formats, "rig" (to stop all in rig), or --all.
|
// Supports: "name", "rig/name" formats, "rig" (to stop all in rig), or --all.
|
||||||
func runCrewStop(cmd *cobra.Command, args []string) error {
|
func runCrewStop(cmd *cobra.Command, args []string) error {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package cmd
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -344,8 +345,10 @@ func ensureRefinerySession(rigName string, r *rig.Rig) (bool, error) {
|
|||||||
refineryRigDir = r.Path
|
refineryRigDir = r.Path
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure Claude settings exist (autonomous role needs mail in SessionStart)
|
// Ensure Claude settings exist in refinery/ (not refinery/rig/) so we don't
|
||||||
if err := claude.EnsureSettingsForRole(refineryRigDir, "refinery"); err != nil {
|
// write into the source repo. Claude walks up the tree to find settings.
|
||||||
|
refineryParentDir := filepath.Join(r.Path, "refinery")
|
||||||
|
if err := claude.EnsureSettingsForRole(refineryParentDir, "refinery"); err != nil {
|
||||||
return false, fmt.Errorf("ensuring Claude settings: %w", err)
|
return false, fmt.Errorf("ensuring Claude settings: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -762,25 +765,6 @@ func runStartCrew(cmd *cobra.Command, args []string) error {
|
|||||||
crewGit := git.NewGit(r.Path)
|
crewGit := git.NewGit(r.Path)
|
||||||
crewMgr := crew.NewManager(r, crewGit)
|
crewMgr := crew.NewManager(r, crewGit)
|
||||||
|
|
||||||
// Check if crew exists, create if not
|
|
||||||
worker, err := crewMgr.Get(name)
|
|
||||||
if err == crew.ErrCrewNotFound {
|
|
||||||
fmt.Printf("Creating crew workspace %s in %s...\n", name, rigName)
|
|
||||||
worker, err = crewMgr.Add(name, false) // No feature branch for crew
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("creating crew workspace: %w", err)
|
|
||||||
}
|
|
||||||
fmt.Printf("%s Created crew workspace: %s/%s\n",
|
|
||||||
style.Bold.Render("✓"), rigName, name)
|
|
||||||
} else if err != nil {
|
|
||||||
return fmt.Errorf("getting crew worker: %w", err)
|
|
||||||
} else {
|
|
||||||
fmt.Printf("Crew workspace %s/%s exists\n", rigName, name)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure crew workspace is on default branch
|
|
||||||
ensureDefaultBranch(worker.ClonePath, fmt.Sprintf("Crew workspace %s/%s", rigName, name), r.Path)
|
|
||||||
|
|
||||||
// Resolve account for Claude config
|
// Resolve account for Claude config
|
||||||
accountsPath := constants.MayorAccountsPath(townRoot)
|
accountsPath := constants.MayorAccountsPath(townRoot)
|
||||||
claudeConfigDir, accountHandle, err := config.ResolveAccountConfigDir(accountsPath, startCrewAccount)
|
claudeConfigDir, accountHandle, err := config.ResolveAccountConfigDir(accountsPath, startCrewAccount)
|
||||||
@@ -791,68 +775,18 @@ func runStartCrew(cmd *cobra.Command, args []string) error {
|
|||||||
fmt.Printf("Using account: %s\n", accountHandle)
|
fmt.Printf("Using account: %s\n", accountHandle)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if session exists
|
// Use manager's Start() method - handles workspace creation, settings, and session
|
||||||
t := tmux.NewTmux()
|
err = crewMgr.Start(name, crew.StartOptions{
|
||||||
sessionID := crewSessionName(rigName, name)
|
Account: startCrewAccount,
|
||||||
hasSession, err := t.HasSession(sessionID)
|
ClaudeConfigDir: claudeConfigDir,
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("checking session: %w", err)
|
if errors.Is(err, crew.ErrSessionRunning) {
|
||||||
}
|
fmt.Printf("%s Session already running: %s\n", style.Dim.Render("○"), crewMgr.SessionName(name))
|
||||||
|
|
||||||
if hasSession {
|
|
||||||
// Session exists - check if Claude is still running
|
|
||||||
agentCfg, _, err := config.ResolveAgentConfigWithOverride(townRoot, r.Path, startCrewAgentOverride)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("resolving agent: %w", err)
|
|
||||||
}
|
|
||||||
if !t.IsAgentRunning(sessionID, config.ExpectedPaneCommands(agentCfg)...) {
|
|
||||||
// Claude has exited, restart it with "gt prime" as initial prompt
|
|
||||||
fmt.Printf("Session exists, restarting Claude...\n")
|
|
||||||
startupCmd, err := config.BuildCrewStartupCommandWithAgentOverride(rigName, name, r.Path, "gt prime", startCrewAgentOverride)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("building startup command: %w", err)
|
|
||||||
}
|
|
||||||
if err := t.SendKeys(sessionID, startupCmd); err != nil {
|
|
||||||
return fmt.Errorf("restarting claude: %w", err)
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
fmt.Printf("%s Session already running: %s\n", style.Dim.Render("○"), sessionID)
|
return err
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Create new session
|
|
||||||
if err := t.NewSession(sessionID, worker.ClonePath); err != nil {
|
|
||||||
return fmt.Errorf("creating session: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set environment (non-fatal: session works without these)
|
|
||||||
_ = t.SetEnvironment(sessionID, "GT_RIG", rigName)
|
|
||||||
_ = t.SetEnvironment(sessionID, "GT_CREW", name)
|
|
||||||
|
|
||||||
// Set CLAUDE_CONFIG_DIR for account selection (non-fatal)
|
|
||||||
if claudeConfigDir != "" {
|
|
||||||
_ = t.SetEnvironment(sessionID, "CLAUDE_CONFIG_DIR", claudeConfigDir)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply rig-based theming (non-fatal: theming failure doesn't affect operation)
|
|
||||||
// Note: ConfigureGasTownSession includes cycle bindings
|
|
||||||
theme := getThemeForRig(rigName)
|
|
||||||
_ = t.ConfigureGasTownSession(sessionID, theme, rigName, name, "crew")
|
|
||||||
|
|
||||||
// Wait for shell to be ready after session creation
|
|
||||||
if err := t.WaitForShellReady(sessionID, constants.ShellReadyTimeout); err != nil {
|
|
||||||
return fmt.Errorf("waiting for shell: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start claude with skip permissions and proper env vars for seance
|
|
||||||
// Pass "gt prime" as initial prompt so context is loaded immediately
|
|
||||||
startupCmd, err := config.BuildCrewStartupCommandWithAgentOverride(rigName, name, r.Path, "gt prime", startCrewAgentOverride)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("building startup command: %w", err)
|
|
||||||
}
|
|
||||||
if err := t.SendKeys(sessionID, startupCmd); err != nil {
|
|
||||||
return fmt.Errorf("starting claude: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("%s Started crew workspace: %s/%s\n",
|
fmt.Printf("%s Started crew workspace: %s/%s\n",
|
||||||
style.Bold.Render("✓"), rigName, name)
|
style.Bold.Render("✓"), rigName, name)
|
||||||
}
|
}
|
||||||
@@ -926,54 +860,14 @@ func startCrewMember(rigName, crewName, townRoot string) error {
|
|||||||
return fmt.Errorf("rig '%s' not found", rigName)
|
return fmt.Errorf("rig '%s' not found", rigName)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create crew manager
|
// Create crew manager and use Start() method
|
||||||
crewGit := git.NewGit(r.Path)
|
crewGit := git.NewGit(r.Path)
|
||||||
crewMgr := crew.NewManager(r, crewGit)
|
crewMgr := crew.NewManager(r, crewGit)
|
||||||
|
|
||||||
// Check if crew exists, create if not
|
// Start handles workspace creation, settings, and session all in one
|
||||||
worker, err := crewMgr.Get(crewName)
|
err = crewMgr.Start(crewName, crew.StartOptions{})
|
||||||
if err == crew.ErrCrewNotFound {
|
if err != nil && !errors.Is(err, crew.ErrSessionRunning) {
|
||||||
worker, err = crewMgr.Add(crewName, false)
|
return err
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("creating crew workspace: %w", err)
|
|
||||||
}
|
|
||||||
} else if err != nil {
|
|
||||||
return fmt.Errorf("getting crew worker: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure crew workspace is on default branch
|
|
||||||
ensureDefaultBranch(worker.ClonePath, fmt.Sprintf("Crew workspace %s/%s", rigName, crewName), r.Path)
|
|
||||||
|
|
||||||
// Create tmux session
|
|
||||||
t := tmux.NewTmux()
|
|
||||||
sessionID := crewSessionName(rigName, crewName)
|
|
||||||
|
|
||||||
if err := t.NewSession(sessionID, worker.ClonePath); err != nil {
|
|
||||||
return fmt.Errorf("creating session: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set environment (non-fatal: session works without these)
|
|
||||||
_ = t.SetEnvironment(sessionID, "GT_RIG", rigName)
|
|
||||||
_ = t.SetEnvironment(sessionID, "GT_CREW", crewName)
|
|
||||||
|
|
||||||
// Apply rig-based theming (non-fatal: theming failure doesn't affect operation)
|
|
||||||
theme := getThemeForRig(rigName)
|
|
||||||
_ = t.ConfigureGasTownSession(sessionID, theme, rigName, crewName, "crew")
|
|
||||||
|
|
||||||
// Set up C-b n/p keybindings for crew session cycling (non-fatal)
|
|
||||||
_ = t.SetCrewCycleBindings(sessionID)
|
|
||||||
|
|
||||||
// Wait for shell to be ready
|
|
||||||
if err := t.WaitForShellReady(sessionID, constants.ShellReadyTimeout); err != nil {
|
|
||||||
return fmt.Errorf("waiting for shell: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start claude with proper env vars for seance
|
|
||||||
// Pass "gt prime" as initial prompt so context is loaded immediately
|
|
||||||
// (SessionStart hook fires, then Claude processes "gt prime" as first user message)
|
|
||||||
claudeCmd := config.BuildCrewStartupCommand(rigName, crewName, r.Path, "gt prime")
|
|
||||||
if err := t.SendKeys(sessionID, claudeCmd); err != nil {
|
|
||||||
return fmt.Errorf("starting claude: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -85,16 +85,21 @@ func runUp(cmd *cobra.Command, args []string) error {
|
|||||||
deaconSession := getDeaconSessionName()
|
deaconSession := getDeaconSessionName()
|
||||||
mayorSession := getMayorSessionName()
|
mayorSession := getMayorSessionName()
|
||||||
|
|
||||||
// 2. Deacon (Claude agent)
|
// 2. Deacon (Claude agent) - runs from townRoot/deacon/
|
||||||
if err := ensureSession(t, deaconSession, townRoot, "deacon"); err != nil {
|
deaconDir := filepath.Join(townRoot, "deacon")
|
||||||
|
if err := ensureSession(t, deaconSession, deaconDir, "deacon"); err != nil {
|
||||||
printStatus("Deacon", false, err.Error())
|
printStatus("Deacon", false, err.Error())
|
||||||
allOK = false
|
allOK = false
|
||||||
} else {
|
} else {
|
||||||
printStatus("Deacon", true, deaconSession)
|
printStatus("Deacon", true, deaconSession)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Mayor (Claude agent)
|
// 3. Mayor (Claude agent) - runs from townRoot/mayor/
|
||||||
if err := ensureSession(t, mayorSession, townRoot, "mayor"); err != nil {
|
// IMPORTANT: Both settings.json and CLAUDE.md must be in ~/gt/mayor/, NOT ~/gt/
|
||||||
|
// Files at town root would be inherited by ALL agents via directory traversal,
|
||||||
|
// causing crew/polecat/etc to receive Mayor-specific context.
|
||||||
|
mayorDir := filepath.Join(townRoot, "mayor")
|
||||||
|
if err := ensureSession(t, mayorSession, mayorDir, "mayor"); err != nil {
|
||||||
printStatus("Mayor", false, err.Error())
|
printStatus("Mayor", false, err.Error())
|
||||||
allOK = false
|
allOK = false
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -11,8 +11,13 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/steveyegge/gastown/internal/beads"
|
"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/git"
|
"github.com/steveyegge/gastown/internal/git"
|
||||||
"github.com/steveyegge/gastown/internal/rig"
|
"github.com/steveyegge/gastown/internal/rig"
|
||||||
|
"github.com/steveyegge/gastown/internal/session"
|
||||||
|
"github.com/steveyegge/gastown/internal/tmux"
|
||||||
"github.com/steveyegge/gastown/internal/util"
|
"github.com/steveyegge/gastown/internal/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -22,8 +27,31 @@ var (
|
|||||||
ErrCrewNotFound = errors.New("crew worker not found")
|
ErrCrewNotFound = errors.New("crew worker not found")
|
||||||
ErrHasChanges = errors.New("crew worker has uncommitted changes")
|
ErrHasChanges = errors.New("crew worker has uncommitted changes")
|
||||||
ErrInvalidCrewName = errors.New("invalid crew name")
|
ErrInvalidCrewName = errors.New("invalid crew name")
|
||||||
|
ErrSessionRunning = errors.New("session already running")
|
||||||
|
ErrSessionNotFound = errors.New("session not found")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// StartOptions configures crew session startup.
|
||||||
|
type StartOptions struct {
|
||||||
|
// Account specifies the account handle to use (overrides default).
|
||||||
|
Account string
|
||||||
|
|
||||||
|
// ClaudeConfigDir is resolved CLAUDE_CONFIG_DIR for the account.
|
||||||
|
// If set, this is injected as an environment variable.
|
||||||
|
ClaudeConfigDir string
|
||||||
|
|
||||||
|
// KillExisting kills any existing session before starting (for restart operations).
|
||||||
|
// If false and a session is running, Start() returns ErrSessionRunning.
|
||||||
|
KillExisting bool
|
||||||
|
|
||||||
|
// Topic is the startup nudge topic (e.g., "start", "restart", "refresh").
|
||||||
|
// Defaults to "start" if empty.
|
||||||
|
Topic string
|
||||||
|
|
||||||
|
// Interactive removes --dangerously-skip-permissions for interactive/refresh mode.
|
||||||
|
Interactive bool
|
||||||
|
}
|
||||||
|
|
||||||
// validateCrewName checks that a crew name is safe and valid.
|
// validateCrewName checks that a crew name is safe and valid.
|
||||||
// Rejects path traversal attempts and characters that break agent ID parsing.
|
// Rejects path traversal attempts and characters that break agent ID parsing.
|
||||||
func validateCrewName(name string) error {
|
func validateCrewName(name string) error {
|
||||||
@@ -386,3 +414,152 @@ func (m *Manager) setupSharedBeads(crewPath string) error {
|
|||||||
townRoot := filepath.Dir(m.rig.Path)
|
townRoot := filepath.Dir(m.rig.Path)
|
||||||
return beads.SetupRedirect(townRoot, crewPath)
|
return beads.SetupRedirect(townRoot, crewPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SessionName returns the tmux session name for a crew member.
|
||||||
|
func (m *Manager) SessionName(name string) string {
|
||||||
|
return fmt.Sprintf("gt-%s-crew-%s", m.rig.Name, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start creates and starts a tmux session for a crew member.
|
||||||
|
// If the crew member doesn't exist, it will be created first.
|
||||||
|
func (m *Manager) Start(name string, opts StartOptions) error {
|
||||||
|
if err := validateCrewName(name); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get or create the crew worker
|
||||||
|
worker, err := m.Get(name)
|
||||||
|
if err == ErrCrewNotFound {
|
||||||
|
worker, err = m.Add(name, false) // No feature branch for crew
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("creating crew workspace: %w", err)
|
||||||
|
}
|
||||||
|
} else if err != nil {
|
||||||
|
return fmt.Errorf("getting crew worker: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t := tmux.NewTmux()
|
||||||
|
sessionID := m.SessionName(name)
|
||||||
|
|
||||||
|
// Check if session already exists
|
||||||
|
running, err := t.HasSession(sessionID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("checking session: %w", err)
|
||||||
|
}
|
||||||
|
if running {
|
||||||
|
if opts.KillExisting {
|
||||||
|
// Restart mode - kill existing session
|
||||||
|
if err := t.KillSession(sessionID); err != nil {
|
||||||
|
return fmt.Errorf("killing existing session: %w", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Normal start - session exists, check if Claude is actually running
|
||||||
|
if t.IsClaudeRunning(sessionID) {
|
||||||
|
return fmt.Errorf("%w: %s", ErrSessionRunning, sessionID)
|
||||||
|
}
|
||||||
|
// Zombie session - kill and recreate
|
||||||
|
if err := t.KillSession(sessionID); err != nil {
|
||||||
|
return fmt.Errorf("killing zombie session: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure Claude settings exist in crew/ (not crew/<name>/) so we don't
|
||||||
|
// write into the source repo. Claude walks up the tree to find settings.
|
||||||
|
// All crew members share the same settings file.
|
||||||
|
crewBaseDir := filepath.Join(m.rig.Path, "crew")
|
||||||
|
if err := claude.EnsureSettingsForRole(crewBaseDir, "crew"); err != nil {
|
||||||
|
return fmt.Errorf("ensuring Claude settings: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create tmux session
|
||||||
|
if err := t.NewSession(sessionID, worker.ClonePath); err != nil {
|
||||||
|
return fmt.Errorf("creating session: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set environment variables (non-fatal: session works without these)
|
||||||
|
_ = t.SetEnvironment(sessionID, "GT_RIG", m.rig.Name)
|
||||||
|
_ = t.SetEnvironment(sessionID, "GT_CREW", name)
|
||||||
|
_ = t.SetEnvironment(sessionID, "GT_ROLE", "crew")
|
||||||
|
|
||||||
|
// Set CLAUDE_CONFIG_DIR for account selection (non-fatal)
|
||||||
|
if opts.ClaudeConfigDir != "" {
|
||||||
|
_ = t.SetEnvironment(sessionID, "CLAUDE_CONFIG_DIR", opts.ClaudeConfigDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply rig-based theming (non-fatal: theming failure doesn't affect operation)
|
||||||
|
theme := tmux.AssignTheme(m.rig.Name)
|
||||||
|
_ = t.ConfigureGasTownSession(sessionID, theme, m.rig.Name, name, "crew")
|
||||||
|
|
||||||
|
// Set up C-b n/p keybindings for crew session cycling (non-fatal)
|
||||||
|
_ = t.SetCrewCycleBindings(sessionID)
|
||||||
|
|
||||||
|
// Wait for shell to be ready
|
||||||
|
if err := t.WaitForShellReady(sessionID, constants.ShellReadyTimeout); err != nil {
|
||||||
|
return fmt.Errorf("waiting for shell: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the startup beacon for predecessor discovery via /resume
|
||||||
|
// Pass it as Claude's initial prompt - processed when Claude is ready
|
||||||
|
address := fmt.Sprintf("%s/crew/%s", m.rig.Name, name)
|
||||||
|
topic := opts.Topic
|
||||||
|
if topic == "" {
|
||||||
|
topic = "start"
|
||||||
|
}
|
||||||
|
beacon := session.FormatStartupNudge(session.StartupNudgeConfig{
|
||||||
|
Recipient: address,
|
||||||
|
Sender: "human",
|
||||||
|
Topic: topic,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Start claude with environment exports and beacon as initial prompt
|
||||||
|
// SessionStart hook handles context loading (gt prime --hook)
|
||||||
|
claudeCmd := config.BuildCrewStartupCommand(m.rig.Name, name, m.rig.Path, beacon)
|
||||||
|
|
||||||
|
// For interactive/refresh mode, remove --dangerously-skip-permissions
|
||||||
|
if opts.Interactive {
|
||||||
|
claudeCmd = strings.Replace(claudeCmd, " --dangerously-skip-permissions", "", 1)
|
||||||
|
}
|
||||||
|
if err := t.SendKeys(sessionID, claudeCmd); err != nil {
|
||||||
|
_ = t.KillSession(sessionID) // best-effort cleanup
|
||||||
|
return fmt.Errorf("starting claude: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for Claude to start (non-fatal: session continues even if this times out)
|
||||||
|
_ = t.WaitForCommand(sessionID, constants.SupportedShells, constants.ClaudeStartTimeout)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop terminates a crew member's tmux session.
|
||||||
|
func (m *Manager) Stop(name string) error {
|
||||||
|
if err := validateCrewName(name); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
t := tmux.NewTmux()
|
||||||
|
sessionID := m.SessionName(name)
|
||||||
|
|
||||||
|
// Check if session exists
|
||||||
|
running, err := t.HasSession(sessionID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("checking session: %w", err)
|
||||||
|
}
|
||||||
|
if !running {
|
||||||
|
return ErrSessionNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kill the session
|
||||||
|
if err := t.KillSession(sessionID); err != nil {
|
||||||
|
return fmt.Errorf("killing session: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsRunning checks if a crew member's session is active.
|
||||||
|
func (m *Manager) IsRunning(name string) (bool, error) {
|
||||||
|
t := tmux.NewTmux()
|
||||||
|
sessionID := m.SessionName(name)
|
||||||
|
return t.HasSession(sessionID)
|
||||||
|
}
|
||||||
|
|||||||
@@ -400,7 +400,7 @@ func (d *Daemon) ensureWitnessesRunning() {
|
|||||||
// ensureWitnessRunning ensures the witness for a specific rig is running.
|
// ensureWitnessRunning ensures the witness for a specific rig is running.
|
||||||
// Discover, don't track: uses Manager.Start() which checks tmux directly (gt-zecmc).
|
// Discover, don't track: uses Manager.Start() which checks tmux directly (gt-zecmc).
|
||||||
func (d *Daemon) ensureWitnessRunning(rigName string) {
|
func (d *Daemon) ensureWitnessRunning(rigName string) {
|
||||||
// Check rig operational state before auto-starting
|
// Check rig operational state before auto-starting
|
||||||
if operational, reason := d.isRigOperational(rigName); !operational {
|
if operational, reason := d.isRigOperational(rigName); !operational {
|
||||||
d.logger.Printf("Skipping witness auto-start for %s: %s", rigName, reason)
|
d.logger.Printf("Skipping witness auto-start for %s: %s", rigName, reason)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -4,11 +4,25 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/steveyegge/gastown/internal/claude"
|
"github.com/steveyegge/gastown/internal/claude"
|
||||||
|
"github.com/steveyegge/gastown/internal/session"
|
||||||
|
"github.com/steveyegge/gastown/internal/templates"
|
||||||
"github.com/steveyegge/gastown/internal/tmux"
|
"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.
|
// ClaudeSettingsCheck verifies that Claude settings.json files match the expected templates.
|
||||||
@@ -19,12 +33,13 @@ type ClaudeSettingsCheck struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type staleSettingsInfo struct {
|
type staleSettingsInfo struct {
|
||||||
path string // Full path to settings.json
|
path string // Full path to settings.json
|
||||||
agentType string // e.g., "witness", "refinery", "deacon", "mayor"
|
agentType string // e.g., "witness", "refinery", "deacon", "mayor"
|
||||||
rigName string // Rig name (empty for town-level agents)
|
rigName string // Rig name (empty for town-level agents)
|
||||||
sessionName string // tmux session name for cycling
|
sessionName string // tmux session name for cycling
|
||||||
missing []string // What's missing from the settings
|
missing []string // What's missing from the settings
|
||||||
wrongLocation bool // True if file is in wrong location (should be deleted)
|
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.
|
// NewClaudeSettingsCheck creates a new Claude settings validation check.
|
||||||
@@ -44,6 +59,7 @@ func (c *ClaudeSettingsCheck) Run(ctx *CheckContext) *CheckResult {
|
|||||||
c.staleSettings = nil
|
c.staleSettings = nil
|
||||||
|
|
||||||
var details []string
|
var details []string
|
||||||
|
var hasModifiedFiles bool
|
||||||
|
|
||||||
// Find all settings.json files
|
// Find all settings.json files
|
||||||
settingsFiles := c.findSettingsFiles(ctx.TownRoot)
|
settingsFiles := c.findSettingsFiles(ctx.TownRoot)
|
||||||
@@ -51,8 +67,24 @@ func (c *ClaudeSettingsCheck) Run(ctx *CheckContext) *CheckResult {
|
|||||||
for _, sf := range settingsFiles {
|
for _, sf := range settingsFiles {
|
||||||
// Files in wrong locations are always stale (should be deleted)
|
// Files in wrong locations are always stale (should be deleted)
|
||||||
if sf.wrongLocation {
|
if sf.wrongLocation {
|
||||||
|
// Check git status to determine safe deletion strategy
|
||||||
|
sf.gitStatus = c.getGitFileStatus(sf.path)
|
||||||
c.staleSettings = append(c.staleSettings, sf)
|
c.staleSettings = append(c.staleSettings, sf)
|
||||||
details = append(details, fmt.Sprintf("%s: wrong location (should be in rig/ subdirectory)", sf.path))
|
|
||||||
|
// 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
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,12 +105,17 @@ func (c *ClaudeSettingsCheck) Run(ctx *CheckContext) *CheckResult {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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{
|
return &CheckResult{
|
||||||
Name: c.Name(),
|
Name: c.Name(),
|
||||||
Status: StatusError,
|
Status: StatusError,
|
||||||
Message: fmt.Sprintf("Found %d stale Claude settings.json file(s)", len(c.staleSettings)),
|
Message: fmt.Sprintf("Found %d stale Claude config file(s) in wrong location", len(c.staleSettings)),
|
||||||
Details: details,
|
Details: details,
|
||||||
FixHint: "Run 'gt doctor --fix' to update settings and restart affected agents",
|
FixHint: fixHint,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,13 +123,44 @@ func (c *ClaudeSettingsCheck) Run(ctx *CheckContext) *CheckResult {
|
|||||||
func (c *ClaudeSettingsCheck) findSettingsFiles(townRoot string) []staleSettingsInfo {
|
func (c *ClaudeSettingsCheck) findSettingsFiles(townRoot string) []staleSettingsInfo {
|
||||||
var files []staleSettingsInfo
|
var files []staleSettingsInfo
|
||||||
|
|
||||||
// Town-level: mayor (~/gt/.claude/settings.json)
|
// Check for STALE settings at town root (~/gt/.claude/settings.json)
|
||||||
mayorSettings := filepath.Join(townRoot, ".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) {
|
if fileExists(mayorSettings) {
|
||||||
files = append(files, staleSettingsInfo{
|
files = append(files, staleSettingsInfo{
|
||||||
path: mayorSettings,
|
path: mayorSettings,
|
||||||
agentType: "mayor",
|
agentType: "mayor",
|
||||||
sessionName: "gt-mayor",
|
sessionName: "hq-mayor",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,7 +170,7 @@ func (c *ClaudeSettingsCheck) findSettingsFiles(townRoot string) []staleSettings
|
|||||||
files = append(files, staleSettingsInfo{
|
files = append(files, staleSettingsInfo{
|
||||||
path: deaconSettings,
|
path: deaconSettings,
|
||||||
agentType: "deacon",
|
agentType: "deacon",
|
||||||
sessionName: "gt-deacon",
|
sessionName: "hq-deacon",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,18 +194,18 @@ func (c *ClaudeSettingsCheck) findSettingsFiles(townRoot string) []staleSettings
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for witness settings - rig/ is correct location, without rig/ is wrong
|
// Check for witness settings - witness/.claude/ is correct (outside git repo)
|
||||||
witnessRigSettings := filepath.Join(rigPath, "witness", "rig", ".claude", "settings.json")
|
// Settings in witness/rig/.claude/ are wrong (inside source repo)
|
||||||
if fileExists(witnessRigSettings) {
|
witnessSettings := filepath.Join(rigPath, "witness", ".claude", "settings.json")
|
||||||
|
if fileExists(witnessSettings) {
|
||||||
files = append(files, staleSettingsInfo{
|
files = append(files, staleSettingsInfo{
|
||||||
path: witnessRigSettings,
|
path: witnessSettings,
|
||||||
agentType: "witness",
|
agentType: "witness",
|
||||||
rigName: rigName,
|
rigName: rigName,
|
||||||
sessionName: fmt.Sprintf("gt-%s-witness", 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", "rig", ".claude", "settings.json")
|
||||||
witnessWrongSettings := filepath.Join(rigPath, "witness", ".claude", "settings.json")
|
|
||||||
if fileExists(witnessWrongSettings) {
|
if fileExists(witnessWrongSettings) {
|
||||||
files = append(files, staleSettingsInfo{
|
files = append(files, staleSettingsInfo{
|
||||||
path: witnessWrongSettings,
|
path: witnessWrongSettings,
|
||||||
@@ -148,18 +216,18 @@ func (c *ClaudeSettingsCheck) findSettingsFiles(townRoot string) []staleSettings
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for refinery settings - rig/ is correct location, without rig/ is wrong
|
// Check for refinery settings - refinery/.claude/ is correct (outside git repo)
|
||||||
refineryRigSettings := filepath.Join(rigPath, "refinery", "rig", ".claude", "settings.json")
|
// Settings in refinery/rig/.claude/ are wrong (inside source repo)
|
||||||
if fileExists(refineryRigSettings) {
|
refinerySettings := filepath.Join(rigPath, "refinery", ".claude", "settings.json")
|
||||||
|
if fileExists(refinerySettings) {
|
||||||
files = append(files, staleSettingsInfo{
|
files = append(files, staleSettingsInfo{
|
||||||
path: refineryRigSettings,
|
path: refinerySettings,
|
||||||
agentType: "refinery",
|
agentType: "refinery",
|
||||||
rigName: rigName,
|
rigName: rigName,
|
||||||
sessionName: fmt.Sprintf("gt-%s-refinery", 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", "rig", ".claude", "settings.json")
|
||||||
refineryWrongSettings := filepath.Join(rigPath, "refinery", ".claude", "settings.json")
|
|
||||||
if fileExists(refineryWrongSettings) {
|
if fileExists(refineryWrongSettings) {
|
||||||
files = append(files, staleSettingsInfo{
|
files = append(files, staleSettingsInfo{
|
||||||
path: refineryWrongSettings,
|
path: refineryWrongSettings,
|
||||||
@@ -170,41 +238,63 @@ func (c *ClaudeSettingsCheck) findSettingsFiles(townRoot string) []staleSettings
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for crew settings (crew/<name>/.claude/)
|
// 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")
|
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) {
|
if dirExists(crewDir) {
|
||||||
crewEntries, _ := os.ReadDir(crewDir)
|
crewEntries, _ := os.ReadDir(crewDir)
|
||||||
for _, crewEntry := range crewEntries {
|
for _, crewEntry := range crewEntries {
|
||||||
if !crewEntry.IsDir() {
|
if !crewEntry.IsDir() || crewEntry.Name() == ".claude" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
crewSettings := filepath.Join(crewDir, crewEntry.Name(), ".claude", "settings.json")
|
crewWrongSettings := filepath.Join(crewDir, crewEntry.Name(), ".claude", "settings.json")
|
||||||
if fileExists(crewSettings) {
|
if fileExists(crewWrongSettings) {
|
||||||
files = append(files, staleSettingsInfo{
|
files = append(files, staleSettingsInfo{
|
||||||
path: crewSettings,
|
path: crewWrongSettings,
|
||||||
agentType: "crew",
|
agentType: "crew",
|
||||||
rigName: rigName,
|
rigName: rigName,
|
||||||
sessionName: fmt.Sprintf("gt-%s-crew-%s", rigName, crewEntry.Name()),
|
sessionName: fmt.Sprintf("gt-%s-crew-%s", rigName, crewEntry.Name()),
|
||||||
|
wrongLocation: true,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for polecat settings (polecats/<name>/.claude/)
|
// 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")
|
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) {
|
if dirExists(polecatsDir) {
|
||||||
polecatEntries, _ := os.ReadDir(polecatsDir)
|
polecatEntries, _ := os.ReadDir(polecatsDir)
|
||||||
for _, pcEntry := range polecatEntries {
|
for _, pcEntry := range polecatEntries {
|
||||||
if !pcEntry.IsDir() {
|
if !pcEntry.IsDir() || pcEntry.Name() == ".claude" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
pcSettings := filepath.Join(polecatsDir, pcEntry.Name(), ".claude", "settings.json")
|
pcWrongSettings := filepath.Join(polecatsDir, pcEntry.Name(), ".claude", "settings.json")
|
||||||
if fileExists(pcSettings) {
|
if fileExists(pcWrongSettings) {
|
||||||
files = append(files, staleSettingsInfo{
|
files = append(files, staleSettingsInfo{
|
||||||
path: pcSettings,
|
path: pcWrongSettings,
|
||||||
agentType: "polecat",
|
agentType: "polecat",
|
||||||
rigName: rigName,
|
rigName: rigName,
|
||||||
sessionName: fmt.Sprintf("gt-%s-polecat-%s", rigName, pcEntry.Name()),
|
sessionName: fmt.Sprintf("gt-%s-%s", rigName, pcEntry.Name()),
|
||||||
|
wrongLocation: true,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -267,6 +357,46 @@ func (c *ClaudeSettingsCheck) checkSettings(path, _ string) []string {
|
|||||||
return missing
|
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.
|
// hookHasPattern checks if a hook contains a specific pattern.
|
||||||
func (c *ClaudeSettingsCheck) hookHasPattern(hooks map[string]any, hookName, pattern string) bool {
|
func (c *ClaudeSettingsCheck) hookHasPattern(hooks map[string]any, hookName, pattern string) bool {
|
||||||
hookList, ok := hooks[hookName].([]any)
|
hookList, ok := hooks[hookName].([]any)
|
||||||
@@ -298,11 +428,19 @@ func (c *ClaudeSettingsCheck) hookHasPattern(hooks map[string]any, hookName, pat
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Fix deletes stale settings files and restarts affected agents.
|
// 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 {
|
func (c *ClaudeSettingsCheck) Fix(ctx *CheckContext) error {
|
||||||
var errors []string
|
var errors []string
|
||||||
|
var skipped []string
|
||||||
t := tmux.NewTmux()
|
t := tmux.NewTmux()
|
||||||
|
|
||||||
for _, sf := range c.staleSettings {
|
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
|
// Delete the stale settings file
|
||||||
if err := os.Remove(sf.path); err != nil {
|
if err := os.Remove(sf.path); err != nil {
|
||||||
errors = append(errors, fmt.Sprintf("failed to delete %s: %v", sf.path, err))
|
errors = append(errors, fmt.Sprintf("failed to delete %s: %v", sf.path, err))
|
||||||
@@ -313,9 +451,40 @@ func (c *ClaudeSettingsCheck) Fix(ctx *CheckContext) error {
|
|||||||
claudeDir := filepath.Dir(sf.path)
|
claudeDir := filepath.Dir(sf.path)
|
||||||
_ = os.Remove(claudeDir) // Best-effort, will fail if not empty
|
_ = os.Remove(claudeDir) // Best-effort, will fail if not empty
|
||||||
|
|
||||||
// For files in wrong locations, just delete - don't recreate
|
// For files in wrong locations, delete and create at correct location
|
||||||
// The correct location will get settings when the agent starts
|
|
||||||
if sf.wrongLocation {
|
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.
|
||||||
|
// Cycle all Gas Town sessions so they pick up the corrected file locations.
|
||||||
|
// This includes gt-* (rig agents) and hq-* (mayor, deacon).
|
||||||
|
sessions, _ := t.ListSessions()
|
||||||
|
for _, sess := range sessions {
|
||||||
|
if strings.HasPrefix(sess, session.Prefix) || strings.HasPrefix(sess, session.HQPrefix) {
|
||||||
|
_ = t.KillSession(sess)
|
||||||
|
}
|
||||||
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -335,6 +504,13 @@ func (c *ClaudeSettingsCheck) Fix(ctx *CheckContext) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 {
|
if len(errors) > 0 {
|
||||||
return fmt.Errorf("%s", strings.Join(errors, "; "))
|
return fmt.Errorf("%s", strings.Join(errors, "; "))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package doctor
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -213,8 +214,8 @@ func TestClaudeSettingsCheck_ValidWitnessSettings(t *testing.T) {
|
|||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
rigName := "testrig"
|
rigName := "testrig"
|
||||||
|
|
||||||
// Create valid witness settings in correct location (rig/.claude/)
|
// Create valid witness settings in correct location (witness/.claude/, outside git repo)
|
||||||
witnessSettings := filepath.Join(tmpDir, rigName, "witness", "rig", ".claude", "settings.json")
|
witnessSettings := filepath.Join(tmpDir, rigName, "witness", ".claude", "settings.json")
|
||||||
createValidSettings(t, witnessSettings)
|
createValidSettings(t, witnessSettings)
|
||||||
|
|
||||||
check := NewClaudeSettingsCheck()
|
check := NewClaudeSettingsCheck()
|
||||||
@@ -231,8 +232,8 @@ func TestClaudeSettingsCheck_ValidRefinerySettings(t *testing.T) {
|
|||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
rigName := "testrig"
|
rigName := "testrig"
|
||||||
|
|
||||||
// Create valid refinery settings in correct location
|
// Create valid refinery settings in correct location (refinery/.claude/, outside git repo)
|
||||||
refinerySettings := filepath.Join(tmpDir, rigName, "refinery", "rig", ".claude", "settings.json")
|
refinerySettings := filepath.Join(tmpDir, rigName, "refinery", ".claude", "settings.json")
|
||||||
createValidSettings(t, refinerySettings)
|
createValidSettings(t, refinerySettings)
|
||||||
|
|
||||||
check := NewClaudeSettingsCheck()
|
check := NewClaudeSettingsCheck()
|
||||||
@@ -249,8 +250,8 @@ func TestClaudeSettingsCheck_ValidCrewSettings(t *testing.T) {
|
|||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
rigName := "testrig"
|
rigName := "testrig"
|
||||||
|
|
||||||
// Create valid crew agent settings
|
// Create valid crew settings in correct location (crew/.claude/, shared by all crew)
|
||||||
crewSettings := filepath.Join(tmpDir, rigName, "crew", "agent1", ".claude", "settings.json")
|
crewSettings := filepath.Join(tmpDir, rigName, "crew", ".claude", "settings.json")
|
||||||
createValidSettings(t, crewSettings)
|
createValidSettings(t, crewSettings)
|
||||||
|
|
||||||
check := NewClaudeSettingsCheck()
|
check := NewClaudeSettingsCheck()
|
||||||
@@ -267,8 +268,8 @@ func TestClaudeSettingsCheck_ValidPolecatSettings(t *testing.T) {
|
|||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
rigName := "testrig"
|
rigName := "testrig"
|
||||||
|
|
||||||
// Create valid polecat settings
|
// Create valid polecat settings in correct location (polecats/.claude/, shared by all polecats)
|
||||||
pcSettings := filepath.Join(tmpDir, rigName, "polecats", "pc1", ".claude", "settings.json")
|
pcSettings := filepath.Join(tmpDir, rigName, "polecats", ".claude", "settings.json")
|
||||||
createValidSettings(t, pcSettings)
|
createValidSettings(t, pcSettings)
|
||||||
|
|
||||||
check := NewClaudeSettingsCheck()
|
check := NewClaudeSettingsCheck()
|
||||||
@@ -403,8 +404,9 @@ func TestClaudeSettingsCheck_WrongLocationWitness(t *testing.T) {
|
|||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
rigName := "testrig"
|
rigName := "testrig"
|
||||||
|
|
||||||
// Create settings in wrong location (witness/.claude/ instead of witness/rig/.claude/)
|
// Create settings in wrong location (witness/rig/.claude/ instead of witness/.claude/)
|
||||||
wrongSettings := filepath.Join(tmpDir, rigName, "witness", ".claude", "settings.json")
|
// Settings inside git repos should be flagged as wrong location
|
||||||
|
wrongSettings := filepath.Join(tmpDir, rigName, "witness", "rig", ".claude", "settings.json")
|
||||||
createValidSettings(t, wrongSettings)
|
createValidSettings(t, wrongSettings)
|
||||||
|
|
||||||
check := NewClaudeSettingsCheck()
|
check := NewClaudeSettingsCheck()
|
||||||
@@ -431,8 +433,9 @@ func TestClaudeSettingsCheck_WrongLocationRefinery(t *testing.T) {
|
|||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
rigName := "testrig"
|
rigName := "testrig"
|
||||||
|
|
||||||
// Create settings in wrong location (refinery/.claude/ instead of refinery/rig/.claude/)
|
// Create settings in wrong location (refinery/rig/.claude/ instead of refinery/.claude/)
|
||||||
wrongSettings := filepath.Join(tmpDir, rigName, "refinery", ".claude", "settings.json")
|
// Settings inside git repos should be flagged as wrong location
|
||||||
|
wrongSettings := filepath.Join(tmpDir, rigName, "refinery", "rig", ".claude", "settings.json")
|
||||||
createValidSettings(t, wrongSettings)
|
createValidSettings(t, wrongSettings)
|
||||||
|
|
||||||
check := NewClaudeSettingsCheck()
|
check := NewClaudeSettingsCheck()
|
||||||
@@ -466,7 +469,8 @@ func TestClaudeSettingsCheck_MultipleStaleFiles(t *testing.T) {
|
|||||||
deaconSettings := filepath.Join(tmpDir, "deacon", ".claude", "settings.json")
|
deaconSettings := filepath.Join(tmpDir, "deacon", ".claude", "settings.json")
|
||||||
createStaleSettings(t, deaconSettings, "Stop")
|
createStaleSettings(t, deaconSettings, "Stop")
|
||||||
|
|
||||||
witnessWrong := filepath.Join(tmpDir, rigName, "witness", ".claude", "settings.json")
|
// Settings inside git repo (witness/rig/.claude/) are wrong location
|
||||||
|
witnessWrong := filepath.Join(tmpDir, rigName, "witness", "rig", ".claude", "settings.json")
|
||||||
createValidSettings(t, witnessWrong) // Valid content but wrong location
|
createValidSettings(t, witnessWrong) // Valid content but wrong location
|
||||||
|
|
||||||
check := NewClaudeSettingsCheck()
|
check := NewClaudeSettingsCheck()
|
||||||
@@ -517,9 +521,9 @@ func TestClaudeSettingsCheck_InvalidJSON(t *testing.T) {
|
|||||||
func TestClaudeSettingsCheck_FixDeletesStaleFile(t *testing.T) {
|
func TestClaudeSettingsCheck_FixDeletesStaleFile(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
// Create stale settings in wrong location (easy to test - just delete, no recreate)
|
// Create stale settings in wrong location (inside git repo - easy to test - just delete, no recreate)
|
||||||
rigName := "testrig"
|
rigName := "testrig"
|
||||||
wrongSettings := filepath.Join(tmpDir, rigName, "witness", ".claude", "settings.json")
|
wrongSettings := filepath.Join(tmpDir, rigName, "witness", "rig", ".claude", "settings.json")
|
||||||
createValidSettings(t, wrongSettings)
|
createValidSettings(t, wrongSettings)
|
||||||
|
|
||||||
check := NewClaudeSettingsCheck()
|
check := NewClaudeSettingsCheck()
|
||||||
@@ -587,12 +591,12 @@ func TestClaudeSettingsCheck_MixedValidAndStale(t *testing.T) {
|
|||||||
mayorSettings := filepath.Join(tmpDir, ".claude", "settings.json")
|
mayorSettings := filepath.Join(tmpDir, ".claude", "settings.json")
|
||||||
createValidSettings(t, mayorSettings)
|
createValidSettings(t, mayorSettings)
|
||||||
|
|
||||||
// Create stale witness settings (missing PATH)
|
// Create stale witness settings in correct location (missing PATH)
|
||||||
witnessSettings := filepath.Join(tmpDir, rigName, "witness", "rig", ".claude", "settings.json")
|
witnessSettings := filepath.Join(tmpDir, rigName, "witness", ".claude", "settings.json")
|
||||||
createStaleSettings(t, witnessSettings, "PATH")
|
createStaleSettings(t, witnessSettings, "PATH")
|
||||||
|
|
||||||
// Create valid refinery settings
|
// Create valid refinery settings in correct location
|
||||||
refinerySettings := filepath.Join(tmpDir, rigName, "refinery", "rig", ".claude", "settings.json")
|
refinerySettings := filepath.Join(tmpDir, rigName, "refinery", ".claude", "settings.json")
|
||||||
createValidSettings(t, refinerySettings)
|
createValidSettings(t, refinerySettings)
|
||||||
|
|
||||||
check := NewClaudeSettingsCheck()
|
check := NewClaudeSettingsCheck()
|
||||||
@@ -611,3 +615,403 @@ func TestClaudeSettingsCheck_MixedValidAndStale(t *testing.T) {
|
|||||||
t.Errorf("expected 1 detail, got %d: %v", len(result.Details), result.Details)
|
t.Errorf("expected 1 detail, got %d: %v", len(result.Details), result.Details)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestClaudeSettingsCheck_WrongLocationCrew(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
rigName := "testrig"
|
||||||
|
|
||||||
|
// Create settings in wrong location (crew/<name>/.claude/ instead of crew/.claude/)
|
||||||
|
// Settings inside git repos should be flagged as wrong location
|
||||||
|
wrongSettings := filepath.Join(tmpDir, rigName, "crew", "agent1", ".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_WrongLocationPolecat(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
rigName := "testrig"
|
||||||
|
|
||||||
|
// Create settings in wrong location (polecats/<name>/.claude/ instead of polecats/.claude/)
|
||||||
|
// Settings inside git repos should be flagged as wrong location
|
||||||
|
wrongSettings := filepath.Join(tmpDir, rigName, "polecats", "pc1", ".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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// initTestGitRepo initializes a git repo in the given directory for settings tests.
|
||||||
|
func initTestGitRepo(t *testing.T, dir string) {
|
||||||
|
t.Helper()
|
||||||
|
cmds := [][]string{
|
||||||
|
{"git", "init"},
|
||||||
|
{"git", "config", "user.email", "test@test.com"},
|
||||||
|
{"git", "config", "user.name", "Test User"},
|
||||||
|
}
|
||||||
|
for _, args := range cmds {
|
||||||
|
cmd := exec.Command(args[0], args[1:]...)
|
||||||
|
cmd.Dir = dir
|
||||||
|
if out, err := cmd.CombinedOutput(); err != nil {
|
||||||
|
t.Fatalf("git command %v failed: %v\n%s", args, err, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// gitAddAndCommit adds and commits a file.
|
||||||
|
func gitAddAndCommit(t *testing.T, repoDir, filePath string) {
|
||||||
|
t.Helper()
|
||||||
|
// Get relative path from repo root
|
||||||
|
relPath, err := filepath.Rel(repoDir, filePath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cmds := [][]string{
|
||||||
|
{"git", "add", relPath},
|
||||||
|
{"git", "commit", "-m", "Add file"},
|
||||||
|
}
|
||||||
|
for _, args := range cmds {
|
||||||
|
cmd := exec.Command(args[0], args[1:]...)
|
||||||
|
cmd.Dir = repoDir
|
||||||
|
if out, err := cmd.CombinedOutput(); err != nil {
|
||||||
|
t.Fatalf("git command %v failed: %v\n%s", args, err, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClaudeSettingsCheck_GitStatusUntracked(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
rigName := "testrig"
|
||||||
|
|
||||||
|
// Create a git repo to simulate a source repo
|
||||||
|
rigDir := filepath.Join(tmpDir, rigName, "witness", "rig")
|
||||||
|
if err := os.MkdirAll(rigDir, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
initTestGitRepo(t, rigDir)
|
||||||
|
|
||||||
|
// Create an untracked settings file (not git added)
|
||||||
|
wrongSettings := filepath.Join(rigDir, ".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)
|
||||||
|
}
|
||||||
|
// Should mention "untracked"
|
||||||
|
found := false
|
||||||
|
for _, d := range result.Details {
|
||||||
|
if strings.Contains(d, "untracked") {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Errorf("expected details to mention untracked, got %v", result.Details)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClaudeSettingsCheck_GitStatusTrackedClean(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
rigName := "testrig"
|
||||||
|
|
||||||
|
// Create a git repo to simulate a source repo
|
||||||
|
rigDir := filepath.Join(tmpDir, rigName, "witness", "rig")
|
||||||
|
if err := os.MkdirAll(rigDir, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
initTestGitRepo(t, rigDir)
|
||||||
|
|
||||||
|
// Create settings and commit it (tracked, clean)
|
||||||
|
wrongSettings := filepath.Join(rigDir, ".claude", "settings.json")
|
||||||
|
createValidSettings(t, wrongSettings)
|
||||||
|
gitAddAndCommit(t, rigDir, 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)
|
||||||
|
}
|
||||||
|
// Should mention "tracked but unmodified"
|
||||||
|
found := false
|
||||||
|
for _, d := range result.Details {
|
||||||
|
if strings.Contains(d, "tracked but unmodified") {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Errorf("expected details to mention tracked but unmodified, got %v", result.Details)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClaudeSettingsCheck_GitStatusTrackedModified(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
rigName := "testrig"
|
||||||
|
|
||||||
|
// Create a git repo to simulate a source repo
|
||||||
|
rigDir := filepath.Join(tmpDir, rigName, "witness", "rig")
|
||||||
|
if err := os.MkdirAll(rigDir, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
initTestGitRepo(t, rigDir)
|
||||||
|
|
||||||
|
// Create settings and commit it
|
||||||
|
wrongSettings := filepath.Join(rigDir, ".claude", "settings.json")
|
||||||
|
createValidSettings(t, wrongSettings)
|
||||||
|
gitAddAndCommit(t, rigDir, wrongSettings)
|
||||||
|
|
||||||
|
// Modify the file after commit
|
||||||
|
if err := os.WriteFile(wrongSettings, []byte(`{"modified": true}`), 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 wrong location, got %v", result.Status)
|
||||||
|
}
|
||||||
|
// Should mention "local modifications"
|
||||||
|
found := false
|
||||||
|
for _, d := range result.Details {
|
||||||
|
if strings.Contains(d, "local modifications") {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Errorf("expected details to mention local modifications, got %v", result.Details)
|
||||||
|
}
|
||||||
|
// Should also mention manual review
|
||||||
|
if !strings.Contains(result.FixHint, "manual review") {
|
||||||
|
t.Errorf("expected fix hint to mention manual review, got %q", result.FixHint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClaudeSettingsCheck_FixSkipsModifiedFiles(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
rigName := "testrig"
|
||||||
|
|
||||||
|
// Create a git repo to simulate a source repo
|
||||||
|
rigDir := filepath.Join(tmpDir, rigName, "witness", "rig")
|
||||||
|
if err := os.MkdirAll(rigDir, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
initTestGitRepo(t, rigDir)
|
||||||
|
|
||||||
|
// Create settings and commit it
|
||||||
|
wrongSettings := filepath.Join(rigDir, ".claude", "settings.json")
|
||||||
|
createValidSettings(t, wrongSettings)
|
||||||
|
gitAddAndCommit(t, rigDir, wrongSettings)
|
||||||
|
|
||||||
|
// Modify the file after commit
|
||||||
|
if err := os.WriteFile(wrongSettings, []byte(`{"modified": true}`), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 - should NOT delete the modified file
|
||||||
|
if err := check.Fix(ctx); err != nil {
|
||||||
|
t.Fatalf("Fix failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify file still exists (was skipped)
|
||||||
|
if _, err := os.Stat(wrongSettings); os.IsNotExist(err) {
|
||||||
|
t.Error("expected modified file to be preserved, but it was deleted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClaudeSettingsCheck_FixDeletesUntrackedFiles(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
rigName := "testrig"
|
||||||
|
|
||||||
|
// Create a git repo to simulate a source repo
|
||||||
|
rigDir := filepath.Join(tmpDir, rigName, "witness", "rig")
|
||||||
|
if err := os.MkdirAll(rigDir, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
initTestGitRepo(t, rigDir)
|
||||||
|
|
||||||
|
// Create an untracked settings file (not git added)
|
||||||
|
wrongSettings := filepath.Join(rigDir, ".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 - should delete the untracked file
|
||||||
|
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 untracked file to be deleted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClaudeSettingsCheck_FixDeletesTrackedCleanFiles(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
rigName := "testrig"
|
||||||
|
|
||||||
|
// Create a git repo to simulate a source repo
|
||||||
|
rigDir := filepath.Join(tmpDir, rigName, "witness", "rig")
|
||||||
|
if err := os.MkdirAll(rigDir, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
initTestGitRepo(t, rigDir)
|
||||||
|
|
||||||
|
// Create settings and commit it (tracked, clean)
|
||||||
|
wrongSettings := filepath.Join(rigDir, ".claude", "settings.json")
|
||||||
|
createValidSettings(t, wrongSettings)
|
||||||
|
gitAddAndCommit(t, rigDir, 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 - should delete the tracked clean file
|
||||||
|
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 tracked clean file to be deleted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClaudeSettingsCheck_DetectsStaleCLAUDEmdAtTownRoot(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
|
// Create CLAUDE.md at town root (wrong location)
|
||||||
|
staleCLAUDEmd := filepath.Join(tmpDir, "CLAUDE.md")
|
||||||
|
if err := os.WriteFile(staleCLAUDEmd, []byte("# Mayor Context\n"), 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 stale CLAUDE.md at town root, got %v", result.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should mention wrong location
|
||||||
|
found := false
|
||||||
|
for _, d := range result.Details {
|
||||||
|
if strings.Contains(d, "CLAUDE.md") && strings.Contains(d, "wrong location") {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Errorf("expected details to mention CLAUDE.md wrong location, got %v", result.Details)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClaudeSettingsCheck_FixMovesCLAUDEmdToMayor(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
|
// Create mayor directory (needed for fix to create CLAUDE.md there)
|
||||||
|
mayorDir := filepath.Join(tmpDir, "mayor")
|
||||||
|
if err := os.MkdirAll(mayorDir, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create CLAUDE.md at town root (wrong location)
|
||||||
|
staleCLAUDEmd := filepath.Join(tmpDir, "CLAUDE.md")
|
||||||
|
if err := os.WriteFile(staleCLAUDEmd, []byte("# Mayor Context\n"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 old file was deleted
|
||||||
|
if _, err := os.Stat(staleCLAUDEmd); !os.IsNotExist(err) {
|
||||||
|
t.Error("expected CLAUDE.md at town root to be deleted")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify new file was created at mayor/
|
||||||
|
correctCLAUDEmd := filepath.Join(mayorDir, "CLAUDE.md")
|
||||||
|
if _, err := os.Stat(correctCLAUDEmd); os.IsNotExist(err) {
|
||||||
|
t.Error("expected CLAUDE.md to be created at mayor/")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -166,8 +166,10 @@ func (m *Manager) Start(foreground bool) error {
|
|||||||
refineryRigDir = m.workDir
|
refineryRigDir = m.workDir
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure Claude settings exist (autonomous role needs mail in SessionStart)
|
// Ensure Claude settings exist in refinery/ (not refinery/rig/) so we don't
|
||||||
if err := claude.EnsureSettingsForRole(refineryRigDir, "refinery"); err != nil {
|
// write into the source repo. Claude walks up the tree to find settings.
|
||||||
|
refineryParentDir := filepath.Join(m.rig.Path, "refinery")
|
||||||
|
if err := claude.EnsureSettingsForRole(refineryParentDir, "refinery"); err != nil {
|
||||||
return fmt.Errorf("ensuring Claude settings: %w", err)
|
return fmt.Errorf("ensuring Claude settings: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/steveyegge/gastown/internal/beads"
|
"github.com/steveyegge/gastown/internal/beads"
|
||||||
|
"github.com/steveyegge/gastown/internal/claude"
|
||||||
"github.com/steveyegge/gastown/internal/config"
|
"github.com/steveyegge/gastown/internal/config"
|
||||||
"github.com/steveyegge/gastown/internal/git"
|
"github.com/steveyegge/gastown/internal/git"
|
||||||
"github.com/steveyegge/gastown/internal/templates"
|
"github.com/steveyegge/gastown/internal/templates"
|
||||||
@@ -436,6 +437,33 @@ Use crew for your own workspace. Polecats are for batch work dispatch.
|
|||||||
return nil, fmt.Errorf("creating polecats dir: %w", err)
|
return nil, fmt.Errorf("creating polecats dir: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Install Claude settings for all agent directories.
|
||||||
|
// Settings are placed in parent directories (not inside git repos) so Claude
|
||||||
|
// finds them via directory traversal without polluting source repos.
|
||||||
|
fmt.Printf(" Installing Claude settings...\n")
|
||||||
|
settingsRoles := []struct {
|
||||||
|
dir string
|
||||||
|
role string
|
||||||
|
}{
|
||||||
|
{witnessPath, "witness"},
|
||||||
|
{filepath.Join(rigPath, "refinery"), "refinery"},
|
||||||
|
{crewPath, "crew"},
|
||||||
|
{polecatsPath, "polecat"},
|
||||||
|
}
|
||||||
|
for _, sr := range settingsRoles {
|
||||||
|
if err := claude.EnsureSettingsForRole(sr.dir, sr.role); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, " Warning: Could not create %s settings: %v\n", sr.role, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Printf(" ✓ Installed Claude settings\n")
|
||||||
|
|
||||||
|
// Initialize beads at rig level
|
||||||
|
fmt.Printf(" Initializing beads database...\n")
|
||||||
|
if err := m.initBeads(rigPath, opts.BeadsPrefix); err != nil {
|
||||||
|
return nil, fmt.Errorf("initializing beads: %w", err)
|
||||||
|
}
|
||||||
|
fmt.Printf(" ✓ Initialized beads (prefix: %s)\n", opts.BeadsPrefix)
|
||||||
|
|
||||||
// Create rig-level agent beads (witness, refinery) in rig beads.
|
// Create rig-level agent beads (witness, refinery) in rig beads.
|
||||||
// Town-level agents (mayor, deacon) are created by gt install in town beads.
|
// Town-level agents (mayor, deacon) are created by gt install in town beads.
|
||||||
if err := m.initAgentBeads(rigPath, opts.Name, opts.BeadsPrefix); err != nil {
|
if err := m.initAgentBeads(rigPath, opts.Name, opts.BeadsPrefix); err != nil {
|
||||||
|
|||||||
@@ -137,8 +137,11 @@ func (m *Manager) Start(polecat string, opts StartOptions) error {
|
|||||||
workDir = m.polecatDir(polecat)
|
workDir = m.polecatDir(polecat)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure Claude settings exist (autonomous role needs mail in SessionStart)
|
// Ensure Claude settings exist in polecats/ (not polecats/<name>/) so we don't
|
||||||
if err := claude.EnsureSettingsForRole(workDir, "polecat"); err != nil {
|
// write into the source repo. Claude walks up the tree to find settings.
|
||||||
|
// All polecats share the same settings file.
|
||||||
|
polecatsDir := filepath.Join(m.rig.Path, "polecats")
|
||||||
|
if err := claude.EnsureSettingsForRole(polecatsDir, "polecat"); err != nil {
|
||||||
return fmt.Errorf("ensuring Claude settings: %w", err)
|
return fmt.Errorf("ensuring Claude settings: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -215,6 +215,32 @@ func CommandNames() ([]string, error) {
|
|||||||
return names, nil
|
return names, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CreateMayorCLAUDEmd creates the Mayor's CLAUDE.md file at the specified directory.
|
||||||
|
// This is used by both gt install and gt doctor --fix.
|
||||||
|
func CreateMayorCLAUDEmd(mayorDir, townRoot, townName, mayorSession, deaconSession string) error {
|
||||||
|
tmpl, err := New()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
data := RoleData{
|
||||||
|
Role: "mayor",
|
||||||
|
TownRoot: townRoot,
|
||||||
|
TownName: townName,
|
||||||
|
WorkDir: mayorDir,
|
||||||
|
MayorSession: mayorSession,
|
||||||
|
DeaconSession: deaconSession,
|
||||||
|
}
|
||||||
|
|
||||||
|
content, err := tmpl.RenderRole("mayor", data)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
claudePath := filepath.Join(mayorDir, "CLAUDE.md")
|
||||||
|
return os.WriteFile(claudePath, []byte(content), 0644)
|
||||||
|
}
|
||||||
|
|
||||||
// HasCommands checks if a workspace has the .claude/commands/ directory provisioned.
|
// HasCommands checks if a workspace has the .claude/commands/ directory provisioned.
|
||||||
func HasCommands(workspacePath string) bool {
|
func HasCommands(workspacePath string) bool {
|
||||||
commandsDir := filepath.Join(workspacePath, ".claude", "commands")
|
commandsDir := filepath.Join(workspacePath, ".claude", "commands")
|
||||||
|
|||||||
@@ -144,8 +144,10 @@ func (m *Manager) Start(foreground bool) error {
|
|||||||
// Working directory
|
// Working directory
|
||||||
witnessDir := m.witnessDir()
|
witnessDir := m.witnessDir()
|
||||||
|
|
||||||
// Ensure Claude settings exist (autonomous role needs mail in SessionStart)
|
// Ensure Claude settings exist in witness/ (not witness/rig/) so we don't
|
||||||
if err := claude.EnsureSettingsForRole(witnessDir, "witness"); err != nil {
|
// write into the source repo. Claude walks up the tree to find settings.
|
||||||
|
witnessParentDir := filepath.Join(m.rig.Path, "witness")
|
||||||
|
if err := claude.EnsureSettingsForRole(witnessParentDir, "witness"); err != nil {
|
||||||
return fmt.Errorf("ensuring Claude settings: %w", err)
|
return fmt.Errorf("ensuring Claude settings: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user