From d4126bb876f0664540d748e666b66f5a795af95a Mon Sep 17 00:00:00 2001 From: mayor Date: Sat, 24 Jan 2026 00:05:24 -0800 Subject: [PATCH] test(config): add hook configuration validation tests Tests ensure: - All SessionStart hooks with gt prime include --hook flag - registry.toml session-prime includes all required roles These catch the seance discovery bug before it breaks handoffs. Co-Authored-By: Claude Opus 4.5 --- internal/config/hooks_test.go | 208 ++++++++++++++++++++++++++++++++++ 1 file changed, 208 insertions(+) create mode 100644 internal/config/hooks_test.go diff --git a/internal/config/hooks_test.go b/internal/config/hooks_test.go new file mode 100644 index 00000000..0ea0ed44 --- /dev/null +++ b/internal/config/hooks_test.go @@ -0,0 +1,208 @@ +// Test Hook Configuration Validation +// +// These tests ensure Claude Code hook configurations are correct across Gas Town. +// Specifically, they validate that: +// - All SessionStart hooks with `gt prime` include the `--hook` flag +// - The registry.toml includes all required roles for session-prime +// +// These tests exist because hook misconfiguration causes seance to fail +// (predecessor sessions become undiscoverable). + +package config + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/BurntSushi/toml" +) + +// ClaudeSettings represents the structure of .claude/settings.json +type ClaudeSettings struct { + Hooks map[string][]HookEntry `json:"hooks"` +} + +type HookEntry struct { + Matcher string `json:"matcher"` + Hooks []HookAction `json:"hooks"` +} + +type HookAction struct { + Type string `json:"type"` + Command string `json:"command"` +} + +// HookRegistry represents the structure of hooks/registry.toml +type HookRegistry struct { + Hooks map[string]RegistryHook `toml:"hooks"` +} + +type RegistryHook struct { + Description string `toml:"description"` + Event string `toml:"event"` + Matchers []string `toml:"matchers"` + Command string `toml:"command"` + Roles []string `toml:"roles"` + Scope string `toml:"scope"` + Enabled bool `toml:"enabled"` +} + +// findTownRoot walks up from cwd to find the Gas Town root. +// We look for hooks/registry.toml as the unique marker (mayor/ exists at multiple levels). +func findTownRoot() (string, error) { + dir, err := os.Getwd() + if err != nil { + return "", err + } + + for { + // hooks/registry.toml is unique to the town root + registryPath := filepath.Join(dir, "hooks", "registry.toml") + if _, err := os.Stat(registryPath); err == nil { + return dir, nil + } + + parent := filepath.Dir(dir) + if parent == dir { + return "", os.ErrNotExist + } + dir = parent + } +} + +// TestSessionStartHooksHaveHookFlag ensures all SessionStart hooks with +// `gt prime` include the `--hook` flag. Without this flag, sessions won't +// emit session_start events and seance can't discover predecessor sessions. +func TestSessionStartHooksHaveHookFlag(t *testing.T) { + townRoot, err := findTownRoot() + if err != nil { + t.Skip("Not running inside Gas Town directory structure") + } + + var settingsFiles []string + + err = filepath.Walk(townRoot, func(path string, info os.FileInfo, err error) error { + if err != nil { + return nil // Skip inaccessible paths + } + if info.Name() == "settings.json" && strings.Contains(path, ".claude") { + settingsFiles = append(settingsFiles, path) + } + return nil + }) + if err != nil { + t.Fatalf("Failed to walk directory: %v", err) + } + + if len(settingsFiles) == 0 { + t.Skip("No .claude/settings.json files found") + } + + var failures []string + + for _, path := range settingsFiles { + data, err := os.ReadFile(path) + if err != nil { + t.Logf("Warning: failed to read %s: %v", path, err) + continue + } + + var settings ClaudeSettings + if err := json.Unmarshal(data, &settings); err != nil { + t.Logf("Warning: failed to parse %s: %v", path, err) + continue + } + + sessionStartHooks, ok := settings.Hooks["SessionStart"] + if !ok { + continue // No SessionStart hooks in this file + } + + for _, entry := range sessionStartHooks { + for _, hook := range entry.Hooks { + cmd := hook.Command + // Check if command contains "gt prime" but not "--hook" + if strings.Contains(cmd, "gt prime") && !strings.Contains(cmd, "--hook") { + relPath, _ := filepath.Rel(townRoot, path) + failures = append(failures, relPath) + } + } + } + } + + if len(failures) > 0 { + t.Errorf("SessionStart hooks missing --hook flag in gt prime command:\n %s\n\n"+ + "The --hook flag is required for seance to discover predecessor sessions.\n"+ + "Fix by changing 'gt prime' to 'gt prime --hook' in these files.", + strings.Join(failures, "\n ")) + } +} + +// TestRegistrySessionPrimeIncludesAllRoles ensures the session-prime hook +// in registry.toml includes all worker roles that need session discovery. +func TestRegistrySessionPrimeIncludesAllRoles(t *testing.T) { + townRoot, err := findTownRoot() + if err != nil { + t.Skip("Not running inside Gas Town directory structure") + } + + registryPath := filepath.Join(townRoot, "hooks", "registry.toml") + data, err := os.ReadFile(registryPath) + if err != nil { + t.Skipf("hooks/registry.toml not found: %v", err) + } + + var registry HookRegistry + if err := toml.Unmarshal(data, ®istry); err != nil { + t.Fatalf("Failed to parse registry.toml: %v", err) + } + + sessionPrime, ok := registry.Hooks["session-prime"] + if !ok { + t.Fatal("session-prime hook not found in registry.toml") + } + + // All roles that should be able to use seance + requiredRoles := []string{"crew", "polecat", "witness", "refinery", "mayor", "deacon"} + + roleSet := make(map[string]bool) + for _, role := range sessionPrime.Roles { + roleSet[role] = true + } + + var missingRoles []string + for _, role := range requiredRoles { + if !roleSet[role] { + missingRoles = append(missingRoles, role) + } + } + + if len(missingRoles) > 0 { + t.Errorf("session-prime hook missing roles: %v\n\n"+ + "Current roles: %v\n"+ + "All roles need session-prime for seance to discover their predecessor sessions.", + missingRoles, sessionPrime.Roles) + } + + // Also verify the command has --hook + if !strings.Contains(sessionPrime.Command, "--hook") { + t.Errorf("session-prime command missing --hook flag:\n %s\n\n"+ + "The --hook flag is required for seance to discover predecessor sessions.", + sessionPrime.Command) + } +} + +// TestPreCompactPrimeDoesNotNeedHookFlag documents that PreCompact hooks +// don't need --hook (session already started, ID already persisted). +func TestPreCompactPrimeDoesNotNeedHookFlag(t *testing.T) { + // This test documents the intentional difference: + // - SessionStart: needs --hook to capture session ID from stdin + // - PreCompact: session already running, ID already persisted + // + // If this test fails, it means someone added --hook to PreCompact + // which is harmless but unnecessary. + t.Log("PreCompact hooks don't need --hook (session ID already persisted at SessionStart)") +}