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:
+29
-4
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 >RootCheck{
|
||||
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",
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
@@ -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()
|
||||
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user