Files
gastown/internal/doctor/config_check.go
Julian Knutsen a9080ed04f fix(doctor): filter bd "Note:" messages from custom types check (#381)
* fix(doctor): filter bd "Note:" messages from custom types check

bd outputs "Note: No git repository initialized..." to stdout when
running outside a git repo, which was contaminating the custom types
parsing and causing false warnings.

- Use Output() instead of CombinedOutput() to avoid stderr
- Filter out lines starting with "Note:" from stdout

Co-Authored-By: Claude <noreply@anthropic.com>

* test(doctor): add unit tests for custom types Note: filtering

Extract parseConfigOutput helper function and add tests verifying
that bd "Note:" informational messages are properly filtered from
config output. Tests fail without the fix and pass with it.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: julianknutsen <julianknutsen@users.noreply.github>
Co-authored-by: Claude <noreply@anthropic.com>
2026-01-12 01:45:36 -08:00

666 lines
20 KiB
Go

package doctor
import (
"bufio"
"fmt"
"os"
"os/exec"
"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",
CheckCategory: CategoryConfig,
},
},
}
}
// 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",
CheckCategory: CategoryConfig,
},
}
}
// 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 worktrees)
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",
CheckCategory: CategoryConfig,
},
},
}
}
// 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)
}
// SessionHookCheck verifies settings.json files use proper session_id passthrough.
// Valid options: session-start.sh wrapper OR 'gt prime --hook'.
// Without proper config, gt seance cannot discover sessions.
type SessionHookCheck struct {
BaseCheck
}
// NewSessionHookCheck creates a new session hook check.
func NewSessionHookCheck() *SessionHookCheck {
return &SessionHookCheck{
BaseCheck: BaseCheck{
CheckName: "session-hooks",
CheckDescription: "Check that settings.json hooks use session-start.sh or --hook flag",
CheckCategory: CategoryConfig,
},
}
}
// Run checks if all settings.json files use session-start.sh or --hook flag.
func (c *SessionHookCheck) Run(ctx *CheckContext) *CheckResult {
var issues []string
var checked int
// Find all settings.json files in the town
settingsFiles := c.findSettingsFiles(ctx.TownRoot)
for _, settingsPath := range settingsFiles {
relPath, _ := filepath.Rel(ctx.TownRoot, settingsPath)
problems := c.checkSettingsFile(settingsPath)
if len(problems) > 0 {
for _, problem := range problems {
issues = append(issues, fmt.Sprintf("%s: %s", relPath, problem))
}
}
checked++
}
if len(issues) == 0 {
return &CheckResult{
Name: c.Name(),
Status: StatusOK,
Message: fmt.Sprintf("All %d settings.json file(s) use proper session_id passthrough", checked),
}
}
return &CheckResult{
Name: c.Name(),
Status: StatusWarning,
Message: fmt.Sprintf("%d hook issue(s) found across settings.json files", len(issues)),
Details: issues,
FixHint: "Update hooks to use 'gt prime --hook' or 'bash ~/.claude/hooks/session-start.sh' for session_id passthrough",
}
}
// checkSettingsFile checks a single settings.json file for hook issues.
func (c *SessionHookCheck) checkSettingsFile(path string) []string {
var problems []string
data, err := os.ReadFile(path)
if err != nil {
return nil // Can't read file, skip
}
content := string(data)
// Check for SessionStart hooks
if strings.Contains(content, "SessionStart") {
if !c.usesSessionStartScript(content, "SessionStart") {
problems = append(problems, "SessionStart uses bare 'gt prime' - add --hook flag or use session-start.sh")
}
}
// Check for PreCompact hooks
if strings.Contains(content, "PreCompact") {
if !c.usesSessionStartScript(content, "PreCompact") {
problems = append(problems, "PreCompact uses bare 'gt prime' - add --hook flag or use session-start.sh")
}
}
return problems
}
// usesSessionStartScript checks if the hook configuration handles session_id properly.
// Valid: session-start.sh wrapper OR 'gt prime --hook'. Returns true if properly configured.
func (c *SessionHookCheck) usesSessionStartScript(content, hookType string) bool {
// Find the hook section - look for the hook type followed by its configuration
// This is a simple heuristic - we look for "gt prime" without session-start.sh
// Split around the hook type to find its section
parts := strings.SplitN(content, `"`+hookType+`"`, 2)
if len(parts) < 2 {
return true // Hook type not found, nothing to check
}
// Get the section after the hook type declaration (until next top-level key)
section := parts[1]
// Find the end of this hook section (next top-level key at same depth)
// Simple approach: look until we find another "Session" or "User" or end of hooks
endMarkers := []string{`"SessionStart"`, `"PreCompact"`, `"UserPromptSubmit"`, `"Stop"`, `"Notification"`}
sectionEnd := len(section)
for _, marker := range endMarkers {
if marker == `"`+hookType+`"` {
continue // Skip the one we're looking for
}
if idx := strings.Index(section, marker); idx > 0 && idx < sectionEnd {
sectionEnd = idx
}
}
section = section[:sectionEnd]
// Check if this section contains session-start.sh
if strings.Contains(section, "session-start.sh") {
return true // Uses the wrapper script
}
// Check if it uses 'gt prime --hook' which handles session_id via stdin
if strings.Contains(section, "gt prime") {
// gt prime --hook is valid - it reads session_id from stdin JSON
// Must match --hook as complete flag, not substring (e.g., --hookup)
if containsFlag(section, "--hook") {
return true
}
// Bare 'gt prime' without --hook doesn't get session_id
return false
}
// No gt prime or session-start.sh found - might be a different hook configuration
return true
}
// findSettingsFiles finds all settings.json files in the town.
func (c *SessionHookCheck) findSettingsFiles(townRoot string) []string {
var files []string
// Town root
townSettings := filepath.Join(townRoot, ".claude", "settings.json")
if _, err := os.Stat(townSettings); err == nil {
files = append(files, townSettings)
}
// Find all rigs
rigs := findAllRigs(townRoot)
for _, rig := range rigs {
// Rig root
rigSettings := filepath.Join(rig, ".claude", "settings.json")
if _, err := os.Stat(rigSettings); err == nil {
files = append(files, rigSettings)
}
// Mayor/rig
mayorRigSettings := filepath.Join(rig, "mayor", "rig", ".claude", "settings.json")
if _, err := os.Stat(mayorRigSettings); err == nil {
files = append(files, mayorRigSettings)
}
// Witness
witnessSettings := filepath.Join(rig, "witness", ".claude", "settings.json")
if _, err := os.Stat(witnessSettings); err == nil {
files = append(files, witnessSettings)
}
// Witness/rig
witnessRigSettings := filepath.Join(rig, "witness", "rig", ".claude", "settings.json")
if _, err := os.Stat(witnessRigSettings); err == nil {
files = append(files, witnessRigSettings)
}
// Refinery
refinerySettings := filepath.Join(rig, "refinery", ".claude", "settings.json")
if _, err := os.Stat(refinerySettings); err == nil {
files = append(files, refinerySettings)
}
// Refinery/rig
refineryRigSettings := filepath.Join(rig, "refinery", "rig", ".claude", "settings.json")
if _, err := os.Stat(refineryRigSettings); err == nil {
files = append(files, refineryRigSettings)
}
// 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(), ".") {
crewSettings := filepath.Join(crewPath, crew.Name(), ".claude", "settings.json")
if _, err := os.Stat(crewSettings); err == nil {
files = append(files, crewSettings)
}
}
}
}
// Polecats (handle both new and old structures)
// New structure: polecats/<name>/<rigname>/.claude/settings.json
// Old structure: polecats/<name>/.claude/settings.json
rigName := filepath.Base(rig)
polecatsPath := filepath.Join(rig, "polecats")
if polecatEntries, err := os.ReadDir(polecatsPath); err == nil {
for _, polecat := range polecatEntries {
if polecat.IsDir() && !strings.HasPrefix(polecat.Name(), ".") {
// Try new structure first
polecatSettings := filepath.Join(polecatsPath, polecat.Name(), rigName, ".claude", "settings.json")
if _, err := os.Stat(polecatSettings); err == nil {
files = append(files, polecatSettings)
} else {
// Fall back to old structure
polecatSettings = filepath.Join(polecatsPath, polecat.Name(), ".claude", "settings.json")
if _, err := os.Stat(polecatSettings); err == nil {
files = append(files, polecatSettings)
}
}
}
}
}
}
return files
}
// 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
}
func containsFlag(s, flag string) bool {
idx := strings.Index(s, flag)
if idx == -1 {
return false
}
end := idx + len(flag)
if end >= len(s) {
return true
}
next := s[end]
return next == '"' || next == ' ' || next == '\'' || next == '\n' || next == '\t'
}
// CustomTypesCheck verifies Gas Town custom types are registered with beads.
type CustomTypesCheck struct {
FixableCheck
missingTypes []string // Cached during Run for use in Fix
townRoot string // Cached during Run for use in Fix
}
// NewCustomTypesCheck creates a new custom types check.
func NewCustomTypesCheck() *CustomTypesCheck {
return &CustomTypesCheck{
FixableCheck: FixableCheck{
BaseCheck: BaseCheck{
CheckName: "beads-custom-types",
CheckDescription: "Check that Gas Town custom types are registered with beads",
CheckCategory: CategoryConfig,
},
},
}
}
// Run checks if custom types are properly configured.
func (c *CustomTypesCheck) Run(ctx *CheckContext) *CheckResult {
// Check if bd command is available
if _, err := exec.LookPath("bd"); err != nil {
return &CheckResult{
Name: c.Name(),
Status: StatusOK,
Message: "beads not installed (skipped)",
}
}
// Check if .beads directory exists at town level
townBeadsDir := filepath.Join(ctx.TownRoot, ".beads")
if _, err := os.Stat(townBeadsDir); os.IsNotExist(err) {
return &CheckResult{
Name: c.Name(),
Status: StatusOK,
Message: "No beads database (skipped)",
}
}
// Get current custom types configuration
// Use Output() not CombinedOutput() to avoid capturing bd's stderr messages
cmd := exec.Command("bd", "config", "get", "types.custom")
cmd.Dir = ctx.TownRoot
output, err := cmd.Output()
if err != nil {
// If config key doesn't exist, types are not configured
c.townRoot = ctx.TownRoot
c.missingTypes = constants.BeadsCustomTypesList()
return &CheckResult{
Name: c.Name(),
Status: StatusWarning,
Message: "Custom types not configured",
Details: []string{
"Gas Town custom types (agent, role, rig, convoy, slot) are not registered",
"This may cause bead creation/validation errors",
},
FixHint: "Run 'gt doctor --fix' or 'bd config set types.custom \"" + constants.BeadsCustomTypes + "\"'",
}
}
// Parse configured types, filtering out bd "Note:" messages that may appear in stdout
configuredTypes := parseConfigOutput(output)
configuredSet := make(map[string]bool)
for _, t := range strings.Split(configuredTypes, ",") {
configuredSet[strings.TrimSpace(t)] = true
}
// Check for missing required types
var missing []string
for _, required := range constants.BeadsCustomTypesList() {
if !configuredSet[required] {
missing = append(missing, required)
}
}
if len(missing) == 0 {
return &CheckResult{
Name: c.Name(),
Status: StatusOK,
Message: "All custom types registered",
}
}
// Cache for Fix
c.townRoot = ctx.TownRoot
c.missingTypes = missing
return &CheckResult{
Name: c.Name(),
Status: StatusWarning,
Message: fmt.Sprintf("%d custom type(s) missing", len(missing)),
Details: []string{
fmt.Sprintf("Missing types: %s", strings.Join(missing, ", ")),
fmt.Sprintf("Configured: %s", configuredTypes),
fmt.Sprintf("Required: %s", constants.BeadsCustomTypes),
},
FixHint: "Run 'gt doctor --fix' to register missing types",
}
}
// parseConfigOutput extracts the config value from bd output, filtering out
// informational messages like "Note: ..." that bd may emit to stdout.
func parseConfigOutput(output []byte) string {
for _, line := range strings.Split(string(output), "\n") {
line = strings.TrimSpace(line)
if line != "" && !strings.HasPrefix(line, "Note:") {
return line
}
}
return ""
}
// Fix registers the missing custom types.
func (c *CustomTypesCheck) Fix(ctx *CheckContext) error {
cmd := exec.Command("bd", "config", "set", "types.custom", constants.BeadsCustomTypes)
cmd.Dir = c.townRoot
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("bd config set types.custom: %s", strings.TrimSpace(string(output)))
}
return nil
}