feat(doctor): Add rig-level health checks (gt-f9x.6)

Add 7 new checks for rig health when using `gt doctor --rig <name>`:

- 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 <noreply@anthropic.com>
This commit is contained in:
dag
2026-01-01 19:08:04 -08:00
committed by Steve Yegge
parent dae85ef764
commit b6eeac416d
2 changed files with 773 additions and 0 deletions

View File

@@ -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 {

View File

@@ -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(),
}
}