From 59d656470e8aa4fd1951db153ef19701412cc179 Mon Sep 17 00:00:00 2001 From: Steve Yegge Date: Mon, 22 Dec 2025 17:51:15 -0800 Subject: [PATCH] Add Claude settings templates for autonomous roles (gt-6957) - Create internal/claude package with embedded settings templates - settings-autonomous.json: gt prime && gt mail check --inject (SessionStart) - settings-interactive.json: gt prime only (SessionStart) - Update witness.go: EnsureSettings before session, remove broken gt prime injection - Update refinery/manager.go: EnsureSettings before session, remove broken NudgeSession - Update session/manager.go: EnsureSettings for polecats, remove broken issue injection All autonomous roles (polecat, witness, refinery) now get proper SessionStart hooks automatically when their sessions are created. No more timing-based gt prime injection. --- .../claude/config/settings-autonomous.json | 40 ++++++++++ .../claude/config/settings-interactive.json | 40 ++++++++++ internal/claude/settings.go | 80 +++++++++++++++++++ internal/cmd/witness.go | 16 ++-- internal/refinery/manager.go | 16 ++-- internal/session/manager.go | 15 ++-- 6 files changed, 183 insertions(+), 24 deletions(-) create mode 100644 internal/claude/config/settings-autonomous.json create mode 100644 internal/claude/config/settings-interactive.json create mode 100644 internal/claude/settings.go diff --git a/internal/claude/config/settings-autonomous.json b/internal/claude/config/settings-autonomous.json new file mode 100644 index 00000000..b14e19b6 --- /dev/null +++ b/internal/claude/config/settings-autonomous.json @@ -0,0 +1,40 @@ +{ + "enabledPlugins": { + "beads@beads-marketplace": false + }, + "hooks": { + "SessionStart": [ + { + "matcher": "", + "hooks": [ + { + "type": "command", + "command": "gt prime && gt mail check --inject" + } + ] + } + ], + "PreCompact": [ + { + "matcher": "", + "hooks": [ + { + "type": "command", + "command": "gt prime" + } + ] + } + ], + "UserPromptSubmit": [ + { + "matcher": "", + "hooks": [ + { + "type": "command", + "command": "gt mail check --inject" + } + ] + } + ] + } +} diff --git a/internal/claude/config/settings-interactive.json b/internal/claude/config/settings-interactive.json new file mode 100644 index 00000000..1dbac45a --- /dev/null +++ b/internal/claude/config/settings-interactive.json @@ -0,0 +1,40 @@ +{ + "enabledPlugins": { + "beads@beads-marketplace": false + }, + "hooks": { + "SessionStart": [ + { + "matcher": "", + "hooks": [ + { + "type": "command", + "command": "gt prime" + } + ] + } + ], + "PreCompact": [ + { + "matcher": "", + "hooks": [ + { + "type": "command", + "command": "gt prime" + } + ] + } + ], + "UserPromptSubmit": [ + { + "matcher": "", + "hooks": [ + { + "type": "command", + "command": "gt mail check --inject" + } + ] + } + ] + } +} diff --git a/internal/claude/settings.go b/internal/claude/settings.go new file mode 100644 index 00000000..bb2d764c --- /dev/null +++ b/internal/claude/settings.go @@ -0,0 +1,80 @@ +// Package claude provides Claude Code configuration management. +package claude + +import ( + "embed" + "fmt" + "os" + "path/filepath" +) + +//go:embed config/*.json +var configFS embed.FS + +// RoleType indicates whether a role is autonomous or interactive. +type RoleType string + +const ( + // Autonomous roles (polecat, witness, refinery) need mail in SessionStart + // because they may be triggered externally without user input. + Autonomous RoleType = "autonomous" + + // Interactive roles (mayor, crew) wait for user input, so UserPromptSubmit + // handles mail injection. + Interactive RoleType = "interactive" +) + +// RoleTypeFor returns the RoleType for a given role name. +func RoleTypeFor(role string) RoleType { + switch role { + case "polecat", "witness", "refinery": + return Autonomous + default: + return Interactive + } +} + +// EnsureSettings ensures .claude/settings.json exists in the given directory. +// If the file doesn't exist, it copies the appropriate template based on role type. +// If the file already exists, it's left unchanged. +func EnsureSettings(workDir string, roleType RoleType) error { + claudeDir := filepath.Join(workDir, ".claude") + settingsPath := filepath.Join(claudeDir, "settings.json") + + // If settings already exist, don't overwrite + if _, err := os.Stat(settingsPath); err == nil { + return nil + } + + // Create .claude directory if needed + if err := os.MkdirAll(claudeDir, 0755); err != nil { + return fmt.Errorf("creating .claude directory: %w", err) + } + + // Select template based on role type + var templateName string + switch roleType { + case Autonomous: + templateName = "config/settings-autonomous.json" + default: + templateName = "config/settings-interactive.json" + } + + // Read template + content, err := configFS.ReadFile(templateName) + if err != nil { + return fmt.Errorf("reading template %s: %w", templateName, err) + } + + // Write settings file + if err := os.WriteFile(settingsPath, content, 0644); err != nil { + return fmt.Errorf("writing settings: %w", err) + } + + return nil +} + +// EnsureSettingsForRole is a convenience function that combines RoleTypeFor and EnsureSettings. +func EnsureSettingsForRole(workDir, role string) error { + return EnsureSettings(workDir, RoleTypeFor(role)) +} diff --git a/internal/cmd/witness.go b/internal/cmd/witness.go index 55b48d1d..94201681 100644 --- a/internal/cmd/witness.go +++ b/internal/cmd/witness.go @@ -5,9 +5,9 @@ import ( "fmt" "os" "os/exec" - "time" "github.com/spf13/cobra" + "github.com/steveyegge/gastown/internal/claude" "github.com/steveyegge/gastown/internal/rig" "github.com/steveyegge/gastown/internal/style" "github.com/steveyegge/gastown/internal/tmux" @@ -280,6 +280,11 @@ func ensureWitnessSession(rigName string, r *rig.Rig) (bool, error) { return false, nil } + // Ensure Claude settings exist (autonomous role needs mail in SessionStart) + if err := claude.EnsureSettingsForRole(r.Path, "witness"); err != nil { + return false, fmt.Errorf("ensuring Claude settings: %w", err) + } + // Create new tmux session if err := t.NewSession(sessionName, r.Path); err != nil { return false, fmt.Errorf("creating session: %w", err) @@ -294,19 +299,12 @@ func ensureWitnessSession(rigName string, r *rig.Rig) (bool, error) { _ = t.ConfigureGasTownSession(sessionName, theme, rigName, "witness", "witness") // Launch Claude in a respawn loop + // NOTE: No gt prime injection needed - SessionStart hook handles it automatically loopCmd := `while true; do echo "👁️ Starting Witness for ` + rigName + `..."; claude --dangerously-skip-permissions; echo ""; echo "Witness exited. Restarting in 2s... (Ctrl-C to stop)"; sleep 2; done` if err := t.SendKeysDelayed(sessionName, loopCmd, 200); err != nil { return false, fmt.Errorf("sending command: %w", err) } - // Wait briefly then send gt prime to initialize context - // This runs after Claude starts up in the respawn loop - time.Sleep(3 * time.Second) - if err := t.SendKeys(sessionName, "gt prime"); err != nil { - // Non-fatal - Claude will still work, just without auto-priming - fmt.Printf("Warning: failed to send gt prime: %v\n", err) - } - return true, nil } diff --git a/internal/refinery/manager.go b/internal/refinery/manager.go index 0b89a1ce..04af5d57 100644 --- a/internal/refinery/manager.go +++ b/internal/refinery/manager.go @@ -13,6 +13,7 @@ import ( "strings" "time" + "github.com/steveyegge/gastown/internal/claude" "github.com/steveyegge/gastown/internal/config" "github.com/steveyegge/gastown/internal/mail" "github.com/steveyegge/gastown/internal/rig" @@ -189,6 +190,11 @@ func (m *Manager) Start(foreground bool) error { refineryRigDir = m.workDir } + // Ensure Claude settings exist (autonomous role needs mail in SessionStart) + if err := claude.EnsureSettingsForRole(refineryRigDir, "refinery"); err != nil { + return fmt.Errorf("ensuring Claude settings: %w", err) + } + if err := t.NewSession(sessionID, refineryRigDir); err != nil { return fmt.Errorf("creating tmux session: %w", err) } @@ -227,20 +233,12 @@ func (m *Manager) Start(foreground bool) error { } // Wait for Claude to start (pane command changes from shell to node) + // NOTE: No gt prime injection needed - SessionStart hook handles it automatically shells := []string{"bash", "zsh", "sh", "fish", "tcsh", "ksh"} if err := t.WaitForCommand(sessionID, shells, 15*time.Second); err != nil { fmt.Fprintf(m.output, "Warning: Timeout waiting for Claude to start: %v\n", err) } - // Give Claude time to initialize after process starts - time.Sleep(500 * time.Millisecond) - - // Prime the agent using NudgeSession for reliable delivery - if err := t.NudgeSession(sessionID, "run gt prime"); err != nil { - // Warning only - don't fail startup - fmt.Fprintf(m.output, "Warning: could not send prime command: %v\n", err) - } - return nil } diff --git a/internal/session/manager.go b/internal/session/manager.go index 91f8a5cc..05cf17bb 100644 --- a/internal/session/manager.go +++ b/internal/session/manager.go @@ -10,6 +10,7 @@ import ( "strings" "time" + "github.com/steveyegge/gastown/internal/claude" "github.com/steveyegge/gastown/internal/rig" "github.com/steveyegge/gastown/internal/tmux" ) @@ -118,6 +119,11 @@ func (m *Manager) Start(polecat string, opts StartOptions) error { workDir = m.polecatDir(polecat) } + // Ensure Claude settings exist (autonomous role needs mail in SessionStart) + if err := claude.EnsureSettingsForRole(workDir, "polecat"); err != nil { + return fmt.Errorf("ensuring Claude settings: %w", err) + } + // Create session if err := m.tmux.NewSession(sessionID, workDir); err != nil { return fmt.Errorf("creating session: %w", err) @@ -149,12 +155,9 @@ func (m *Manager) Start(polecat string, opts StartOptions) error { return fmt.Errorf("sending command: %w", err) } - // If issue specified, wait a bit then inject it - if opts.Issue != "" { - time.Sleep(500 * time.Millisecond) - prompt := fmt.Sprintf("Work on issue: %s", opts.Issue) - _ = m.Inject(polecat, prompt) // Non-fatal error - } + // NOTE: No issue injection needed here. Work assignments are sent via mail + // before session start, and the SessionStart hook runs gt prime + mail check + // which shows the polecat its assignment. return nil }