feat(doctor): Add session-hooks check for settings.json consistency
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>
This commit is contained in:
@@ -58,6 +58,9 @@ Rig checks (with --rig flag):
|
|||||||
Routing checks (fixable):
|
Routing checks (fixable):
|
||||||
- routes-config Check beads routing configuration
|
- routes-config Check beads routing configuration
|
||||||
|
|
||||||
|
Session hook checks:
|
||||||
|
- session-hooks Check settings.json use session-start.sh
|
||||||
|
|
||||||
Patrol checks:
|
Patrol checks:
|
||||||
- patrol-molecules-exist Verify patrol molecules exist
|
- patrol-molecules-exist Verify patrol molecules exist
|
||||||
- patrol-hooks-wired Verify daemon triggers patrols
|
- patrol-hooks-wired Verify daemon triggers patrols
|
||||||
@@ -128,6 +131,7 @@ func runDoctor(cmd *cobra.Command, args []string) error {
|
|||||||
|
|
||||||
// Config architecture checks
|
// Config architecture checks
|
||||||
d.Register(doctor.NewSettingsCheck())
|
d.Register(doctor.NewSettingsCheck())
|
||||||
|
d.Register(doctor.NewSessionHookCheck())
|
||||||
d.Register(doctor.NewRuntimeGitignoreCheck())
|
d.Register(doctor.NewRuntimeGitignoreCheck())
|
||||||
d.Register(doctor.NewLegacyGastownCheck())
|
d.Register(doctor.NewLegacyGastownCheck())
|
||||||
|
|
||||||
|
|||||||
@@ -267,6 +267,210 @@ func (c *SettingsCheck) findRigs(townRoot string) []string {
|
|||||||
return findAllRigs(townRoot)
|
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.
|
// findAllRigs is a shared helper that returns all rig directories within a town.
|
||||||
func findAllRigs(townRoot string) []string {
|
func findAllRigs(townRoot string) []string {
|
||||||
var rigs []string
|
var rigs []string
|
||||||
|
|||||||
Reference in New Issue
Block a user