feat: crew attach auto-detection, worktree polecats, beads mail
- gt crew at: auto-detect crew from cwd, run gt prime after launch - Polecats now use git worktrees from refinery (faster than clones) - Updated architecture.md for two-tier beads mail model - Town beads (gm-*) for Mayor mail/coordination - Rig .beads/ symlinks to refinery/rig/.beads 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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"}]}
|
||||
|
||||
@@ -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 <name> <git-url>`:
|
||||
@@ -174,28 +186,30 @@ Created by `gt rig add <name> <git-url>`:
|
||||
```
|
||||
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)
|
||||
│ └── <name>/ # 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/
|
||||
│ │ │ └── <project files>
|
||||
│ │ └── state.json
|
||||
│ │
|
||||
│ ├── mayor/ # Mayor's rig-specific clone
|
||||
│ │ ├── rig/ # Mayor's clone for this rig
|
||||
│ │ │ ├── .git/
|
||||
│ │ │ ├── .beads/ # CANONICAL rig beads (gt-* prefix)
|
||||
│ │ │ └── <project files>
|
||||
│ │ └── 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)
|
||||
│ │ └── <name>/ # Full clone (inherits beads from rig)
|
||||
│ │ ├── .git/
|
||||
│ │ └── <project files>
|
||||
│ │
|
||||
│ ├── polecats/ # Worker directories (initially empty)
|
||||
│ │ ├── Nux/ # Full clone (BEADS_DIR=../../.beads)
|
||||
│ │ │ ├── .git/
|
||||
│ │ │ └── <project files>
|
||||
│ │ └── Toast/ # Full clone
|
||||
│ ├── polecats/ # Worker directories (worktrees)
|
||||
│ │ ├── Nux/ # Git worktree from refinery
|
||||
│ │ │ └── <project files> # (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:
|
||||
|
||||
@@ -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 <name>",
|
||||
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 <rig>/crew/<name>/, 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 <name>", 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 <town>/<rig>/crew/<name>/ 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: <rig>/crew/<name>/...
|
||||
// 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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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/<name> <path>
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user