diff --git a/internal/cmd/doctor.go b/internal/cmd/doctor.go index 565b0839..b15f5a5c 100644 --- a/internal/cmd/doctor.go +++ b/internal/cmd/doctor.go @@ -137,6 +137,7 @@ func runDoctor(cmd *cobra.Command, args []string) error { // Crew workspace checks d.Register(doctor.NewCrewStateCheck()) + d.Register(doctor.NewCommandsCheck()) // Lifecycle hygiene checks d.Register(doctor.NewLifecycleHygieneCheck()) diff --git a/internal/crew/manager.go b/internal/crew/manager.go index e123856e..6f8f68bd 100644 --- a/internal/crew/manager.go +++ b/internal/crew/manager.go @@ -11,6 +11,7 @@ import ( "github.com/steveyegge/gastown/internal/git" "github.com/steveyegge/gastown/internal/rig" + "github.com/steveyegge/gastown/internal/templates" "github.com/steveyegge/gastown/internal/util" ) @@ -104,6 +105,13 @@ func (m *Manager) Add(name string, createBranch bool) (*CrewWorker, error) { fmt.Printf("Warning: could not set up shared beads: %v\n", err) } + // Provision .claude/commands/ with standard slash commands (e.g., /handoff) + // This ensures crew workers have Gas Town utilities even if source repo lacks them. + if err := templates.ProvisionCommands(crewPath); err != nil { + // Non-fatal - crew can still work, warn but don't fail + fmt.Printf("Warning: could not provision slash commands: %v\n", err) + } + // NOTE: We intentionally do NOT write to CLAUDE.md here. // Gas Town context is injected ephemerally via SessionStart hook (gt prime). // Writing to CLAUDE.md would overwrite project instructions and leak diff --git a/internal/doctor/commands_check.go b/internal/doctor/commands_check.go new file mode 100644 index 00000000..426e894d --- /dev/null +++ b/internal/doctor/commands_check.go @@ -0,0 +1,169 @@ +package doctor + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/steveyegge/gastown/internal/templates" +) + +// CommandsCheck validates that crew/polecat workspaces have .claude/commands/ provisioned. +// This ensures all agents have access to slash commands like /handoff. +type CommandsCheck struct { + FixableCheck + missingWorkspaces []workspaceWithMissingCommands // Cached during Run for use in Fix +} + +type workspaceWithMissingCommands struct { + path string + rigName string + workerName string + workerType string // "crew" or "polecat" + missingFiles []string +} + +// NewCommandsCheck creates a new commands check. +func NewCommandsCheck() *CommandsCheck { + return &CommandsCheck{ + FixableCheck: FixableCheck{ + BaseCheck: BaseCheck{ + CheckName: "commands-provisioned", + CheckDescription: "Check .claude/commands/ is provisioned in crew/polecat workspaces", + }, + }, + } +} + +// Run checks all crew and polecat workspaces for missing slash commands. +func (c *CommandsCheck) Run(ctx *CheckContext) *CheckResult { + c.missingWorkspaces = nil + + workspaces := c.findAllWorkerDirs(ctx.TownRoot) + if len(workspaces) == 0 { + return &CheckResult{ + Name: c.Name(), + Status: StatusOK, + Message: "No crew/polecat workspaces found", + } + } + + var validCount int + var details []string + + for _, ws := range workspaces { + missing, err := templates.MissingCommands(ws.path) + if err != nil { + details = append(details, fmt.Sprintf("%s/%s/%s: error checking commands: %v", + ws.rigName, ws.workerType, ws.workerName, err)) + continue + } + + if len(missing) > 0 { + c.missingWorkspaces = append(c.missingWorkspaces, workspaceWithMissingCommands{ + path: ws.path, + rigName: ws.rigName, + workerName: ws.workerName, + workerType: ws.workerType, + missingFiles: missing, + }) + details = append(details, fmt.Sprintf("%s/%s/%s: missing %s", + ws.rigName, ws.workerType, ws.workerName, strings.Join(missing, ", "))) + } else { + validCount++ + } + } + + if len(c.missingWorkspaces) == 0 { + return &CheckResult{ + Name: c.Name(), + Status: StatusOK, + Message: fmt.Sprintf("All %d workspaces have slash commands provisioned", validCount), + } + } + + return &CheckResult{ + Name: c.Name(), + Status: StatusWarning, + Message: fmt.Sprintf("%d workspace(s) missing slash commands (e.g., /handoff)", len(c.missingWorkspaces)), + Details: details, + FixHint: "Run 'gt doctor --fix' to provision missing commands", + } +} + +// Fix provisions missing slash commands to workspaces. +func (c *CommandsCheck) Fix(ctx *CheckContext) error { + if len(c.missingWorkspaces) == 0 { + return nil + } + + var lastErr error + for _, ws := range c.missingWorkspaces { + if err := templates.ProvisionCommands(ws.path); err != nil { + lastErr = fmt.Errorf("%s/%s/%s: %w", ws.rigName, ws.workerType, ws.workerName, err) + continue + } + } + + return lastErr +} + +type workerDir struct { + path string + rigName string + workerName string + workerType string // "crew" or "polecat" +} + +// findAllWorkerDirs finds all crew and polecat directories in the workspace. +func (c *CommandsCheck) findAllWorkerDirs(townRoot string) []workerDir { + var dirs []workerDir + + entries, err := os.ReadDir(townRoot) + if err != nil { + return dirs + } + + for _, entry := range entries { + if !entry.IsDir() || strings.HasPrefix(entry.Name(), ".") || entry.Name() == "mayor" { + continue + } + + rigName := entry.Name() + + // Check crew directory + crewPath := filepath.Join(townRoot, rigName, "crew") + if crewEntries, err := os.ReadDir(crewPath); err == nil { + for _, crew := range crewEntries { + if !crew.IsDir() || strings.HasPrefix(crew.Name(), ".") { + continue + } + dirs = append(dirs, workerDir{ + path: filepath.Join(crewPath, crew.Name()), + rigName: rigName, + workerName: crew.Name(), + workerType: "crew", + }) + } + } + + // Check polecats directory + polecatsPath := filepath.Join(townRoot, rigName, "polecats") + if polecatEntries, err := os.ReadDir(polecatsPath); err == nil { + for _, polecat := range polecatEntries { + if !polecat.IsDir() || strings.HasPrefix(polecat.Name(), ".") { + continue + } + dirs = append(dirs, workerDir{ + path: filepath.Join(polecatsPath, polecat.Name()), + rigName: rigName, + workerName: polecat.Name(), + workerType: "polecat", + }) + } + } + } + + return dirs +} diff --git a/internal/polecat/manager.go b/internal/polecat/manager.go index 623f0e72..b0cbcce9 100644 --- a/internal/polecat/manager.go +++ b/internal/polecat/manager.go @@ -12,6 +12,7 @@ import ( "github.com/steveyegge/gastown/internal/config" "github.com/steveyegge/gastown/internal/git" "github.com/steveyegge/gastown/internal/rig" + "github.com/steveyegge/gastown/internal/templates" "github.com/steveyegge/gastown/internal/workspace" ) @@ -244,6 +245,13 @@ func (m *Manager) AddWithOptions(name string, opts AddOptions) (*Polecat, error) fmt.Printf("Warning: could not set up shared beads: %v\n", err) } + // Provision .claude/commands/ with standard slash commands (e.g., /handoff) + // This ensures polecats have Gas Town utilities even if source repo lacks them. + if err := templates.ProvisionCommands(polecatPath); err != nil { + // Non-fatal - polecat can still work, warn but don't fail + fmt.Printf("Warning: could not provision slash commands: %v\n", err) + } + // Create agent bead for ZFC compliance (self-report state). // State starts as "spawning" - will be updated to "working" when Claude starts. // HookBead is set atomically at creation time if provided (avoids cross-beads routing issues). @@ -460,6 +468,11 @@ func (m *Manager) RecreateWithOptions(name string, force bool, opts AddOptions) fmt.Printf("Warning: could not set up shared beads: %v\n", err) } + // Provision .claude/commands/ with standard slash commands (e.g., /handoff) + if err := templates.ProvisionCommands(polecatPath); err != nil { + fmt.Printf("Warning: could not provision slash commands: %v\n", err) + } + // Create fresh agent bead for ZFC compliance // HookBead is set atomically at recreation time if provided. _, err = m.beads.CreateAgentBead(agentID, agentID, &beads.AgentFields{ diff --git a/internal/templates/commands/handoff.md b/internal/templates/commands/handoff.md new file mode 100644 index 00000000..3933ec77 --- /dev/null +++ b/internal/templates/commands/handoff.md @@ -0,0 +1,21 @@ +--- +description: Hand off to fresh session, work continues from hook +allowed-tools: Bash(gt mail send:*),Bash(gt handoff:*) +argument-hint: [message] +--- + +Hand off to a fresh session. + +User's handoff message (if any): $ARGUMENTS + +Execute these steps in order: + +1. If user provided a message, send handoff mail to yourself first. + Construct your mail address from your identity (e.g., gastown/crew/max for crew, mayor/ for mayor). + Example: `gt mail send gastown/crew/max -s "HANDOFF: Session cycling" -m "USER_MESSAGE_HERE"` + +2. Run the handoff command (this will respawn your session with a fresh Claude): + `gt handoff` + +Note: The new session will auto-prime via the SessionStart hook and find your handoff mail. +End watch. A new session takes over, picking up any molecule on the hook. diff --git a/internal/templates/templates.go b/internal/templates/templates.go index 97916f1a..33cde25e 100644 --- a/internal/templates/templates.go +++ b/internal/templates/templates.go @@ -5,12 +5,17 @@ import ( "bytes" "embed" "fmt" + "os" + "path/filepath" "text/template" ) //go:embed roles/*.md.tmpl messages/*.md.tmpl var templateFS embed.FS +//go:embed commands/*.md +var commandsFS embed.FS + // Templates manages role and message templates. type Templates struct { roleTemplates *template.Template @@ -148,3 +153,90 @@ func GetAllRoleTemplates() (map[string][]byte, error) { return result, nil } + +// ProvisionCommands creates the .claude/commands/ directory with standard slash commands. +// This ensures crew/polecat workspaces have the handoff command and other utilities +// even if the source repo doesn't have them tracked. +// If a command already exists, it is skipped (no overwrite). +func ProvisionCommands(workspacePath string) error { + entries, err := commandsFS.ReadDir("commands") + if err != nil { + return fmt.Errorf("reading commands directory: %w", err) + } + + // Create .claude/commands/ directory + commandsDir := filepath.Join(workspacePath, ".claude", "commands") + if err := os.MkdirAll(commandsDir, 0755); err != nil { + return fmt.Errorf("creating commands directory: %w", err) + } + + for _, entry := range entries { + if entry.IsDir() { + continue + } + + destPath := filepath.Join(commandsDir, entry.Name()) + + // Skip if command already exists (don't overwrite user customizations) + if _, err := os.Stat(destPath); err == nil { + continue + } + + content, err := commandsFS.ReadFile("commands/" + entry.Name()) + if err != nil { + return fmt.Errorf("reading %s: %w", entry.Name(), err) + } + + if err := os.WriteFile(destPath, content, 0644); err != nil { + return fmt.Errorf("writing %s: %w", entry.Name(), err) + } + } + + return nil +} + +// CommandNames returns the list of embedded slash commands. +func CommandNames() ([]string, error) { + entries, err := commandsFS.ReadDir("commands") + if err != nil { + return nil, fmt.Errorf("reading commands directory: %w", err) + } + + var names []string + for _, entry := range entries { + if !entry.IsDir() { + names = append(names, entry.Name()) + } + } + return names, nil +} + +// HasCommands checks if a workspace has the .claude/commands/ directory provisioned. +func HasCommands(workspacePath string) bool { + commandsDir := filepath.Join(workspacePath, ".claude", "commands") + info, err := os.Stat(commandsDir) + return err == nil && info.IsDir() +} + +// MissingCommands returns the list of embedded commands missing from the workspace. +func MissingCommands(workspacePath string) ([]string, error) { + entries, err := commandsFS.ReadDir("commands") + if err != nil { + return nil, fmt.Errorf("reading commands directory: %w", err) + } + + commandsDir := filepath.Join(workspacePath, ".claude", "commands") + var missing []string + + for _, entry := range entries { + if entry.IsDir() { + continue + } + destPath := filepath.Join(commandsDir, entry.Name()) + if _, err := os.Stat(destPath); os.IsNotExist(err) { + missing = append(missing, entry.Name()) + } + } + + return missing, nil +}