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
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
# Set by daemon for polecat 'toast' in rig 'gastown'
export BD_ACTOR="gastown/polecats/toast"
export GIT_AUTHOR_NAME="gastown/polecats/toast"
# Set automatically for polecat 'toast' in rig 'gastown'
export GT_ROLE="polecat"
export GT_RIG="gastown"
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
@@ -108,6 +130,9 @@ export BD_ACTOR="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
The format supports programmatic parsing:
+52 -9
View File
@@ -206,17 +206,60 @@ gt mol step done <step> # Complete a molecule step
## 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 |
|----------|---------|
| `BD_ACTOR` | Agent identity for attribution (see [identity.md](identity.md)) |
| `BEADS_DIR` | Point to shared beads database |
| `BEADS_NO_DAEMON` | Required for worktree polecats |
| `GIT_AUTHOR_NAME` | Set to BD_ACTOR for commit attribution |
| `GIT_AUTHOR_EMAIL` | Workspace owner email |
| `GT_TOWN_ROOT` | Override town root detection |
| `GT_ROLE` | Agent role type (mayor, polecat, etc.) |
| `GT_RIG` | Rig name for rig-level agents |
| `GT_POLECAT` | Polecat name (for polecats only) |
| `GIT_AUTHOR_EMAIL` | Workspace owner email (from git config) |
| `GT_TOWN_ROOT` | Override town root detection (manual use) |
| `CLAUDE_RUNTIME_CONFIG_DIR` | Custom Claude settings directory |
### Environment by Role
| Role | Key Variables |
|------|---------------|
| **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
+19 -8
View File
@@ -11,6 +11,7 @@ import (
"path/filepath"
"time"
"github.com/steveyegge/gastown/internal/beads"
"github.com/steveyegge/gastown/internal/config"
"github.com/steveyegge/gastown/internal/tmux"
)
@@ -190,9 +191,15 @@ func (b *Boot) spawnTmux() error {
return fmt.Errorf("creating boot session: %w", err)
}
// Set environment
_ = b.tmux.SetEnvironment(SessionName, "GT_ROLE", "boot")
_ = b.tmux.SetEnvironment(SessionName, "BD_ACTOR", "deacon-boot")
// Set environment using centralized AgentEnv for consistency
envVars := config.AgentEnv(config.AgentEnvConfig{
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
// 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
cmd := exec.Command("gt", "boot", "triage", "--degraded")
cmd.Dir = b.deaconDir
cmd.Env = append(os.Environ(),
"GT_ROLE=boot",
"BD_ACTOR=deacon-boot",
"GT_DEGRADED=true",
)
// Use centralized AgentEnv for consistency with tmux mode
envVars := config.AgentEnv(config.AgentEnvConfig{
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
return cmd.Start()
+13 -7
View File
@@ -4,6 +4,7 @@ import (
"fmt"
"github.com/spf13/cobra"
"github.com/steveyegge/gastown/internal/beads"
"github.com/steveyegge/gastown/internal/config"
"github.com/steveyegge/gastown/internal/constants"
"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)
_ = t.SetEnvironment(sessionID, "GT_ROLE", "crew")
_ = t.SetEnvironment(sessionID, "GT_RIG", r.Name)
_ = t.SetEnvironment(sessionID, "GT_CREW", name)
// Set runtime config dir for account selection (non-fatal)
if runtimeConfig.Session != nil && runtimeConfig.Session.ConfigDirEnv != "" && claudeConfigDir != "" {
_ = t.SetEnvironment(sessionID, runtimeConfig.Session.ConfigDirEnv, claudeConfigDir)
// Use centralized AgentEnv for consistency across all role startup paths
envVars := config.AgentEnv(config.AgentEnvConfig{
Role: "crew",
Rig: r.Name,
AgentName: name,
TownRoot: townRoot,
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)
+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)
_ = t.SetEnvironment(sessionName, "GT_ROLE", "deacon")
_ = t.SetEnvironment(sessionName, "BD_ACTOR", "deacon")
// Use centralized AgentEnv for consistency across all role startup paths
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)
// 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.NewOrphanSessionCheck())
d.Register(doctor.NewOrphanProcessCheck())
d.Register(doctor.NewGTRootCheck())
d.Register(doctor.NewWispGCCheck())
d.Register(doctor.NewBranchCheck())
d.Register(doctor.NewBeadsSyncOrphanCheck())
@@ -142,6 +141,7 @@ func runDoctor(cmd *cobra.Command, args []string) error {
d.Register(doctor.NewLinkedPaneCheck())
d.Register(doctor.NewThemeCheck())
d.Register(doctor.NewCrashReportCheck())
d.Register(doctor.NewEnvVarsCheck())
// Patrol system checks
d.Register(doctor.NewPatrolMoleculesExistCheck())
+109 -59
View File
@@ -4,9 +4,11 @@ import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"github.com/spf13/cobra"
"github.com/steveyegge/gastown/internal/config"
"github.com/steveyegge/gastown/internal/style"
"github.com/steveyegge/gastown/internal/workspace"
)
@@ -21,16 +23,17 @@ const (
// This is the canonical struct for role detection - used by both GetRole()
// and detectRole() functions.
type RoleInfo struct {
Role Role `json:"role"`
Source string `json:"source"` // "env", "cwd", or "explicit"
Home string `json:"home"`
Rig string `json:"rig,omitempty"`
Polecat string `json:"polecat,omitempty"`
EnvRole string `json:"env_role,omitempty"` // Value of GT_ROLE if set
CwdRole Role `json:"cwd_role,omitempty"` // Role detected from cwd
Mismatch bool `json:"mismatch,omitempty"` // True if env != cwd detection
TownRoot string `json:"town_root,omitempty"`
WorkDir string `json:"work_dir,omitempty"` // Current working directory
Role Role `json:"role"`
Source string `json:"source"` // "env", "cwd", or "explicit"
Home string `json:"home"`
Rig string `json:"rig,omitempty"`
Polecat string `json:"polecat,omitempty"`
EnvRole string `json:"env_role,omitempty"` // Value of GT_ROLE if set
CwdRole Role `json:"cwd_role,omitempty"` // Role detected from cwd
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"`
WorkDir string `json:"work_dir,omitempty"` // Current working directory
}
var roleCmd = &cobra.Command{
@@ -86,14 +89,18 @@ var roleListCmd = &cobra.Command{
var roleEnvCmd = &cobra.Command{
Use: "env",
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:
eval $(gt role env) # Set role env vars in current shell`,
Role is determined from GT_ROLE environment variable or current working directory.
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,
}
// Flags
// Flags for role home command
var (
roleRig string
rolePolecat string
@@ -107,7 +114,7 @@ func init() {
roleCmd.AddCommand(roleListCmd)
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(&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
if cwdCtx.Role != RoleUnknown && cwdCtx.Role != parsedRole {
info.Mismatch = true
@@ -277,7 +298,7 @@ func getRoleHome(role Role, rig, polecat, townRoot string) string {
if rig == "" {
return ""
}
return filepath.Join(townRoot, rig, "witness", "rig")
return filepath.Join(townRoot, rig, "witness")
case RoleRefinery:
if rig == "" {
return ""
@@ -287,12 +308,12 @@ func getRoleHome(role Role, rig, polecat, townRoot string) string {
if rig == "" || polecat == "" {
return ""
}
return filepath.Join(townRoot, rig, "polecats", polecat)
return filepath.Join(townRoot, rig, "polecats", polecat, "rig")
case RoleCrew:
if rig == "" || polecat == "" {
return ""
}
return filepath.Join(townRoot, rig, "crew", polecat)
return filepath.Join(townRoot, rig, "crew", polecat, "rig")
default:
return ""
}
@@ -335,6 +356,11 @@ func runRoleShow(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()
if err != nil {
return fmt.Errorf("finding workspace: %w", err)
@@ -343,29 +369,29 @@ func runRoleHome(cmd *cobra.Command, args []string) error {
return fmt.Errorf("not in a Gas Town workspace")
}
var role Role
var rig, polecat string
// Validate flag combinations: --polecat requires --rig to prevent strange merges
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 {
// Explicit role provided
role, rig, polecat = parseRoleString(args[0])
// Override with flags if provided
if roleRig != "" {
rig = roleRig
}
if rolePolecat != "" {
polecat = rolePolecat
}
} else {
// Use current role
info, err := GetRole()
if err != nil {
return err
}
role = info.Role
rig = info.Rig
polecat = info.Polecat
role, _, _ = parseRoleString(args[0])
}
if roleRig != "" {
rig = roleRig
}
if rolePolecat != "" {
polecat = rolePolecat
}
home := getRoleHome(role, rig, polecat, townRoot)
@@ -373,6 +399,11 @@ func runRoleHome(cmd *cobra.Command, args []string) error {
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)
return nil
}
@@ -440,33 +471,52 @@ func runRoleList(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()
if err != nil {
return err
}
// Build the role string for GT_ROLE
var roleStr string
switch info.Role {
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)
home := getRoleHome(info.Role, info.Rig, info.Polecat, townRoot)
if home == "" {
return fmt.Errorf("cannot determine home for role %s (rig=%q, polecat=%q)", info.Role, info.Rig, info.Polecat)
}
fmt.Printf("export %s=%s\n", EnvGTRole, roleStr)
if info.Home != "" {
fmt.Printf("export %s=%s\n", EnvGTRoleHome, info.Home)
// Warn if env was incomplete and we filled from cwd
if info.EnvIncomplete {
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
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
}
// BuildAgentStartupCommand is a convenience function for starting agent sessions.
// It sets standard environment variables (GT_ROLE, BD_ACTOR, GIT_AUTHOR_NAME)
// 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.
// Sets GT_ROLE, GT_RIG, GT_POLECAT, BD_ACTOR, and GIT_AUTHOR_NAME.
func BuildPolecatStartupCommand(rigName, polecatName, rigPath, prompt string) string {
bdActor := fmt.Sprintf("%s/polecats/%s", rigName, polecatName)
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)
return BuildStartupCommand(AgentEnvSimple("polecat", rigName, polecatName), rigPath, prompt)
}
// BuildPolecatStartupCommandWithAgentOverride is like BuildPolecatStartupCommand, but uses agentOverride if non-empty.
func BuildPolecatStartupCommandWithAgentOverride(rigName, polecatName, rigPath, prompt, agentOverride string) (string, error) {
bdActor := fmt.Sprintf("%s/polecats/%s", rigName, polecatName)
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)
return BuildStartupCommandWithAgentOverride(AgentEnvSimple("polecat", rigName, polecatName), rigPath, prompt, agentOverride)
}
// BuildCrewStartupCommand builds the startup command for a crew member.
// Sets GT_ROLE, GT_RIG, GT_CREW, BD_ACTOR, and GIT_AUTHOR_NAME.
func BuildCrewStartupCommand(rigName, crewName, rigPath, prompt string) string {
bdActor := fmt.Sprintf("%s/crew/%s", rigName, crewName)
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)
return BuildStartupCommand(AgentEnvSimple("crew", rigName, crewName), rigPath, prompt)
}
// BuildCrewStartupCommandWithAgentOverride is like BuildCrewStartupCommand, but uses agentOverride if non-empty.
func BuildCrewStartupCommandWithAgentOverride(rigName, crewName, rigPath, prompt, agentOverride string) (string, error) {
bdActor := fmt.Sprintf("%s/crew/%s", rigName, crewName)
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)
return BuildStartupCommandWithAgentOverride(AgentEnvSimple("crew", rigName, crewName), rigPath, prompt, agentOverride)
}
// 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)
_ = t.SetEnvironment(sessionID, "GT_RIG", m.rig.Name)
_ = t.SetEnvironment(sessionID, "GT_CREW", name)
_ = t.SetEnvironment(sessionID, "GT_ROLE", "crew")
// Set CLAUDE_CONFIG_DIR for account selection (non-fatal)
if opts.ClaudeConfigDir != "" {
_ = t.SetEnvironment(sessionID, "CLAUDE_CONFIG_DIR", opts.ClaudeConfigDir)
// Use centralized AgentEnv for consistency across all role startup paths
townRoot := filepath.Dir(m.rig.Path)
envVars := config.AgentEnv(config.AgentEnvConfig{
Role: "crew",
Rig: m.rig.Name,
AgentName: name,
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)
+14 -11
View File
@@ -847,17 +847,21 @@ func (d *Daemon) restartPolecatSession(rigName, polecatName, sessionName string)
}
// Set environment variables
_ = d.tmux.SetEnvironment(sessionName, "GT_ROLE", "polecat")
_ = d.tmux.SetEnvironment(sessionName, "GT_RIG", rigName)
_ = d.tmux.SetEnvironment(sessionName, "GT_POLECAT", polecatName)
// Use centralized AgentEnvSimple for consistency across all role startup paths
envVars := config.AgentEnvSimple("polecat", rigName, polecatName)
bdActor := fmt.Sprintf("%s/polecats/%s", rigName, polecatName)
_ = d.tmux.SetEnvironment(sessionName, "BD_ACTOR", bdActor)
// Add polecat-specific beads configuration
// 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")
_ = d.tmux.SetEnvironment(sessionName, "BEADS_DIR", beadsDir)
_ = d.tmux.SetEnvironment(sessionName, "BEADS_NO_DAEMON", "1")
_ = d.tmux.SetEnvironment(sessionName, "BEADS_AGENT_NAME", fmt.Sprintf("%s/%s", rigName, polecatName))
// Set all env vars in tmux session (for debugging) and they'll also be exported to Claude
for k, v := range envVars {
_ = d.tmux.SetEnvironment(sessionName, k, v)
}
// Apply theme
theme := tmux.AssignTheme(rigName)
@@ -869,8 +873,7 @@ func (d *Daemon) restartPolecatSession(rigName, polecatName, sessionName string)
// Launch Claude with environment exported inline
// Pass rigPath so rig agent settings are honored (not town-level defaults)
rigPath := filepath.Join(d.config.TownRoot, rigName)
startCmd := config.BuildPolecatStartupCommand(rigName, polecatName, rigPath, "")
startCmd := config.BuildStartupCommand(envVars, rigPath, "")
if err := d.tmux.SendKeys(sessionName, startCmd); err != nil {
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
d.setSessionEnvironment(sessionName, identity, config, parsed)
d.setSessionEnvironment(sessionName, config, parsed)
// Apply theme (non-fatal: theming failure doesn't affect operation)
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.
// Uses role bead config if available, falls back to hardcoded defaults.
func (d *Daemon) setSessionEnvironment(sessionName, identity string, config *beads.RoleConfig, parsed *ParsedIdentity) {
// Always set GT_ROLE
_ = d.tmux.SetEnvironment(sessionName, "GT_ROLE", identity)
// Uses centralized AgentEnv for consistency, plus role bead custom env vars if available.
func (d *Daemon) setSessionEnvironment(sessionName string, roleConfig *beads.RoleConfig, parsed *ParsedIdentity) {
// Determine beads dir based on role type
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
bdActor := identityToBDActor(identity)
_ = d.tmux.SetEnvironment(sessionName, "BD_ACTOR", bdActor)
// Use centralized AgentEnv for base environment variables
envVars := config.AgentEnv(config.AgentEnvConfig{
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
if config != nil {
for k, v := range config.EnvVars {
// Set any custom env vars from role config (bead-defined overrides)
if roleConfig != nil {
for k, v := range roleConfig.EnvVars {
expanded := beads.ExpandRolePattern(v, d.config.TownRoot, parsed.RigName, parsed.AgentName, parsed.RoleType)
_ = d.tmux.SetEnvironment(sessionName, k, expanded)
}
+10 -2
View File
@@ -7,6 +7,7 @@ import (
"path/filepath"
"time"
"github.com/steveyegge/gastown/internal/beads"
"github.com/steveyegge/gastown/internal/claude"
"github.com/steveyegge/gastown/internal/config"
"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)
_ = t.SetEnvironment(sessionID, "GT_ROLE", "deacon")
_ = t.SetEnvironment(sessionID, "BD_ACTOR", "deacon")
// Use centralized AgentEnv for consistency across all role startup paths
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)
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.
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]]
id = "dispatch-gated-molecules"
title = "Dispatch molecules with resolved gates"
needs = ["gate-evaluation", "github-gate-check"]
needs = ["gate-evaluation"]
description = """
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"
"time"
"github.com/steveyegge/gastown/internal/beads"
"github.com/steveyegge/gastown/internal/claude"
"github.com/steveyegge/gastown/internal/config"
"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)
_ = t.SetEnvironment(sessionID, "GT_ROLE", "mayor")
_ = t.SetEnvironment(sessionID, "BD_ACTOR", "mayor")
// Use centralized AgentEnv for consistency across all role startup paths
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)
theme := tmux.MayorTheme()
+14 -13
View File
@@ -10,6 +10,7 @@ import (
"strings"
"time"
"github.com/steveyegge/gastown/internal/beads"
"github.com/steveyegge/gastown/internal/config"
"github.com/steveyegge/gastown/internal/constants"
"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)
debugSession("SetEnvironment GT_RIG", m.tmux.SetEnvironment(sessionID, "GT_RIG", m.rig.Name))
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)
// Use centralized AgentEnv for consistency across all role startup paths
townRoot := filepath.Dir(m.rig.Path)
beadsDir := filepath.Join(townRoot, ".beads")
debugSession("SetEnvironment BEADS_DIR", m.tmux.SetEnvironment(sessionID, "BEADS_DIR", beadsDir))
debugSession("SetEnvironment BEADS_NO_DAEMON", m.tmux.SetEnvironment(sessionID, "BEADS_NO_DAEMON", "1"))
debugSession("SetEnvironment BEADS_AGENT_NAME", m.tmux.SetEnvironment(sessionID, "BEADS_AGENT_NAME", fmt.Sprintf("%s/%s", m.rig.Name, polecat)))
envVars := config.AgentEnv(config.AgentEnvConfig{
Role: "polecat",
Rig: m.rig.Name,
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
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)
_ = t.SetEnvironment(sessionID, "GT_RIG", m.rig.Name)
_ = t.SetEnvironment(sessionID, "GT_REFINERY", "1")
_ = t.SetEnvironment(sessionID, "GT_ROLE", "refinery")
_ = t.SetEnvironment(sessionID, "BD_ACTOR", bdActor)
// Use centralized AgentEnv for consistency across all role startup paths
townRoot := filepath.Dir(m.rig.Path)
envVars := config.AgentEnv(config.AgentEnvConfig{
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)
// Use ResolveBeadsDir to handle both tracked (mayor/rig) and local beads
beadsDir := beads.ResolveBeadsDir(m.rig.Path)
_ = t.SetEnvironment(sessionID, "BEADS_DIR", beadsDir)
_ = t.SetEnvironment(sessionID, "BEADS_NO_DAEMON", "1")
_ = t.SetEnvironment(sessionID, "BEADS_AGENT_NAME", fmt.Sprintf("%s/refinery", m.rig.Name))
// Add refinery-specific flag
envVars["GT_REFINERY"] = "1"
// Set all env vars in tmux session (for debugging) and they'll also be exported to Claude
for k, v := range envVars {
_ = t.SetEnvironment(sessionID, k, v)
}
// Apply theme (non-fatal: theming failure doesn't affect operation)
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
}
// 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.
func (t *Tmux) RenameSession(oldName, newName string) error {
_, err := t.run("rename-session", "-t", oldName, newName)
+12 -3
View File
@@ -8,6 +8,7 @@ import (
"time"
"github.com/steveyegge/gastown/internal/agent"
"github.com/steveyegge/gastown/internal/beads"
"github.com/steveyegge/gastown/internal/claude"
"github.com/steveyegge/gastown/internal/config"
"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)
_ = t.SetEnvironment(sessionID, "GT_ROLE", "witness")
_ = t.SetEnvironment(sessionID, "GT_RIG", m.rig.Name)
_ = t.SetEnvironment(sessionID, "BD_ACTOR", bdActor)
// Use centralized AgentEnv for consistency across all role startup paths
townRoot := filepath.Dir(m.rig.Path)
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)
theme := tmux.AssignTheme(m.rig.Name)