From b6eeac416da66c209900bf43906844e2680f9ed5 Mon Sep 17 00:00:00 2001 From: dag Date: Thu, 1 Jan 2026 19:08:04 -0800 Subject: [PATCH] feat(doctor): Add rig-level health checks (gt-f9x.6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add 7 new checks for rig health when using `gt doctor --rig `: - rig-is-git-repo: Verify mayor/rig/ is a valid git clone - git-exclude-configured: Check .git/info/exclude has Gas Town dirs (fixable) - witness-exists: Verify witness/ structure exists (fixable) - refinery-exists: Verify refinery/ structure exists (fixable) - mayor-clone-exists: Verify mayor/rig/ clone exists (fixable) - polecat-clones-valid: Verify polecat directories are valid clones - beads-config-valid: Verify beads configuration works (fixable) Checks are only registered when --rig flag is provided. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- internal/cmd/doctor.go | 14 + internal/doctor/rig_check.go | 759 +++++++++++++++++++++++++++++++++++ 2 files changed, 773 insertions(+) create mode 100644 internal/doctor/rig_check.go diff --git a/internal/cmd/doctor.go b/internal/cmd/doctor.go index d8212c04..aef06ca5 100644 --- a/internal/cmd/doctor.go +++ b/internal/cmd/doctor.go @@ -37,6 +37,15 @@ Clone divergence checks: - persistent-role-branches Detect crew/witness/refinery not on main - clone-divergence Detect clones significantly behind origin/main +Rig checks (with --rig flag): + - rig-is-git-repo Verify rig is a valid git repository + - git-exclude-configured Check .git/info/exclude has Gas Town dirs (fixable) + - witness-exists Verify witness/ structure exists (fixable) + - refinery-exists Verify refinery/ structure exists (fixable) + - mayor-clone-exists Verify mayor/rig/ clone exists (fixable) + - polecat-clones-valid Verify polecat directories are valid clones + - beads-config-valid Verify beads configuration (fixable) + Routing checks (fixable): - routes-config Check beads routing configuration @@ -119,6 +128,11 @@ func runDoctor(cmd *cobra.Command, args []string) error { d.Register(doctor.NewHookSingletonCheck()) d.Register(doctor.NewOrphanedAttachmentsCheck()) + // Rig-specific checks (only when --rig is specified) + if doctorRig != "" { + d.RegisterAll(doctor.RigChecks()...) + } + // Run checks var report *doctor.Report if doctorFix { diff --git a/internal/doctor/rig_check.go b/internal/doctor/rig_check.go new file mode 100644 index 00000000..0f4bdf67 --- /dev/null +++ b/internal/doctor/rig_check.go @@ -0,0 +1,759 @@ +package doctor + +import ( + "bufio" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" +) + +// RigIsGitRepoCheck verifies the rig has a valid mayor/rig git clone. +// Note: The rig directory itself is not a git repo - it contains clones. +type RigIsGitRepoCheck struct { + BaseCheck +} + +// NewRigIsGitRepoCheck creates a new rig git repo check. +func NewRigIsGitRepoCheck() *RigIsGitRepoCheck { + return &RigIsGitRepoCheck{ + BaseCheck: BaseCheck{ + CheckName: "rig-is-git-repo", + CheckDescription: "Verify rig has a valid mayor/rig git clone", + }, + } +} + +// Run checks if the rig has a valid mayor/rig git clone. +func (c *RigIsGitRepoCheck) Run(ctx *CheckContext) *CheckResult { + rigPath := ctx.RigPath() + if rigPath == "" { + return &CheckResult{ + Name: c.Name(), + Status: StatusError, + Message: "No rig specified", + } + } + + // Check mayor/rig/ which is the authoritative clone for the rig + mayorRigPath := filepath.Join(rigPath, "mayor", "rig") + gitPath := filepath.Join(mayorRigPath, ".git") + info, err := os.Stat(gitPath) + if os.IsNotExist(err) { + return &CheckResult{ + Name: c.Name(), + Status: StatusError, + Message: "No mayor/rig clone found", + Details: []string{fmt.Sprintf("Missing: %s", gitPath)}, + FixHint: "Clone the repository to mayor/rig/", + } + } + if err != nil { + return &CheckResult{ + Name: c.Name(), + Status: StatusError, + Message: fmt.Sprintf("Cannot access mayor/rig/.git: %v", err), + } + } + + // Verify git status works + cmd := exec.Command("git", "-C", mayorRigPath, "status", "--porcelain") + if err := cmd.Run(); err != nil { + return &CheckResult{ + Name: c.Name(), + Status: StatusError, + Message: "git status failed on mayor/rig", + Details: []string{fmt.Sprintf("Error: %v", err)}, + FixHint: "Check git configuration and repository integrity", + } + } + + gitType := "clone" + if info.Mode().IsRegular() { + gitType = "worktree" + } + + return &CheckResult{ + Name: c.Name(), + Status: StatusOK, + Message: fmt.Sprintf("Valid mayor/rig %s", gitType), + } +} + +// GitExcludeConfiguredCheck verifies .git/info/exclude has Gas Town directories. +type GitExcludeConfiguredCheck struct { + FixableCheck + missingEntries []string + excludePath string +} + +// NewGitExcludeConfiguredCheck creates a new git exclude check. +func NewGitExcludeConfiguredCheck() *GitExcludeConfiguredCheck { + return &GitExcludeConfiguredCheck{ + FixableCheck: FixableCheck{ + BaseCheck: BaseCheck{ + CheckName: "git-exclude-configured", + CheckDescription: "Check .git/info/exclude has Gas Town directories", + }, + }, + } +} + +// requiredExcludes returns the directories that should be excluded. +func (c *GitExcludeConfiguredCheck) requiredExcludes() []string { + return []string{"polecats/", "witness/", "refinery/", "mayor/"} +} + +// Run checks if .git/info/exclude contains required entries. +func (c *GitExcludeConfiguredCheck) Run(ctx *CheckContext) *CheckResult { + rigPath := ctx.RigPath() + if rigPath == "" { + return &CheckResult{ + Name: c.Name(), + Status: StatusError, + Message: "No rig specified", + } + } + + // Check mayor/rig/ which is the authoritative clone + mayorRigPath := filepath.Join(rigPath, "mayor", "rig") + gitDir := filepath.Join(mayorRigPath, ".git") + info, err := os.Stat(gitDir) + if os.IsNotExist(err) { + return &CheckResult{ + Name: c.Name(), + Status: StatusWarning, + Message: "No mayor/rig clone found", + FixHint: "Run rig-is-git-repo check first", + } + } + + // If .git is a file (worktree), read the actual git dir + if info.Mode().IsRegular() { + content, err := os.ReadFile(gitDir) + if err != nil { + return &CheckResult{ + Name: c.Name(), + Status: StatusError, + Message: fmt.Sprintf("Cannot read .git file: %v", err), + } + } + // Format: "gitdir: /path/to/actual/git/dir" + line := strings.TrimSpace(string(content)) + if strings.HasPrefix(line, "gitdir: ") { + gitDir = strings.TrimPrefix(line, "gitdir: ") + // Resolve relative paths + if !filepath.IsAbs(gitDir) { + gitDir = filepath.Join(rigPath, gitDir) + } + } + } + + c.excludePath = filepath.Join(gitDir, "info", "exclude") + + // Read existing excludes + existing := make(map[string]bool) + if file, err := os.Open(c.excludePath); err == nil { + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line != "" && !strings.HasPrefix(line, "#") { + existing[line] = true + } + } + file.Close() + } + + // Check for missing entries + c.missingEntries = nil + for _, required := range c.requiredExcludes() { + if !existing[required] { + c.missingEntries = append(c.missingEntries, required) + } + } + + if len(c.missingEntries) == 0 { + return &CheckResult{ + Name: c.Name(), + Status: StatusOK, + Message: "Git exclude properly configured", + } + } + + return &CheckResult{ + Name: c.Name(), + Status: StatusWarning, + Message: fmt.Sprintf("%d Gas Town directories not excluded", len(c.missingEntries)), + Details: []string{fmt.Sprintf("Missing: %s", strings.Join(c.missingEntries, ", "))}, + FixHint: "Run 'gt doctor --fix' to add missing entries", + } +} + +// Fix appends missing entries to .git/info/exclude. +func (c *GitExcludeConfiguredCheck) Fix(ctx *CheckContext) error { + if len(c.missingEntries) == 0 { + return nil + } + + // Ensure info directory exists + infoDir := filepath.Dir(c.excludePath) + if err := os.MkdirAll(infoDir, 0755); err != nil { + return fmt.Errorf("failed to create info directory: %w", err) + } + + // Append missing entries + f, err := os.OpenFile(c.excludePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + return fmt.Errorf("failed to open exclude file: %w", err) + } + defer f.Close() + + // Add a header comment if file is empty or new + info, _ := f.Stat() + if info.Size() == 0 { + if _, err := f.WriteString("# Gas Town directories\n"); err != nil { + return err + } + } else { + // Add newline before new entries + if _, err := f.WriteString("\n# Gas Town directories\n"); err != nil { + return err + } + } + + for _, entry := range c.missingEntries { + if _, err := f.WriteString(entry + "\n"); err != nil { + return err + } + } + + return nil +} + +// WitnessExistsCheck verifies the witness directory structure exists. +type WitnessExistsCheck struct { + FixableCheck + rigPath string + needsCreate bool + needsClone bool + needsMail bool +} + +// NewWitnessExistsCheck creates a new witness exists check. +func NewWitnessExistsCheck() *WitnessExistsCheck { + return &WitnessExistsCheck{ + FixableCheck: FixableCheck{ + BaseCheck: BaseCheck{ + CheckName: "witness-exists", + CheckDescription: "Verify witness/ directory structure exists", + }, + }, + } +} + +// Run checks if the witness directory structure exists. +func (c *WitnessExistsCheck) Run(ctx *CheckContext) *CheckResult { + c.rigPath = ctx.RigPath() + if c.rigPath == "" { + return &CheckResult{ + Name: c.Name(), + Status: StatusError, + Message: "No rig specified", + } + } + + witnessDir := filepath.Join(c.rigPath, "witness") + rigClone := filepath.Join(witnessDir, "rig") + mailInbox := filepath.Join(witnessDir, "mail", "inbox.jsonl") + + var issues []string + c.needsCreate = false + c.needsClone = false + c.needsMail = false + + // Check witness/ directory + if _, err := os.Stat(witnessDir); os.IsNotExist(err) { + issues = append(issues, "Missing: witness/") + c.needsCreate = true + } else { + // Check witness/rig/ clone + rigGit := filepath.Join(rigClone, ".git") + if _, err := os.Stat(rigGit); os.IsNotExist(err) { + issues = append(issues, "Missing: witness/rig/ (git clone)") + c.needsClone = true + } + + // Check witness/mail/inbox.jsonl + if _, err := os.Stat(mailInbox); os.IsNotExist(err) { + issues = append(issues, "Missing: witness/mail/inbox.jsonl") + c.needsMail = true + } + } + + if len(issues) == 0 { + return &CheckResult{ + Name: c.Name(), + Status: StatusOK, + Message: "Witness structure exists", + } + } + + return &CheckResult{ + Name: c.Name(), + Status: StatusWarning, + Message: "Witness structure incomplete", + Details: issues, + FixHint: "Run 'gt doctor --fix' to create missing structure", + } +} + +// Fix creates missing witness structure. +func (c *WitnessExistsCheck) Fix(ctx *CheckContext) error { + witnessDir := filepath.Join(c.rigPath, "witness") + + if c.needsCreate { + if err := os.MkdirAll(witnessDir, 0755); err != nil { + return fmt.Errorf("failed to create witness/: %w", err) + } + } + + if c.needsMail { + mailDir := filepath.Join(witnessDir, "mail") + if err := os.MkdirAll(mailDir, 0755); err != nil { + return fmt.Errorf("failed to create witness/mail/: %w", err) + } + inboxPath := filepath.Join(mailDir, "inbox.jsonl") + if err := os.WriteFile(inboxPath, []byte{}, 0644); err != nil { + return fmt.Errorf("failed to create inbox.jsonl: %w", err) + } + } + + // Note: Cannot auto-fix clone without knowing the repo URL + if c.needsClone { + return fmt.Errorf("cannot auto-create witness/rig/ clone (requires repo URL)") + } + + return nil +} + +// RefineryExistsCheck verifies the refinery directory structure exists. +type RefineryExistsCheck struct { + FixableCheck + rigPath string + needsCreate bool + needsClone bool + needsMail bool +} + +// NewRefineryExistsCheck creates a new refinery exists check. +func NewRefineryExistsCheck() *RefineryExistsCheck { + return &RefineryExistsCheck{ + FixableCheck: FixableCheck{ + BaseCheck: BaseCheck{ + CheckName: "refinery-exists", + CheckDescription: "Verify refinery/ directory structure exists", + }, + }, + } +} + +// Run checks if the refinery directory structure exists. +func (c *RefineryExistsCheck) Run(ctx *CheckContext) *CheckResult { + c.rigPath = ctx.RigPath() + if c.rigPath == "" { + return &CheckResult{ + Name: c.Name(), + Status: StatusError, + Message: "No rig specified", + } + } + + refineryDir := filepath.Join(c.rigPath, "refinery") + rigClone := filepath.Join(refineryDir, "rig") + mailInbox := filepath.Join(refineryDir, "mail", "inbox.jsonl") + + var issues []string + c.needsCreate = false + c.needsClone = false + c.needsMail = false + + // Check refinery/ directory + if _, err := os.Stat(refineryDir); os.IsNotExist(err) { + issues = append(issues, "Missing: refinery/") + c.needsCreate = true + } else { + // Check refinery/rig/ clone + rigGit := filepath.Join(rigClone, ".git") + if _, err := os.Stat(rigGit); os.IsNotExist(err) { + issues = append(issues, "Missing: refinery/rig/ (git clone)") + c.needsClone = true + } + + // Check refinery/mail/inbox.jsonl + if _, err := os.Stat(mailInbox); os.IsNotExist(err) { + issues = append(issues, "Missing: refinery/mail/inbox.jsonl") + c.needsMail = true + } + } + + if len(issues) == 0 { + return &CheckResult{ + Name: c.Name(), + Status: StatusOK, + Message: "Refinery structure exists", + } + } + + return &CheckResult{ + Name: c.Name(), + Status: StatusWarning, + Message: "Refinery structure incomplete", + Details: issues, + FixHint: "Run 'gt doctor --fix' to create missing structure", + } +} + +// Fix creates missing refinery structure. +func (c *RefineryExistsCheck) Fix(ctx *CheckContext) error { + refineryDir := filepath.Join(c.rigPath, "refinery") + + if c.needsCreate { + if err := os.MkdirAll(refineryDir, 0755); err != nil { + return fmt.Errorf("failed to create refinery/: %w", err) + } + } + + if c.needsMail { + mailDir := filepath.Join(refineryDir, "mail") + if err := os.MkdirAll(mailDir, 0755); err != nil { + return fmt.Errorf("failed to create refinery/mail/: %w", err) + } + inboxPath := filepath.Join(mailDir, "inbox.jsonl") + if err := os.WriteFile(inboxPath, []byte{}, 0644); err != nil { + return fmt.Errorf("failed to create inbox.jsonl: %w", err) + } + } + + // Note: Cannot auto-fix clone without knowing the repo URL + if c.needsClone { + return fmt.Errorf("cannot auto-create refinery/rig/ clone (requires repo URL)") + } + + return nil +} + +// MayorCloneExistsCheck verifies the mayor/rig clone exists. +type MayorCloneExistsCheck struct { + FixableCheck + rigPath string + needsCreate bool + needsClone bool +} + +// NewMayorCloneExistsCheck creates a new mayor clone check. +func NewMayorCloneExistsCheck() *MayorCloneExistsCheck { + return &MayorCloneExistsCheck{ + FixableCheck: FixableCheck{ + BaseCheck: BaseCheck{ + CheckName: "mayor-clone-exists", + CheckDescription: "Verify mayor/rig/ git clone exists", + }, + }, + } +} + +// Run checks if the mayor/rig clone exists. +func (c *MayorCloneExistsCheck) Run(ctx *CheckContext) *CheckResult { + c.rigPath = ctx.RigPath() + if c.rigPath == "" { + return &CheckResult{ + Name: c.Name(), + Status: StatusError, + Message: "No rig specified", + } + } + + mayorDir := filepath.Join(c.rigPath, "mayor") + rigClone := filepath.Join(mayorDir, "rig") + + var issues []string + c.needsCreate = false + c.needsClone = false + + // Check mayor/ directory + if _, err := os.Stat(mayorDir); os.IsNotExist(err) { + issues = append(issues, "Missing: mayor/") + c.needsCreate = true + } else { + // Check mayor/rig/ clone + rigGit := filepath.Join(rigClone, ".git") + if _, err := os.Stat(rigGit); os.IsNotExist(err) { + issues = append(issues, "Missing: mayor/rig/ (git clone)") + c.needsClone = true + } + } + + if len(issues) == 0 { + return &CheckResult{ + Name: c.Name(), + Status: StatusOK, + Message: "Mayor clone exists", + } + } + + return &CheckResult{ + Name: c.Name(), + Status: StatusWarning, + Message: "Mayor structure incomplete", + Details: issues, + FixHint: "Run 'gt doctor --fix' to create structure (clone requires repo URL)", + } +} + +// Fix creates missing mayor structure. +func (c *MayorCloneExistsCheck) Fix(ctx *CheckContext) error { + mayorDir := filepath.Join(c.rigPath, "mayor") + + if c.needsCreate { + if err := os.MkdirAll(mayorDir, 0755); err != nil { + return fmt.Errorf("failed to create mayor/: %w", err) + } + } + + // Note: Cannot auto-fix clone without knowing the repo URL + if c.needsClone { + return fmt.Errorf("cannot auto-create mayor/rig/ clone (requires repo URL)") + } + + return nil +} + +// PolecatClonesValidCheck verifies each polecat directory is a valid clone. +type PolecatClonesValidCheck struct { + BaseCheck +} + +// NewPolecatClonesValidCheck creates a new polecat clones check. +func NewPolecatClonesValidCheck() *PolecatClonesValidCheck { + return &PolecatClonesValidCheck{ + BaseCheck: BaseCheck{ + CheckName: "polecat-clones-valid", + CheckDescription: "Verify polecat directories are valid git clones", + }, + } +} + +// Run checks if each polecat directory is a valid git clone. +func (c *PolecatClonesValidCheck) Run(ctx *CheckContext) *CheckResult { + rigPath := ctx.RigPath() + if rigPath == "" { + return &CheckResult{ + Name: c.Name(), + Status: StatusError, + Message: "No rig specified", + } + } + + polecatsDir := filepath.Join(rigPath, "polecats") + entries, err := os.ReadDir(polecatsDir) + if os.IsNotExist(err) { + return &CheckResult{ + Name: c.Name(), + Status: StatusOK, + Message: "No polecats/ directory (none deployed)", + } + } + if err != nil { + return &CheckResult{ + Name: c.Name(), + Status: StatusError, + Message: fmt.Sprintf("Cannot read polecats/: %v", err), + } + } + + var issues []string + var warnings []string + validCount := 0 + + for _, entry := range entries { + if !entry.IsDir() || strings.HasPrefix(entry.Name(), ".") { + continue + } + + polecatPath := filepath.Join(polecatsDir, entry.Name()) + polecatName := entry.Name() + + // Check if it's a git clone + gitPath := filepath.Join(polecatPath, ".git") + if _, err := os.Stat(gitPath); os.IsNotExist(err) { + issues = append(issues, fmt.Sprintf("%s: not a git clone", polecatName)) + continue + } + + // Verify git status works and check for uncommitted changes + cmd := exec.Command("git", "-C", polecatPath, "status", "--porcelain") + output, err := cmd.Output() + if err != nil { + issues = append(issues, fmt.Sprintf("%s: git status failed", polecatName)) + continue + } + + if len(output) > 0 { + warnings = append(warnings, fmt.Sprintf("%s: has uncommitted changes", polecatName)) + } + + // Check if on a polecat branch + cmd = exec.Command("git", "-C", polecatPath, "branch", "--show-current") + branchOutput, err := cmd.Output() + if err == nil { + branch := strings.TrimSpace(string(branchOutput)) + if !strings.HasPrefix(branch, "polecat/") { + warnings = append(warnings, fmt.Sprintf("%s: on branch '%s' (expected polecat/*)", polecatName, branch)) + } + } + + validCount++ + } + + if len(issues) > 0 { + return &CheckResult{ + Name: c.Name(), + Status: StatusError, + Message: fmt.Sprintf("%d polecat(s) invalid", len(issues)), + Details: append(issues, warnings...), + FixHint: "Cannot auto-fix (data loss risk)", + } + } + + if len(warnings) > 0 { + return &CheckResult{ + Name: c.Name(), + Status: StatusWarning, + Message: fmt.Sprintf("%d polecat(s) valid, %d warning(s)", validCount, len(warnings)), + Details: warnings, + } + } + + if validCount == 0 { + return &CheckResult{ + Name: c.Name(), + Status: StatusOK, + Message: "No polecats deployed", + } + } + + return &CheckResult{ + Name: c.Name(), + Status: StatusOK, + Message: fmt.Sprintf("%d polecat(s) valid", validCount), + } +} + +// BeadsConfigValidCheck verifies beads configuration if .beads/ exists. +type BeadsConfigValidCheck struct { + FixableCheck + rigPath string + needsSync bool +} + +// NewBeadsConfigValidCheck creates a new beads config check. +func NewBeadsConfigValidCheck() *BeadsConfigValidCheck { + return &BeadsConfigValidCheck{ + FixableCheck: FixableCheck{ + BaseCheck: BaseCheck{ + CheckName: "beads-config-valid", + CheckDescription: "Verify beads configuration if .beads/ exists", + }, + }, + } +} + +// Run checks if beads is properly configured. +func (c *BeadsConfigValidCheck) Run(ctx *CheckContext) *CheckResult { + c.rigPath = ctx.RigPath() + if c.rigPath == "" { + return &CheckResult{ + Name: c.Name(), + Status: StatusError, + Message: "No rig specified", + } + } + + beadsDir := filepath.Join(c.rigPath, ".beads") + if _, err := os.Stat(beadsDir); os.IsNotExist(err) { + return &CheckResult{ + Name: c.Name(), + Status: StatusOK, + Message: "No .beads/ directory (beads not configured)", + } + } + + // Check if bd command works + cmd := exec.Command("bd", "stats", "--json") + cmd.Dir = c.rigPath + if err := cmd.Run(); err != nil { + return &CheckResult{ + Name: c.Name(), + Status: StatusError, + Message: "bd command failed", + Details: []string{fmt.Sprintf("Error: %v", err)}, + FixHint: "Check beads installation and .beads/ configuration", + } + } + + // Check sync status + cmd = exec.Command("bd", "sync", "--status") + cmd.Dir = c.rigPath + output, err := cmd.CombinedOutput() + c.needsSync = false + if err != nil { + // sync --status may exit non-zero if out of sync + outputStr := string(output) + if strings.Contains(outputStr, "out of sync") || strings.Contains(outputStr, "behind") { + c.needsSync = true + return &CheckResult{ + Name: c.Name(), + Status: StatusWarning, + Message: "Beads out of sync", + Details: []string{strings.TrimSpace(outputStr)}, + FixHint: "Run 'gt doctor --fix' or 'bd sync' to synchronize", + } + } + } + + return &CheckResult{ + Name: c.Name(), + Status: StatusOK, + Message: "Beads configured and in sync", + } +} + +// Fix runs bd sync if needed. +func (c *BeadsConfigValidCheck) Fix(ctx *CheckContext) error { + if !c.needsSync { + return nil + } + + cmd := exec.Command("bd", "sync") + cmd.Dir = c.rigPath + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("bd sync failed: %s", string(output)) + } + + return nil +} + +// RigChecks returns all rig-level health checks. +func RigChecks() []Check { + return []Check{ + NewRigIsGitRepoCheck(), + NewGitExcludeConfiguredCheck(), + NewWitnessExistsCheck(), + NewRefineryExistsCheck(), + NewMayorCloneExistsCheck(), + NewPolecatClonesValidCheck(), + NewBeadsConfigValidCheck(), + } +}