refactor: consolidate agent environment variables (#294)

Introduces config.AgentEnv() as the single source of truth for all agent
environment variables. Previously, different roles received different subsets
of variables depending on their startup path.

Changes:
- All agents now receive GT_ROOT and BEADS_DIR (previously only polecat/refinery)
- Add gt doctor env-vars check to validate tmux session variables
- Fix gt role home witness returning incorrect path
- Fix BEADS_DIR not following redirects for repos with tracked beads

Co-authored-by: julianknutsen <julianknutsen@users.noreply.github.com>
This commit is contained in:
gastown/crew/gus
2026-01-09 21:55:28 -08:00
committed by Steve Yegge
29 changed files with 12665 additions and 494 deletions
+29 -4
View File
@@ -88,15 +88,37 @@ All events include actor attribution:
## Environment Setup ## Environment Setup
The daemon sets these automatically when spawning agents: Gas Town uses a centralized `config.AgentEnv()` function to set environment
variables consistently across all agent spawn paths (managers, daemon, boot).
### Example: Polecat Environment
```bash ```bash
# Set by daemon for polecat 'toast' in rig 'gastown' # Set automatically for polecat 'toast' in rig 'gastown'
export BD_ACTOR="gastown/polecats/toast"
export GIT_AUTHOR_NAME="gastown/polecats/toast"
export GT_ROLE="polecat" export GT_ROLE="polecat"
export GT_RIG="gastown" export GT_RIG="gastown"
export GT_POLECAT="toast" export GT_POLECAT="toast"
export BD_ACTOR="gastown/polecats/toast"
export GIT_AUTHOR_NAME="gastown/polecats/toast"
export GT_ROOT="/home/user/gt"
export BEADS_DIR="/home/user/gt/gastown/.beads"
export BEADS_AGENT_NAME="gastown/toast"
export BEADS_NO_DAEMON="1" # Polecats use isolated beads context
```
### Example: Crew Environment
```bash
# Set automatically for crew member 'joe' in rig 'gastown'
export GT_ROLE="crew"
export GT_RIG="gastown"
export GT_CREW="joe"
export BD_ACTOR="gastown/crew/joe"
export GIT_AUTHOR_NAME="gastown/crew/joe"
export GT_ROOT="/home/user/gt"
export BEADS_DIR="/home/user/gt/gastown/.beads"
export BEADS_AGENT_NAME="gastown/joe"
export BEADS_NO_DAEMON="1" # Crew uses isolated beads context
``` ```
### Manual Override ### Manual Override
@@ -108,6 +130,9 @@ export BD_ACTOR="gastown/crew/debug"
bd create --title="Test issue" # Will show created_by: gastown/crew/debug bd create --title="Test issue" # Will show created_by: gastown/crew/debug
``` ```
See [reference.md](reference.md#environment-variables) for the complete
environment variable reference.
## Identity Parsing ## Identity Parsing
The format supports programmatic parsing: The format supports programmatic parsing:
+52 -9
View File
@@ -206,17 +206,60 @@ gt mol step done <step> # Complete a molecule step
## Environment Variables ## Environment Variables
Gas Town sets environment variables for each agent session via `config.AgentEnv()`.
These are set in tmux session environment when agents are spawned.
### Core Variables (All Agents)
| Variable | Purpose | Example |
|----------|---------|---------|
| `GT_ROLE` | Agent role type | `mayor`, `witness`, `polecat`, `crew` |
| `GT_ROOT` | Town root directory | `/home/user/gt` |
| `BD_ACTOR` | Agent identity for attribution | `gastown/polecats/toast` |
| `GIT_AUTHOR_NAME` | Commit attribution (same as BD_ACTOR) | `gastown/polecats/toast` |
| `BEADS_DIR` | Beads database location | `/home/user/gt/gastown/.beads` |
### Rig-Level Variables
| Variable | Purpose | Roles |
|----------|---------|-------|
| `GT_RIG` | Rig name | witness, refinery, polecat, crew |
| `GT_POLECAT` | Polecat worker name | polecat only |
| `GT_CREW` | Crew worker name | crew only |
| `BEADS_AGENT_NAME` | Agent name for beads operations | polecat, crew |
| `BEADS_NO_DAEMON` | Disable beads daemon (isolated context) | polecat, crew |
### Other Variables
| Variable | Purpose | | Variable | Purpose |
|----------|---------| |----------|---------|
| `BD_ACTOR` | Agent identity for attribution (see [identity.md](identity.md)) | | `GIT_AUTHOR_EMAIL` | Workspace owner email (from git config) |
| `BEADS_DIR` | Point to shared beads database | | `GT_TOWN_ROOT` | Override town root detection (manual use) |
| `BEADS_NO_DAEMON` | Required for worktree polecats | | `CLAUDE_RUNTIME_CONFIG_DIR` | Custom Claude settings directory |
| `GIT_AUTHOR_NAME` | Set to BD_ACTOR for commit attribution |
| `GIT_AUTHOR_EMAIL` | Workspace owner email | ### Environment by Role
| `GT_TOWN_ROOT` | Override town root detection |
| `GT_ROLE` | Agent role type (mayor, polecat, etc.) | | Role | Key Variables |
| `GT_RIG` | Rig name for rig-level agents | |------|---------------|
| `GT_POLECAT` | Polecat name (for polecats only) | | **Mayor** | `GT_ROLE=mayor`, `BD_ACTOR=mayor` |
| **Deacon** | `GT_ROLE=deacon`, `BD_ACTOR=deacon` |
| **Boot** | `GT_ROLE=boot`, `BD_ACTOR=deacon-boot` |
| **Witness** | `GT_ROLE=witness`, `GT_RIG=<rig>`, `BD_ACTOR=<rig>/witness` |
| **Refinery** | `GT_ROLE=refinery`, `GT_RIG=<rig>`, `BD_ACTOR=<rig>/refinery` |
| **Polecat** | `GT_ROLE=polecat`, `GT_RIG=<rig>`, `GT_POLECAT=<name>`, `BD_ACTOR=<rig>/polecats/<name>` |
| **Crew** | `GT_ROLE=crew`, `GT_RIG=<rig>`, `GT_CREW=<name>`, `BD_ACTOR=<rig>/crew/<name>` |
### Doctor Check
The `gt doctor` command verifies that running tmux sessions have correct
environment variables. Mismatches are reported as warnings:
```
⚠ env-vars: Found 3 env var mismatch(es) across 1 session(s)
hq-mayor: missing GT_ROOT (expected "/home/user/gt")
```
Fix by restarting sessions: `gt shutdown && gt up`
## Agent Working Directories and Settings ## Agent Working Directories and Settings
+19 -8
View File
@@ -11,6 +11,7 @@ import (
"path/filepath" "path/filepath"
"time" "time"
"github.com/steveyegge/gastown/internal/beads"
"github.com/steveyegge/gastown/internal/config" "github.com/steveyegge/gastown/internal/config"
"github.com/steveyegge/gastown/internal/tmux" "github.com/steveyegge/gastown/internal/tmux"
) )
@@ -190,9 +191,15 @@ func (b *Boot) spawnTmux() error {
return fmt.Errorf("creating boot session: %w", err) return fmt.Errorf("creating boot session: %w", err)
} }
// Set environment // Set environment using centralized AgentEnv for consistency
_ = b.tmux.SetEnvironment(SessionName, "GT_ROLE", "boot") envVars := config.AgentEnv(config.AgentEnvConfig{
_ = b.tmux.SetEnvironment(SessionName, "BD_ACTOR", "deacon-boot") Role: "boot",
TownRoot: b.townRoot,
BeadsDir: beads.ResolveBeadsDir(b.townRoot),
})
for k, v := range envVars {
_ = b.tmux.SetEnvironment(SessionName, k, v)
}
// Launch Claude with environment exported inline and initial triage prompt // Launch Claude with environment exported inline and initial triage prompt
// The "gt boot triage" prompt tells Boot to immediately start triage (GUPP principle) // The "gt boot triage" prompt tells Boot to immediately start triage (GUPP principle)
@@ -216,11 +223,15 @@ func (b *Boot) spawnDegraded() error {
// This performs the triage logic without a full Claude session // This performs the triage logic without a full Claude session
cmd := exec.Command("gt", "boot", "triage", "--degraded") cmd := exec.Command("gt", "boot", "triage", "--degraded")
cmd.Dir = b.deaconDir cmd.Dir = b.deaconDir
cmd.Env = append(os.Environ(),
"GT_ROLE=boot", // Use centralized AgentEnv for consistency with tmux mode
"BD_ACTOR=deacon-boot", envVars := config.AgentEnv(config.AgentEnvConfig{
"GT_DEGRADED=true", Role: "boot",
) TownRoot: b.townRoot,
BeadsDir: beads.ResolveBeadsDir(b.townRoot),
})
cmd.Env = config.EnvForExecCommand(envVars)
cmd.Env = append(cmd.Env, "GT_DEGRADED=true")
// Run async - don't wait for completion // Run async - don't wait for completion
return cmd.Start() return cmd.Start()
+13 -7
View File
@@ -4,6 +4,7 @@ import (
"fmt" "fmt"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/steveyegge/gastown/internal/beads"
"github.com/steveyegge/gastown/internal/config" "github.com/steveyegge/gastown/internal/config"
"github.com/steveyegge/gastown/internal/constants" "github.com/steveyegge/gastown/internal/constants"
"github.com/steveyegge/gastown/internal/crew" "github.com/steveyegge/gastown/internal/crew"
@@ -138,13 +139,18 @@ func runCrewAt(cmd *cobra.Command, args []string) error {
} }
// Set environment (non-fatal: session works without these) // Set environment (non-fatal: session works without these)
_ = t.SetEnvironment(sessionID, "GT_ROLE", "crew") // Use centralized AgentEnv for consistency across all role startup paths
_ = t.SetEnvironment(sessionID, "GT_RIG", r.Name) envVars := config.AgentEnv(config.AgentEnvConfig{
_ = t.SetEnvironment(sessionID, "GT_CREW", name) Role: "crew",
Rig: r.Name,
// Set runtime config dir for account selection (non-fatal) AgentName: name,
if runtimeConfig.Session != nil && runtimeConfig.Session.ConfigDirEnv != "" && claudeConfigDir != "" { TownRoot: townRoot,
_ = t.SetEnvironment(sessionID, runtimeConfig.Session.ConfigDirEnv, claudeConfigDir) BeadsDir: beads.ResolveBeadsDir(r.Path),
RuntimeConfigDir: claudeConfigDir,
BeadsNoDaemon: true,
})
for k, v := range envVars {
_ = t.SetEnvironment(sessionID, k, v)
} }
// Apply rig-based theming (non-fatal: theming failure doesn't affect operation) // Apply rig-based theming (non-fatal: theming failure doesn't affect operation)
+9 -2
View File
@@ -358,8 +358,15 @@ func startDeaconSession(t *tmux.Tmux, sessionName, agentOverride string) error {
} }
// Set environment (non-fatal: session works without these) // Set environment (non-fatal: session works without these)
_ = t.SetEnvironment(sessionName, "GT_ROLE", "deacon") // Use centralized AgentEnv for consistency across all role startup paths
_ = t.SetEnvironment(sessionName, "BD_ACTOR", "deacon") envVars := config.AgentEnv(config.AgentEnvConfig{
Role: "deacon",
TownRoot: townRoot,
BeadsDir: beads.ResolveBeadsDir(townRoot),
})
for k, v := range envVars {
_ = t.SetEnvironment(sessionName, k, v)
}
// Apply Deacon theme (non-fatal: theming failure doesn't affect operation) // Apply Deacon theme (non-fatal: theming failure doesn't affect operation)
// Note: ConfigureGasTownSession includes cycle bindings // Note: ConfigureGasTownSession includes cycle bindings
+1 -1
View File
@@ -133,7 +133,6 @@ func runDoctor(cmd *cobra.Command, args []string) error {
d.Register(doctor.NewRoutesCheck()) d.Register(doctor.NewRoutesCheck())
d.Register(doctor.NewOrphanSessionCheck()) d.Register(doctor.NewOrphanSessionCheck())
d.Register(doctor.NewOrphanProcessCheck()) d.Register(doctor.NewOrphanProcessCheck())
d.Register(doctor.NewGTRootCheck())
d.Register(doctor.NewWispGCCheck()) d.Register(doctor.NewWispGCCheck())
d.Register(doctor.NewBranchCheck()) d.Register(doctor.NewBranchCheck())
d.Register(doctor.NewBeadsSyncOrphanCheck()) d.Register(doctor.NewBeadsSyncOrphanCheck())
@@ -142,6 +141,7 @@ func runDoctor(cmd *cobra.Command, args []string) error {
d.Register(doctor.NewLinkedPaneCheck()) d.Register(doctor.NewLinkedPaneCheck())
d.Register(doctor.NewThemeCheck()) d.Register(doctor.NewThemeCheck())
d.Register(doctor.NewCrashReportCheck()) d.Register(doctor.NewCrashReportCheck())
d.Register(doctor.NewEnvVarsCheck())
// Patrol system checks // Patrol system checks
d.Register(doctor.NewPatrolMoleculesExistCheck()) d.Register(doctor.NewPatrolMoleculesExistCheck())
+94 -44
View File
@@ -4,9 +4,11 @@ import (
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"sort"
"strings" "strings"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/steveyegge/gastown/internal/config"
"github.com/steveyegge/gastown/internal/style" "github.com/steveyegge/gastown/internal/style"
"github.com/steveyegge/gastown/internal/workspace" "github.com/steveyegge/gastown/internal/workspace"
) )
@@ -29,6 +31,7 @@ type RoleInfo struct {
EnvRole string `json:"env_role,omitempty"` // Value of GT_ROLE if set EnvRole string `json:"env_role,omitempty"` // Value of GT_ROLE if set
CwdRole Role `json:"cwd_role,omitempty"` // Role detected from cwd CwdRole Role `json:"cwd_role,omitempty"` // Role detected from cwd
Mismatch bool `json:"mismatch,omitempty"` // True if env != cwd detection Mismatch bool `json:"mismatch,omitempty"` // True if env != cwd detection
EnvIncomplete bool `json:"env_incomplete,omitempty"` // True if env was set but missing rig/polecat, filled from cwd
TownRoot string `json:"town_root,omitempty"` TownRoot string `json:"town_root,omitempty"`
WorkDir string `json:"work_dir,omitempty"` // Current working directory WorkDir string `json:"work_dir,omitempty"` // Current working directory
} }
@@ -86,14 +89,18 @@ var roleListCmd = &cobra.Command{
var roleEnvCmd = &cobra.Command{ var roleEnvCmd = &cobra.Command{
Use: "env", Use: "env",
Short: "Print export statements for current role", Short: "Print export statements for current role",
Long: `Print shell export statements to set GT_ROLE and GT_ROLE_HOME. Long: `Print shell export statements for the current role.
Usage: Role is determined from GT_ROLE environment variable or current working directory.
eval $(gt role env) # Set role env vars in current shell`, This is a read-only command that displays the current role's env vars.
Examples:
eval $(gt role env) # Export current role's env vars
gt role env # View what would be exported`,
RunE: runRoleEnv, RunE: runRoleEnv,
} }
// Flags // Flags for role home command
var ( var (
roleRig string roleRig string
rolePolecat string rolePolecat string
@@ -107,7 +114,7 @@ func init() {
roleCmd.AddCommand(roleListCmd) roleCmd.AddCommand(roleListCmd)
roleCmd.AddCommand(roleEnvCmd) roleCmd.AddCommand(roleEnvCmd)
// Add --rig flag to home command for witness/refinery/polecat // Add --rig and --polecat flags to home command for overrides
roleHomeCmd.Flags().StringVar(&roleRig, "rig", "", "Rig name (required for rig-specific roles)") roleHomeCmd.Flags().StringVar(&roleRig, "rig", "", "Rig name (required for rig-specific roles)")
roleHomeCmd.Flags().StringVar(&rolePolecat, "polecat", "", "Polecat/crew member name") roleHomeCmd.Flags().StringVar(&rolePolecat, "polecat", "", "Polecat/crew member name")
} }
@@ -170,6 +177,20 @@ func GetRoleWithContext(cwd, townRoot string) (RoleInfo, error) {
} }
} }
// If env is incomplete (missing rig/polecat for roles that need them),
// fill gaps from cwd detection and mark as incomplete
needsRig := parsedRole == RoleWitness || parsedRole == RoleRefinery || parsedRole == RolePolecat || parsedRole == RoleCrew
needsPolecat := parsedRole == RolePolecat || parsedRole == RoleCrew
if needsRig && info.Rig == "" && cwdCtx.Rig != "" {
info.Rig = cwdCtx.Rig
info.EnvIncomplete = true
}
if needsPolecat && info.Polecat == "" && cwdCtx.Polecat != "" {
info.Polecat = cwdCtx.Polecat
info.EnvIncomplete = true
}
// Check for mismatch with cwd detection // Check for mismatch with cwd detection
if cwdCtx.Role != RoleUnknown && cwdCtx.Role != parsedRole { if cwdCtx.Role != RoleUnknown && cwdCtx.Role != parsedRole {
info.Mismatch = true info.Mismatch = true
@@ -277,7 +298,7 @@ func getRoleHome(role Role, rig, polecat, townRoot string) string {
if rig == "" { if rig == "" {
return "" return ""
} }
return filepath.Join(townRoot, rig, "witness", "rig") return filepath.Join(townRoot, rig, "witness")
case RoleRefinery: case RoleRefinery:
if rig == "" { if rig == "" {
return "" return ""
@@ -287,12 +308,12 @@ func getRoleHome(role Role, rig, polecat, townRoot string) string {
if rig == "" || polecat == "" { if rig == "" || polecat == "" {
return "" return ""
} }
return filepath.Join(townRoot, rig, "polecats", polecat) return filepath.Join(townRoot, rig, "polecats", polecat, "rig")
case RoleCrew: case RoleCrew:
if rig == "" || polecat == "" { if rig == "" || polecat == "" {
return "" return ""
} }
return filepath.Join(townRoot, rig, "crew", polecat) return filepath.Join(townRoot, rig, "crew", polecat, "rig")
default: default:
return "" return ""
} }
@@ -335,6 +356,11 @@ func runRoleShow(cmd *cobra.Command, args []string) error {
} }
func runRoleHome(cmd *cobra.Command, args []string) error { func runRoleHome(cmd *cobra.Command, args []string) error {
cwd, err := os.Getwd()
if err != nil {
return fmt.Errorf("getting current directory: %w", err)
}
townRoot, err := workspace.FindFromCwd() townRoot, err := workspace.FindFromCwd()
if err != nil { if err != nil {
return fmt.Errorf("finding workspace: %w", err) return fmt.Errorf("finding workspace: %w", err)
@@ -343,36 +369,41 @@ func runRoleHome(cmd *cobra.Command, args []string) error {
return fmt.Errorf("not in a Gas Town workspace") return fmt.Errorf("not in a Gas Town workspace")
} }
var role Role // Validate flag combinations: --polecat requires --rig to prevent strange merges
var rig, polecat string if rolePolecat != "" && roleRig == "" {
return fmt.Errorf("--polecat requires --rig to be specified")
}
// Start with current role detection (from env vars or cwd)
info, err := GetRole()
if err != nil {
return err
}
role := info.Role
rig := info.Rig
polecat := info.Polecat
// Apply overrides from arguments/flags
if len(args) > 0 { if len(args) > 0 {
// Explicit role provided role, _, _ = parseRoleString(args[0])
role, rig, polecat = parseRoleString(args[0]) }
// Override with flags if provided
if roleRig != "" { if roleRig != "" {
rig = roleRig rig = roleRig
} }
if rolePolecat != "" { if rolePolecat != "" {
polecat = rolePolecat polecat = rolePolecat
} }
} else {
// Use current role
info, err := GetRole()
if err != nil {
return err
}
role = info.Role
rig = info.Rig
polecat = info.Polecat
}
home := getRoleHome(role, rig, polecat, townRoot) home := getRoleHome(role, rig, polecat, townRoot)
if home == "" { if home == "" {
return fmt.Errorf("cannot determine home for role %s (rig=%q, polecat=%q)", role, rig, polecat) return fmt.Errorf("cannot determine home for role %s (rig=%q, polecat=%q)", role, rig, polecat)
} }
// Warn if computed home doesn't match cwd
if home != cwd && !strings.HasPrefix(cwd, home) {
fmt.Fprintf(os.Stderr, "⚠️ Warning: cwd (%s) is not within role home (%s)\n", cwd, home)
}
fmt.Println(home) fmt.Println(home)
return nil return nil
} }
@@ -440,33 +471,52 @@ func runRoleList(cmd *cobra.Command, args []string) error {
} }
func runRoleEnv(cmd *cobra.Command, args []string) error { func runRoleEnv(cmd *cobra.Command, args []string) error {
cwd, err := os.Getwd()
if err != nil {
return fmt.Errorf("getting current directory: %w", err)
}
townRoot, err := workspace.FindFromCwd()
if err != nil {
return fmt.Errorf("finding workspace: %w", err)
}
if townRoot == "" {
return fmt.Errorf("not in a Gas Town workspace")
}
// Get current role (read-only - from env vars or cwd)
info, err := GetRole() info, err := GetRole()
if err != nil { if err != nil {
return err return err
} }
// Build the role string for GT_ROLE home := getRoleHome(info.Role, info.Rig, info.Polecat, townRoot)
var roleStr string if home == "" {
switch info.Role { return fmt.Errorf("cannot determine home for role %s (rig=%q, polecat=%q)", info.Role, info.Rig, info.Polecat)
case RoleMayor:
roleStr = "mayor"
case RoleDeacon:
roleStr = "deacon"
case RoleWitness:
roleStr = fmt.Sprintf("%s/witness", info.Rig)
case RoleRefinery:
roleStr = fmt.Sprintf("%s/refinery", info.Rig)
case RolePolecat:
roleStr = fmt.Sprintf("%s/polecats/%s", info.Rig, info.Polecat)
case RoleCrew:
roleStr = fmt.Sprintf("%s/crew/%s", info.Rig, info.Polecat)
default:
roleStr = string(info.Role)
} }
fmt.Printf("export %s=%s\n", EnvGTRole, roleStr) // Warn if env was incomplete and we filled from cwd
if info.Home != "" { if info.EnvIncomplete {
fmt.Printf("export %s=%s\n", EnvGTRoleHome, info.Home) fmt.Fprintf(os.Stderr, "⚠️ Warning: env vars incomplete, filled from cwd\n")
}
// Warn if computed home doesn't match cwd
if home != cwd && !strings.HasPrefix(cwd, home) {
fmt.Fprintf(os.Stderr, "⚠️ Warning: cwd (%s) is not within role home (%s)\n", cwd, home)
}
// Get canonical env vars from shared source of truth
envVars := config.AgentEnvSimple(string(info.Role), info.Rig, info.Polecat)
envVars[EnvGTRoleHome] = home
// Output in sorted order for consistent output
keys := make([]string, 0, len(envVars))
for k := range envVars {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
fmt.Printf("export %s=%s\n", k, envVars[k])
} }
return nil return nil
File diff suppressed because it is too large Load Diff
+204
View File
@@ -0,0 +1,204 @@
// Package config provides configuration loading and environment variable management.
package config
import (
"fmt"
"os"
"sort"
"strings"
)
// AgentEnvConfig specifies the configuration for generating agent environment variables.
// This is the single source of truth for all agent environment configuration.
type AgentEnvConfig struct {
// Role is the agent role: mayor, deacon, witness, refinery, crew, polecat, boot
Role string
// Rig is the rig name (empty for town-level agents like mayor/deacon)
Rig string
// AgentName is the specific agent name (empty for singletons like witness/refinery)
// For polecats, this is the polecat name. For crew, this is the crew member name.
AgentName string
// TownRoot is the root of the Gas Town workspace.
// Sets GT_ROOT environment variable.
TownRoot string
// BeadsDir is the resolved BEADS_DIR path.
// Callers should use beads.ResolveBeadsDir() to compute this.
BeadsDir string
// RuntimeConfigDir is the optional CLAUDE_CONFIG_DIR path
RuntimeConfigDir string
// BeadsNoDaemon sets BEADS_NO_DAEMON=1 if true
// Used for polecats that should bypass the beads daemon
BeadsNoDaemon bool
}
// AgentEnv returns all environment variables for an agent based on the config.
// This is the single source of truth for agent environment variables.
func AgentEnv(cfg AgentEnvConfig) map[string]string {
env := make(map[string]string)
env["GT_ROLE"] = cfg.Role
// Set role-specific variables
switch cfg.Role {
case "mayor":
env["BD_ACTOR"] = "mayor"
env["GIT_AUTHOR_NAME"] = "mayor"
case "deacon":
env["BD_ACTOR"] = "deacon"
env["GIT_AUTHOR_NAME"] = "deacon"
case "boot":
env["BD_ACTOR"] = "deacon-boot"
env["GIT_AUTHOR_NAME"] = "boot"
case "witness":
env["GT_RIG"] = cfg.Rig
env["BD_ACTOR"] = fmt.Sprintf("%s/witness", cfg.Rig)
env["GIT_AUTHOR_NAME"] = fmt.Sprintf("%s/witness", cfg.Rig)
case "refinery":
env["GT_RIG"] = cfg.Rig
env["BD_ACTOR"] = fmt.Sprintf("%s/refinery", cfg.Rig)
env["GIT_AUTHOR_NAME"] = fmt.Sprintf("%s/refinery", cfg.Rig)
case "polecat":
env["GT_RIG"] = cfg.Rig
env["GT_POLECAT"] = cfg.AgentName
env["BD_ACTOR"] = fmt.Sprintf("%s/polecats/%s", cfg.Rig, cfg.AgentName)
env["GIT_AUTHOR_NAME"] = cfg.AgentName
case "crew":
env["GT_RIG"] = cfg.Rig
env["GT_CREW"] = cfg.AgentName
env["BD_ACTOR"] = fmt.Sprintf("%s/crew/%s", cfg.Rig, cfg.AgentName)
env["GIT_AUTHOR_NAME"] = cfg.AgentName
}
env["GT_ROOT"] = cfg.TownRoot
env["BEADS_DIR"] = cfg.BeadsDir
// Set BEADS_AGENT_NAME for polecat/crew (uses same format as BD_ACTOR)
if cfg.Role == "polecat" || cfg.Role == "crew" {
env["BEADS_AGENT_NAME"] = fmt.Sprintf("%s/%s", cfg.Rig, cfg.AgentName)
}
if cfg.BeadsNoDaemon {
env["BEADS_NO_DAEMON"] = "1"
}
// Add optional runtime config directory
if cfg.RuntimeConfigDir != "" {
env["CLAUDE_CONFIG_DIR"] = cfg.RuntimeConfigDir
}
return env
}
// AgentEnvSimple is a convenience function for simple role-based env var lookup.
// Use this when you only need role, rig, and agentName without advanced options.
func AgentEnvSimple(role, rig, agentName string) map[string]string {
return AgentEnv(AgentEnvConfig{
Role: role,
Rig: rig,
AgentName: agentName,
})
}
// ExportPrefix builds an export statement prefix for shell commands.
// Returns a string like "export GT_ROLE=mayor BD_ACTOR=mayor && "
// The keys are sorted for deterministic output.
func ExportPrefix(env map[string]string) string {
if len(env) == 0 {
return ""
}
// Sort keys for deterministic output
keys := make([]string, 0, len(env))
for k := range env {
keys = append(keys, k)
}
sort.Strings(keys)
var parts []string
for _, k := range keys {
parts = append(parts, fmt.Sprintf("%s=%s", k, env[k]))
}
return "export " + strings.Join(parts, " ") + " && "
}
// BuildStartupCommandWithEnv builds a startup command with the given environment variables.
// This combines the export prefix with the agent command and optional prompt.
func BuildStartupCommandWithEnv(env map[string]string, agentCmd, prompt string) string {
prefix := ExportPrefix(env)
if prompt != "" {
// Include prompt as argument to agent command
return fmt.Sprintf("%s%s %q", prefix, agentCmd, prompt)
}
return prefix + agentCmd
}
// MergeEnv merges multiple environment maps, with later maps taking precedence.
func MergeEnv(maps ...map[string]string) map[string]string {
result := make(map[string]string)
for _, m := range maps {
for k, v := range m {
result[k] = v
}
}
return result
}
// FilterEnv returns a new map with only the specified keys.
func FilterEnv(env map[string]string, keys ...string) map[string]string {
result := make(map[string]string)
for _, k := range keys {
if v, ok := env[k]; ok {
result[k] = v
}
}
return result
}
// WithoutEnv returns a new map without the specified keys.
func WithoutEnv(env map[string]string, keys ...string) map[string]string {
result := make(map[string]string)
exclude := make(map[string]bool)
for _, k := range keys {
exclude[k] = true
}
for k, v := range env {
if !exclude[k] {
result[k] = v
}
}
return result
}
// EnvForExecCommand returns os.Environ() with the given env vars appended.
// This is useful for setting cmd.Env on exec.Command.
func EnvForExecCommand(env map[string]string) []string {
result := os.Environ()
for k, v := range env {
result = append(result, k+"="+v)
}
return result
}
// EnvToSlice converts an env map to a slice of "K=V" strings.
// Useful for appending to os.Environ() manually.
func EnvToSlice(env map[string]string) []string {
result := make([]string, 0, len(env))
for k, v := range env {
result = append(result, k+"="+v)
}
return result
}
+303
View File
@@ -0,0 +1,303 @@
package config
import (
"testing"
)
func TestAgentEnv_Mayor(t *testing.T) {
env := AgentEnv(AgentEnvConfig{
Role: "mayor",
TownRoot: "/town",
BeadsDir: "/town/.beads",
})
assertEnv(t, env, "GT_ROLE", "mayor")
assertEnv(t, env, "BD_ACTOR", "mayor")
assertEnv(t, env, "GIT_AUTHOR_NAME", "mayor")
assertEnv(t, env, "GT_ROOT", "/town")
assertEnv(t, env, "BEADS_DIR", "/town/.beads")
assertNotSet(t, env, "GT_RIG")
assertNotSet(t, env, "BEADS_NO_DAEMON")
}
func TestAgentEnv_Witness(t *testing.T) {
env := AgentEnv(AgentEnvConfig{
Role: "witness",
Rig: "myrig",
TownRoot: "/town",
BeadsDir: "/town/myrig/.beads",
})
assertEnv(t, env, "GT_ROLE", "witness")
assertEnv(t, env, "GT_RIG", "myrig")
assertEnv(t, env, "BD_ACTOR", "myrig/witness")
assertEnv(t, env, "GIT_AUTHOR_NAME", "myrig/witness")
assertEnv(t, env, "GT_ROOT", "/town")
assertEnv(t, env, "BEADS_DIR", "/town/myrig/.beads")
}
func TestAgentEnv_Polecat(t *testing.T) {
env := AgentEnv(AgentEnvConfig{
Role: "polecat",
Rig: "myrig",
AgentName: "Toast",
TownRoot: "/town",
BeadsDir: "/town/myrig/.beads",
BeadsNoDaemon: true,
})
assertEnv(t, env, "GT_ROLE", "polecat")
assertEnv(t, env, "GT_RIG", "myrig")
assertEnv(t, env, "GT_POLECAT", "Toast")
assertEnv(t, env, "BD_ACTOR", "myrig/polecats/Toast")
assertEnv(t, env, "GIT_AUTHOR_NAME", "Toast")
assertEnv(t, env, "BEADS_AGENT_NAME", "myrig/Toast")
assertEnv(t, env, "BEADS_NO_DAEMON", "1")
}
func TestAgentEnv_Crew(t *testing.T) {
env := AgentEnv(AgentEnvConfig{
Role: "crew",
Rig: "myrig",
AgentName: "emma",
TownRoot: "/town",
BeadsDir: "/town/myrig/.beads",
BeadsNoDaemon: true,
})
assertEnv(t, env, "GT_ROLE", "crew")
assertEnv(t, env, "GT_RIG", "myrig")
assertEnv(t, env, "GT_CREW", "emma")
assertEnv(t, env, "BD_ACTOR", "myrig/crew/emma")
assertEnv(t, env, "GIT_AUTHOR_NAME", "emma")
assertEnv(t, env, "BEADS_AGENT_NAME", "myrig/emma")
assertEnv(t, env, "BEADS_NO_DAEMON", "1")
}
func TestAgentEnv_Refinery(t *testing.T) {
env := AgentEnv(AgentEnvConfig{
Role: "refinery",
Rig: "myrig",
TownRoot: "/town",
BeadsDir: "/town/myrig/.beads",
BeadsNoDaemon: true,
})
assertEnv(t, env, "GT_ROLE", "refinery")
assertEnv(t, env, "GT_RIG", "myrig")
assertEnv(t, env, "BD_ACTOR", "myrig/refinery")
assertEnv(t, env, "GIT_AUTHOR_NAME", "myrig/refinery")
assertEnv(t, env, "BEADS_NO_DAEMON", "1")
}
func TestAgentEnv_Deacon(t *testing.T) {
env := AgentEnv(AgentEnvConfig{
Role: "deacon",
TownRoot: "/town",
BeadsDir: "/town/.beads",
})
assertEnv(t, env, "GT_ROLE", "deacon")
assertEnv(t, env, "BD_ACTOR", "deacon")
assertEnv(t, env, "GIT_AUTHOR_NAME", "deacon")
assertEnv(t, env, "GT_ROOT", "/town")
assertEnv(t, env, "BEADS_DIR", "/town/.beads")
assertNotSet(t, env, "GT_RIG")
assertNotSet(t, env, "BEADS_NO_DAEMON")
}
func TestAgentEnv_Boot(t *testing.T) {
env := AgentEnv(AgentEnvConfig{
Role: "boot",
TownRoot: "/town",
BeadsDir: "/town/.beads",
})
assertEnv(t, env, "GT_ROLE", "boot")
assertEnv(t, env, "BD_ACTOR", "deacon-boot")
assertEnv(t, env, "GIT_AUTHOR_NAME", "boot")
assertEnv(t, env, "GT_ROOT", "/town")
assertEnv(t, env, "BEADS_DIR", "/town/.beads")
assertNotSet(t, env, "GT_RIG")
assertNotSet(t, env, "BEADS_NO_DAEMON")
}
func TestAgentEnv_WithRuntimeConfigDir(t *testing.T) {
env := AgentEnv(AgentEnvConfig{
Role: "polecat",
Rig: "myrig",
AgentName: "Toast",
TownRoot: "/town",
BeadsDir: "/town/myrig/.beads",
RuntimeConfigDir: "/home/user/.config/claude",
})
assertEnv(t, env, "CLAUDE_CONFIG_DIR", "/home/user/.config/claude")
}
func TestAgentEnv_WithoutRuntimeConfigDir(t *testing.T) {
env := AgentEnv(AgentEnvConfig{
Role: "polecat",
Rig: "myrig",
AgentName: "Toast",
TownRoot: "/town",
BeadsDir: "/town/myrig/.beads",
})
assertNotSet(t, env, "CLAUDE_CONFIG_DIR")
}
func TestAgentEnvSimple(t *testing.T) {
env := AgentEnvSimple("polecat", "myrig", "Toast")
assertEnv(t, env, "GT_ROLE", "polecat")
assertEnv(t, env, "GT_RIG", "myrig")
assertEnv(t, env, "GT_POLECAT", "Toast")
// Simple doesn't set TownRoot/BeadsDir
assertEnv(t, env, "GT_ROOT", "")
assertEnv(t, env, "BEADS_DIR", "")
}
func TestExportPrefix(t *testing.T) {
tests := []struct {
name string
env map[string]string
expected string
}{
{
name: "empty",
env: map[string]string{},
expected: "",
},
{
name: "single var",
env: map[string]string{"FOO": "bar"},
expected: "export FOO=bar && ",
},
{
name: "multiple vars sorted",
env: map[string]string{
"ZZZ": "last",
"AAA": "first",
"MMM": "middle",
},
expected: "export AAA=first MMM=middle ZZZ=last && ",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := ExportPrefix(tt.env)
if result != tt.expected {
t.Errorf("ExportPrefix() = %q, want %q", result, tt.expected)
}
})
}
}
func TestBuildStartupCommandWithEnv(t *testing.T) {
tests := []struct {
name string
env map[string]string
agentCmd string
prompt string
expected string
}{
{
name: "no env no prompt",
env: map[string]string{},
agentCmd: "claude",
prompt: "",
expected: "claude",
},
{
name: "env no prompt",
env: map[string]string{"GT_ROLE": "polecat"},
agentCmd: "claude",
prompt: "",
expected: "export GT_ROLE=polecat && claude",
},
{
name: "env with prompt",
env: map[string]string{"GT_ROLE": "polecat"},
agentCmd: "claude",
prompt: "gt prime",
expected: `export GT_ROLE=polecat && claude "gt prime"`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := BuildStartupCommandWithEnv(tt.env, tt.agentCmd, tt.prompt)
if result != tt.expected {
t.Errorf("BuildStartupCommandWithEnv() = %q, want %q", result, tt.expected)
}
})
}
}
func TestMergeEnv(t *testing.T) {
a := map[string]string{"A": "1", "B": "2"}
b := map[string]string{"B": "override", "C": "3"}
result := MergeEnv(a, b)
assertEnv(t, result, "A", "1")
assertEnv(t, result, "B", "override")
assertEnv(t, result, "C", "3")
}
func TestFilterEnv(t *testing.T) {
env := map[string]string{"A": "1", "B": "2", "C": "3"}
result := FilterEnv(env, "A", "C")
assertEnv(t, result, "A", "1")
assertNotSet(t, result, "B")
assertEnv(t, result, "C", "3")
}
func TestWithoutEnv(t *testing.T) {
env := map[string]string{"A": "1", "B": "2", "C": "3"}
result := WithoutEnv(env, "B")
assertEnv(t, result, "A", "1")
assertNotSet(t, result, "B")
assertEnv(t, result, "C", "3")
}
func TestEnvToSlice(t *testing.T) {
env := map[string]string{"A": "1", "B": "2"}
result := EnvToSlice(env)
if len(result) != 2 {
t.Errorf("EnvToSlice() returned %d items, want 2", len(result))
}
// Check both entries exist (order not guaranteed)
found := make(map[string]bool)
for _, s := range result {
found[s] = true
}
if !found["A=1"] || !found["B=2"] {
t.Errorf("EnvToSlice() = %v, want [A=1, B=2]", result)
}
}
// Helper functions
func assertEnv(t *testing.T, env map[string]string, key, expected string) {
t.Helper()
if got := env[key]; got != expected {
t.Errorf("env[%q] = %q, want %q", key, got, expected)
}
}
func assertNotSet(t *testing.T, env map[string]string, key string) {
t.Helper()
if _, ok := env[key]; ok {
t.Errorf("env[%q] should not be set, but is %q", key, env[key])
}
}
+5 -36
View File
@@ -1170,6 +1170,7 @@ func BuildStartupCommandWithAgentOverride(envVars map[string]string, rigPath, pr
return cmd, nil return cmd, nil
} }
// BuildAgentStartupCommand is a convenience function for starting agent sessions. // BuildAgentStartupCommand is a convenience function for starting agent sessions.
// It sets standard environment variables (GT_ROLE, BD_ACTOR, GIT_AUTHOR_NAME) // It sets standard environment variables (GT_ROLE, BD_ACTOR, GIT_AUTHOR_NAME)
// and builds the full startup command. // and builds the full startup command.
@@ -1195,55 +1196,23 @@ func BuildAgentStartupCommandWithAgentOverride(role, bdActor, rigPath, prompt, a
// BuildPolecatStartupCommand builds the startup command for a polecat. // BuildPolecatStartupCommand builds the startup command for a polecat.
// Sets GT_ROLE, GT_RIG, GT_POLECAT, BD_ACTOR, and GIT_AUTHOR_NAME. // Sets GT_ROLE, GT_RIG, GT_POLECAT, BD_ACTOR, and GIT_AUTHOR_NAME.
func BuildPolecatStartupCommand(rigName, polecatName, rigPath, prompt string) string { func BuildPolecatStartupCommand(rigName, polecatName, rigPath, prompt string) string {
bdActor := fmt.Sprintf("%s/polecats/%s", rigName, polecatName) return BuildStartupCommand(AgentEnvSimple("polecat", rigName, polecatName), rigPath, prompt)
envVars := map[string]string{
"GT_ROLE": "polecat",
"GT_RIG": rigName,
"GT_POLECAT": polecatName,
"BD_ACTOR": bdActor,
"GIT_AUTHOR_NAME": polecatName,
}
return BuildStartupCommand(envVars, rigPath, prompt)
} }
// BuildPolecatStartupCommandWithAgentOverride is like BuildPolecatStartupCommand, but uses agentOverride if non-empty. // BuildPolecatStartupCommandWithAgentOverride is like BuildPolecatStartupCommand, but uses agentOverride if non-empty.
func BuildPolecatStartupCommandWithAgentOverride(rigName, polecatName, rigPath, prompt, agentOverride string) (string, error) { func BuildPolecatStartupCommandWithAgentOverride(rigName, polecatName, rigPath, prompt, agentOverride string) (string, error) {
bdActor := fmt.Sprintf("%s/polecats/%s", rigName, polecatName) return BuildStartupCommandWithAgentOverride(AgentEnvSimple("polecat", rigName, polecatName), rigPath, prompt, agentOverride)
envVars := map[string]string{
"GT_ROLE": "polecat",
"GT_RIG": rigName,
"GT_POLECAT": polecatName,
"BD_ACTOR": bdActor,
"GIT_AUTHOR_NAME": polecatName,
}
return BuildStartupCommandWithAgentOverride(envVars, rigPath, prompt, agentOverride)
} }
// BuildCrewStartupCommand builds the startup command for a crew member. // BuildCrewStartupCommand builds the startup command for a crew member.
// Sets GT_ROLE, GT_RIG, GT_CREW, BD_ACTOR, and GIT_AUTHOR_NAME. // Sets GT_ROLE, GT_RIG, GT_CREW, BD_ACTOR, and GIT_AUTHOR_NAME.
func BuildCrewStartupCommand(rigName, crewName, rigPath, prompt string) string { func BuildCrewStartupCommand(rigName, crewName, rigPath, prompt string) string {
bdActor := fmt.Sprintf("%s/crew/%s", rigName, crewName) return BuildStartupCommand(AgentEnvSimple("crew", rigName, crewName), rigPath, prompt)
envVars := map[string]string{
"GT_ROLE": "crew",
"GT_RIG": rigName,
"GT_CREW": crewName,
"BD_ACTOR": bdActor,
"GIT_AUTHOR_NAME": crewName,
}
return BuildStartupCommand(envVars, rigPath, prompt)
} }
// BuildCrewStartupCommandWithAgentOverride is like BuildCrewStartupCommand, but uses agentOverride if non-empty. // BuildCrewStartupCommandWithAgentOverride is like BuildCrewStartupCommand, but uses agentOverride if non-empty.
func BuildCrewStartupCommandWithAgentOverride(rigName, crewName, rigPath, prompt, agentOverride string) (string, error) { func BuildCrewStartupCommandWithAgentOverride(rigName, crewName, rigPath, prompt, agentOverride string) (string, error) {
bdActor := fmt.Sprintf("%s/crew/%s", rigName, crewName) return BuildStartupCommandWithAgentOverride(AgentEnvSimple("crew", rigName, crewName), rigPath, prompt, agentOverride)
envVars := map[string]string{
"GT_ROLE": "crew",
"GT_RIG": rigName,
"GT_CREW": crewName,
"BD_ACTOR": bdActor,
"GIT_AUTHOR_NAME": crewName,
}
return BuildStartupCommandWithAgentOverride(envVars, rigPath, prompt, agentOverride)
} }
// ExpectedPaneCommands returns tmux pane command names that indicate the runtime is running. // ExpectedPaneCommands returns tmux pane command names that indicate the runtime is running.
+13 -7
View File
@@ -514,13 +514,19 @@ func (m *Manager) Start(name string, opts StartOptions) error {
} }
// Set environment variables (non-fatal: session works without these) // Set environment variables (non-fatal: session works without these)
_ = t.SetEnvironment(sessionID, "GT_RIG", m.rig.Name) // Use centralized AgentEnv for consistency across all role startup paths
_ = t.SetEnvironment(sessionID, "GT_CREW", name) townRoot := filepath.Dir(m.rig.Path)
_ = t.SetEnvironment(sessionID, "GT_ROLE", "crew") envVars := config.AgentEnv(config.AgentEnvConfig{
Role: "crew",
// Set CLAUDE_CONFIG_DIR for account selection (non-fatal) Rig: m.rig.Name,
if opts.ClaudeConfigDir != "" { AgentName: name,
_ = t.SetEnvironment(sessionID, "CLAUDE_CONFIG_DIR", opts.ClaudeConfigDir) TownRoot: townRoot,
BeadsDir: beads.ResolveBeadsDir(m.rig.Path),
RuntimeConfigDir: opts.ClaudeConfigDir,
BeadsNoDaemon: true,
})
for k, v := range envVars {
_ = t.SetEnvironment(sessionID, k, v)
} }
// Apply rig-based theming (non-fatal: theming failure doesn't affect operation) // Apply rig-based theming (non-fatal: theming failure doesn't affect operation)
+14 -11
View File
@@ -847,17 +847,21 @@ func (d *Daemon) restartPolecatSession(rigName, polecatName, sessionName string)
} }
// Set environment variables // Set environment variables
_ = d.tmux.SetEnvironment(sessionName, "GT_ROLE", "polecat") // Use centralized AgentEnvSimple for consistency across all role startup paths
_ = d.tmux.SetEnvironment(sessionName, "GT_RIG", rigName) envVars := config.AgentEnvSimple("polecat", rigName, polecatName)
_ = d.tmux.SetEnvironment(sessionName, "GT_POLECAT", polecatName)
bdActor := fmt.Sprintf("%s/polecats/%s", rigName, polecatName) // Add polecat-specific beads configuration
_ = d.tmux.SetEnvironment(sessionName, "BD_ACTOR", bdActor) // Use ResolveBeadsDir to follow redirects for repos with tracked beads
rigPath := filepath.Join(d.config.TownRoot, rigName)
beadsDir := beads.ResolveBeadsDir(rigPath)
envVars["BEADS_DIR"] = beadsDir
envVars["BEADS_NO_DAEMON"] = "1"
envVars["BEADS_AGENT_NAME"] = fmt.Sprintf("%s/%s", rigName, polecatName)
beadsDir := filepath.Join(d.config.TownRoot, rigName, ".beads") // Set all env vars in tmux session (for debugging) and they'll also be exported to Claude
_ = d.tmux.SetEnvironment(sessionName, "BEADS_DIR", beadsDir) for k, v := range envVars {
_ = d.tmux.SetEnvironment(sessionName, "BEADS_NO_DAEMON", "1") _ = d.tmux.SetEnvironment(sessionName, k, v)
_ = d.tmux.SetEnvironment(sessionName, "BEADS_AGENT_NAME", fmt.Sprintf("%s/%s", rigName, polecatName)) }
// Apply theme // Apply theme
theme := tmux.AssignTheme(rigName) theme := tmux.AssignTheme(rigName)
@@ -869,8 +873,7 @@ func (d *Daemon) restartPolecatSession(rigName, polecatName, sessionName string)
// Launch Claude with environment exported inline // Launch Claude with environment exported inline
// Pass rigPath so rig agent settings are honored (not town-level defaults) // Pass rigPath so rig agent settings are honored (not town-level defaults)
rigPath := filepath.Join(d.config.TownRoot, rigName) startCmd := config.BuildStartupCommand(envVars, rigPath, "")
startCmd := config.BuildPolecatStartupCommand(rigName, polecatName, rigPath, "")
if err := d.tmux.SendKeys(sessionName, startCmd); err != nil { if err := d.tmux.SendKeys(sessionName, startCmd); err != nil {
return fmt.Errorf("sending startup command: %w", err) return fmt.Errorf("sending startup command: %w", err)
} }
+24 -11
View File
@@ -371,7 +371,7 @@ func (d *Daemon) restartSession(sessionName, identity string) error {
} }
// Set environment variables // Set environment variables
d.setSessionEnvironment(sessionName, identity, config, parsed) d.setSessionEnvironment(sessionName, config, parsed)
// Apply theme (non-fatal: theming failure doesn't affect operation) // Apply theme (non-fatal: theming failure doesn't affect operation)
d.applySessionTheme(sessionName, parsed) d.applySessionTheme(sessionName, parsed)
@@ -487,18 +487,31 @@ func (d *Daemon) getStartCommand(roleConfig *beads.RoleConfig, parsed *ParsedIde
} }
// setSessionEnvironment sets environment variables for the tmux session. // setSessionEnvironment sets environment variables for the tmux session.
// Uses role bead config if available, falls back to hardcoded defaults. // Uses centralized AgentEnv for consistency, plus role bead custom env vars if available.
func (d *Daemon) setSessionEnvironment(sessionName, identity string, config *beads.RoleConfig, parsed *ParsedIdentity) { func (d *Daemon) setSessionEnvironment(sessionName string, roleConfig *beads.RoleConfig, parsed *ParsedIdentity) {
// Always set GT_ROLE // Determine beads dir based on role type
_ = d.tmux.SetEnvironment(sessionName, "GT_ROLE", identity) var beadsPath string
if parsed.RigName != "" {
beadsPath = filepath.Join(d.config.TownRoot, parsed.RigName)
} else {
beadsPath = d.config.TownRoot
}
// BD_ACTOR uses slashes instead of dashes for path-like identity // Use centralized AgentEnv for base environment variables
bdActor := identityToBDActor(identity) envVars := config.AgentEnv(config.AgentEnvConfig{
_ = d.tmux.SetEnvironment(sessionName, "BD_ACTOR", bdActor) Role: parsed.RoleType,
Rig: parsed.RigName,
AgentName: parsed.AgentName,
TownRoot: d.config.TownRoot,
BeadsDir: beads.ResolveBeadsDir(beadsPath),
})
for k, v := range envVars {
_ = d.tmux.SetEnvironment(sessionName, k, v)
}
// Set any custom env vars from role config // Set any custom env vars from role config (bead-defined overrides)
if config != nil { if roleConfig != nil {
for k, v := range config.EnvVars { for k, v := range roleConfig.EnvVars {
expanded := beads.ExpandRolePattern(v, d.config.TownRoot, parsed.RigName, parsed.AgentName, parsed.RoleType) expanded := beads.ExpandRolePattern(v, d.config.TownRoot, parsed.RigName, parsed.AgentName, parsed.RoleType)
_ = d.tmux.SetEnvironment(sessionName, k, expanded) _ = d.tmux.SetEnvironment(sessionName, k, expanded)
} }
+10 -2
View File
@@ -7,6 +7,7 @@ import (
"path/filepath" "path/filepath"
"time" "time"
"github.com/steveyegge/gastown/internal/beads"
"github.com/steveyegge/gastown/internal/claude" "github.com/steveyegge/gastown/internal/claude"
"github.com/steveyegge/gastown/internal/config" "github.com/steveyegge/gastown/internal/config"
"github.com/steveyegge/gastown/internal/constants" "github.com/steveyegge/gastown/internal/constants"
@@ -93,8 +94,15 @@ func (m *Manager) Start(agentOverride string) error {
} }
// Set environment variables (non-fatal: session works without these) // Set environment variables (non-fatal: session works without these)
_ = t.SetEnvironment(sessionID, "GT_ROLE", "deacon") // Use centralized AgentEnv for consistency across all role startup paths
_ = t.SetEnvironment(sessionID, "BD_ACTOR", "deacon") envVars := config.AgentEnv(config.AgentEnvConfig{
Role: "deacon",
TownRoot: m.townRoot,
BeadsDir: beads.ResolveBeadsDir(m.townRoot),
})
for k, v := range envVars {
_ = t.SetEnvironment(sessionID, k, v)
}
// Apply Deacon theming (non-fatal: theming failure doesn't affect operation) // Apply Deacon theming (non-fatal: theming failure doesn't affect operation)
theme := tmux.DeaconTheme() theme := tmux.DeaconTheme()
+158
View File
@@ -0,0 +1,158 @@
package doctor
import (
"fmt"
"path/filepath"
"strings"
"github.com/steveyegge/gastown/internal/beads"
"github.com/steveyegge/gastown/internal/config"
"github.com/steveyegge/gastown/internal/session"
"github.com/steveyegge/gastown/internal/tmux"
)
// SessionEnvReader abstracts tmux session environment access for testing.
type SessionEnvReader interface {
ListSessions() ([]string, error)
GetAllEnvironment(session string) (map[string]string, error)
}
// tmuxEnvReader wraps real tmux operations.
type tmuxEnvReader struct {
t *tmux.Tmux
}
func (r *tmuxEnvReader) ListSessions() ([]string, error) {
return r.t.ListSessions()
}
func (r *tmuxEnvReader) GetAllEnvironment(session string) (map[string]string, error) {
return r.t.GetAllEnvironment(session)
}
// EnvVarsCheck verifies that tmux session environment variables match expected values.
type EnvVarsCheck struct {
BaseCheck
reader SessionEnvReader // nil means use real tmux
}
// NewEnvVarsCheck creates a new env vars check.
func NewEnvVarsCheck() *EnvVarsCheck {
return &EnvVarsCheck{
BaseCheck: BaseCheck{
CheckName: "env-vars",
CheckDescription: "Verify tmux session environment variables match expected values",
},
}
}
// NewEnvVarsCheckWithReader creates a check with a custom reader (for testing).
func NewEnvVarsCheckWithReader(reader SessionEnvReader) *EnvVarsCheck {
c := NewEnvVarsCheck()
c.reader = reader
return c
}
// Run checks environment variables for all Gas Town sessions.
func (c *EnvVarsCheck) Run(ctx *CheckContext) *CheckResult {
reader := c.reader
if reader == nil {
reader = &tmuxEnvReader{t: tmux.NewTmux()}
}
sessions, err := reader.ListSessions()
if err != nil {
// No tmux server - treat as success (valid when Gas Town is down)
return &CheckResult{
Name: c.Name(),
Status: StatusOK,
Message: "No tmux sessions running",
}
}
// Filter to Gas Town sessions only (gt-* and hq-*)
var gtSessions []string
for _, sess := range sessions {
if strings.HasPrefix(sess, "gt-") || strings.HasPrefix(sess, "hq-") {
gtSessions = append(gtSessions, sess)
}
}
if len(gtSessions) == 0 {
// No Gas Town sessions - treat as success (valid when Gas Town is down)
return &CheckResult{
Name: c.Name(),
Status: StatusOK,
Message: "No Gas Town sessions running",
}
}
var mismatches []string
checkedCount := 0
for _, sess := range gtSessions {
identity, err := session.ParseSessionName(sess)
if err != nil {
// Skip unparseable sessions
continue
}
// Get expected env vars based on role, emulating real call sites
// Town-level roles use TownRoot for beads, rig-level roles use rig path
var beadsDir string
if identity.Rig != "" {
rigPath := filepath.Join(ctx.TownRoot, identity.Rig)
beadsDir = beads.ResolveBeadsDir(rigPath)
} else {
beadsDir = beads.ResolveBeadsDir(ctx.TownRoot)
}
expected := config.AgentEnv(config.AgentEnvConfig{
Role: string(identity.Role),
Rig: identity.Rig,
AgentName: identity.Name,
TownRoot: ctx.TownRoot,
BeadsDir: beadsDir,
})
// Get actual tmux env vars
actual, err := reader.GetAllEnvironment(sess)
if err != nil {
mismatches = append(mismatches, fmt.Sprintf("%s: could not read env vars: %v", sess, err))
continue
}
checkedCount++
// Compare each expected var
for key, expectedVal := range expected {
actualVal, exists := actual[key]
if !exists {
mismatches = append(mismatches, fmt.Sprintf("%s: missing %s (expected %q)", sess, key, expectedVal))
} else if actualVal != expectedVal {
mismatches = append(mismatches, fmt.Sprintf("%s: %s=%q (expected %q)", sess, key, actualVal, expectedVal))
}
}
}
if len(mismatches) == 0 {
return &CheckResult{
Name: c.Name(),
Status: StatusOK,
Message: fmt.Sprintf("All %d session(s) have correct environment variables", checkedCount),
}
}
// Add explanation about needing restart
details := append(mismatches,
"",
"Note: Mismatched session env vars won't affect running Claude until sessions restart.",
)
return &CheckResult{
Name: c.Name(),
Status: StatusWarning,
Message: fmt.Sprintf("Found %d env var mismatch(es) across %d session(s)", len(mismatches), checkedCount),
Details: details,
FixHint: "Run 'gt shutdown && gt up' to restart sessions with correct env vars",
}
}
+350
View File
@@ -0,0 +1,350 @@
package doctor
import (
"errors"
"testing"
"github.com/steveyegge/gastown/internal/config"
)
// mockEnvReader implements SessionEnvReader for testing.
type mockEnvReader struct {
sessions []string
sessionEnvs map[string]map[string]string
listErr error
envErrs map[string]error
}
func (m *mockEnvReader) ListSessions() ([]string, error) {
if m.listErr != nil {
return nil, m.listErr
}
return m.sessions, nil
}
func (m *mockEnvReader) GetAllEnvironment(session string) (map[string]string, error) {
if m.envErrs != nil {
if err, ok := m.envErrs[session]; ok {
return nil, err
}
}
if m.sessionEnvs != nil {
if env, ok := m.sessionEnvs[session]; ok {
return env, nil
}
}
return map[string]string{}, nil
}
// testTownRoot is the town root used in tests.
// Tests use this fixed path so expected values match what the check generates.
const testTownRoot = "/town"
// expectedEnv generates expected env vars matching what the check generates.
// For town-level roles (mayor, deacon), beadsDir is /town/.beads
// For rig-level roles, beadsDir is /town/rigName/.beads
func expectedEnv(role, rig, agentName string) map[string]string {
var beadsDir string
if rig != "" {
beadsDir = testTownRoot + "/" + rig + "/.beads"
} else {
beadsDir = testTownRoot + "/.beads"
}
return config.AgentEnv(config.AgentEnvConfig{
Role: role,
Rig: rig,
AgentName: agentName,
TownRoot: testTownRoot,
BeadsDir: beadsDir,
})
}
// testCtx returns a CheckContext with the test town root.
func testCtx() *CheckContext {
return &CheckContext{TownRoot: testTownRoot}
}
func TestEnvVarsCheck_NoSessions(t *testing.T) {
reader := &mockEnvReader{
sessions: []string{},
}
check := NewEnvVarsCheckWithReader(reader)
result := check.Run(testCtx())
if result.Status != StatusOK {
t.Errorf("Status = %v, want StatusOK", result.Status)
}
if result.Message != "No Gas Town sessions running" {
t.Errorf("Message = %q, want %q", result.Message, "No Gas Town sessions running")
}
}
func TestEnvVarsCheck_ListSessionsError(t *testing.T) {
reader := &mockEnvReader{
listErr: errors.New("tmux not running"),
}
check := NewEnvVarsCheckWithReader(reader)
result := check.Run(testCtx())
// No tmux server is valid (Gas Town can be down)
if result.Status != StatusOK {
t.Errorf("Status = %v, want StatusOK", result.Status)
}
if result.Message != "No tmux sessions running" {
t.Errorf("Message = %q, want %q", result.Message, "No tmux sessions running")
}
}
func TestEnvVarsCheck_NonGasTownSessions(t *testing.T) {
reader := &mockEnvReader{
sessions: []string{"other-session", "my-dev"},
}
check := NewEnvVarsCheckWithReader(reader)
result := check.Run(testCtx())
if result.Status != StatusOK {
t.Errorf("Status = %v, want StatusOK", result.Status)
}
if result.Message != "No Gas Town sessions running" {
t.Errorf("Message = %q, want %q", result.Message, "No Gas Town sessions running")
}
}
func TestEnvVarsCheck_MayorCorrect(t *testing.T) {
expected := expectedEnv("mayor", "", "")
reader := &mockEnvReader{
sessions: []string{"hq-mayor"},
sessionEnvs: map[string]map[string]string{
"hq-mayor": expected,
},
}
check := NewEnvVarsCheckWithReader(reader)
result := check.Run(testCtx())
if result.Status != StatusOK {
t.Errorf("Status = %v, want StatusOK", result.Status)
}
}
func TestEnvVarsCheck_MayorMissing(t *testing.T) {
reader := &mockEnvReader{
sessions: []string{"hq-mayor"},
sessionEnvs: map[string]map[string]string{
"hq-mayor": {}, // Missing all env vars
},
}
check := NewEnvVarsCheckWithReader(reader)
result := check.Run(testCtx())
if result.Status != StatusWarning {
t.Errorf("Status = %v, want StatusWarning", result.Status)
}
}
func TestEnvVarsCheck_WitnessCorrect(t *testing.T) {
expected := expectedEnv("witness", "myrig", "")
reader := &mockEnvReader{
sessions: []string{"gt-myrig-witness"},
sessionEnvs: map[string]map[string]string{
"gt-myrig-witness": expected,
},
}
check := NewEnvVarsCheckWithReader(reader)
result := check.Run(testCtx())
if result.Status != StatusOK {
t.Errorf("Status = %v, want StatusOK", result.Status)
}
}
func TestEnvVarsCheck_WitnessMismatch(t *testing.T) {
reader := &mockEnvReader{
sessions: []string{"gt-myrig-witness"},
sessionEnvs: map[string]map[string]string{
"gt-myrig-witness": {
"GT_ROLE": "witness",
"GT_RIG": "wrongrig", // Wrong rig
},
},
}
check := NewEnvVarsCheckWithReader(reader)
result := check.Run(testCtx())
if result.Status != StatusWarning {
t.Errorf("Status = %v, want StatusWarning", result.Status)
}
}
func TestEnvVarsCheck_RefineryCorrect(t *testing.T) {
expected := expectedEnv("refinery", "myrig", "")
reader := &mockEnvReader{
sessions: []string{"gt-myrig-refinery"},
sessionEnvs: map[string]map[string]string{
"gt-myrig-refinery": expected,
},
}
check := NewEnvVarsCheckWithReader(reader)
result := check.Run(testCtx())
if result.Status != StatusOK {
t.Errorf("Status = %v, want StatusOK", result.Status)
}
}
func TestEnvVarsCheck_PolecatCorrect(t *testing.T) {
expected := expectedEnv("polecat", "myrig", "Toast")
reader := &mockEnvReader{
sessions: []string{"gt-myrig-Toast"},
sessionEnvs: map[string]map[string]string{
"gt-myrig-Toast": expected,
},
}
check := NewEnvVarsCheckWithReader(reader)
result := check.Run(testCtx())
if result.Status != StatusOK {
t.Errorf("Status = %v, want StatusOK", result.Status)
}
}
func TestEnvVarsCheck_PolecatMissing(t *testing.T) {
reader := &mockEnvReader{
sessions: []string{"gt-myrig-Toast"},
sessionEnvs: map[string]map[string]string{
"gt-myrig-Toast": {
"GT_ROLE": "polecat",
// Missing GT_RIG, GT_POLECAT, BD_ACTOR, GIT_AUTHOR_NAME
},
},
}
check := NewEnvVarsCheckWithReader(reader)
result := check.Run(testCtx())
if result.Status != StatusWarning {
t.Errorf("Status = %v, want StatusWarning", result.Status)
}
}
func TestEnvVarsCheck_CrewCorrect(t *testing.T) {
expected := expectedEnv("crew", "myrig", "worker1")
reader := &mockEnvReader{
sessions: []string{"gt-myrig-crew-worker1"},
sessionEnvs: map[string]map[string]string{
"gt-myrig-crew-worker1": expected,
},
}
check := NewEnvVarsCheckWithReader(reader)
result := check.Run(testCtx())
if result.Status != StatusOK {
t.Errorf("Status = %v, want StatusOK", result.Status)
}
}
func TestEnvVarsCheck_MultipleSessions(t *testing.T) {
mayorEnv := expectedEnv("mayor", "", "")
witnessEnv := expectedEnv("witness", "rig1", "")
polecatEnv := expectedEnv("polecat", "rig1", "Toast")
reader := &mockEnvReader{
sessions: []string{"hq-mayor", "gt-rig1-witness", "gt-rig1-Toast"},
sessionEnvs: map[string]map[string]string{
"hq-mayor": mayorEnv,
"gt-rig1-witness": witnessEnv,
"gt-rig1-Toast": polecatEnv,
},
}
check := NewEnvVarsCheckWithReader(reader)
result := check.Run(testCtx())
if result.Status != StatusOK {
t.Errorf("Status = %v, want StatusOK", result.Status)
}
if result.Message != "All 3 session(s) have correct environment variables" {
t.Errorf("Message = %q", result.Message)
}
}
func TestEnvVarsCheck_MixedCorrectAndMismatch(t *testing.T) {
mayorEnv := expectedEnv("mayor", "", "")
reader := &mockEnvReader{
sessions: []string{"hq-mayor", "gt-rig1-witness"},
sessionEnvs: map[string]map[string]string{
"hq-mayor": mayorEnv,
"gt-rig1-witness": {
"GT_ROLE": "witness",
// Missing GT_RIG and other vars
},
},
}
check := NewEnvVarsCheckWithReader(reader)
result := check.Run(testCtx())
if result.Status != StatusWarning {
t.Errorf("Status = %v, want StatusWarning", result.Status)
}
}
func TestEnvVarsCheck_DeaconCorrect(t *testing.T) {
expected := expectedEnv("deacon", "", "")
reader := &mockEnvReader{
sessions: []string{"hq-deacon"},
sessionEnvs: map[string]map[string]string{
"hq-deacon": expected,
},
}
check := NewEnvVarsCheckWithReader(reader)
result := check.Run(testCtx())
if result.Status != StatusOK {
t.Errorf("Status = %v, want StatusOK", result.Status)
}
}
func TestEnvVarsCheck_DeaconMissing(t *testing.T) {
reader := &mockEnvReader{
sessions: []string{"hq-deacon"},
sessionEnvs: map[string]map[string]string{
"hq-deacon": {}, // Missing all env vars
},
}
check := NewEnvVarsCheckWithReader(reader)
result := check.Run(testCtx())
if result.Status != StatusWarning {
t.Errorf("Status = %v, want StatusWarning", result.Status)
}
}
func TestEnvVarsCheck_GetEnvError(t *testing.T) {
reader := &mockEnvReader{
sessions: []string{"gt-myrig-witness"},
envErrs: map[string]error{
"gt-myrig-witness": errors.New("session not found"),
},
}
check := NewEnvVarsCheckWithReader(reader)
result := check.Run(testCtx())
if result.Status != StatusWarning {
t.Errorf("Status = %v, want StatusWarning", result.Status)
}
}
func TestEnvVarsCheck_HyphenatedRig(t *testing.T) {
// Test rig name with hyphens: "foo-bar"
expected := expectedEnv("witness", "foo-bar", "")
reader := &mockEnvReader{
sessions: []string{"gt-foo-bar-witness"},
sessionEnvs: map[string]map[string]string{
"gt-foo-bar-witness": expected,
},
}
check := NewEnvVarsCheckWithReader(reader)
result := check.Run(testCtx())
if result.Status != StatusOK {
t.Errorf("Status = %v, want StatusOK", result.Status)
}
}
-119
View File
@@ -1,119 +0,0 @@
package doctor
import (
"fmt"
"strings"
"github.com/steveyegge/gastown/internal/tmux"
)
// GTRootCheck verifies that tmux sessions have GT_ROOT set.
// Sessions without GT_ROOT cannot find town-level formulas.
type GTRootCheck struct {
BaseCheck
tmux TmuxEnvGetter // nil means use real tmux
}
// TmuxEnvGetter abstracts tmux environment access for testing.
type TmuxEnvGetter interface {
ListSessions() ([]string, error)
GetEnvironment(session, key string) (string, error)
}
// realTmux wraps real tmux operations.
type realTmux struct {
t *tmux.Tmux
}
func (r *realTmux) ListSessions() ([]string, error) {
return r.t.ListSessions()
}
func (r *realTmux) GetEnvironment(session, key string) (string, error) {
return r.t.GetEnvironment(session, key)
}
// NewGTRootCheck creates a new GT_ROOT check.
func NewGTRootCheck() *GTRootCheck {
return &GTRootCheck{
BaseCheck: BaseCheck{
CheckName: "gt-root-env",
CheckDescription: "Verify sessions have GT_ROOT set for formula discovery",
},
}
}
// NewGTRootCheckWithTmux creates a check with a custom tmux interface (for testing).
func NewGTRootCheckWithTmux(t TmuxEnvGetter) *GTRootCheck {
c := NewGTRootCheck()
c.tmux = t
return c
}
// Run checks GT_ROOT environment variable for all Gas Town sessions.
func (c *GTRootCheck) Run(ctx *CheckContext) *CheckResult {
t := c.tmux
if t == nil {
t = &realTmux{t: tmux.NewTmux()}
}
sessions, err := t.ListSessions()
if err != nil {
// No tmux server - not an error, Gas Town might just be down
return &CheckResult{
Name: c.Name(),
Status: StatusOK,
Message: "No tmux sessions running",
}
}
// Filter to Gas Town sessions (gt-* and hq-*)
var gtSessions []string
for _, sess := range sessions {
if strings.HasPrefix(sess, "gt-") || strings.HasPrefix(sess, "hq-") {
gtSessions = append(gtSessions, sess)
}
}
if len(gtSessions) == 0 {
return &CheckResult{
Name: c.Name(),
Status: StatusOK,
Message: "No Gas Town sessions running",
}
}
var missingSessions []string
var okCount int
for _, sess := range gtSessions {
gtRoot, err := t.GetEnvironment(sess, "GT_ROOT")
if err != nil || gtRoot == "" {
missingSessions = append(missingSessions, sess)
} else {
okCount++
}
}
if len(missingSessions) == 0 {
return &CheckResult{
Name: c.Name(),
Status: StatusOK,
Message: fmt.Sprintf("All %d session(s) have GT_ROOT set", okCount),
}
}
details := make([]string, 0, len(missingSessions)+2)
for _, sess := range missingSessions {
details = append(details, fmt.Sprintf("Missing GT_ROOT: %s", sess))
}
details = append(details, "", "Sessions without GT_ROOT cannot find town-level formulas.")
return &CheckResult{
Name: c.Name(),
Status: StatusWarning,
Message: fmt.Sprintf("%d session(s) missing GT_ROOT environment variable", len(missingSessions)),
Details: details,
FixHint: "Restart sessions to pick up GT_ROOT: gt shutdown && gt up",
}
}
-147
View File
@@ -1,147 +0,0 @@
package doctor
import (
"testing"
)
// mockTmuxEnv implements TmuxEnvGetter for testing.
type mockTmuxEnv struct {
sessions map[string]map[string]string // session -> env vars
listErr error
getErr error
}
func (m *mockTmuxEnv) ListSessions() ([]string, error) {
if m.listErr != nil {
return nil, m.listErr
}
sessions := make([]string, 0, len(m.sessions))
for s := range m.sessions {
sessions = append(sessions, s)
}
return sessions, nil
}
func (m *mockTmuxEnv) GetEnvironment(session, key string) (string, error) {
if m.getErr != nil {
return "", m.getErr
}
if env, ok := m.sessions[session]; ok {
return env[key], nil
}
return "", nil
}
func TestGTRootCheck_NoSessions(t *testing.T) {
mock := &mockTmuxEnv{sessions: map[string]map[string]string{}}
check := NewGTRootCheckWithTmux(mock)
result := check.Run(&CheckContext{})
if result.Status != StatusOK {
t.Errorf("expected StatusOK, got %v", result.Status)
}
if result.Message != "No Gas Town sessions running" {
t.Errorf("unexpected message: %s", result.Message)
}
}
func TestGTRootCheck_NoGasTownSessions(t *testing.T) {
mock := &mockTmuxEnv{
sessions: map[string]map[string]string{
"other-session": {"SOME_VAR": "value"},
},
}
check := NewGTRootCheckWithTmux(mock)
result := check.Run(&CheckContext{})
if result.Status != StatusOK {
t.Errorf("expected StatusOK, got %v", result.Status)
}
if result.Message != "No Gas Town sessions running" {
t.Errorf("unexpected message: %s", result.Message)
}
}
func TestGTRootCheck_AllSessionsHaveGTRoot(t *testing.T) {
mock := &mockTmuxEnv{
sessions: map[string]map[string]string{
"hq-mayor": {"GT_ROOT": "/home/user/gt", "GT_ROLE": "mayor"},
"hq-deacon": {"GT_ROOT": "/home/user/gt", "GT_ROLE": "deacon"},
"gt-myrig-witness": {"GT_ROOT": "/home/user/gt", "GT_ROLE": "witness"},
"gt-myrig-refinery": {"GT_ROOT": "/home/user/gt", "GT_ROLE": "refinery"},
},
}
check := NewGTRootCheckWithTmux(mock)
result := check.Run(&CheckContext{})
if result.Status != StatusOK {
t.Errorf("expected StatusOK, got %v", result.Status)
}
if result.Message != "All 4 session(s) have GT_ROOT set" {
t.Errorf("unexpected message: %s", result.Message)
}
}
func TestGTRootCheck_MissingGTRoot(t *testing.T) {
mock := &mockTmuxEnv{
sessions: map[string]map[string]string{
"hq-mayor": {"GT_ROOT": "/home/user/gt"},
"gt-myrig-witness": {"GT_ROLE": "witness"}, // Missing GT_ROOT
"gt-myrig-refinery": {"GT_ROLE": "refinery"}, // Missing GT_ROOT
},
}
check := NewGTRootCheckWithTmux(mock)
result := check.Run(&CheckContext{})
if result.Status != StatusWarning {
t.Errorf("expected StatusWarning, got %v", result.Status)
}
if result.Message != "2 session(s) missing GT_ROOT environment variable" {
t.Errorf("unexpected message: %s", result.Message)
}
if result.FixHint != "Restart sessions to pick up GT_ROOT: gt shutdown && gt up" {
t.Errorf("unexpected fix hint: %s", result.FixHint)
}
}
func TestGTRootCheck_EmptyGTRoot(t *testing.T) {
mock := &mockTmuxEnv{
sessions: map[string]map[string]string{
"hq-mayor": {"GT_ROOT": ""}, // Empty GT_ROOT should be treated as missing
},
}
check := NewGTRootCheckWithTmux(mock)
result := check.Run(&CheckContext{})
if result.Status != StatusWarning {
t.Errorf("expected StatusWarning, got %v", result.Status)
}
}
func TestGTRootCheck_MixedPrefixes(t *testing.T) {
// Test that both gt-* and hq-* sessions are checked
mock := &mockTmuxEnv{
sessions: map[string]map[string]string{
"hq-mayor": {"GT_ROOT": "/home/user/gt"},
"gt-rig-witness": {"GT_ROOT": "/home/user/gt"},
"other-session": {}, // Should be ignored
"random": {}, // Should be ignored
},
}
check := NewGTRootCheckWithTmux(mock)
result := check.Run(&CheckContext{})
if result.Status != StatusOK {
t.Errorf("expected StatusOK, got %v", result.Status)
}
// Should only count the 2 Gas Town sessions
if result.Message != "All 2 session(s) have GT_ROOT set" {
t.Errorf("unexpected message: %s", result.Message)
}
}
@@ -148,52 +148,10 @@ bd gate list --json
After closing a gate, the Waiters field contains mail addresses to notify. After closing a gate, the Waiters field contains mail addresses to notify.
Send a brief notification to each waiter that the gate has cleared.""" Send a brief notification to each waiter that the gate has cleared."""
[[steps]]
id = "github-gate-check"
title = "Check GitHub CI gates"
needs = ["inbox-check"]
description = """
Discover and evaluate GitHub CI gates.
GitHub gates (await_type: gh:run, gh:pr) require checking external CI status.
This step discovers new gates from GitHub activity and evaluates pending ones.
**Step 1: Discover new GitHub gates**
```bash
bd gate discover
```
This scans for GitHub CI gates that should be created based on:
- Active PRs with required CI checks
- Workflow runs that molecules are waiting on
**Step 2: Evaluate pending GitHub gates**
```bash
bd gate check --type=gh
```
For each GitHub gate, this checks:
- gh:run gates: Has the workflow run completed? Did it succeed?
- gh:pr gates: Has the PR been merged/closed?
Gates that pass their condition are automatically closed.
**Step 3: Report closures**
For any gates that were just closed, log the result:
```bash
# Gate <id> closed: GitHub CI passed
# Gate <id> closed: PR merged
```
**If no GitHub gates exist:**
Skip - nothing to check.
**Exit criteria:** All GitHub gates evaluated, passing gates closed."""
[[steps]] [[steps]]
id = "dispatch-gated-molecules" id = "dispatch-gated-molecules"
title = "Dispatch molecules with resolved gates" title = "Dispatch molecules with resolved gates"
needs = ["gate-evaluation", "github-gate-check"] needs = ["gate-evaluation"]
description = """ description = """
Find molecules blocked on gates that have now closed and dispatch them. Find molecules blocked on gates that have now closed and dispatch them.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,812 @@
description = """
AGENT EXECUTION PROTOCOL - Towers of Hanoi (7 disks, 127 moves)
PURPOSE: This is a durability proof, not computation. Steps are pre-computed.
Your job is to execute them mechanically, proving crash-recovery at scale.
EXECUTION LOOP:
1. Find current state: bd mol current <mol-id>
2. Find the next ready (unblocked) step
3. Close it: bd close <step-id>
4. Repeat until no ready steps remain
ON RESUME (after crash/restart/handoff):
- Same as fresh start. The molecule IS the state.
- Query `bd mol current <mol-id>`, continue from there.
- No memory of previous session needed.
DO:
- Close steps as fast as possible (they're trivial mechanical moves)
- Use `gt handoff` when context fills (proactive cycling)
- Trust the pre-computed solution - every move is already correct
DO NOT:
- Try to solve Hanoi yourself - moves are already computed
- Mark steps in_progress - just close them directly
- Ask for permission - this is GUPP territory, just execute
- Stop for human input - run autonomously until complete
MONITORING:
- Progress: Count closed children of the molecule
- For mega-molecules: Use convoy dashboard when available
- Completion: All steps closed = molecule complete
This proves Gas Town can execute arbitrarily long workflows with
nondeterministic idempotence - different sessions, same outcome.
"""
formula = "towers-of-hanoi-7"
version = 1
[[steps]]
id = "setup"
title = "Verify initial state"
description = "All 7 disks stacked on peg A. Largest on bottom."
[[steps]]
id = "move-1"
title = "Move disk 1: A → C"
description = "Move disk 1 from peg A to peg C. (Move 1/127)"
needs = ["setup"]
[[steps]]
id = "move-2"
title = "Move disk 2: A → B"
description = "Move disk 2 from peg A to peg B. (Move 2/127)"
needs = ["move-1"]
[[steps]]
id = "move-3"
title = "Move disk 1: C → B"
description = "Move disk 1 from peg C to peg B. (Move 3/127)"
needs = ["move-2"]
[[steps]]
id = "move-4"
title = "Move disk 3: A → C"
description = "Move disk 3 from peg A to peg C. (Move 4/127)"
needs = ["move-3"]
[[steps]]
id = "move-5"
title = "Move disk 1: B → A"
description = "Move disk 1 from peg B to peg A. (Move 5/127)"
needs = ["move-4"]
[[steps]]
id = "move-6"
title = "Move disk 2: B → C"
description = "Move disk 2 from peg B to peg C. (Move 6/127)"
needs = ["move-5"]
[[steps]]
id = "move-7"
title = "Move disk 1: A → C"
description = "Move disk 1 from peg A to peg C. (Move 7/127)"
needs = ["move-6"]
[[steps]]
id = "move-8"
title = "Move disk 4: A → B"
description = "Move disk 4 from peg A to peg B. (Move 8/127)"
needs = ["move-7"]
[[steps]]
id = "move-9"
title = "Move disk 1: C → B"
description = "Move disk 1 from peg C to peg B. (Move 9/127)"
needs = ["move-8"]
[[steps]]
id = "move-10"
title = "Move disk 2: C → A"
description = "Move disk 2 from peg C to peg A. (Move 10/127)"
needs = ["move-9"]
[[steps]]
id = "move-11"
title = "Move disk 1: B → A"
description = "Move disk 1 from peg B to peg A. (Move 11/127)"
needs = ["move-10"]
[[steps]]
id = "move-12"
title = "Move disk 3: C → B"
description = "Move disk 3 from peg C to peg B. (Move 12/127)"
needs = ["move-11"]
[[steps]]
id = "move-13"
title = "Move disk 1: A → C"
description = "Move disk 1 from peg A to peg C. (Move 13/127)"
needs = ["move-12"]
[[steps]]
id = "move-14"
title = "Move disk 2: A → B"
description = "Move disk 2 from peg A to peg B. (Move 14/127)"
needs = ["move-13"]
[[steps]]
id = "move-15"
title = "Move disk 1: C → B"
description = "Move disk 1 from peg C to peg B. (Move 15/127)"
needs = ["move-14"]
[[steps]]
id = "move-16"
title = "Move disk 5: A → C"
description = "Move disk 5 from peg A to peg C. (Move 16/127)"
needs = ["move-15"]
[[steps]]
id = "move-17"
title = "Move disk 1: B → A"
description = "Move disk 1 from peg B to peg A. (Move 17/127)"
needs = ["move-16"]
[[steps]]
id = "move-18"
title = "Move disk 2: B → C"
description = "Move disk 2 from peg B to peg C. (Move 18/127)"
needs = ["move-17"]
[[steps]]
id = "move-19"
title = "Move disk 1: A → C"
description = "Move disk 1 from peg A to peg C. (Move 19/127)"
needs = ["move-18"]
[[steps]]
id = "move-20"
title = "Move disk 3: B → A"
description = "Move disk 3 from peg B to peg A. (Move 20/127)"
needs = ["move-19"]
[[steps]]
id = "move-21"
title = "Move disk 1: C → B"
description = "Move disk 1 from peg C to peg B. (Move 21/127)"
needs = ["move-20"]
[[steps]]
id = "move-22"
title = "Move disk 2: C → A"
description = "Move disk 2 from peg C to peg A. (Move 22/127)"
needs = ["move-21"]
[[steps]]
id = "move-23"
title = "Move disk 1: B → A"
description = "Move disk 1 from peg B to peg A. (Move 23/127)"
needs = ["move-22"]
[[steps]]
id = "move-24"
title = "Move disk 4: B → C"
description = "Move disk 4 from peg B to peg C. (Move 24/127)"
needs = ["move-23"]
[[steps]]
id = "move-25"
title = "Move disk 1: A → C"
description = "Move disk 1 from peg A to peg C. (Move 25/127)"
needs = ["move-24"]
[[steps]]
id = "move-26"
title = "Move disk 2: A → B"
description = "Move disk 2 from peg A to peg B. (Move 26/127)"
needs = ["move-25"]
[[steps]]
id = "move-27"
title = "Move disk 1: C → B"
description = "Move disk 1 from peg C to peg B. (Move 27/127)"
needs = ["move-26"]
[[steps]]
id = "move-28"
title = "Move disk 3: A → C"
description = "Move disk 3 from peg A to peg C. (Move 28/127)"
needs = ["move-27"]
[[steps]]
id = "move-29"
title = "Move disk 1: B → A"
description = "Move disk 1 from peg B to peg A. (Move 29/127)"
needs = ["move-28"]
[[steps]]
id = "move-30"
title = "Move disk 2: B → C"
description = "Move disk 2 from peg B to peg C. (Move 30/127)"
needs = ["move-29"]
[[steps]]
id = "move-31"
title = "Move disk 1: A → C"
description = "Move disk 1 from peg A to peg C. (Move 31/127)"
needs = ["move-30"]
[[steps]]
id = "move-32"
title = "Move disk 6: A → B"
description = "Move disk 6 from peg A to peg B. (Move 32/127)"
needs = ["move-31"]
[[steps]]
id = "move-33"
title = "Move disk 1: C → B"
description = "Move disk 1 from peg C to peg B. (Move 33/127)"
needs = ["move-32"]
[[steps]]
id = "move-34"
title = "Move disk 2: C → A"
description = "Move disk 2 from peg C to peg A. (Move 34/127)"
needs = ["move-33"]
[[steps]]
id = "move-35"
title = "Move disk 1: B → A"
description = "Move disk 1 from peg B to peg A. (Move 35/127)"
needs = ["move-34"]
[[steps]]
id = "move-36"
title = "Move disk 3: C → B"
description = "Move disk 3 from peg C to peg B. (Move 36/127)"
needs = ["move-35"]
[[steps]]
id = "move-37"
title = "Move disk 1: A → C"
description = "Move disk 1 from peg A to peg C. (Move 37/127)"
needs = ["move-36"]
[[steps]]
id = "move-38"
title = "Move disk 2: A → B"
description = "Move disk 2 from peg A to peg B. (Move 38/127)"
needs = ["move-37"]
[[steps]]
id = "move-39"
title = "Move disk 1: C → B"
description = "Move disk 1 from peg C to peg B. (Move 39/127)"
needs = ["move-38"]
[[steps]]
id = "move-40"
title = "Move disk 4: C → A"
description = "Move disk 4 from peg C to peg A. (Move 40/127)"
needs = ["move-39"]
[[steps]]
id = "move-41"
title = "Move disk 1: B → A"
description = "Move disk 1 from peg B to peg A. (Move 41/127)"
needs = ["move-40"]
[[steps]]
id = "move-42"
title = "Move disk 2: B → C"
description = "Move disk 2 from peg B to peg C. (Move 42/127)"
needs = ["move-41"]
[[steps]]
id = "move-43"
title = "Move disk 1: A → C"
description = "Move disk 1 from peg A to peg C. (Move 43/127)"
needs = ["move-42"]
[[steps]]
id = "move-44"
title = "Move disk 3: B → A"
description = "Move disk 3 from peg B to peg A. (Move 44/127)"
needs = ["move-43"]
[[steps]]
id = "move-45"
title = "Move disk 1: C → B"
description = "Move disk 1 from peg C to peg B. (Move 45/127)"
needs = ["move-44"]
[[steps]]
id = "move-46"
title = "Move disk 2: C → A"
description = "Move disk 2 from peg C to peg A. (Move 46/127)"
needs = ["move-45"]
[[steps]]
id = "move-47"
title = "Move disk 1: B → A"
description = "Move disk 1 from peg B to peg A. (Move 47/127)"
needs = ["move-46"]
[[steps]]
id = "move-48"
title = "Move disk 5: C → B"
description = "Move disk 5 from peg C to peg B. (Move 48/127)"
needs = ["move-47"]
[[steps]]
id = "move-49"
title = "Move disk 1: A → C"
description = "Move disk 1 from peg A to peg C. (Move 49/127)"
needs = ["move-48"]
[[steps]]
id = "move-50"
title = "Move disk 2: A → B"
description = "Move disk 2 from peg A to peg B. (Move 50/127)"
needs = ["move-49"]
[[steps]]
id = "move-51"
title = "Move disk 1: C → B"
description = "Move disk 1 from peg C to peg B. (Move 51/127)"
needs = ["move-50"]
[[steps]]
id = "move-52"
title = "Move disk 3: A → C"
description = "Move disk 3 from peg A to peg C. (Move 52/127)"
needs = ["move-51"]
[[steps]]
id = "move-53"
title = "Move disk 1: B → A"
description = "Move disk 1 from peg B to peg A. (Move 53/127)"
needs = ["move-52"]
[[steps]]
id = "move-54"
title = "Move disk 2: B → C"
description = "Move disk 2 from peg B to peg C. (Move 54/127)"
needs = ["move-53"]
[[steps]]
id = "move-55"
title = "Move disk 1: A → C"
description = "Move disk 1 from peg A to peg C. (Move 55/127)"
needs = ["move-54"]
[[steps]]
id = "move-56"
title = "Move disk 4: A → B"
description = "Move disk 4 from peg A to peg B. (Move 56/127)"
needs = ["move-55"]
[[steps]]
id = "move-57"
title = "Move disk 1: C → B"
description = "Move disk 1 from peg C to peg B. (Move 57/127)"
needs = ["move-56"]
[[steps]]
id = "move-58"
title = "Move disk 2: C → A"
description = "Move disk 2 from peg C to peg A. (Move 58/127)"
needs = ["move-57"]
[[steps]]
id = "move-59"
title = "Move disk 1: B → A"
description = "Move disk 1 from peg B to peg A. (Move 59/127)"
needs = ["move-58"]
[[steps]]
id = "move-60"
title = "Move disk 3: C → B"
description = "Move disk 3 from peg C to peg B. (Move 60/127)"
needs = ["move-59"]
[[steps]]
id = "move-61"
title = "Move disk 1: A → C"
description = "Move disk 1 from peg A to peg C. (Move 61/127)"
needs = ["move-60"]
[[steps]]
id = "move-62"
title = "Move disk 2: A → B"
description = "Move disk 2 from peg A to peg B. (Move 62/127)"
needs = ["move-61"]
[[steps]]
id = "move-63"
title = "Move disk 1: C → B"
description = "Move disk 1 from peg C to peg B. (Move 63/127)"
needs = ["move-62"]
[[steps]]
id = "move-64"
title = "Move disk 7: A → C"
description = "Move disk 7 from peg A to peg C. (Move 64/127)"
needs = ["move-63"]
[[steps]]
id = "move-65"
title = "Move disk 1: B → A"
description = "Move disk 1 from peg B to peg A. (Move 65/127)"
needs = ["move-64"]
[[steps]]
id = "move-66"
title = "Move disk 2: B → C"
description = "Move disk 2 from peg B to peg C. (Move 66/127)"
needs = ["move-65"]
[[steps]]
id = "move-67"
title = "Move disk 1: A → C"
description = "Move disk 1 from peg A to peg C. (Move 67/127)"
needs = ["move-66"]
[[steps]]
id = "move-68"
title = "Move disk 3: B → A"
description = "Move disk 3 from peg B to peg A. (Move 68/127)"
needs = ["move-67"]
[[steps]]
id = "move-69"
title = "Move disk 1: C → B"
description = "Move disk 1 from peg C to peg B. (Move 69/127)"
needs = ["move-68"]
[[steps]]
id = "move-70"
title = "Move disk 2: C → A"
description = "Move disk 2 from peg C to peg A. (Move 70/127)"
needs = ["move-69"]
[[steps]]
id = "move-71"
title = "Move disk 1: B → A"
description = "Move disk 1 from peg B to peg A. (Move 71/127)"
needs = ["move-70"]
[[steps]]
id = "move-72"
title = "Move disk 4: B → C"
description = "Move disk 4 from peg B to peg C. (Move 72/127)"
needs = ["move-71"]
[[steps]]
id = "move-73"
title = "Move disk 1: A → C"
description = "Move disk 1 from peg A to peg C. (Move 73/127)"
needs = ["move-72"]
[[steps]]
id = "move-74"
title = "Move disk 2: A → B"
description = "Move disk 2 from peg A to peg B. (Move 74/127)"
needs = ["move-73"]
[[steps]]
id = "move-75"
title = "Move disk 1: C → B"
description = "Move disk 1 from peg C to peg B. (Move 75/127)"
needs = ["move-74"]
[[steps]]
id = "move-76"
title = "Move disk 3: A → C"
description = "Move disk 3 from peg A to peg C. (Move 76/127)"
needs = ["move-75"]
[[steps]]
id = "move-77"
title = "Move disk 1: B → A"
description = "Move disk 1 from peg B to peg A. (Move 77/127)"
needs = ["move-76"]
[[steps]]
id = "move-78"
title = "Move disk 2: B → C"
description = "Move disk 2 from peg B to peg C. (Move 78/127)"
needs = ["move-77"]
[[steps]]
id = "move-79"
title = "Move disk 1: A → C"
description = "Move disk 1 from peg A to peg C. (Move 79/127)"
needs = ["move-78"]
[[steps]]
id = "move-80"
title = "Move disk 5: B → A"
description = "Move disk 5 from peg B to peg A. (Move 80/127)"
needs = ["move-79"]
[[steps]]
id = "move-81"
title = "Move disk 1: C → B"
description = "Move disk 1 from peg C to peg B. (Move 81/127)"
needs = ["move-80"]
[[steps]]
id = "move-82"
title = "Move disk 2: C → A"
description = "Move disk 2 from peg C to peg A. (Move 82/127)"
needs = ["move-81"]
[[steps]]
id = "move-83"
title = "Move disk 1: B → A"
description = "Move disk 1 from peg B to peg A. (Move 83/127)"
needs = ["move-82"]
[[steps]]
id = "move-84"
title = "Move disk 3: C → B"
description = "Move disk 3 from peg C to peg B. (Move 84/127)"
needs = ["move-83"]
[[steps]]
id = "move-85"
title = "Move disk 1: A → C"
description = "Move disk 1 from peg A to peg C. (Move 85/127)"
needs = ["move-84"]
[[steps]]
id = "move-86"
title = "Move disk 2: A → B"
description = "Move disk 2 from peg A to peg B. (Move 86/127)"
needs = ["move-85"]
[[steps]]
id = "move-87"
title = "Move disk 1: C → B"
description = "Move disk 1 from peg C to peg B. (Move 87/127)"
needs = ["move-86"]
[[steps]]
id = "move-88"
title = "Move disk 4: C → A"
description = "Move disk 4 from peg C to peg A. (Move 88/127)"
needs = ["move-87"]
[[steps]]
id = "move-89"
title = "Move disk 1: B → A"
description = "Move disk 1 from peg B to peg A. (Move 89/127)"
needs = ["move-88"]
[[steps]]
id = "move-90"
title = "Move disk 2: B → C"
description = "Move disk 2 from peg B to peg C. (Move 90/127)"
needs = ["move-89"]
[[steps]]
id = "move-91"
title = "Move disk 1: A → C"
description = "Move disk 1 from peg A to peg C. (Move 91/127)"
needs = ["move-90"]
[[steps]]
id = "move-92"
title = "Move disk 3: B → A"
description = "Move disk 3 from peg B to peg A. (Move 92/127)"
needs = ["move-91"]
[[steps]]
id = "move-93"
title = "Move disk 1: C → B"
description = "Move disk 1 from peg C to peg B. (Move 93/127)"
needs = ["move-92"]
[[steps]]
id = "move-94"
title = "Move disk 2: C → A"
description = "Move disk 2 from peg C to peg A. (Move 94/127)"
needs = ["move-93"]
[[steps]]
id = "move-95"
title = "Move disk 1: B → A"
description = "Move disk 1 from peg B to peg A. (Move 95/127)"
needs = ["move-94"]
[[steps]]
id = "move-96"
title = "Move disk 6: B → C"
description = "Move disk 6 from peg B to peg C. (Move 96/127)"
needs = ["move-95"]
[[steps]]
id = "move-97"
title = "Move disk 1: A → C"
description = "Move disk 1 from peg A to peg C. (Move 97/127)"
needs = ["move-96"]
[[steps]]
id = "move-98"
title = "Move disk 2: A → B"
description = "Move disk 2 from peg A to peg B. (Move 98/127)"
needs = ["move-97"]
[[steps]]
id = "move-99"
title = "Move disk 1: C → B"
description = "Move disk 1 from peg C to peg B. (Move 99/127)"
needs = ["move-98"]
[[steps]]
id = "move-100"
title = "Move disk 3: A → C"
description = "Move disk 3 from peg A to peg C. (Move 100/127)"
needs = ["move-99"]
[[steps]]
id = "move-101"
title = "Move disk 1: B → A"
description = "Move disk 1 from peg B to peg A. (Move 101/127)"
needs = ["move-100"]
[[steps]]
id = "move-102"
title = "Move disk 2: B → C"
description = "Move disk 2 from peg B to peg C. (Move 102/127)"
needs = ["move-101"]
[[steps]]
id = "move-103"
title = "Move disk 1: A → C"
description = "Move disk 1 from peg A to peg C. (Move 103/127)"
needs = ["move-102"]
[[steps]]
id = "move-104"
title = "Move disk 4: A → B"
description = "Move disk 4 from peg A to peg B. (Move 104/127)"
needs = ["move-103"]
[[steps]]
id = "move-105"
title = "Move disk 1: C → B"
description = "Move disk 1 from peg C to peg B. (Move 105/127)"
needs = ["move-104"]
[[steps]]
id = "move-106"
title = "Move disk 2: C → A"
description = "Move disk 2 from peg C to peg A. (Move 106/127)"
needs = ["move-105"]
[[steps]]
id = "move-107"
title = "Move disk 1: B → A"
description = "Move disk 1 from peg B to peg A. (Move 107/127)"
needs = ["move-106"]
[[steps]]
id = "move-108"
title = "Move disk 3: C → B"
description = "Move disk 3 from peg C to peg B. (Move 108/127)"
needs = ["move-107"]
[[steps]]
id = "move-109"
title = "Move disk 1: A → C"
description = "Move disk 1 from peg A to peg C. (Move 109/127)"
needs = ["move-108"]
[[steps]]
id = "move-110"
title = "Move disk 2: A → B"
description = "Move disk 2 from peg A to peg B. (Move 110/127)"
needs = ["move-109"]
[[steps]]
id = "move-111"
title = "Move disk 1: C → B"
description = "Move disk 1 from peg C to peg B. (Move 111/127)"
needs = ["move-110"]
[[steps]]
id = "move-112"
title = "Move disk 5: A → C"
description = "Move disk 5 from peg A to peg C. (Move 112/127)"
needs = ["move-111"]
[[steps]]
id = "move-113"
title = "Move disk 1: B → A"
description = "Move disk 1 from peg B to peg A. (Move 113/127)"
needs = ["move-112"]
[[steps]]
id = "move-114"
title = "Move disk 2: B → C"
description = "Move disk 2 from peg B to peg C. (Move 114/127)"
needs = ["move-113"]
[[steps]]
id = "move-115"
title = "Move disk 1: A → C"
description = "Move disk 1 from peg A to peg C. (Move 115/127)"
needs = ["move-114"]
[[steps]]
id = "move-116"
title = "Move disk 3: B → A"
description = "Move disk 3 from peg B to peg A. (Move 116/127)"
needs = ["move-115"]
[[steps]]
id = "move-117"
title = "Move disk 1: C → B"
description = "Move disk 1 from peg C to peg B. (Move 117/127)"
needs = ["move-116"]
[[steps]]
id = "move-118"
title = "Move disk 2: C → A"
description = "Move disk 2 from peg C to peg A. (Move 118/127)"
needs = ["move-117"]
[[steps]]
id = "move-119"
title = "Move disk 1: B → A"
description = "Move disk 1 from peg B to peg A. (Move 119/127)"
needs = ["move-118"]
[[steps]]
id = "move-120"
title = "Move disk 4: B → C"
description = "Move disk 4 from peg B to peg C. (Move 120/127)"
needs = ["move-119"]
[[steps]]
id = "move-121"
title = "Move disk 1: A → C"
description = "Move disk 1 from peg A to peg C. (Move 121/127)"
needs = ["move-120"]
[[steps]]
id = "move-122"
title = "Move disk 2: A → B"
description = "Move disk 2 from peg A to peg B. (Move 122/127)"
needs = ["move-121"]
[[steps]]
id = "move-123"
title = "Move disk 1: C → B"
description = "Move disk 1 from peg C to peg B. (Move 123/127)"
needs = ["move-122"]
[[steps]]
id = "move-124"
title = "Move disk 3: A → C"
description = "Move disk 3 from peg A to peg C. (Move 124/127)"
needs = ["move-123"]
[[steps]]
id = "move-125"
title = "Move disk 1: B → A"
description = "Move disk 1 from peg B to peg A. (Move 125/127)"
needs = ["move-124"]
[[steps]]
id = "move-126"
title = "Move disk 2: B → C"
description = "Move disk 2 from peg B to peg C. (Move 126/127)"
needs = ["move-125"]
[[steps]]
id = "move-127"
title = "Move disk 1: A → C"
description = "Move disk 1 from peg A to peg C. (Move 127/127)"
needs = ["move-126"]
[[steps]]
id = "verify"
title = "Verify final state"
description = "All 7 disks now on peg C. Tower intact, all moves were legal."
needs = ["move-127"]
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,105 @@
description = """
AGENT EXECUTION PROTOCOL - Towers of Hanoi
PURPOSE: This is a durability proof, not computation. Steps are pre-computed.
Your job is to execute them mechanically, proving crash-recovery at scale.
EXECUTION LOOP:
1. Find current state: bd mol current <mol-id>
2. Find the next ready (unblocked) step
3. Close it: bd close <step-id>
4. Repeat until no ready steps remain
ON RESUME (after crash/restart/handoff):
- Same as fresh start. The molecule IS the state.
- Query `bd mol current <mol-id>`, continue from there.
- No memory of previous session needed.
DO:
- Close steps as fast as possible (they're trivial mechanical moves)
- Use `gt handoff` when context fills (proactive cycling)
- Trust the pre-computed solution - every move is already correct
DO NOT:
- Try to solve Hanoi yourself - moves are already computed
- Mark steps in_progress - just close them directly
- Ask for permission - this is GUPP territory, just execute
- Stop for human input - run autonomously until complete
MONITORING:
- Progress: Count closed children of the molecule
- For mega-molecules: Use convoy dashboard when available
- Completion: All steps closed = molecule complete
This proves Gas Town can execute arbitrarily long workflows with
nondeterministic idempotence - different sessions, same outcome.
"""
formula = "towers-of-hanoi"
version = 2
[vars]
[vars.source_peg]
default = "A"
description = "Starting peg"
[vars.target_peg]
default = "C"
description = "Target peg"
[vars.auxiliary_peg]
default = "B"
description = "Helper peg"
# 3-disk solution: 7 moves (2^3 - 1)
# Each step is a simple acknowledgment - the agent just closes it.
[[steps]]
id = "setup"
title = "Verify initial state"
description = "All 3 disks stacked on peg A. Largest on bottom."
[[steps]]
id = "move-1"
title = "Move disk 1: A → C"
description = "Move the smallest disk from peg A to peg C."
needs = ["setup"]
[[steps]]
id = "move-2"
title = "Move disk 2: A → B"
description = "Move disk 2 from peg A to peg B."
needs = ["move-1"]
[[steps]]
id = "move-3"
title = "Move disk 1: C → B"
description = "Move disk 1 from peg C to peg B."
needs = ["move-2"]
[[steps]]
id = "move-4"
title = "Move disk 3: A → C"
description = "Move the largest disk from peg A to peg C."
needs = ["move-3"]
[[steps]]
id = "move-5"
title = "Move disk 1: B → A"
description = "Move disk 1 from peg B to peg A."
needs = ["move-4"]
[[steps]]
id = "move-6"
title = "Move disk 2: B → C"
description = "Move disk 2 from peg B to peg C."
needs = ["move-5"]
[[steps]]
id = "move-7"
title = "Move disk 1: A → C"
description = "Move disk 1 from peg A to peg C."
needs = ["move-6"]
[[steps]]
id = "verify"
title = "Verify final state"
description = "All 3 disks now on peg C. Tower intact, all moves were legal."
needs = ["move-7"]
+10 -2
View File
@@ -7,6 +7,7 @@ import (
"path/filepath" "path/filepath"
"time" "time"
"github.com/steveyegge/gastown/internal/beads"
"github.com/steveyegge/gastown/internal/claude" "github.com/steveyegge/gastown/internal/claude"
"github.com/steveyegge/gastown/internal/config" "github.com/steveyegge/gastown/internal/config"
"github.com/steveyegge/gastown/internal/constants" "github.com/steveyegge/gastown/internal/constants"
@@ -94,8 +95,15 @@ func (m *Manager) Start(agentOverride string) error {
} }
// Set environment variables (non-fatal: session works without these) // Set environment variables (non-fatal: session works without these)
_ = t.SetEnvironment(sessionID, "GT_ROLE", "mayor") // Use centralized AgentEnv for consistency across all role startup paths
_ = t.SetEnvironment(sessionID, "BD_ACTOR", "mayor") envVars := config.AgentEnv(config.AgentEnvConfig{
Role: "mayor",
TownRoot: m.townRoot,
BeadsDir: beads.ResolveBeadsDir(m.townRoot),
})
for k, v := range envVars {
_ = t.SetEnvironment(sessionID, k, v)
}
// Apply Mayor theming (non-fatal: theming failure doesn't affect operation) // Apply Mayor theming (non-fatal: theming failure doesn't affect operation)
theme := tmux.MayorTheme() theme := tmux.MayorTheme()
+14 -13
View File
@@ -10,6 +10,7 @@ import (
"strings" "strings"
"time" "time"
"github.com/steveyegge/gastown/internal/beads"
"github.com/steveyegge/gastown/internal/config" "github.com/steveyegge/gastown/internal/config"
"github.com/steveyegge/gastown/internal/constants" "github.com/steveyegge/gastown/internal/constants"
"github.com/steveyegge/gastown/internal/rig" "github.com/steveyegge/gastown/internal/rig"
@@ -185,20 +186,20 @@ func (m *SessionManager) Start(polecat string, opts SessionStartOptions) error {
} }
// Set environment (non-fatal: session works without these) // Set environment (non-fatal: session works without these)
debugSession("SetEnvironment GT_RIG", m.tmux.SetEnvironment(sessionID, "GT_RIG", m.rig.Name)) // Use centralized AgentEnv for consistency across all role startup paths
debugSession("SetEnvironment GT_POLECAT", m.tmux.SetEnvironment(sessionID, "GT_POLECAT", polecat))
// Set runtime config dir for account selection (non-fatal)
if runtimeConfig.Session != nil && runtimeConfig.Session.ConfigDirEnv != "" && opts.RuntimeConfigDir != "" {
debugSession("SetEnvironment "+runtimeConfig.Session.ConfigDirEnv, m.tmux.SetEnvironment(sessionID, runtimeConfig.Session.ConfigDirEnv, opts.RuntimeConfigDir))
}
// Set beads environment for worktree polecats (non-fatal)
townRoot := filepath.Dir(m.rig.Path) townRoot := filepath.Dir(m.rig.Path)
beadsDir := filepath.Join(townRoot, ".beads") envVars := config.AgentEnv(config.AgentEnvConfig{
debugSession("SetEnvironment BEADS_DIR", m.tmux.SetEnvironment(sessionID, "BEADS_DIR", beadsDir)) Role: "polecat",
debugSession("SetEnvironment BEADS_NO_DAEMON", m.tmux.SetEnvironment(sessionID, "BEADS_NO_DAEMON", "1")) Rig: m.rig.Name,
debugSession("SetEnvironment BEADS_AGENT_NAME", m.tmux.SetEnvironment(sessionID, "BEADS_AGENT_NAME", fmt.Sprintf("%s/%s", m.rig.Name, polecat))) AgentName: polecat,
TownRoot: townRoot,
BeadsDir: beads.ResolveBeadsDir(m.rig.Path),
RuntimeConfigDir: opts.RuntimeConfigDir,
BeadsNoDaemon: true,
})
for k, v := range envVars {
debugSession("SetEnvironment "+k, m.tmux.SetEnvironment(sessionID, k, v))
}
// Hook the issue to the polecat if provided via --issue flag // Hook the issue to the polecat if provided via --issue flag
if opts.Issue != "" { if opts.Issue != "" {
+16 -10
View File
@@ -186,17 +186,23 @@ func (m *Manager) Start(foreground bool) error {
} }
// Set environment variables (non-fatal: session works without these) // Set environment variables (non-fatal: session works without these)
_ = t.SetEnvironment(sessionID, "GT_RIG", m.rig.Name) // Use centralized AgentEnv for consistency across all role startup paths
_ = t.SetEnvironment(sessionID, "GT_REFINERY", "1") townRoot := filepath.Dir(m.rig.Path)
_ = t.SetEnvironment(sessionID, "GT_ROLE", "refinery") envVars := config.AgentEnv(config.AgentEnvConfig{
_ = t.SetEnvironment(sessionID, "BD_ACTOR", bdActor) Role: "refinery",
Rig: m.rig.Name,
TownRoot: townRoot,
BeadsDir: beads.ResolveBeadsDir(m.rig.Path),
BeadsNoDaemon: true,
})
// Set beads environment - refinery uses rig-level beads (non-fatal) // Add refinery-specific flag
// Use ResolveBeadsDir to handle both tracked (mayor/rig) and local beads envVars["GT_REFINERY"] = "1"
beadsDir := beads.ResolveBeadsDir(m.rig.Path)
_ = t.SetEnvironment(sessionID, "BEADS_DIR", beadsDir) // Set all env vars in tmux session (for debugging) and they'll also be exported to Claude
_ = t.SetEnvironment(sessionID, "BEADS_NO_DAEMON", "1") for k, v := range envVars {
_ = t.SetEnvironment(sessionID, "BEADS_AGENT_NAME", fmt.Sprintf("%s/refinery", m.rig.Name)) _ = t.SetEnvironment(sessionID, k, v)
}
// Apply theme (non-fatal: theming failure doesn't affect operation) // Apply theme (non-fatal: theming failure doesn't affect operation)
theme := tmux.AssignTheme(m.rig.Name) theme := tmux.AssignTheme(m.rig.Name)
+22
View File
@@ -497,6 +497,28 @@ func (t *Tmux) GetEnvironment(session, key string) (string, error) {
return parts[1], nil return parts[1], nil
} }
// GetAllEnvironment returns all environment variables for a session.
func (t *Tmux) GetAllEnvironment(session string) (map[string]string, error) {
out, err := t.run("show-environment", "-t", session)
if err != nil {
return nil, err
}
env := make(map[string]string)
for _, line := range strings.Split(out, "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "-") {
// Skip empty lines and unset markers (lines starting with -)
continue
}
parts := strings.SplitN(line, "=", 2)
if len(parts) == 2 {
env[parts[0]] = parts[1]
}
}
return env, nil
}
// RenameSession renames a session. // RenameSession renames a session.
func (t *Tmux) RenameSession(oldName, newName string) error { func (t *Tmux) RenameSession(oldName, newName string) error {
_, err := t.run("rename-session", "-t", oldName, newName) _, err := t.run("rename-session", "-t", oldName, newName)
+12 -3
View File
@@ -8,6 +8,7 @@ import (
"time" "time"
"github.com/steveyegge/gastown/internal/agent" "github.com/steveyegge/gastown/internal/agent"
"github.com/steveyegge/gastown/internal/beads"
"github.com/steveyegge/gastown/internal/claude" "github.com/steveyegge/gastown/internal/claude"
"github.com/steveyegge/gastown/internal/config" "github.com/steveyegge/gastown/internal/config"
"github.com/steveyegge/gastown/internal/constants" "github.com/steveyegge/gastown/internal/constants"
@@ -164,9 +165,17 @@ func (m *Manager) Start(foreground bool) error {
} }
// Set environment variables (non-fatal: session works without these) // Set environment variables (non-fatal: session works without these)
_ = t.SetEnvironment(sessionID, "GT_ROLE", "witness") // Use centralized AgentEnv for consistency across all role startup paths
_ = t.SetEnvironment(sessionID, "GT_RIG", m.rig.Name) townRoot := filepath.Dir(m.rig.Path)
_ = t.SetEnvironment(sessionID, "BD_ACTOR", bdActor) envVars := config.AgentEnv(config.AgentEnvConfig{
Role: "witness",
Rig: m.rig.Name,
TownRoot: townRoot,
BeadsDir: beads.ResolveBeadsDir(m.rig.Path),
})
for k, v := range envVars {
_ = t.SetEnvironment(sessionID, k, v)
}
// Apply Gas Town theming (non-fatal: theming failure doesn't affect operation) // Apply Gas Town theming (non-fatal: theming failure doesn't affect operation)
theme := tmux.AssignTheme(m.rig.Name) theme := tmux.AssignTheme(m.rig.Name)