Files
gastown/docs/town-design.md
Steve Yegge 4c782bc59d Initial commit: Go port scaffolding
- Go project structure (go.mod, cmd/gt/main.go)
- Beads database initialized with gt- prefix
- Town management design doc (docs/town-design.md)
- Basic README and CLAUDE.md

Epics tracked:
- gt-u1j: Port Gas Town to Go
- gt-f9x: Town & Rig Management

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-15 16:38:23 -08:00

13 KiB

Town Management Design

Design for gt install, gt doctor, and federation in the Gas Town Go port.

Overview

A Town is a complete Gas Town installation containing:

  • Workspace config (.gastown/ or mayor/)
  • Rigs (project workspaces)
  • Polecats (worker clones)
  • Mail system
  • Beads integration

Config Files

Workspace Root

A town is identified by a config directory (mayor/ or .gastown/) containing config.json with type: "workspace".

~/ai/                           # Workspace root
├── mayor/                      # or .gastown/
│   ├── config.json             # Workspace identity
│   ├── state.json              # Workspace state
│   ├── mail/                   # Mail system
│   │   └── inbox.jsonl         # Mayor's inbox
│   ├── boss/                   # Boss state
│   │   └── state.json
│   └── rigs/                   # Mayor's rig clones (for beads access)
│       ├── gastown/
│       └── beads/
├── gastown/                    # Rig
│   ├── .gastown/               # Rig config (type: "rig")
│   │   └── config.json
│   ├── refinery/               # Refinery infrastructure
│   │   ├── README.md
│   │   ├── state.json
│   │   ├── state.json.lock
│   │   └── rig/                # Refinery's git clone
│   └── <polecats>/             # Worker clones
└── beads/                      # Another rig

Workspace config.json

{
  "type": "workspace",
  "version": 1,
  "created_at": "2024-01-15T10:30:00Z"
}

Workspace state.json

{
  "version": 1,
  "projects": {}
}

Rig config.json

{
  "type": "rig",
  "git_url": "https://github.com/steveyegge/gastown",
  "beads_path": "~/.gastown/rigs/gastown/.beads",
  "federation": {
    "machines": ["local", "gcp-west-1"],
    "preferred_machine": "local"
  }
}

Refinery state.json

{
  "version": 1,
  "state": "stopped",
  "awake": false,
  "created_at": "2024-01-15T10:30:00Z",
  "last_started": null,
  "last_stopped": null,
  "last_wake": null,
  "last_sleep": null
}

gt install

Command

gt install [path]    # Default: current directory
gt install ~/ai

Behavior

  1. Check if already installed: Look for mayor/ or .gastown/ with type: "workspace"
  2. Create workspace structure:
    • mayor/config.json - workspace identity
    • mayor/state.json - workspace state
    • mayor/mail/ - mail directory
    • mayor/boss/state.json - boss state
    • mayor/rigs/ - empty, populated when rigs are added
  3. Create .gitignore - ignore ephemeral state, polecat clones
  4. Create CLAUDE.md - Mayor instructions
  5. Initialize git if not present

Implementation (Go)

// pkg/workspace/install.go
package workspace

type InstallOptions struct {
    Path string
    Force bool  // Overwrite existing
}

func Install(opts InstallOptions) (*Workspace, error) {
    path := opts.Path
    if path == "" {
        path = "."
    }

    // Resolve and validate
    absPath, err := filepath.Abs(path)
    if err != nil {
        return nil, fmt.Errorf("invalid path: %w", err)
    }

    // Check existing
    if ws, _ := Find(absPath); ws != nil && !opts.Force {
        return nil, ErrAlreadyInstalled
    }

    // Create structure
    mayorDir := filepath.Join(absPath, "mayor")
    if err := os.MkdirAll(mayorDir, 0755); err != nil {
        return nil, err
    }

    // Write config
    config := Config{
        Type:      "workspace",
        Version:   1,
        CreatedAt: time.Now().UTC(),
    }
    if err := writeJSON(filepath.Join(mayorDir, "config.json"), config); err != nil {
        return nil, err
    }

    // ... create state, mail, boss, rigs

    return &Workspace{Path: absPath, Config: config}, nil
}

gt doctor

Command

gt doctor           # Check workspace health
gt doctor --fix     # Auto-fix issues
gt doctor <rig>     # Check specific rig

Checks

Workspace Level

  1. Workspace exists: mayor/ or .gastown/ directory
  2. Valid config: config.json has type: "workspace"
  3. State file: state.json exists and is valid JSON
  4. Mail directory: mail/ exists
  5. Boss state: boss/state.json exists
  6. Rigs directory: rigs/ exists

