diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 6cac02a0..a9f59d45 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -4,6 +4,7 @@ {"id":"gt-0pc","title":"Document Overseer role (human operator)","description":"Document the Overseer role in Gas Town architecture.\n\n## The Overseer\n\nThe **Overseer** is the human operator of Gas Town. Not an agent - a person.\n\n## Responsibilities\n\n| Area | Overseer Does | Mayor/Agents Do |\n|------|---------------|-----------------|\n| Strategy | Define project goals | Execute toward goals |\n| Priorities | Set priority order | Work in priority order |\n| Escalations | Final decision on stuck work | Escalate to Overseer |\n| Resources | Provision machines | Use allocated resources |\n| Quality | Review \u0026 approve swarm output | Produce output |\n| Operations | Run gt commands, monitor dashboards | Do the work |\n\n## Key Interactions\n\n### Overseer → Mayor\n- Start/stop Mayor sessions\n- Direct Mayor via conversation\n- Review Mayor recommendations\n- Approve cross-rig decisions\n\n### Mayor → Overseer (Escalations)\n- Stuck workers after retries\n- Resource decisions (add machines, polecats)\n- Ambiguous requirements\n- Architecture decisions\n\n## Operating Cadence\n\nTypical Overseer workflow:\n1. Morning: Check status, review overnight work\n2. During day: Monitor, respond to escalations, adjust priorities\n3. End of day: Review progress, plan next batch\n\n## Commands for Overseers\n\n```bash\ngt status # Quick health check\ngt doctor # Detailed diagnostics \ngt doctor --fix # Auto-repair issues\ngt inbox # Messages from agents\ngt stop --all # Emergency halt\n```\n\n## Documentation Updates\n\nAdd to docs/architecture.md:\n- Overseer section under Agent Roles\n- Clarify Mayor reports to Overseer\n- Add Overseer to workflow diagrams","status":"closed","priority":1,"issue_type":"task","created_at":"2025-12-15T23:18:03.177633-08:00","updated_at":"2025-12-15T23:22:51.477786-08:00","closed_at":"2025-12-15T23:22:51.477786-08:00","close_reason":"Overseer role documented in docs/architecture.md"} {"id":"gt-0pl","title":"Polecat CLAUDE.md: configure auto-approve for bd and gt commands","description":"Polecats get stuck waiting for bash command approval when running\nbd and gt commands. Need to configure Claude Code to auto-approve these.\n\nOptions:\n1. Add allowedTools to polecat CLAUDE.md\n2. Configure .claude/settings.json in polecat directory\n3. Use --dangerously-skip-permissions flag (not recommended)\n\nShould auto-approve:\n- bd (beads commands)\n- gt (gastown commands)\n- go build/test\n- git status/add/commit/push\n\nShould still require approval:\n- rm -rf\n- Arbitrary commands outside project\n\nRelated to polecat prompting (gt-e1y, gt-sd6).","status":"closed","priority":1,"issue_type":"task","created_at":"2025-12-17T14:10:27.611612-08:00","updated_at":"2025-12-17T14:22:00.715979-08:00","closed_at":"2025-12-17T14:22:00.715979-08:00","close_reason":"Permissions configured in .claude/settings.local.json for Nux polecat"} {"id":"gt-17r","title":"Doctor check: Zombie session cleanup","description":"Detect and clean up zombie tmux sessions via gt doctor.\n\n## Problem\n\nZombie sessions occur when:\n- Agent crashes without cleanup\n- gt kill fails mid-operation\n- System restart leaves orphan sessions\n- Session naming collision\n\n## Checks\n\n### ZombieSessionCheck\n- List all tmux sessions matching gt-* pattern\n- Cross-reference with known polecats\n- Flag sessions with no corresponding polecat state\n- Flag sessions for removed polecats\n- Check session age vs polecat creation time\n\n### Detection Criteria\n- Session exists but polecat directory doesn't\n- Session name doesn't match any registered polecat\n- Polecat state=idle but session running\n- Multiple sessions for same polecat\n\n## Output\n\n```\n[WARN] Zombie tmux sessions detected:\n - gt-wyvern-OldPolecat (polecat removed)\n - gt-beads-Unknown (no matching polecat)\n - gt-wyvern-Toast (duplicate session)\n\n Run 'gt doctor --fix' to clean up\n```\n\n## Auto-Fix (--fix flag)\n\n- Kill orphan tmux sessions\n- Update polecat state to match reality\n- Log all cleanup actions\n\n## Safety\n\n- Never kill sessions where polecat state=working\n- Prompt before killing if --fix used without --force\n- Create audit log of killed sessions","status":"open","priority":1,"issue_type":"task","created_at":"2025-12-15T23:18:01.446702-08:00","updated_at":"2025-12-15T23:18:39.517644-08:00","dependencies":[{"issue_id":"gt-17r","depends_on_id":"gt-f9x.4","type":"blocks","created_at":"2025-12-15T23:19:05.66301-08:00","created_by":"daemon"},{"issue_id":"gt-17r","depends_on_id":"gt-7ik","type":"blocks","created_at":"2025-12-17T15:44:41.945064-08:00","created_by":"daemon"}]} +{"id":"gt-1fl","title":"gt crew restart command","description":"Add a 'gt crew restart' command that kills the tmux session and restarts fresh. Useful when a crew member gets confused or needs a clean slate.\n\nShould:\n- Kill existing tmux session if running\n- Start fresh session with claude\n- Run gt prime to reinitialize context\n\nAlias: gt crew rs","status":"open","priority":2,"issue_type":"task","created_at":"2025-12-17T19:47:32.131386-08:00","updated_at":"2025-12-17T19:47:32.131386-08:00"} {"id":"gt-1j6","title":"Document harness concept in docs/harness.md","description":"Create comprehensive harness documentation:\n- What is a harness (installation directory)\n- Recommended structure and naming\n- .beads/redirect for default project\n- config/ contents (rigs.json, town.json)\n- Mayor home vs rig-level mayor/\n- Example configurations\n- Relationship to rigs","status":"open","priority":2,"issue_type":"task","created_at":"2025-12-17T17:15:37.559374-08:00","updated_at":"2025-12-17T17:15:37.559374-08:00","dependencies":[{"issue_id":"gt-1j6","depends_on_id":"gt-cr9","type":"blocks","created_at":"2025-12-17T17:15:51.974059-08:00","created_by":"daemon"}]} {"id":"gt-1ky","title":"CLI: workspace commands (init, add, list)","description":"GGT needs workspace management commands beyond install.\n\n## Commands (beyond gt-f9x.3 install)\n\n### gt workspace list\nList all rigs in current workspace.\n```\ngt workspace list [--json]\n```\nEssentially `gt rig list` but framed as workspace view.\n\n### gt workspace add\nAdd existing rig to workspace (alternative to gt rig add).\n```\ngt workspace add \u003cgit-url\u003e [--name NAME]\n```\n\n### gt onboard\nInteractive first-time setup wizard.\n```\ngt onboard\n```\n- Prompts for workspace location\n- Creates structure via gt install\n- Offers to add first rig\n\n## Note\nMay be redundant with gt-f9x.3 (install) and gt-u1j.16 (rig commands).\nConsider if this is needed or should be closed as covered by those.\n\n## PGT Reference\ngastown-py/src/gastown/cli/workspace_cmd.py","status":"open","priority":2,"issue_type":"task","created_at":"2025-12-16T14:47:38.070203-08:00","updated_at":"2025-12-16T16:03:49.715667-08:00"} {"id":"gt-1le","title":"town handoff command (optional)","description":"CLI commands for session handoff workflow (optional convenience).\n\n## Commands\n\n### gt handoff\nGenerate handoff interactively.\n```\ngt handoff [--send]\n```\n- Collects current state (status, inbox, beads)\n- Prompts for additional notes\n- --send: Mail to self and exit\n\n### gt resume\nCheck for and display pending handoff.\n```\ngt resume\n```\n- Checks inbox for handoff message\n- Displays formatted handoff if found\n- Suggests next actions\n\n## Implementation\n\nThese are convenience wrappers. The same workflow can be done manually:\n```bash\n# Manual handoff\ntown status \u003e /tmp/handoff\ntown inbox \u003e\u003e /tmp/handoff\nbd ready \u003e\u003e /tmp/handoff\n# Edit and send\ntown mail send mayor/ -s \"Session Handoff\" -f /tmp/handoff\n```\n\n## Priority\n\nP2 - Optional. Manual workflow works fine. Nice to have for UX.\n\n## Notes\n\nPart of session cycling workflow designed in gt-u82.","status":"open","priority":3,"issue_type":"task","created_at":"2025-12-15T20:15:31.954724-08:00","updated_at":"2025-12-15T23:17:23.967562-08:00","dependencies":[{"issue_id":"gt-1le","depends_on_id":"gt-u82","type":"blocks","created_at":"2025-12-15T20:15:39.647043-08:00","created_by":"daemon"}]} diff --git a/docs/architecture.md b/docs/architecture.md index f5a3d9db..69466ca1 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -86,12 +86,23 @@ Gas Town has four AI agent roles: ### Mail -Agents communicate via **mail** - JSONL-based inboxes for asynchronous messaging. Each agent has an inbox at `mail/inbox.jsonl`. Mail enables: +Agents communicate via **mail** - messages stored as beads issues with `type=message`. Mail enables: - Work assignment (Mayor → Refinery → Polecat) - Status reporting (Polecat → Witness → Mayor) - Session handoff (Agent → Self for context cycling) - Escalation (Witness → Mayor for stuck workers) +**Two-tier mail architecture:** +- **Town beads** (prefix: `gm-`): Mayor inbox, cross-rig coordination, handoffs +- **Rig beads** (prefix: varies): Rig-local agent communication + +Mail commands use `bd mail` under the hood: +```bash +gt mail send mayor/ -s "Subject" -m "Body" # Uses bd mail send +gt mail inbox # Uses bd mail inbox +gt mail read gm-abc # Uses bd mail read +``` + ```mermaid flowchart LR subgraph "Communication Flows" @@ -153,20 +164,21 @@ sync-branch: beads-sync # Separate branch for beads commits ``` ~/gt/ # Town root (Gas Town harness) ├── CLAUDE.md # Mayor role prompting (at town root) -├── .beads/ # Town-level beads (optional) +├── .beads/ # Town-level beads (prefix: gm-) +│ ├── beads.db # Mayor mail, coordination, handoffs +│ └── config.yaml │ ├── mayor/ # Mayor's HOME at town level │ ├── town.json # {"type": "town", "name": "..."} │ ├── rigs.json # Registry of managed rigs -│ ├── mail/inbox.jsonl # Mayor's inbox -│ └── state.json # Mayor state -│ -├── rigs/ # Empty by default, for future use +│ └── state.json # Mayor state (NO mail/ directory) │ ├── gastown/ # A rig (project container) └── wyvern/ # Another rig ``` +**Note**: Mayor's mail is now in town beads (`gm-*` issues), not JSONL files. + ### Rig Level Created by `gt rig add `: @@ -174,28 +186,30 @@ Created by `gt rig add `: ``` gastown/ # Rig = container (NOT a git clone) ├── config.json # Rig configuration (git_url, beads prefix) -├── .beads/ # Rig-level issue tracking -│ └── config.yaml # Beads config (prefix, sync settings) +├── .beads/ → refinery/rig/.beads # Symlink to canonical beads in refinery │ ├── refinery/ # Refinery agent │ ├── rig/ # Authoritative "main" clone -│ └── state.json -│ -├── mayor/ # Mayor's presence in this rig -│ ├── rig/ # Mayor's rig-specific clone +│ │ └── .beads/ # Canonical rig beads (prefix: gt-, etc.) │ └── state.json │ ├── witness/ # Witness agent (per-rig pit boss) │ └── state.json # No clone needed (monitors polecats) │ ├── crew/ # Overseer's personal workspaces -│ └── main/ # Default workspace (full git clone) +│ └── / # Workspace (full git clone) │ -└── polecats/ # Worker directories (initially empty) - ├── Nux/ # Full git clone (created by gt spawn) - └── Toast/ # Full git clone (created by gt spawn) +└── polecats/ # Worker directories (git worktrees) + ├── Nux/ # Worktree from refinery (faster than clone) + └── Toast/ # Worktree from refinery ``` +**Beads architecture:** +- Refinery's clone holds the canonical `.beads/` for the rig +- Rig root symlinks `.beads/` → `refinery/rig/.beads` +- All agents (crew, polecats) inherit beads via parent directory lookup +- Polecats are git worktrees, not full clones (much faster) + **Key points:** - The rig root has no `.git/` - it's not a repository - All agents use `BEADS_DIR` to point to the rig's `.beads/` @@ -246,28 +260,23 @@ For reference without mermaid rendering: ``` ~/gt/ # TOWN ROOT (Gas Town harness) ├── CLAUDE.md # Mayor role prompting -├── .beads/ # Town-level beads (optional) +├── .beads/ # Town-level beads (gm-* prefix) +│ ├── beads.db # Mayor mail, coordination +│ └── config.yaml │ ├── mayor/ # Mayor's home (at town level) │ ├── town.json # {"type": "town", "name": "..."} │ ├── rigs.json # Registry of managed rigs -│ ├── mail/inbox.jsonl # Mayor's inbox -│ └── state.json # Mayor state +│ └── state.json # Mayor state (no mail/ dir) │ ├── gastown/ # RIG (container, NOT a git clone) │ ├── config.json # Rig configuration -│ ├── .beads/ # Rig-level issue tracking -│ │ └── config.yaml # Beads config (prefix, sync settings) +│ ├── .beads/ → refinery/rig/.beads # Symlink to canonical beads │ │ │ ├── refinery/ # Refinery agent │ │ ├── rig/ # Canonical "main" clone │ │ │ ├── .git/ -│ │ │ └── -│ │ └── state.json -│ │ -│ ├── mayor/ # Mayor's rig-specific clone -│ │ ├── rig/ # Mayor's clone for this rig -│ │ │ ├── .git/ +│ │ │ ├── .beads/ # CANONICAL rig beads (gt-* prefix) │ │ │ └── │ │ └── state.json │ │ @@ -275,31 +284,35 @@ For reference without mermaid rendering: │ │ └── state.json # No clone needed │ │ │ ├── crew/ # Overseer's personal workspaces -│ │ └── main/ # Default workspace (full clone) +│ │ └── / # Full clone (inherits beads from rig) │ │ ├── .git/ │ │ └── │ │ -│ ├── polecats/ # Worker directories (initially empty) -│ │ ├── Nux/ # Full clone (BEADS_DIR=../../.beads) -│ │ │ ├── .git/ -│ │ │ └── -│ │ └── Toast/ # Full clone +│ ├── polecats/ # Worker directories (worktrees) +│ │ ├── Nux/ # Git worktree from refinery +│ │ │ └── # (inherits beads from rig) +│ │ └── Toast/ # Git worktree from refinery │ │ │ └── plugins/ # Optional plugins │ └── merge-oracle/ │ ├── CLAUDE.md -│ └── mail/inbox.jsonl +│ └── state.json │ └── wyvern/ # Another rig (same structure) ├── config.json - ├── .beads/ + ├── .beads/ → refinery/rig/.beads ├── polecats/ ├── refinery/ ├── witness/ - ├── crew/ - └── mayor/ + └── crew/ ``` +**Key changes from earlier design:** +- Town beads (`gm-*`) hold Mayor mail instead of JSONL files +- Rig `.beads/` symlinks to refinery's canonical beads +- Polecats use git worktrees (not full clones) for speed +- No `mayor/rig/` in each rig (Mayor works from town level) + ### Why Decentralized? Agents live IN rigs rather than in a central location: diff --git a/internal/cmd/crew.go b/internal/cmd/crew.go index e0c10d9d..01bdc1e7 100644 --- a/internal/cmd/crew.go +++ b/internal/cmd/crew.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "os" + "os/exec" "path/filepath" "strings" @@ -83,7 +84,7 @@ Examples: } var crewAtCmd = &cobra.Command{ - Use: "at ", + Use: "at [name]", Aliases: []string{"attach"}, Short: "Attach to crew workspace session", Long: `Start or attach to a tmux session for a crew workspace. @@ -91,10 +92,16 @@ var crewAtCmd = &cobra.Command{ Creates a new tmux session if none exists, or attaches to existing. Use --no-tmux to just print the directory path instead. +Role Discovery: + If no name is provided, attempts to detect the crew workspace from the + current directory. If you're in /crew//, it will attach to + that workspace automatically. + Examples: gt crew at dave # Attach to dave's session + gt crew at # Auto-detect from cwd gt crew at dave --no-tmux # Just print path`, - Args: cobra.ExactArgs(1), + Args: cobra.MaximumNArgs(1), RunE: runCrewAt, } @@ -184,7 +191,7 @@ func runCrewAdd(cmd *cobra.Command, args []string) error { } // Load rigs config - rigsConfigPath := filepath.Join(townRoot, "config", "rigs.json") + rigsConfigPath := filepath.Join(townRoot, "mayor", "rigs.json") rigsConfig, err := config.LoadRigsConfig(rigsConfigPath) if err != nil { rigsConfig = &config.RigsConfig{Rigs: make(map[string]config.RigEntry)} @@ -274,7 +281,7 @@ func getCrewManager(rigName string) (*crew.Manager, *rig.Rig, error) { } // Load rigs config - rigsConfigPath := filepath.Join(townRoot, "config", "rigs.json") + rigsConfigPath := filepath.Join(townRoot, "mayor", "rigs.json") rigsConfig, err := config.LoadRigsConfig(rigsConfigPath) if err != nil { rigsConfig = &config.RigsConfig{Rigs: make(map[string]config.RigEntry)} @@ -386,7 +393,23 @@ func runCrewList(cmd *cobra.Command, args []string) error { } func runCrewAt(cmd *cobra.Command, args []string) error { - name := args[0] + var name string + + // Determine crew name: from arg, or auto-detect from cwd + if len(args) > 0 { + name = args[0] + } else { + // Try to detect from current directory + detected, err := detectCrewFromCwd() + if err != nil { + return fmt.Errorf("could not detect crew workspace from current directory: %w\n\nUsage: gt crew at ", err) + } + name = detected.crewName + if crewRig == "" { + crewRig = detected.rigName + } + fmt.Printf("Detected crew workspace: %s/%s\n", detected.rigName, name) + } crewMgr, r, err := getCrewManager(crewRig) if err != nil { @@ -426,17 +449,89 @@ func runCrewAt(cmd *cobra.Command, args []string) error { t.SetEnvironment(sessionID, "GT_RIG", r.Name) t.SetEnvironment(sessionID, "GT_CREW", name) - // Start claude - if err := t.SendKeys(sessionID, "claude"); err != nil { + // Start claude with skip permissions (crew workers are trusted like Mayor) + if err := t.SendKeys(sessionID, "claude --dangerously-skip-permissions"); err != nil { return fmt.Errorf("starting claude: %w", err) } + // Wait a moment for Claude to initialize, then prime it + // We send gt prime after a short delay to ensure Claude is ready + if err := t.SendKeysDelayed(sessionID, "gt prime", 2000); err != nil { + // Non-fatal: Claude started but priming failed + fmt.Printf("Warning: Could not send prime command: %v\n", err) + } + fmt.Printf("%s Created session for %s/%s\n", style.Bold.Render("✓"), r.Name, name) } - // Attach to session - return t.AttachSession(sessionID) + // Attach to session using exec to properly forward TTY + return attachToTmuxSession(sessionID) +} + +// attachToTmuxSession attaches to a tmux session with proper TTY forwarding. +func attachToTmuxSession(sessionID string) error { + tmuxPath, err := exec.LookPath("tmux") + if err != nil { + return fmt.Errorf("tmux not found: %w", err) + } + + cmd := exec.Command(tmuxPath, "attach-session", "-t", sessionID) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + +// crewDetection holds the result of detecting crew workspace from cwd. +type crewDetection struct { + rigName string + crewName string +} + +// detectCrewFromCwd attempts to detect the crew workspace from the current directory. +// It looks for the pattern //crew// in the current path. +func detectCrewFromCwd() (*crewDetection, error) { + cwd, err := os.Getwd() + if err != nil { + return nil, fmt.Errorf("getting cwd: %w", err) + } + + // Find town root + townRoot, err := workspace.FindFromCwd() + if err != nil { + return nil, fmt.Errorf("not in Gas Town workspace: %w", err) + } + if townRoot == "" { + return nil, fmt.Errorf("not in Gas Town workspace") + } + + // Get relative path from town root + relPath, err := filepath.Rel(townRoot, cwd) + if err != nil { + return nil, fmt.Errorf("getting relative path: %w", err) + } + + // Normalize and split path + relPath = filepath.ToSlash(relPath) + parts := strings.Split(relPath, "/") + + // Look for pattern: /crew//... + // Minimum: rig, crew, name = 3 parts + if len(parts) < 3 { + return nil, fmt.Errorf("not in a crew workspace (path too short)") + } + + rigName := parts[0] + if parts[1] != "crew" { + return nil, fmt.Errorf("not in a crew workspace (not in crew/ directory)") + } + crewName := parts[2] + + return &crewDetection{ + rigName: rigName, + crewName: crewName, + }, nil } func runCrewRemove(cmd *cobra.Command, args []string) error { diff --git a/internal/cmd/install.go b/internal/cmd/install.go index 31af47ac..a46f0e23 100644 --- a/internal/cmd/install.go +++ b/internal/cmd/install.go @@ -26,9 +26,9 @@ var installCmd = &cobra.Command{ Long: `Create a new Gas Town harness at the specified path. A harness is the top-level directory where Gas Town is installed. It contains: - - config/town.json Town configuration - - config/rigs.json Registry of managed rigs - - mayor/ Mayor agent home + - CLAUDE.md Mayor role context (Mayor runs from harness root) + - mayor/ Mayor config, state, and mail + - rigs/ Managed rig clones (created by 'gt rig add') - .beads/redirect (optional) Default beads location If path is omitted, uses the current directory. @@ -94,43 +94,43 @@ func runInstall(cmd *cobra.Command, args []string) error { return fmt.Errorf("creating directory: %w", err) } - // Create config directory - configDir := filepath.Join(absPath, "config") - if err := os.MkdirAll(configDir, 0755); err != nil { - return fmt.Errorf("creating config directory: %w", err) + // Create mayor directory (holds config, state, and mail) + mayorDir := filepath.Join(absPath, "mayor") + if err := os.MkdirAll(mayorDir, 0755); err != nil { + return fmt.Errorf("creating mayor directory: %w", err) } - fmt.Printf(" ✓ Created config/\n") + fmt.Printf(" ✓ Created mayor/\n") - // Create town.json + // Create town.json in mayor/ townConfig := &config.TownConfig{ Type: "town", Version: config.CurrentTownVersion, Name: townName, CreatedAt: time.Now(), } - townPath := filepath.Join(configDir, "town.json") + townPath := filepath.Join(mayorDir, "town.json") if err := config.SaveTownConfig(townPath, townConfig); err != nil { return fmt.Errorf("writing town.json: %w", err) } - fmt.Printf(" ✓ Created config/town.json\n") + fmt.Printf(" ✓ Created mayor/town.json\n") - // Create rigs.json + // Create rigs.json in mayor/ rigsConfig := &config.RigsConfig{ Version: config.CurrentRigsVersion, Rigs: make(map[string]config.RigEntry), } - rigsPath := filepath.Join(configDir, "rigs.json") + rigsPath := filepath.Join(mayorDir, "rigs.json") if err := config.SaveRigsConfig(rigsPath, rigsConfig); err != nil { return fmt.Errorf("writing rigs.json: %w", err) } - fmt.Printf(" ✓ Created config/rigs.json\n") + fmt.Printf(" ✓ Created mayor/rigs.json\n") - // Create mayor directory - mayorDir := filepath.Join(absPath, "mayor") - if err := os.MkdirAll(mayorDir, 0755); err != nil { - return fmt.Errorf("creating mayor directory: %w", err) + // Create rigs directory (for managed rig clones) + rigsDir := filepath.Join(absPath, "rigs") + if err := os.MkdirAll(rigsDir, 0755); err != nil { + return fmt.Errorf("creating rigs directory: %w", err) } - fmt.Printf(" ✓ Created mayor/\n") + fmt.Printf(" ✓ Created rigs/\n") // Create mayor mail directory mailDir := filepath.Join(mayorDir, "mail") @@ -156,22 +156,11 @@ func runInstall(cmd *cobra.Command, args []string) error { } fmt.Printf(" ✓ Created mayor/state.json\n") - // Create mayor config.json (this is what distinguishes town-level mayor) - mayorConfig := map[string]interface{}{ - "type": "mayor", - "version": 1, - } - mayorConfigPath := filepath.Join(mayorDir, "config.json") - if err := writeJSON(mayorConfigPath, mayorConfig); err != nil { - return fmt.Errorf("writing mayor config: %w", err) - } - fmt.Printf(" ✓ Created mayor/config.json\n") - - // Create Mayor CLAUDE.md from template - if err := createMayorCLAUDEmd(mayorDir, absPath); err != nil { + // Create Mayor CLAUDE.md at harness root (Mayor runs from there) + if err := createMayorCLAUDEmd(absPath, absPath); err != nil { fmt.Printf(" %s Could not create CLAUDE.md: %v\n", style.Dim.Render("⚠"), err) } else { - fmt.Printf(" ✓ Created mayor/CLAUDE.md\n") + fmt.Printf(" ✓ Created CLAUDE.md\n") } // Create .beads directory with redirect (optional) @@ -201,7 +190,7 @@ func runInstall(cmd *cobra.Command, args []string) error { return nil } -func createMayorCLAUDEmd(mayorDir, townRoot string) error { +func createMayorCLAUDEmd(harnessRoot, townRoot string) error { tmpl, err := templates.New() if err != nil { return err @@ -210,7 +199,7 @@ func createMayorCLAUDEmd(mayorDir, townRoot string) error { data := templates.RoleData{ Role: "mayor", TownRoot: townRoot, - WorkDir: mayorDir, + WorkDir: harnessRoot, } content, err := tmpl.RenderRole("mayor", data) @@ -218,7 +207,7 @@ func createMayorCLAUDEmd(mayorDir, townRoot string) error { return err } - claudePath := filepath.Join(mayorDir, "CLAUDE.md") + claudePath := filepath.Join(harnessRoot, "CLAUDE.md") return os.WriteFile(claudePath, []byte(content), 0644) } diff --git a/internal/cmd/polecat.go b/internal/cmd/polecat.go index 81d91129..69031718 100644 --- a/internal/cmd/polecat.go +++ b/internal/cmd/polecat.go @@ -145,7 +145,7 @@ func getPolecatManager(rigName string) (*polecat.Manager, *rig.Rig, error) { } // Load rigs config - rigsConfigPath := filepath.Join(townRoot, "config", "rigs.json") + rigsConfigPath := filepath.Join(townRoot, "mayor", "rigs.json") rigsConfig, err := config.LoadRigsConfig(rigsConfigPath) if err != nil { rigsConfig = &config.RigsConfig{Rigs: make(map[string]config.RigEntry)} @@ -303,8 +303,8 @@ func runPolecatRemove(cmd *cobra.Command, args []string) error { fmt.Printf("Removing polecat %s/%s...\n", rigName, polecatName) - if err := mgr.Remove(polecatName); err != nil { - if errors.Is(err, polecat.ErrHasChanges) && !polecatForce { + if err := mgr.Remove(polecatName, polecatForce); err != nil { + if errors.Is(err, polecat.ErrHasChanges) { return fmt.Errorf("polecat has uncommitted changes. Use --force to remove anyway") } return fmt.Errorf("removing polecat: %w", err) diff --git a/internal/cmd/refinery.go b/internal/cmd/refinery.go index f9f352c3..1a1157d3 100644 --- a/internal/cmd/refinery.go +++ b/internal/cmd/refinery.go @@ -102,7 +102,7 @@ func getRefineryManager(rigName string) (*refinery.Manager, *rig.Rig, error) { return nil, nil, fmt.Errorf("not in a Gas Town workspace: %w", err) } - rigsConfigPath := filepath.Join(townRoot, "config", "rigs.json") + rigsConfigPath := filepath.Join(townRoot, "mayor", "rigs.json") rigsConfig, err := config.LoadRigsConfig(rigsConfigPath) if err != nil { rigsConfig = &config.RigsConfig{Rigs: make(map[string]config.RigEntry)} diff --git a/internal/cmd/session.go b/internal/cmd/session.go index c9062786..aa0af1f1 100644 --- a/internal/cmd/session.go +++ b/internal/cmd/session.go @@ -154,7 +154,7 @@ func getSessionManager(rigName string) (*session.Manager, *rig.Rig, error) { } // Load rigs config - rigsConfigPath := filepath.Join(townRoot, "config", "rigs.json") + rigsConfigPath := filepath.Join(townRoot, "mayor", "rigs.json") rigsConfig, err := config.LoadRigsConfig(rigsConfigPath) if err != nil { rigsConfig = &config.RigsConfig{Rigs: make(map[string]config.RigEntry)} @@ -269,7 +269,7 @@ func runSessionList(cmd *cobra.Command, args []string) error { } // Load rigs config - rigsConfigPath := filepath.Join(townRoot, "config", "rigs.json") + rigsConfigPath := filepath.Join(townRoot, "mayor", "rigs.json") rigsConfig, err := config.LoadRigsConfig(rigsConfigPath) if err != nil { rigsConfig = &config.RigsConfig{Rigs: make(map[string]config.RigEntry)} diff --git a/internal/cmd/spawn.go b/internal/cmd/spawn.go index 04dea0f0..608922a0 100644 --- a/internal/cmd/spawn.go +++ b/internal/cmd/spawn.go @@ -81,7 +81,7 @@ func runSpawn(cmd *cobra.Command, args []string) error { return fmt.Errorf("not in a Gas Town workspace: %w", err) } - rigsConfigPath := filepath.Join(townRoot, "config", "rigs.json") + rigsConfigPath := filepath.Join(townRoot, "mayor", "rigs.json") rigsConfig, err := config.LoadRigsConfig(rigsConfigPath) if err != nil { rigsConfig = &config.RigsConfig{Rigs: make(map[string]config.RigEntry)} diff --git a/internal/cmd/status.go b/internal/cmd/status.go index a94ab5ec..bbb0c754 100644 --- a/internal/cmd/status.go +++ b/internal/cmd/status.go @@ -63,7 +63,7 @@ func runStatus(cmd *cobra.Command, args []string) error { } // Load town config - townConfigPath := filepath.Join(townRoot, "config", "town.json") + townConfigPath := filepath.Join(townRoot, "mayor", "town.json") townConfig, err := config.LoadTownConfig(townConfigPath) if err != nil { // Try to continue without config @@ -71,7 +71,7 @@ func runStatus(cmd *cobra.Command, args []string) error { } // Load rigs config - rigsConfigPath := filepath.Join(townRoot, "config", "rigs.json") + rigsConfigPath := filepath.Join(townRoot, "mayor", "rigs.json") rigsConfig, err := config.LoadRigsConfig(rigsConfigPath) if err != nil { // Empty config if file doesn't exist diff --git a/internal/cmd/stop.go b/internal/cmd/stop.go index 64144940..e541d04f 100644 --- a/internal/cmd/stop.go +++ b/internal/cmd/stop.go @@ -63,7 +63,7 @@ func runStop(cmd *cobra.Command, args []string) error { } // Load rigs config - rigsConfigPath := filepath.Join(townRoot, "config", "rigs.json") + rigsConfigPath := filepath.Join(townRoot, "mayor", "rigs.json") rigsConfig, err := config.LoadRigsConfig(rigsConfigPath) if err != nil { rigsConfig = &config.RigsConfig{Rigs: make(map[string]config.RigEntry)} diff --git a/internal/cmd/swarm.go b/internal/cmd/swarm.go index 4f3dba1c..5bcf6aba 100644 --- a/internal/cmd/swarm.go +++ b/internal/cmd/swarm.go @@ -189,7 +189,7 @@ func getSwarmRig(rigName string) (*rig.Rig, string, error) { return nil, "", fmt.Errorf("not in a Gas Town workspace: %w", err) } - rigsConfigPath := filepath.Join(townRoot, "config", "rigs.json") + rigsConfigPath := filepath.Join(townRoot, "mayor", "rigs.json") rigsConfig, err := config.LoadRigsConfig(rigsConfigPath) if err != nil { rigsConfig = &config.RigsConfig{Rigs: make(map[string]config.RigEntry)} @@ -212,7 +212,7 @@ func getAllRigs() ([]*rig.Rig, string, error) { return nil, "", fmt.Errorf("not in a Gas Town workspace: %w", err) } - rigsConfigPath := filepath.Join(townRoot, "config", "rigs.json") + rigsConfigPath := filepath.Join(townRoot, "mayor", "rigs.json") rigsConfig, err := config.LoadRigsConfig(rigsConfigPath) if err != nil { rigsConfig = &config.RigsConfig{Rigs: make(map[string]config.RigEntry)} diff --git a/internal/config/loader_test.go b/internal/config/loader_test.go index d76825e3..d00fbe94 100644 --- a/internal/config/loader_test.go +++ b/internal/config/loader_test.go @@ -8,7 +8,7 @@ import ( func TestTownConfigRoundTrip(t *testing.T) { dir := t.TempDir() - path := filepath.Join(dir, "config", "town.json") + path := filepath.Join(dir, "mayor", "town.json") original := &TownConfig{ Type: "town", @@ -36,7 +36,7 @@ func TestTownConfigRoundTrip(t *testing.T) { func TestRigsConfigRoundTrip(t *testing.T) { dir := t.TempDir() - path := filepath.Join(dir, "config", "rigs.json") + path := filepath.Join(dir, "mayor", "rigs.json") original := &RigsConfig{ Version: 1, diff --git a/internal/config/types.go b/internal/config/types.go index 45d4fbde..0c6a6933 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -3,7 +3,7 @@ package config import "time" -// TownConfig represents the main town configuration (config/town.json). +// TownConfig represents the main town configuration (mayor/town.json). type TownConfig struct { Type string `json:"type"` // "town" Version int `json:"version"` // schema version @@ -11,7 +11,7 @@ type TownConfig struct { CreatedAt time.Time `json:"created_at"` } -// RigsConfig represents the rigs registry (config/rigs.json). +// RigsConfig represents the rigs registry (mayor/rigs.json). type RigsConfig struct { Version int `json:"version"` Rigs map[string]RigEntry `json:"rigs"` diff --git a/internal/git/git.go b/internal/git/git.go index 9d33db0e..fcd9db3a 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -246,3 +246,82 @@ func (g *Git) IsAncestor(ancestor, descendant string) (bool, error) { } return true, nil } + +// WorktreeAdd creates a new worktree at the given path with a new branch. +// The new branch is created from the current HEAD. +func (g *Git) WorktreeAdd(path, branch string) error { + _, err := g.run("worktree", "add", "-b", branch, path) + return err +} + +// WorktreeAddDetached creates a new worktree at the given path with a detached HEAD. +func (g *Git) WorktreeAddDetached(path, ref string) error { + _, err := g.run("worktree", "add", "--detach", path, ref) + return err +} + +// WorktreeAddExisting creates a new worktree at the given path for an existing branch. +func (g *Git) WorktreeAddExisting(path, branch string) error { + _, err := g.run("worktree", "add", path, branch) + return err +} + +// WorktreeRemove removes a worktree. +func (g *Git) WorktreeRemove(path string, force bool) error { + args := []string{"worktree", "remove", path} + if force { + args = append(args, "--force") + } + _, err := g.run(args...) + return err +} + +// WorktreePrune removes worktree entries for deleted paths. +func (g *Git) WorktreePrune() error { + _, err := g.run("worktree", "prune") + return err +} + +// Worktree represents a git worktree. +type Worktree struct { + Path string + Branch string + Commit string +} + +// WorktreeList returns all worktrees for this repository. +func (g *Git) WorktreeList() ([]Worktree, error) { + out, err := g.run("worktree", "list", "--porcelain") + if err != nil { + return nil, err + } + + var worktrees []Worktree + var current Worktree + + for _, line := range strings.Split(out, "\n") { + if line == "" { + if current.Path != "" { + worktrees = append(worktrees, current) + current = Worktree{} + } + continue + } + + switch { + case strings.HasPrefix(line, "worktree "): + current.Path = strings.TrimPrefix(line, "worktree ") + case strings.HasPrefix(line, "HEAD "): + current.Commit = strings.TrimPrefix(line, "HEAD ") + case strings.HasPrefix(line, "branch "): + current.Branch = strings.TrimPrefix(line, "branch refs/heads/") + } + } + + // Don't forget the last one + if current.Path != "" { + worktrees = append(worktrees, current) + } + + return worktrees, nil +} diff --git a/internal/polecat/manager.go b/internal/polecat/manager.go index 63634400..3d892a47 100644 --- a/internal/polecat/manager.go +++ b/internal/polecat/manager.go @@ -49,13 +49,15 @@ func (m *Manager) exists(name string) bool { return err == nil } -// Add creates a new polecat with a clone of the rig. +// Add creates a new polecat as a git worktree from the refinery clone. +// This is much faster than a full clone and shares objects with the refinery. func (m *Manager) Add(name string) (*Polecat, error) { if m.exists(name) { return nil, ErrPolecatExists } polecatPath := m.polecatDir(name) + branchName := fmt.Sprintf("polecat/%s", name) // Create polecats directory if needed polecatsDir := filepath.Join(m.rig.Path, "polecats") @@ -63,21 +65,19 @@ func (m *Manager) Add(name string) (*Polecat, error) { return nil, fmt.Errorf("creating polecats dir: %w", err) } - // Clone the rig repo - if err := m.git.Clone(m.rig.GitURL, polecatPath); err != nil { - return nil, fmt.Errorf("cloning rig: %w", err) + // Use refinery clone as the base for worktrees + refineryPath := filepath.Join(m.rig.Path, "refinery", "rig") + refineryGit := git.NewGit(refineryPath) + + // Verify refinery clone exists + if _, err := os.Stat(refineryPath); os.IsNotExist(err) { + return nil, fmt.Errorf("refinery clone not found at %s (run 'gt rig add' to set up rig structure)", refineryPath) } - // Create working branch - polecatGit := git.NewGit(polecatPath) - branchName := fmt.Sprintf("polecat/%s", name) - if err := polecatGit.CreateBranch(branchName); err != nil { - os.RemoveAll(polecatPath) - return nil, fmt.Errorf("creating branch: %w", err) - } - if err := polecatGit.Checkout(branchName); err != nil { - os.RemoveAll(polecatPath) - return nil, fmt.Errorf("checking out branch: %w", err) + // Create worktree with new branch + // git worktree add -b polecat/ + if err := refineryGit.WorktreeAdd(polecatPath, branchName); err != nil { + return nil, fmt.Errorf("creating worktree: %w", err) } // Create polecat state @@ -94,15 +94,17 @@ func (m *Manager) Add(name string) (*Polecat, error) { // Save state if err := m.saveState(polecat); err != nil { - os.RemoveAll(polecatPath) + // Clean up worktree on failure + refineryGit.WorktreeRemove(polecatPath, true) return nil, fmt.Errorf("saving state: %w", err) } return polecat, nil } -// Remove deletes a polecat. -func (m *Manager) Remove(name string) error { +// Remove deletes a polecat worktree. +// If force is true, removes even with uncommitted changes. +func (m *Manager) Remove(name string, force bool) error { if !m.exists(name) { return ErrPolecatNotFound } @@ -110,17 +112,30 @@ func (m *Manager) Remove(name string) error { polecatPath := m.polecatDir(name) polecatGit := git.NewGit(polecatPath) - // Check for uncommitted changes - hasChanges, err := polecatGit.HasUncommittedChanges() - if err == nil && hasChanges { - return ErrHasChanges + // Check for uncommitted changes unless force + if !force { + hasChanges, err := polecatGit.HasUncommittedChanges() + if err == nil && hasChanges { + return ErrHasChanges + } } - // Remove directory - if err := os.RemoveAll(polecatPath); err != nil { - return fmt.Errorf("removing polecat dir: %w", err) + // Use refinery to remove the worktree properly + refineryPath := filepath.Join(m.rig.Path, "refinery", "rig") + refineryGit := git.NewGit(refineryPath) + + // Try to remove as a worktree first (use force flag for worktree removal too) + if err := refineryGit.WorktreeRemove(polecatPath, force); err != nil { + // Fall back to direct removal if worktree removal fails + // (e.g., if this is an old-style clone, not a worktree) + if removeErr := os.RemoveAll(polecatPath); removeErr != nil { + return fmt.Errorf("removing polecat dir: %w", removeErr) + } } + // Prune any stale worktree entries + refineryGit.WorktreePrune() + return nil } diff --git a/internal/tmux/tmux.go b/internal/tmux/tmux.go index ffd56838..8db4a620 100644 --- a/internal/tmux/tmux.go +++ b/internal/tmux/tmux.go @@ -7,6 +7,7 @@ import ( "fmt" "os/exec" "strings" + "time" ) // Common errors @@ -125,6 +126,13 @@ func (t *Tmux) SendKeysRaw(session, keys string) error { return err } +// SendKeysDelayed sends keystrokes after a delay (in milliseconds). +// Useful for waiting for a process to be ready before sending input. +func (t *Tmux) SendKeysDelayed(session, keys string, delayMs int) error { + time.Sleep(time.Duration(delayMs) * time.Millisecond) + return t.SendKeys(session, keys) +} + // CapturePane captures the visible content of a pane. func (t *Tmux) CapturePane(session string, lines int) (string, error) { return t.run("capture-pane", "-p", "-t", session, "-S", fmt.Sprintf("-%d", lines)) diff --git a/internal/workspace/find.go b/internal/workspace/find.go index ea9eb0a6..38df14d2 100644 --- a/internal/workspace/find.go +++ b/internal/workspace/find.go @@ -14,11 +14,8 @@ var ErrNotFound = errors.New("not in a Gas Town workspace") // Markers used to detect a Gas Town workspace. const ( // PrimaryMarker is the main config file that identifies a workspace. - PrimaryMarker = "config/town.json" - - // AlternativePrimaryMarker is the town-level mayor config file. - // This distinguishes a town mayor from a rig-level mayor clone. - AlternativePrimaryMarker = "mayor/config.json" + // The town.json file lives in mayor/ along with other mayor config. + PrimaryMarker = "mayor/town.json" // SecondaryMarker is an alternative indicator at the town level. // Note: This can match rig-level mayors too, so we continue searching @@ -27,8 +24,7 @@ const ( ) // Find locates the town root by walking up from the given directory. -// It looks for config/town.json or mayor/config.json (primary markers) -// or mayor/ directory (secondary marker). +// It looks for mayor/town.json (primary marker) or mayor/ directory (secondary marker). // // To avoid matching rig-level mayor directories, we continue searching // upward after finding a secondary marker, preferring primary matches. @@ -50,19 +46,12 @@ func Find(startDir string) (string, error) { // Walk up the directory tree current := absDir for { - // Check for primary marker (config/town.json) + // Check for primary marker (mayor/town.json) primaryPath := filepath.Join(current, PrimaryMarker) if _, err := os.Stat(primaryPath); err == nil { return current, nil } - // Check for alternative primary marker (mayor/config.json) - // This distinguishes a town-level mayor from a rig-level mayor clone - altPrimaryPath := filepath.Join(current, AlternativePrimaryMarker) - if _, err := os.Stat(altPrimaryPath); err == nil { - return current, nil - } - // Check for secondary marker (mayor/ directory) // Don't return immediately - continue searching for primary markers if secondaryMatch == "" { @@ -114,27 +103,21 @@ func FindFromCwdOrError() (string, error) { } // IsWorkspace checks if the given directory is a Gas Town workspace root. -// A directory is a workspace if it has primary markers (config/town.json -// or mayor/config.json) or a secondary marker (mayor/ directory). +// A directory is a workspace if it has a primary marker (mayor/town.json) +// or a secondary marker (mayor/ directory). func IsWorkspace(dir string) (bool, error) { absDir, err := filepath.Abs(dir) if err != nil { return false, fmt.Errorf("resolving path: %w", err) } - // Check for primary marker + // Check for primary marker (mayor/town.json) primaryPath := filepath.Join(absDir, PrimaryMarker) if _, err := os.Stat(primaryPath); err == nil { return true, nil } - // Check for alternative primary marker - altPrimaryPath := filepath.Join(absDir, AlternativePrimaryMarker) - if _, err := os.Stat(altPrimaryPath); err == nil { - return true, nil - } - - // Check for secondary marker + // Check for secondary marker (mayor/ directory) secondaryPath := filepath.Join(absDir, SecondaryMarker) info, err := os.Stat(secondaryPath) if err == nil && info.IsDir() { diff --git a/internal/workspace/find_test.go b/internal/workspace/find_test.go index 64ef2929..6b71745a 100644 --- a/internal/workspace/find_test.go +++ b/internal/workspace/find_test.go @@ -18,11 +18,11 @@ func realPath(t *testing.T, path string) string { func TestFindWithPrimaryMarker(t *testing.T) { // Create temp workspace structure root := realPath(t, t.TempDir()) - configDir := filepath.Join(root, "config") - if err := os.MkdirAll(configDir, 0755); err != nil { + mayorDir := filepath.Join(root, "mayor") + if err := os.MkdirAll(mayorDir, 0755); err != nil { t.Fatalf("mkdir: %v", err) } - townFile := filepath.Join(configDir, "town.json") + townFile := filepath.Join(mayorDir, "town.json") if err := os.WriteFile(townFile, []byte(`{"type":"town"}`), 0644); err != nil { t.Fatalf("write: %v", err) } @@ -92,11 +92,11 @@ func TestFindOrErrorNotFound(t *testing.T) { func TestFindAtRoot(t *testing.T) { // Create workspace at temp root level root := realPath(t, t.TempDir()) - configDir := filepath.Join(root, "config") - if err := os.MkdirAll(configDir, 0755); err != nil { + mayorDir := filepath.Join(root, "mayor") + if err := os.MkdirAll(mayorDir, 0755); err != nil { t.Fatalf("mkdir: %v", err) } - townFile := filepath.Join(configDir, "town.json") + townFile := filepath.Join(mayorDir, "town.json") if err := os.WriteFile(townFile, []byte(`{"type":"town"}`), 0644); err != nil { t.Fatalf("write: %v", err) } @@ -123,12 +123,12 @@ func TestIsWorkspace(t *testing.T) { t.Error("expected not a workspace initially") } - // Add primary marker - configDir := filepath.Join(root, "config") - if err := os.MkdirAll(configDir, 0755); err != nil { + // Add primary marker (mayor/town.json) + mayorDir := filepath.Join(root, "mayor") + if err := os.MkdirAll(mayorDir, 0755); err != nil { t.Fatalf("mkdir: %v", err) } - townFile := filepath.Join(configDir, "town.json") + townFile := filepath.Join(mayorDir, "town.json") if err := os.WriteFile(townFile, []byte(`{"type":"town"}`), 0644); err != nil { t.Fatalf("write: %v", err) } @@ -146,11 +146,11 @@ func TestIsWorkspace(t *testing.T) { func TestFindFollowsSymlinks(t *testing.T) { // Create workspace root := realPath(t, t.TempDir()) - configDir := filepath.Join(root, "config") - if err := os.MkdirAll(configDir, 0755); err != nil { + mayorDir := filepath.Join(root, "mayor") + if err := os.MkdirAll(mayorDir, 0755); err != nil { t.Fatalf("mkdir: %v", err) } - townFile := filepath.Join(configDir, "town.json") + townFile := filepath.Join(mayorDir, "town.json") if err := os.WriteFile(townFile, []byte(`{"type":"town"}`), 0644); err != nil { t.Fatalf("write: %v", err) }