Adds a new doctor check that verifies all settings.json files in the town use session-start.sh wrapper for SessionStart and PreCompact hooks. Without this wrapper, session_id passthrough fails, which breaks gt seance discovery of sessions. The check: - Scans all settings.json files across town, rigs, crew, and polecats - Warns if any file uses bare 'gt prime' without session-start.sh - Provides fix hint pointing to the correct wrapper configuration (gt-77fhi) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
507 lines
14 KiB
Go
507 lines
14 KiB
Go
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 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",
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
// 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 session-start.sh for proper
|
|
// session_id passthrough. Without this wrapper, 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",
|
|
},
|
|
}
|
|
}
|
|
|
|
// Run checks if all settings.json files use session-start.sh wrapper.
|
|
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 session-start.sh", 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 SessionStart/PreCompact hooks to use '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' (missing session_id passthrough)")
|
|
}
|
|
}
|
|
|
|
// Check for PreCompact hooks
|
|
if strings.Contains(content, "PreCompact") {
|
|
if !c.usesSessionStartScript(content, "PreCompact") {
|
|
problems = append(problems, "PreCompact uses bare 'gt prime' (missing session_id passthrough)")
|
|
}
|
|
}
|
|
|
|
return problems
|
|
}
|
|
|
|
// usesSessionStartScript checks if the hook configuration uses session-start.sh.
|
|
// Returns true if the hook is properly configured or if no hook is 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 bare 'gt prime' without the wrapper
|
|
// Patterns to detect: "gt prime", "'gt prime'", "gt prime\""
|
|
if strings.Contains(section, "gt prime") {
|
|
return false // Uses bare gt prime without session-start.sh
|
|
}
|
|
|
|
// 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
|
|
polecatsPath := filepath.Join(rig, "polecats")
|
|
if polecatEntries, err := os.ReadDir(polecatsPath); err == nil {
|
|
for _, polecat := range polecatEntries {
|
|
if polecat.IsDir() && !strings.HasPrefix(polecat.Name(), ".") {
|
|
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
|
|
}
|