Per-Rig Checks

  1. Refinery directory: <rig>/refinery/ exists
  2. Refinery README: refinery/README.md exists
  3. Refinery state: refinery/state.json exists
  4. Refinery lock: refinery/state.json.lock exists
  5. Refinery clone: refinery/rig/ has valid .git
  6. Boss rig clone: mayor/rigs/<rig>/ has valid .git
  7. Gitignore entries: workspace .gitignore has rig patterns

Output Format

$ gt doctor
Workspace: ~/ai

✓ Workspace config valid
✓ Workspace state valid
✓ Mail directory exists
✓ Boss state valid

Rig: gastown
✓ Refinery directory exists
✓ Refinery README exists
✓ Refinery state valid
✗ Missing refinery/rig/ clone
✓ Mayor rig clone exists
✓ Gitignore entries present

Rig: beads
✓ Refinery directory exists
✓ Refinery README exists
✓ Refinery state valid
✓ Refinery clone valid
✓ Mayor rig clone exists
✓ Gitignore entries present

Issues: 1 found, 0 fixed
Run with --fix to auto-repair

Implementation (Go)

// pkg/doctor/doctor.go
package doctor

type CheckResult struct {
    Name    string
    Status  Status  // Pass, Fail, Warn
    Message string
    Fixable bool
}

type DoctorOptions struct {
    Fix     bool
    Rig     string  // Empty = all rigs
    Verbose bool
}

func Run(ws *workspace.Workspace, opts DoctorOptions) (*Report, error) {
    report := &Report{}

    // Workspace checks
    report.Add(checkWorkspaceConfig(ws))
    report.Add(checkWorkspaceState(ws))
    report.Add(checkMailDir(ws))
    report.Add(checkBossState(ws))
    report.Add(checkRigsDir(ws))

    // Per-rig checks
    rigs, _ := ws.ListRigs()
    for _, rig := range rigs {
        if opts.Rig != "" && rig.Name != opts.Rig {
            continue
        }
        report.AddRig(rig.Name, checkRig(rig, ws, opts.Fix))
    }

    return report, nil
}

func checkRefineryHealth(rig *rig.Rig, fix bool) []CheckResult {
    var results []CheckResult

    refineryDir := filepath.Join(rig.Path, "refinery")

    // Check refinery directory
    if !dirExists(refineryDir) {
        r := CheckResult{Name: "Refinery directory", Status: Fail, Fixable: true}
        if fix {
            if err := os.MkdirAll(refineryDir, 0755); err == nil {
                r.Status = Fixed
            }
        }
        results = append(results, r)
    }

    // Check README.md
    readmePath := filepath.Join(refineryDir, "README.md")
    if !fileExists(readmePath) {
        r := CheckResult{Name: "Refinery README", Status: Fail, Fixable: true}
        if fix {
            if err := writeRefineryReadme(readmePath); err == nil {
                r.Status = Fixed
            }
        }
        results = append(results, r)
    }

    // ... more checks
    return results
}

Workspace Detection

Find workspace root by walking up from current directory:

// pkg/workspace/find.go
func Find(startPath string) (*Workspace, error) {
    current, _ := filepath.Abs(startPath)

    for current != filepath.Dir(current) {
        // Check both "mayor" and ".gastown" directories
        for _, dirName := range []string{"mayor", ".gastown"} {
            configDir := filepath.Join(current, dirName)
            configPath := filepath.Join(configDir, "config.json")

            if fileExists(configPath) {
                var config Config
                if err := readJSON(configPath, &config); err != nil {
                    continue
                }
                if config.Type == "workspace" {
                    return &Workspace{
                        Path:      current,
                        ConfigDir: dirName,
                        Config:    config,
                    }, nil
                }
            }
        }
        current = filepath.Dir(current)
    }
    return nil, ErrNotFound
}

Minimal Federation Protocol

Federation enables work distribution across multiple machines via SSH.

Core Abstractions

Connection Interface

// pkg/connection/connection.go
type Connection interface {
    // Command execution
    Execute(ctx context.Context, cmd string, opts ExecOpts) (*Result, error)

    // File operations
    ReadFile(path string) ([]byte, error)
    WriteFile(path string, data []byte) error
    AppendFile(path string, data []byte) error
    FileExists(path string) (bool, error)
    ListDir(path string) ([]string, error)
    MkdirAll(path string) error

    // Tmux operations
    TmuxSend(session string, text string) error
    TmuxCapture(session string, lines int) (string, error)
    TmuxHasSession(session string) (bool, error)

    // Health
    IsHealthy() bool
}

