diff --git a/.gitignore b/.gitignore index 815a7622..582d2133 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,4 @@ gt # Runtime state state.json +.runtime/ diff --git a/docs/architecture.md b/docs/architecture.md index 4f098660..549c7b61 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1027,9 +1027,15 @@ The HQ (town root) is created by `gt install`: │ ├── beads.db # Mayor mail, coordination, handoffs │ └── config.yaml │ +├── .runtime/ # Town runtime state (gitignored) +│ ├── daemon.json # Daemon PID, heartbeat +│ ├── deacon.json # Deacon cycle state +│ └── agent-requests.json # Lifecycle requests +│ ├── mayor/ # Mayor configuration and state │ ├── town.json # {"type": "town", "name": "..."} │ ├── rigs.json # Registry of managed rigs +│ ├── config.json # Town-level config (theme defaults, etc.) │ └── state.json # Mayor agent state │ ├── rigs/ # Standard location for rigs @@ -1042,6 +1048,7 @@ The HQ (town root) is created by `gt install`: **Notes**: - Mayor's mail is in town beads (`hq-*` issues), not JSONL files - Rigs can be in `rigs/` or at HQ root (both work) +- `.runtime/` is gitignored - contains ephemeral process state - See [docs/hq.md](hq.md) for advanced HQ configurations ### Rig Level @@ -1050,9 +1057,18 @@ Created by `gt rig add `: ``` gastown/ # Rig = container (NOT a git clone) -├── config.json # Rig configuration (git_url, beads prefix) +├── config.json # Rig identity only (type, name, git_url, beads.prefix) ├── .beads/ → mayor/rig/.beads # Symlink to canonical beads in Mayor │ +├── settings/ # Rig behavioral config (git-tracked) +│ ├── config.json # Theme, merge_queue, max_workers +│ └── namepool.json # Pool settings (style, max) +│ +├── .runtime/ # Rig runtime state (gitignored) +│ ├── witness.json # Witness process state +│ ├── refinery.json # Refinery process state +│ └── namepool-state.json # In-use names, overflow counter +│ ├── mayor/ # Mayor's per-rig presence │ ├── rig/ # CANONICAL clone (beads authority) │ │ └── .beads/ # Canonical rig beads (prefix: gt-, etc.) @@ -1073,6 +1089,11 @@ gastown/ # Rig = container (NOT a git clone) └── Toast/ # Worktree from Mayor's clone ``` +**Configuration tiers:** +- **Identity** (`config.json`): Rig name, git_url, beads prefix - rarely changes +- **Settings** (`settings/`): Behavioral config - git-tracked, shareable +- **Runtime** (`.runtime/`): Process state - gitignored, transient + **Beads architecture:** - Mayor's clone holds the canonical `.beads/` for the rig - Rig root symlinks `.beads/` → `mayor/rig/.beads` @@ -1499,11 +1520,18 @@ sequenceDiagram - Simpler role detection - Cleaner directory structure -### 5. Visible Config Directory +### 5. Three-Tier Configuration Architecture -**Decision**: Use `config/` not `.gastown/` for town configuration. +**Decision**: Separate identity, settings, and runtime into distinct locations: +- **Identity** (`config.json`): Rig name, git_url, beads prefix - rarely changes +- **Settings** (`settings/`): Behavioral config - git-tracked, shareable, visible +- **Runtime** (`.runtime/`): Process state - gitignored, transient -**Rationale**: AI models often miss hidden directories. Visible is better. +**Rationale**: +- AI models often miss hidden directories (so `.gastown/` was bad) +- Identity config rarely changes; behavioral config may change often +- Runtime state should never be committed +- `settings/` is visible to agents, unlike hidden `.gastown/` ### 6. Rig as Container, Not Clone @@ -1838,9 +1866,9 @@ Work is a continuous stream - you can add new issues, spawn new workers, reprior } ``` -### rig.json (Per-Rig Config) +### rig.json (Per-Rig Identity) -Each rig has a `config.json` at its root: +Each rig has a `config.json` at its root containing **identity only** (rarely changes): ```json { @@ -1855,6 +1883,30 @@ Each rig has a `config.json` at its root: } ``` +Behavioral settings live in `settings/config.json` (git-tracked, shareable): + +```json +{ + "theme": "desert", + "merge_queue": { + "enabled": true, + "auto_merge": true + }, + "max_workers": 5 +} +``` + +Runtime state lives in `.runtime/` (gitignored, transient): + +```json +// .runtime/witness.json +{ + "state": "running", + "started_at": "2024-01-15T10:30:00Z", + "stats": { "polecats_spawned": 42 } +} +``` + The rig's `.beads/` directory is always at the rig root. Gas Town: 1. Creates `.beads/` when adding a rig (`gt rig add`) 2. Runs `bd init --prefix ` to initialize it diff --git a/docs/hq.md b/docs/hq.md index a72be914..d2fa6ab2 100644 --- a/docs/hq.md +++ b/docs/hq.md @@ -131,12 +131,16 @@ Sometimes you need to run multiple Gas Town systems from the same parent directo If Python Gas Town (PGT) and Go Gas Town (GGT) both use `~/ai/`: ``` ~/ai/ -├── .gastown/ # PGT config +├── .gastown/ # PGT runtime config (hidden) +├── .runtime/ # GGT runtime state (gitignored) ├── .beads/ # Which system owns this? ├── mayor/ # PGT mayor? GGT mayor? └── gastown/ # PGT rig? GGT rig? ``` +Note: GGT uses `.runtime/` for runtime state and `settings/` for behavioral config. +PGT uses `.gastown/` for both. + ### Solutions **Option 1: Separate HQs (recommended)** diff --git a/internal/cmd/doctor.go b/internal/cmd/doctor.go index aef96869..798936aa 100644 --- a/internal/cmd/doctor.go +++ b/internal/cmd/doctor.go @@ -70,6 +70,11 @@ func runDoctor(cmd *cobra.Command, args []string) error { d.Register(doctor.NewWispSizeCheck()) d.Register(doctor.NewWispStaleCheck()) + // Config architecture checks + d.Register(doctor.NewSettingsCheck()) + d.Register(doctor.NewRuntimeGitignoreCheck()) + d.Register(doctor.NewLegacyGastownCheck()) + // Run checks var report *doctor.Report if doctorFix { diff --git a/internal/doctor/config_check.go b/internal/doctor/config_check.go new file mode 100644 index 00000000..6e46b931 --- /dev/null +++ b/internal/doctor/config_check.go @@ -0,0 +1,302 @@ +package doctor + +import ( + "bufio" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/steveyegge/gastown/internal/constants" +) + +// SettingsCheck verifies each rig has a settings/ directory. +type SettingsCheck struct { + FixableCheck + missingSettings []string // Cached during Run for use in Fix +} + +// NewSettingsCheck creates a new settings directory check. +func NewSettingsCheck() *SettingsCheck { + return &SettingsCheck{ + FixableCheck: FixableCheck{ + BaseCheck: BaseCheck{ + CheckName: "rig-settings", + CheckDescription: "Check that rigs have settings/ directory", + }, + }, + } +} + +// Run checks if all rigs have a settings/ directory. +func (c *SettingsCheck) Run(ctx *CheckContext) *CheckResult { + rigs := c.findRigs(ctx.TownRoot) + if len(rigs) == 0 { + return &CheckResult{ + Name: c.Name(), + Status: StatusOK, + Message: "No rigs found", + } + } + + var missing []string + var ok int + + for _, rig := range rigs { + settingsPath := constants.RigSettingsPath(rig) + if _, err := os.Stat(settingsPath); os.IsNotExist(err) { + relPath, _ := filepath.Rel(ctx.TownRoot, rig) + missing = append(missing, relPath) + } else { + ok++ + } + } + + // Cache for Fix + c.missingSettings = nil + for _, rig := range rigs { + settingsPath := constants.RigSettingsPath(rig) + if _, err := os.Stat(settingsPath); os.IsNotExist(err) { + c.missingSettings = append(c.missingSettings, settingsPath) + } + } + + if len(missing) == 0 { + return &CheckResult{ + Name: c.Name(), + Status: StatusOK, + Message: fmt.Sprintf("All %d rig(s) have settings/ directory", ok), + } + } + + details := make([]string, len(missing)) + for i, m := range missing { + details[i] = fmt.Sprintf("Missing: %s/settings/", m) + } + + return &CheckResult{ + Name: c.Name(), + Status: StatusWarning, + Message: fmt.Sprintf("%d rig(s) missing settings/ directory", len(missing)), + Details: details, + FixHint: "Run 'gt doctor --fix' to create missing directories", + } +} + +// Fix creates missing settings/ directories. +func (c *SettingsCheck) Fix(ctx *CheckContext) error { + for _, path := range c.missingSettings { + if err := os.MkdirAll(path, 0755); err != nil { + return fmt.Errorf("failed to create %s: %w", path, err) + } + } + return nil +} + +// RuntimeGitignoreCheck verifies .runtime/ is gitignored at town and rig levels. +type RuntimeGitignoreCheck struct { + BaseCheck +} + +// NewRuntimeGitignoreCheck creates a new runtime gitignore check. +func NewRuntimeGitignoreCheck() *RuntimeGitignoreCheck { + return &RuntimeGitignoreCheck{ + BaseCheck: BaseCheck{ + CheckName: "runtime-gitignore", + CheckDescription: "Check that .runtime/ directories are gitignored", + }, + } +} + +// Run checks if .runtime/ is properly gitignored. +func (c *RuntimeGitignoreCheck) Run(ctx *CheckContext) *CheckResult { + var issues []string + + // Check town-level .gitignore + townGitignore := filepath.Join(ctx.TownRoot, ".gitignore") + if !c.containsPattern(townGitignore, ".runtime") { + issues = append(issues, "Town .gitignore missing .runtime/ pattern") + } + + // Check each rig's .gitignore (in their git clones) + rigs := c.findRigs(ctx.TownRoot) + for _, rig := range rigs { + // Check crew members + crewPath := filepath.Join(rig, "crew") + if crewEntries, err := os.ReadDir(crewPath); err == nil { + for _, crew := range crewEntries { + if crew.IsDir() && !strings.HasPrefix(crew.Name(), ".") { + crewGitignore := filepath.Join(crewPath, crew.Name(), ".gitignore") + if !c.containsPattern(crewGitignore, ".runtime") { + relPath, _ := filepath.Rel(ctx.TownRoot, filepath.Join(crewPath, crew.Name())) + issues = append(issues, fmt.Sprintf("%s .gitignore missing .runtime/ pattern", relPath)) + } + } + } + } + } + + if len(issues) == 0 { + return &CheckResult{ + Name: c.Name(), + Status: StatusOK, + Message: ".runtime/ properly gitignored", + } + } + + return &CheckResult{ + Name: c.Name(), + Status: StatusWarning, + Message: fmt.Sprintf("%d location(s) missing .runtime gitignore", len(issues)), + Details: issues, + FixHint: "Add '.runtime/' to .gitignore files", + } +} + +// containsPattern checks if a gitignore file contains a pattern. +func (c *RuntimeGitignoreCheck) containsPattern(gitignorePath, pattern string) bool { + file, err := os.Open(gitignorePath) + if err != nil { + return false // File doesn't exist + } + defer file.Close() + + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + // Check for pattern match (with or without trailing slash, with or without glob prefix) + // Accept: .runtime, .runtime/, /.runtime, /.runtime/, **/.runtime, **/.runtime/ + if line == pattern || line == pattern+"/" || + line == "/"+pattern || line == "/"+pattern+"/" || + line == "**/"+pattern || line == "**/"+pattern+"/" { + return true + } + } + return false +} + +// findRigs returns rig directories within the town. +func (c *RuntimeGitignoreCheck) findRigs(townRoot string) []string { + return findAllRigs(townRoot) +} + +// LegacyGastownCheck warns if old .gastown/ directories still exist. +type LegacyGastownCheck struct { + FixableCheck + legacyDirs []string // Cached during Run for use in Fix +} + +// NewLegacyGastownCheck creates a new legacy gastown check. +func NewLegacyGastownCheck() *LegacyGastownCheck { + return &LegacyGastownCheck{ + FixableCheck: FixableCheck{ + BaseCheck: BaseCheck{ + CheckName: "legacy-gastown", + CheckDescription: "Check for old .gastown/ directories that should be migrated", + }, + }, + } +} + +// Run checks for legacy .gastown/ directories. +func (c *LegacyGastownCheck) Run(ctx *CheckContext) *CheckResult { + var found []string + + // Check town-level .gastown/ + townGastown := filepath.Join(ctx.TownRoot, ".gastown") + if info, err := os.Stat(townGastown); err == nil && info.IsDir() { + found = append(found, ".gastown/ (town root)") + } + + // Check each rig for .gastown/ + rigs := c.findRigs(ctx.TownRoot) + for _, rig := range rigs { + rigGastown := filepath.Join(rig, ".gastown") + if info, err := os.Stat(rigGastown); err == nil && info.IsDir() { + relPath, _ := filepath.Rel(ctx.TownRoot, rig) + found = append(found, fmt.Sprintf("%s/.gastown/", relPath)) + } + } + + // Cache for Fix + c.legacyDirs = nil + if info, err := os.Stat(townGastown); err == nil && info.IsDir() { + c.legacyDirs = append(c.legacyDirs, townGastown) + } + for _, rig := range rigs { + rigGastown := filepath.Join(rig, ".gastown") + if info, err := os.Stat(rigGastown); err == nil && info.IsDir() { + c.legacyDirs = append(c.legacyDirs, rigGastown) + } + } + + if len(found) == 0 { + return &CheckResult{ + Name: c.Name(), + Status: StatusOK, + Message: "No legacy .gastown/ directories found", + } + } + + return &CheckResult{ + Name: c.Name(), + Status: StatusWarning, + Message: fmt.Sprintf("%d legacy .gastown/ directory(ies) found", len(found)), + Details: found, + FixHint: "Run 'gt doctor --fix' to remove after verifying migration is complete", + } +} + +// Fix removes legacy .gastown/ directories. +func (c *LegacyGastownCheck) Fix(ctx *CheckContext) error { + for _, dir := range c.legacyDirs { + if err := os.RemoveAll(dir); err != nil { + return fmt.Errorf("failed to remove %s: %w", dir, err) + } + } + return nil +} + +// findRigs returns rig directories within the town. +func (c *LegacyGastownCheck) findRigs(townRoot string) []string { + return findAllRigs(townRoot) +} + +// findRigs returns rig directories within the town. +func (c *SettingsCheck) findRigs(townRoot string) []string { + return findAllRigs(townRoot) +} + +// findAllRigs is a shared helper that returns all rig directories within a town. +func findAllRigs(townRoot string) []string { + var rigs []string + + entries, err := os.ReadDir(townRoot) + if err != nil { + return rigs + } + + for _, entry := range entries { + if !entry.IsDir() { + continue + } + // Skip non-rig directories + name := entry.Name() + if name == "mayor" || name == ".beads" || strings.HasPrefix(name, ".") { + continue + } + + rigPath := filepath.Join(townRoot, name) + + // Check if this looks like a rig (has crew/, polecats/, witness/, or refinery/) + markers := []string{"crew", "polecats", "witness", "refinery"} + for _, marker := range markers { + if _, err := os.Stat(filepath.Join(rigPath, marker)); err == nil { + rigs = append(rigs, rigPath) + break + } + } + } + + return rigs +}