- 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>
13 KiB
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/ormayor/) - 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
- Check if already installed: Look for
mayor/or.gastown/withtype: "workspace" - Create workspace structure:
mayor/config.json- workspace identitymayor/state.json- workspace statemayor/mail/- mail directorymayor/boss/state.json- boss statemayor/rigs/- empty, populated when rigs are added
- Create .gitignore - ignore ephemeral state, polecat clones
- Create CLAUDE.md - Mayor instructions
- 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
- Workspace exists:
mayor/or.gastown/directory - Valid config:
config.jsonhastype: "workspace" - State file:
state.jsonexists and is valid JSON - Mail directory:
mail/exists - Boss state:
boss/state.jsonexists - Rigs directory:
rigs/exists
Per-Rig Checks
- Refinery directory:
<rig>/refinery/exists - Refinery README:
refinery/README.mdexists - Refinery state:
refinery/state.jsonexists - Refinery lock:
refinery/state.json.lockexists - Refinery clone:
refinery/rig/has valid.git - Boss rig clone:
mayor/rigs/<rig>/has valid.git - Gitignore entries: workspace
.gitignorehas 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
- Config package - Config, State types and JSON serialization
- Workspace detection - Find() walking up directory tree
- gt install command - Create workspace structure
- Doctor framework - Check interface, Result types, Report
- Workspace doctor checks - Config, state, mail, boss, rigs
- Rig doctor checks - Refinery health, clones, gitignore
- Connection interface - Define protocol for local/remote ops
- LocalConnection - Local file/exec/tmux operations
- Machine registry - Store and manage machine configs
- 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