LocalConnection

// pkg/connection/local.go
type LocalConnection struct{}

func (c *LocalConnection) Execute(ctx context.Context, cmd string, opts ExecOpts) (*Result, error) {
    // Direct exec.Command
}

func (c *LocalConnection) ReadFile(path string) ([]byte, error) {
    return os.ReadFile(path)
}

SSHConnection

// pkg/connection/ssh.go
type SSHConnection struct {
    Host    string
    User    string
    KeyPath string
    client  *ssh.Client
}

func (c *SSHConnection) Execute(ctx context.Context, cmd string, opts ExecOpts) (*Result, error) {
    session, err := c.client.NewSession()
    if err != nil {
        return nil, err
    }
    defer session.Close()
    // Run command via SSH
}

Machine Registry

// pkg/federation/registry.go
type Machine struct {
    Name        string
    Type        string  // "local", "ssh", "gcp"
    Workspace   string  // Remote workspace path
    SSHHost     string
    SSHUser     string
    SSHKeyPath  string
    GCPProject  string
    GCPZone     string
    GCPInstance string
}

type Registry struct {
    machines map[string]*Machine
    conns    map[string]Connection
}

func (r *Registry) GetConnection(name string) (Connection, error) {
    if conn, ok := r.conns[name]; ok {
        return conn, nil
    }

    machine, ok := r.machines[name]
    if !ok {
        return nil, ErrMachineNotFound
    }

    var conn Connection
    switch machine.Type {
    case "local":
        conn = &LocalConnection{}
    case "ssh", "gcp":
        conn = NewSSHConnection(machine.SSHHost, machine.SSHUser, machine.SSHKeyPath)
    }

    r.conns[name] = conn
    return conn, nil
}

Extended Addressing

Polecat addresses support optional machine prefix:

[machine:]rig/polecat

Examples:
  beads/happy           # Local machine (default)
  gcp-west:beads/happy  # Remote machine
// pkg/identity/address.go
type PolecatAddress struct {
    Machine string  // Default: "local"
    Rig     string
    Polecat string
}

func ParseAddress(addr string) (*PolecatAddress, error) {
    parts := strings.SplitN(addr, ":", 2)
    if len(parts) == 2 {
        // machine:rig/polecat
        machine := parts[0]
        rigPolecat := strings.SplitN(parts[1], "/", 2)
        return &PolecatAddress{Machine: machine, Rig: rigPolecat[0], Polecat: rigPolecat[1]}, nil
    }
    // rig/polecat (local default)
    rigPolecat := strings.SplitN(addr, "/", 2)
    return &PolecatAddress{Machine: "local", Rig: rigPolecat[0], Polecat: rigPolecat[1]}, nil
}

Mail Routing

For federation < 50 agents, use centralized mail through Mayor's machine:

// pkg/mail/router.go
type MailRouter struct {
    registry *federation.Registry
}

func (r *MailRouter) Deliver(msg *Message) error {
    addr, _ := identity.ParseAddress(msg.Recipient)
    conn, err := r.registry.GetConnection(addr.Machine)
    if err != nil {
        return err
    }

    mailboxPath := filepath.Join(addr.Rig, addr.Polecat, "mail", "inbox.jsonl")
    return conn.AppendFile(mailboxPath, msg.ToJSONL())
}

Implementation Plan

Subtasks for gt-evp2

  1. Config package - Config, State types and JSON serialization
  2. Workspace detection - Find() walking up directory tree
  3. gt install command - Create workspace structure
  4. Doctor framework - Check interface, Result types, Report
  5. Workspace doctor checks - Config, state, mail, boss, rigs
  6. Rig doctor checks - Refinery health, clones, gitignore
  7. Connection interface - Define protocol for local/remote ops
  8. LocalConnection - Local file/exec/tmux operations
  9. Machine registry - Store and manage machine configs
  10. Extended addressing - Parse [machine:]rig/polecat

Deferred (Federation Phase 2)

  • SSHConnection implementation
  • GCPConnection with gcloud integration
  • Cross-machine mail routing
  • Remote session management
  • Worker pool across machines

CLI Commands Summary

# Installation
gt install [path]           # Install workspace at path
gt install --force          # Overwrite existing

# Diagnostics
gt doctor                   # Check workspace health
gt doctor --fix             # Auto-fix issues
gt doctor <rig>             # Check specific rig
gt doctor --verbose         # Show all checks (not just failures)

# Future (federation)
gt machine list             # List machines
gt machine add <name>       # Add machine
gt machine status           # Check all machine health