Consolidate Claude settings management
Settings creation was scattered across multiple places (createPatrolHooks, ensurePatrolHooks, inline code). Now unified via claude.EnsureSettingsForRole(). Changes: - Add "deacon" to autonomous roles in claude/settings.go - Remove ensurePatrolHooks() from cmd/deacon.go, use EnsureSettingsForRole - Remove createPatrolHooks() from rig/manager.go (no longer needed at rig add) - Add EnsureSettingsForRole call in crew_lifecycle.go - Add doctor check for stale/missing Claude settings files - Wire up claude-settings check in cmd/doctor.go 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -27,7 +27,7 @@ const (
|
||||
// RoleTypeFor returns the RoleType for a given role name.
|
||||
func RoleTypeFor(role string) RoleType {
|
||||
switch role {
|
||||
case "polecat", "witness", "refinery":
|
||||
case "polecat", "witness", "refinery", "deacon":
|
||||
return Autonomous
|
||||
default:
|
||||
return Interactive
|
||||
@@ -35,8 +35,8 @@ func RoleTypeFor(role string) RoleType {
|
||||
}
|
||||
|
||||
// EnsureSettings ensures .claude/settings.json exists in the given directory.
|
||||
// If the file doesn't exist, it copies the appropriate template based on role type.
|
||||
// If the file already exists, it's left unchanged.
|
||||
// For worktrees, we use sparse checkout to exclude source repo's .claude/ directory,
|
||||
// so our settings.json is the only one Claude Code sees.
|
||||
func EnsureSettings(workDir string, roleType RoleType) error {
|
||||
claudeDir := filepath.Join(workDir, ".claude")
|
||||
settingsPath := filepath.Join(claudeDir, "settings.json")
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steveyegge/gastown/internal/beads"
|
||||
"github.com/steveyegge/gastown/internal/claude"
|
||||
"github.com/steveyegge/gastown/internal/config"
|
||||
"github.com/steveyegge/gastown/internal/constants"
|
||||
"github.com/steveyegge/gastown/internal/crew"
|
||||
@@ -577,6 +578,11 @@ func restartCrewSession(rigName, crewName, clonePath string) error {
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure Claude settings exist (crew is interactive role)
|
||||
if err := claude.EnsureSettingsForRole(clonePath, "crew"); err != nil {
|
||||
return fmt.Errorf("ensuring Claude settings: %w", err)
|
||||
}
|
||||
|
||||
// Start new session
|
||||
if err := t.NewSession(sessionID, clonePath); err != nil {
|
||||
return fmt.Errorf("creating session: %w", err)
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/steveyegge/gastown/internal/beads"
|
||||
"github.com/steveyegge/gastown/internal/claude"
|
||||
"github.com/steveyegge/gastown/internal/config"
|
||||
"github.com/steveyegge/gastown/internal/constants"
|
||||
"github.com/steveyegge/gastown/internal/deacon"
|
||||
@@ -274,9 +275,9 @@ func startDeaconSession(t *tmux.Tmux, sessionName string) error {
|
||||
return fmt.Errorf("creating deacon directory: %w", err)
|
||||
}
|
||||
|
||||
// Ensure deacon has patrol hooks (idempotent)
|
||||
if err := ensurePatrolHooks(deaconDir); err != nil {
|
||||
style.PrintWarning("Could not create deacon hooks: %v", err)
|
||||
// Ensure Claude settings exist (autonomous role needs mail in SessionStart)
|
||||
if err := claude.EnsureSettingsForRole(deaconDir, "deacon"); err != nil {
|
||||
style.PrintWarning("Could not create deacon settings: %v", err)
|
||||
}
|
||||
|
||||
// Create session in deacon directory
|
||||
@@ -526,64 +527,6 @@ func runDeaconTriggerPending(cmd *cobra.Command, args []string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensurePatrolHooks creates .claude/settings.json with hooks for patrol roles.
|
||||
// This is idempotent - if hooks already exist, it does nothing.
|
||||
func ensurePatrolHooks(workspacePath string) error {
|
||||
settingsPath := filepath.Join(workspacePath, ".claude", "settings.json")
|
||||
|
||||
// Check if already exists
|
||||
if _, err := os.Stat(settingsPath); err == nil {
|
||||
return nil // Already exists
|
||||
}
|
||||
|
||||
claudeDir := filepath.Join(workspacePath, ".claude")
|
||||
if err := os.MkdirAll(claudeDir, 0755); err != nil {
|
||||
return fmt.Errorf("creating .claude dir: %w", err)
|
||||
}
|
||||
|
||||
// Standard patrol hooks
|
||||
// Note: SessionStart nudges Deacon for GUPP backstop (agent wake notification)
|
||||
hooksJSON := `{
|
||||
"hooks": {
|
||||
"SessionStart": [
|
||||
{
|
||||
"matcher": "",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "gt prime && gt mail check --inject && gt nudge deacon session-started"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PreCompact": [
|
||||
{
|
||||
"matcher": "",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "gt prime"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"UserPromptSubmit": [
|
||||
{
|
||||
"matcher": "",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "gt mail check --inject"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
`
|
||||
return os.WriteFile(settingsPath, []byte(hooksJSON), 0600)
|
||||
}
|
||||
|
||||
// runDeaconHealthCheck implements the health-check command.
|
||||
// It sends a HEALTH_CHECK nudge to an agent, waits for response, and tracks state.
|
||||
func runDeaconHealthCheck(cmd *cobra.Command, args []string) error {
|
||||
|
||||
@@ -63,6 +63,7 @@ Routing checks (fixable):
|
||||
|
||||
Session hook checks:
|
||||
- session-hooks Check settings.json use session-start.sh
|
||||
- claude-settings Check Claude settings.json match templates (fixable)
|
||||
|
||||
Patrol checks:
|
||||
- patrol-molecules-exist Verify patrol molecules exist
|
||||
@@ -137,6 +138,7 @@ func runDoctor(cmd *cobra.Command, args []string) error {
|
||||
d.Register(doctor.NewSessionHookCheck())
|
||||
d.Register(doctor.NewRuntimeGitignoreCheck())
|
||||
d.Register(doctor.NewLegacyGastownCheck())
|
||||
d.Register(doctor.NewClaudeSettingsCheck())
|
||||
|
||||
// Crew workspace checks
|
||||
d.Register(doctor.NewCrewStateCheck())
|
||||
|
||||
350
internal/doctor/claude_settings_check.go
Normal file
350
internal/doctor/claude_settings_check.go
Normal file
@@ -0,0 +1,350 @@
|
||||
package doctor
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/steveyegge/gastown/internal/claude"
|
||||
"github.com/steveyegge/gastown/internal/tmux"
|
||||
)
|
||||
|
||||
// ClaudeSettingsCheck verifies that Claude settings.json files match the expected templates.
|
||||
// Detects stale settings files that are missing required hooks or configuration.
|
||||
type ClaudeSettingsCheck struct {
|
||||
FixableCheck
|
||||
staleSettings []staleSettingsInfo
|
||||
}
|
||||
|
||||
type staleSettingsInfo struct {
|
||||
path string // Full path to settings.json
|
||||
agentType string // e.g., "witness", "refinery", "deacon", "mayor"
|
||||
rigName string // Rig name (empty for town-level agents)
|
||||
sessionName string // tmux session name for cycling
|
||||
missing []string // What's missing from the settings
|
||||
wrongLocation bool // True if file is in wrong location (should be deleted)
|
||||
}
|
||||
|
||||
// NewClaudeSettingsCheck creates a new Claude settings validation check.
|
||||
func NewClaudeSettingsCheck() *ClaudeSettingsCheck {
|
||||
return &ClaudeSettingsCheck{
|
||||
FixableCheck: FixableCheck{
|
||||
BaseCheck: BaseCheck{
|
||||
CheckName: "claude-settings",
|
||||
CheckDescription: "Verify Claude settings.json files match expected templates",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Run checks all Claude settings.json files for staleness.
|
||||
func (c *ClaudeSettingsCheck) Run(ctx *CheckContext) *CheckResult {
|
||||
c.staleSettings = nil
|
||||
|
||||
var details []string
|
||||
|
||||
// Find all settings.json files
|
||||
settingsFiles := c.findSettingsFiles(ctx.TownRoot)
|
||||
|
||||
for _, sf := range settingsFiles {
|
||||
// Files in wrong locations are always stale (should be deleted)
|
||||
if sf.wrongLocation {
|
||||
c.staleSettings = append(c.staleSettings, sf)
|
||||
details = append(details, fmt.Sprintf("%s: wrong location (should be in rig/ subdirectory)", sf.path))
|
||||
continue
|
||||
}
|
||||
|
||||
// Check content of files in correct locations
|
||||
missing := c.checkSettings(sf.path, sf.agentType)
|
||||
if len(missing) > 0 {
|
||||
sf.missing = missing
|
||||
c.staleSettings = append(c.staleSettings, sf)
|
||||
details = append(details, fmt.Sprintf("%s: missing %s", sf.path, strings.Join(missing, ", ")))
|
||||
}
|
||||
}
|
||||
|
||||
if len(c.staleSettings) == 0 {
|
||||
return &CheckResult{
|
||||
Name: c.Name(),
|
||||
Status: StatusOK,
|
||||
Message: "All Claude settings.json files are up to date",
|
||||
}
|
||||
}
|
||||
|
||||
return &CheckResult{
|
||||
Name: c.Name(),
|
||||
Status: StatusError,
|
||||
Message: fmt.Sprintf("Found %d stale Claude settings.json file(s)", len(c.staleSettings)),
|
||||
Details: details,
|
||||
FixHint: "Run 'gt doctor --fix' to update settings and restart affected agents",
|
||||
}
|
||||
}
|
||||
|
||||
// findSettingsFiles locates all .claude/settings.json files and identifies their agent type.
|
||||
func (c *ClaudeSettingsCheck) findSettingsFiles(townRoot string) []staleSettingsInfo {
|
||||
var files []staleSettingsInfo
|
||||
|
||||
// Town-level: mayor (~/gt/.claude/settings.json)
|
||||
mayorSettings := filepath.Join(townRoot, ".claude", "settings.json")
|
||||
if fileExists(mayorSettings) {
|
||||
files = append(files, staleSettingsInfo{
|
||||
path: mayorSettings,
|
||||
agentType: "mayor",
|
||||
sessionName: "gt-mayor",
|
||||
})
|
||||
}
|
||||
|
||||
// Town-level: deacon (~/gt/deacon/.claude/settings.json)
|
||||
deaconSettings := filepath.Join(townRoot, "deacon", ".claude", "settings.json")
|
||||
if fileExists(deaconSettings) {
|
||||
files = append(files, staleSettingsInfo{
|
||||
path: deaconSettings,
|
||||
agentType: "deacon",
|
||||
sessionName: "gt-deacon",
|
||||
})
|
||||
}
|
||||
|
||||
// Find rig directories
|
||||
entries, err := os.ReadDir(townRoot)
|
||||
if err != nil {
|
||||
return files
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
rigName := entry.Name()
|
||||
rigPath := filepath.Join(townRoot, rigName)
|
||||
|
||||
// Skip known non-rig directories
|
||||
if rigName == "mayor" || rigName == "deacon" || rigName == "daemon" ||
|
||||
rigName == ".git" || rigName == "docs" || rigName[0] == '.' {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check for witness settings - rig/ is correct location, without rig/ is wrong
|
||||
witnessRigSettings := filepath.Join(rigPath, "witness", "rig", ".claude", "settings.json")
|
||||
if fileExists(witnessRigSettings) {
|
||||
files = append(files, staleSettingsInfo{
|
||||
path: witnessRigSettings,
|
||||
agentType: "witness",
|
||||
rigName: rigName,
|
||||
sessionName: fmt.Sprintf("gt-%s-witness", rigName),
|
||||
})
|
||||
}
|
||||
// Settings in witness/.claude/ (not witness/rig/.claude/) are in wrong location
|
||||
witnessWrongSettings := filepath.Join(rigPath, "witness", ".claude", "settings.json")
|
||||
if fileExists(witnessWrongSettings) {
|
||||
files = append(files, staleSettingsInfo{
|
||||
path: witnessWrongSettings,
|
||||
agentType: "witness",
|
||||
rigName: rigName,
|
||||
sessionName: fmt.Sprintf("gt-%s-witness", rigName),
|
||||
wrongLocation: true,
|
||||
})
|
||||
}
|
||||
|
||||
// Check for refinery settings - rig/ is correct location, without rig/ is wrong
|
||||
refineryRigSettings := filepath.Join(rigPath, "refinery", "rig", ".claude", "settings.json")
|
||||
if fileExists(refineryRigSettings) {
|
||||
files = append(files, staleSettingsInfo{
|
||||
path: refineryRigSettings,
|
||||
agentType: "refinery",
|
||||
rigName: rigName,
|
||||
sessionName: fmt.Sprintf("gt-%s-refinery", rigName),
|
||||
})
|
||||
}
|
||||
// Settings in refinery/.claude/ (not refinery/rig/.claude/) are in wrong location
|
||||
refineryWrongSettings := filepath.Join(rigPath, "refinery", ".claude", "settings.json")
|
||||
if fileExists(refineryWrongSettings) {
|
||||
files = append(files, staleSettingsInfo{
|
||||
path: refineryWrongSettings,
|
||||
agentType: "refinery",
|
||||
rigName: rigName,
|
||||
sessionName: fmt.Sprintf("gt-%s-refinery", rigName),
|
||||
wrongLocation: true,
|
||||
})
|
||||
}
|
||||
|
||||
// Check for crew settings (crew/<name>/.claude/)
|
||||
crewDir := filepath.Join(rigPath, "crew")
|
||||
if dirExists(crewDir) {
|
||||
crewEntries, _ := os.ReadDir(crewDir)
|
||||
for _, crewEntry := range crewEntries {
|
||||
if !crewEntry.IsDir() {
|
||||
continue
|
||||
}
|
||||
crewSettings := filepath.Join(crewDir, crewEntry.Name(), ".claude", "settings.json")
|
||||
if fileExists(crewSettings) {
|
||||
files = append(files, staleSettingsInfo{
|
||||
path: crewSettings,
|
||||
agentType: "crew",
|
||||
rigName: rigName,
|
||||
sessionName: fmt.Sprintf("gt-%s-crew-%s", rigName, crewEntry.Name()),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for polecat settings (polecats/<name>/.claude/)
|
||||
polecatsDir := filepath.Join(rigPath, "polecats")
|
||||
if dirExists(polecatsDir) {
|
||||
polecatEntries, _ := os.ReadDir(polecatsDir)
|
||||
for _, pcEntry := range polecatEntries {
|
||||
if !pcEntry.IsDir() {
|
||||
continue
|
||||
}
|
||||
pcSettings := filepath.Join(polecatsDir, pcEntry.Name(), ".claude", "settings.json")
|
||||
if fileExists(pcSettings) {
|
||||
files = append(files, staleSettingsInfo{
|
||||
path: pcSettings,
|
||||
agentType: "polecat",
|
||||
rigName: rigName,
|
||||
sessionName: fmt.Sprintf("gt-%s-polecat-%s", rigName, pcEntry.Name()),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return files
|
||||
}
|
||||
|
||||
// checkSettings compares a settings file against the expected template.
|
||||
// Returns a list of what's missing.
|
||||
func (c *ClaudeSettingsCheck) checkSettings(path, agentType string) []string {
|
||||
var missing []string
|
||||
|
||||
// Read the actual settings
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return []string{"unreadable"}
|
||||
}
|
||||
|
||||
var actual map[string]any
|
||||
if err := json.Unmarshal(data, &actual); err != nil {
|
||||
return []string{"invalid JSON"}
|
||||
}
|
||||
|
||||
// Check for required elements based on template
|
||||
// All templates should have:
|
||||
// 1. enabledPlugins
|
||||
// 2. PATH export in hooks
|
||||
// 3. Stop hook with gt costs record (for autonomous)
|
||||
// 4. gt nudge deacon session-started in SessionStart
|
||||
|
||||
// Check enabledPlugins
|
||||
if _, ok := actual["enabledPlugins"]; !ok {
|
||||
missing = append(missing, "enabledPlugins")
|
||||
}
|
||||
|
||||
// Check hooks
|
||||
hooks, ok := actual["hooks"].(map[string]any)
|
||||
if !ok {
|
||||
return append(missing, "hooks")
|
||||
}
|
||||
|
||||
// Check SessionStart hook has PATH export
|
||||
if !c.hookHasPattern(hooks, "SessionStart", "PATH=") {
|
||||
missing = append(missing, "PATH export")
|
||||
}
|
||||
|
||||
// Check SessionStart hook has deacon nudge
|
||||
if !c.hookHasPattern(hooks, "SessionStart", "gt nudge deacon session-started") {
|
||||
missing = append(missing, "deacon nudge")
|
||||
}
|
||||
|
||||
// Check Stop hook exists with gt costs record (for all roles)
|
||||
if !c.hookHasPattern(hooks, "Stop", "gt costs record") {
|
||||
missing = append(missing, "Stop hook")
|
||||
}
|
||||
|
||||
return missing
|
||||
}
|
||||
|
||||
// hookHasPattern checks if a hook contains a specific pattern.
|
||||
func (c *ClaudeSettingsCheck) hookHasPattern(hooks map[string]any, hookName, pattern string) bool {
|
||||
hookList, ok := hooks[hookName].([]any)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, hook := range hookList {
|
||||
hookMap, ok := hook.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
innerHooks, ok := hookMap["hooks"].([]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for _, inner := range innerHooks {
|
||||
innerMap, ok := inner.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
cmd, ok := innerMap["command"].(string)
|
||||
if ok && strings.Contains(cmd, pattern) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Fix deletes stale settings files and restarts affected agents.
|
||||
func (c *ClaudeSettingsCheck) Fix(ctx *CheckContext) error {
|
||||
var errors []string
|
||||
t := tmux.NewTmux()
|
||||
|
||||
for _, sf := range c.staleSettings {
|
||||
// Delete the stale settings file
|
||||
if err := os.Remove(sf.path); err != nil {
|
||||
errors = append(errors, fmt.Sprintf("failed to delete %s: %v", sf.path, err))
|
||||
continue
|
||||
}
|
||||
|
||||
// Also delete parent .claude directory if empty
|
||||
claudeDir := filepath.Dir(sf.path)
|
||||
_ = os.Remove(claudeDir) // Best-effort, will fail if not empty
|
||||
|
||||
// For files in wrong locations, just delete - don't recreate
|
||||
// The correct location will get settings when the agent starts
|
||||
if sf.wrongLocation {
|
||||
continue
|
||||
}
|
||||
|
||||
// Recreate settings using EnsureSettingsForRole
|
||||
workDir := filepath.Dir(claudeDir) // agent work directory
|
||||
if err := claude.EnsureSettingsForRole(workDir, sf.agentType); err != nil {
|
||||
errors = append(errors, fmt.Sprintf("failed to recreate settings for %s: %v", sf.path, err))
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if agent has a running session
|
||||
running, _ := t.HasSession(sf.sessionName)
|
||||
if running {
|
||||
// Cycle the agent by killing and letting gt up restart it
|
||||
// (or the daemon will restart it)
|
||||
_ = t.KillSession(sf.sessionName)
|
||||
}
|
||||
}
|
||||
|
||||
if len(errors) > 0 {
|
||||
return fmt.Errorf("%s", strings.Join(errors, "; "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// fileExists checks if a file exists.
|
||||
func fileExists(path string) bool {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return !info.IsDir()
|
||||
}
|
||||
613
internal/doctor/claude_settings_check_test.go
Normal file
613
internal/doctor/claude_settings_check_test.go
Normal file
@@ -0,0 +1,613 @@
|
||||
package doctor
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNewClaudeSettingsCheck(t *testing.T) {
|
||||
check := NewClaudeSettingsCheck()
|
||||
|
||||
if check.Name() != "claude-settings" {
|
||||
t.Errorf("expected name 'claude-settings', got %q", check.Name())
|
||||
}
|
||||
|
||||
if !check.CanFix() {
|
||||
t.Error("expected CanFix to return true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudeSettingsCheck_NoSettingsFiles(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
check := NewClaudeSettingsCheck()
|
||||
ctx := &CheckContext{TownRoot: tmpDir}
|
||||
|
||||
result := check.Run(ctx)
|
||||
|
||||
if result.Status != StatusOK {
|
||||
t.Errorf("expected StatusOK when no settings files, got %v", result.Status)
|
||||
}
|
||||
}
|
||||
|
||||
// createValidSettings creates a valid settings.json with all required elements.
|
||||
func createValidSettings(t *testing.T, path string) {
|
||||
t.Helper()
|
||||
|
||||
settings := map[string]any{
|
||||
"enabledPlugins": []string{"plugin1"},
|
||||
"hooks": map[string]any{
|
||||
"SessionStart": []any{
|
||||
map[string]any{
|
||||
"matcher": "**",
|
||||
"hooks": []any{
|
||||
map[string]any{
|
||||
"type": "command",
|
||||
"command": "export PATH=/usr/local/bin:$PATH",
|
||||
},
|
||||
map[string]any{
|
||||
"type": "command",
|
||||
"command": "gt nudge deacon session-started",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"Stop": []any{
|
||||
map[string]any{
|
||||
"matcher": "**",
|
||||
"hooks": []any{
|
||||
map[string]any{
|
||||
"type": "command",
|
||||
"command": "gt costs record --session $CLAUDE_SESSION_ID",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(settings, "", " ")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(path, data, 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// createStaleSettings creates a settings.json missing required elements.
|
||||
func createStaleSettings(t *testing.T, path string, missingElements ...string) {
|
||||
t.Helper()
|
||||
|
||||
settings := map[string]any{
|
||||
"enabledPlugins": []string{"plugin1"},
|
||||
"hooks": map[string]any{
|
||||
"SessionStart": []any{
|
||||
map[string]any{
|
||||
"matcher": "**",
|
||||
"hooks": []any{
|
||||
map[string]any{
|
||||
"type": "command",
|
||||
"command": "export PATH=/usr/local/bin:$PATH",
|
||||
},
|
||||
map[string]any{
|
||||
"type": "command",
|
||||
"command": "gt nudge deacon session-started",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"Stop": []any{
|
||||
map[string]any{
|
||||
"matcher": "**",
|
||||
"hooks": []any{
|
||||
map[string]any{
|
||||
"type": "command",
|
||||
"command": "gt costs record --session $CLAUDE_SESSION_ID",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, missing := range missingElements {
|
||||
switch missing {
|
||||
case "enabledPlugins":
|
||||
delete(settings, "enabledPlugins")
|
||||
case "hooks":
|
||||
delete(settings, "hooks")
|
||||
case "PATH":
|
||||
// Remove PATH from SessionStart hooks
|
||||
hooks := settings["hooks"].(map[string]any)
|
||||
sessionStart := hooks["SessionStart"].([]any)
|
||||
hookObj := sessionStart[0].(map[string]any)
|
||||
innerHooks := hookObj["hooks"].([]any)
|
||||
// Filter out PATH command
|
||||
var filtered []any
|
||||
for _, h := range innerHooks {
|
||||
hMap := h.(map[string]any)
|
||||
if cmd, ok := hMap["command"].(string); ok && !strings.Contains(cmd, "PATH=") {
|
||||
filtered = append(filtered, h)
|
||||
}
|
||||
}
|
||||
hookObj["hooks"] = filtered
|
||||
case "deacon-nudge":
|
||||
// Remove deacon nudge from SessionStart hooks
|
||||
hooks := settings["hooks"].(map[string]any)
|
||||
sessionStart := hooks["SessionStart"].([]any)
|
||||
hookObj := sessionStart[0].(map[string]any)
|
||||
innerHooks := hookObj["hooks"].([]any)
|
||||
// Filter out deacon nudge
|
||||
var filtered []any
|
||||
for _, h := range innerHooks {
|
||||
hMap := h.(map[string]any)
|
||||
if cmd, ok := hMap["command"].(string); ok && !strings.Contains(cmd, "gt nudge deacon") {
|
||||
filtered = append(filtered, h)
|
||||
}
|
||||
}
|
||||
hookObj["hooks"] = filtered
|
||||
case "Stop":
|
||||
hooks := settings["hooks"].(map[string]any)
|
||||
delete(hooks, "Stop")
|
||||
}
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(settings, "", " ")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(path, data, 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudeSettingsCheck_ValidMayorSettings(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
// Create valid mayor settings
|
||||
mayorSettings := filepath.Join(tmpDir, ".claude", "settings.json")
|
||||
createValidSettings(t, mayorSettings)
|
||||
|
||||
check := NewClaudeSettingsCheck()
|
||||
ctx := &CheckContext{TownRoot: tmpDir}
|
||||
|
||||
result := check.Run(ctx)
|
||||
|
||||
if result.Status != StatusOK {
|
||||
t.Errorf("expected StatusOK for valid settings, got %v: %s", result.Status, result.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudeSettingsCheck_ValidDeaconSettings(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
// Create valid deacon settings
|
||||
deaconSettings := filepath.Join(tmpDir, "deacon", ".claude", "settings.json")
|
||||
createValidSettings(t, deaconSettings)
|
||||
|
||||
check := NewClaudeSettingsCheck()
|
||||
ctx := &CheckContext{TownRoot: tmpDir}
|
||||
|
||||
result := check.Run(ctx)
|
||||
|
||||
if result.Status != StatusOK {
|
||||
t.Errorf("expected StatusOK for valid deacon settings, got %v: %s", result.Status, result.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudeSettingsCheck_ValidWitnessSettings(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
rigName := "testrig"
|
||||
|
||||
// Create valid witness settings in correct location (rig/.claude/)
|
||||
witnessSettings := filepath.Join(tmpDir, rigName, "witness", "rig", ".claude", "settings.json")
|
||||
createValidSettings(t, witnessSettings)
|
||||
|
||||
check := NewClaudeSettingsCheck()
|
||||
ctx := &CheckContext{TownRoot: tmpDir}
|
||||
|
||||
result := check.Run(ctx)
|
||||
|
||||
if result.Status != StatusOK {
|
||||
t.Errorf("expected StatusOK for valid witness settings, got %v: %s", result.Status, result.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudeSettingsCheck_ValidRefinerySettings(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
rigName := "testrig"
|
||||
|
||||
// Create valid refinery settings in correct location
|
||||
refinerySettings := filepath.Join(tmpDir, rigName, "refinery", "rig", ".claude", "settings.json")
|
||||
createValidSettings(t, refinerySettings)
|
||||
|
||||
check := NewClaudeSettingsCheck()
|
||||
ctx := &CheckContext{TownRoot: tmpDir}
|
||||
|
||||
result := check.Run(ctx)
|
||||
|
||||
if result.Status != StatusOK {
|
||||
t.Errorf("expected StatusOK for valid refinery settings, got %v: %s", result.Status, result.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudeSettingsCheck_ValidCrewSettings(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
rigName := "testrig"
|
||||
|
||||
// Create valid crew agent settings
|
||||
crewSettings := filepath.Join(tmpDir, rigName, "crew", "agent1", ".claude", "settings.json")
|
||||
createValidSettings(t, crewSettings)
|
||||
|
||||
check := NewClaudeSettingsCheck()
|
||||
ctx := &CheckContext{TownRoot: tmpDir}
|
||||
|
||||
result := check.Run(ctx)
|
||||
|
||||
if result.Status != StatusOK {
|
||||
t.Errorf("expected StatusOK for valid crew settings, got %v: %s", result.Status, result.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudeSettingsCheck_ValidPolecatSettings(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
rigName := "testrig"
|
||||
|
||||
// Create valid polecat settings
|
||||
pcSettings := filepath.Join(tmpDir, rigName, "polecats", "pc1", ".claude", "settings.json")
|
||||
createValidSettings(t, pcSettings)
|
||||
|
||||
check := NewClaudeSettingsCheck()
|
||||
ctx := &CheckContext{TownRoot: tmpDir}
|
||||
|
||||
result := check.Run(ctx)
|
||||
|
||||
if result.Status != StatusOK {
|
||||
t.Errorf("expected StatusOK for valid polecat settings, got %v: %s", result.Status, result.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudeSettingsCheck_MissingEnabledPlugins(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
// Create stale mayor settings missing enabledPlugins
|
||||
mayorSettings := filepath.Join(tmpDir, ".claude", "settings.json")
|
||||
createStaleSettings(t, mayorSettings, "enabledPlugins")
|
||||
|
||||
check := NewClaudeSettingsCheck()
|
||||
ctx := &CheckContext{TownRoot: tmpDir}
|
||||
|
||||
result := check.Run(ctx)
|
||||
|
||||
if result.Status != StatusError {
|
||||
t.Errorf("expected StatusError for missing enabledPlugins, got %v", result.Status)
|
||||
}
|
||||
if !strings.Contains(result.Message, "1 stale") {
|
||||
t.Errorf("expected message about stale settings, got %q", result.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudeSettingsCheck_MissingHooks(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
// Create stale settings missing hooks entirely
|
||||
mayorSettings := filepath.Join(tmpDir, ".claude", "settings.json")
|
||||
createStaleSettings(t, mayorSettings, "hooks")
|
||||
|
||||
check := NewClaudeSettingsCheck()
|
||||
ctx := &CheckContext{TownRoot: tmpDir}
|
||||
|
||||
result := check.Run(ctx)
|
||||
|
||||
if result.Status != StatusError {
|
||||
t.Errorf("expected StatusError for missing hooks, got %v", result.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudeSettingsCheck_MissingPATH(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
// Create stale settings missing PATH export
|
||||
mayorSettings := filepath.Join(tmpDir, ".claude", "settings.json")
|
||||
createStaleSettings(t, mayorSettings, "PATH")
|
||||
|
||||
check := NewClaudeSettingsCheck()
|
||||
ctx := &CheckContext{TownRoot: tmpDir}
|
||||
|
||||
result := check.Run(ctx)
|
||||
|
||||
if result.Status != StatusError {
|
||||
t.Errorf("expected StatusError for missing PATH, got %v", result.Status)
|
||||
}
|
||||
found := false
|
||||
for _, d := range result.Details {
|
||||
if strings.Contains(d, "PATH export") {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("expected details to mention PATH export, got %v", result.Details)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudeSettingsCheck_MissingDeaconNudge(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
// Create stale settings missing deacon nudge
|
||||
mayorSettings := filepath.Join(tmpDir, ".claude", "settings.json")
|
||||
createStaleSettings(t, mayorSettings, "deacon-nudge")
|
||||
|
||||
check := NewClaudeSettingsCheck()
|
||||
ctx := &CheckContext{TownRoot: tmpDir}
|
||||
|
||||
result := check.Run(ctx)
|
||||
|
||||
if result.Status != StatusError {
|
||||
t.Errorf("expected StatusError for missing deacon nudge, got %v", result.Status)
|
||||
}
|
||||
found := false
|
||||
for _, d := range result.Details {
|
||||
if strings.Contains(d, "deacon nudge") {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("expected details to mention deacon nudge, got %v", result.Details)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudeSettingsCheck_MissingStopHook(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
// Create stale settings missing Stop hook
|
||||
mayorSettings := filepath.Join(tmpDir, ".claude", "settings.json")
|
||||
createStaleSettings(t, mayorSettings, "Stop")
|
||||
|
||||
check := NewClaudeSettingsCheck()
|
||||
ctx := &CheckContext{TownRoot: tmpDir}
|
||||
|
||||
result := check.Run(ctx)
|
||||
|
||||
if result.Status != StatusError {
|
||||
t.Errorf("expected StatusError for missing Stop hook, got %v", result.Status)
|
||||
}
|
||||
found := false
|
||||
for _, d := range result.Details {
|
||||
if strings.Contains(d, "Stop hook") {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("expected details to mention Stop hook, got %v", result.Details)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudeSettingsCheck_WrongLocationWitness(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
rigName := "testrig"
|
||||
|
||||
// Create settings in wrong location (witness/.claude/ instead of witness/rig/.claude/)
|
||||
wrongSettings := filepath.Join(tmpDir, rigName, "witness", ".claude", "settings.json")
|
||||
createValidSettings(t, wrongSettings)
|
||||
|
||||
check := NewClaudeSettingsCheck()
|
||||
ctx := &CheckContext{TownRoot: tmpDir}
|
||||
|
||||
result := check.Run(ctx)
|
||||
|
||||
if result.Status != StatusError {
|
||||
t.Errorf("expected StatusError for wrong location, got %v", result.Status)
|
||||
}
|
||||
found := false
|
||||
for _, d := range result.Details {
|
||||
if strings.Contains(d, "wrong location") {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("expected details to mention wrong location, got %v", result.Details)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudeSettingsCheck_WrongLocationRefinery(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
rigName := "testrig"
|
||||
|
||||
// Create settings in wrong location (refinery/.claude/ instead of refinery/rig/.claude/)
|
||||
wrongSettings := filepath.Join(tmpDir, rigName, "refinery", ".claude", "settings.json")
|
||||
createValidSettings(t, wrongSettings)
|
||||
|
||||
check := NewClaudeSettingsCheck()
|
||||
ctx := &CheckContext{TownRoot: tmpDir}
|
||||
|
||||
result := check.Run(ctx)
|
||||
|
||||
if result.Status != StatusError {
|
||||
t.Errorf("expected StatusError for wrong location, got %v", result.Status)
|
||||
}
|
||||
found := false
|
||||
for _, d := range result.Details {
|
||||
if strings.Contains(d, "wrong location") {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("expected details to mention wrong location, got %v", result.Details)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudeSettingsCheck_MultipleStaleFiles(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
rigName := "testrig"
|
||||
|
||||
// Create multiple stale settings files
|
||||
mayorSettings := filepath.Join(tmpDir, ".claude", "settings.json")
|
||||
createStaleSettings(t, mayorSettings, "PATH")
|
||||
|
||||
deaconSettings := filepath.Join(tmpDir, "deacon", ".claude", "settings.json")
|
||||
createStaleSettings(t, deaconSettings, "Stop")
|
||||
|
||||
witnessWrong := filepath.Join(tmpDir, rigName, "witness", ".claude", "settings.json")
|
||||
createValidSettings(t, witnessWrong) // Valid content but wrong location
|
||||
|
||||
check := NewClaudeSettingsCheck()
|
||||
ctx := &CheckContext{TownRoot: tmpDir}
|
||||
|
||||
result := check.Run(ctx)
|
||||
|
||||
if result.Status != StatusError {
|
||||
t.Errorf("expected StatusError for multiple stale files, got %v", result.Status)
|
||||
}
|
||||
if !strings.Contains(result.Message, "3 stale") {
|
||||
t.Errorf("expected message about 3 stale files, got %q", result.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudeSettingsCheck_InvalidJSON(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
// Create invalid JSON file
|
||||
mayorSettings := filepath.Join(tmpDir, ".claude", "settings.json")
|
||||
if err := os.MkdirAll(filepath.Dir(mayorSettings), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(mayorSettings, []byte("not valid json {"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
check := NewClaudeSettingsCheck()
|
||||
ctx := &CheckContext{TownRoot: tmpDir}
|
||||
|
||||
result := check.Run(ctx)
|
||||
|
||||
if result.Status != StatusError {
|
||||
t.Errorf("expected StatusError for invalid JSON, got %v", result.Status)
|
||||
}
|
||||
found := false
|
||||
for _, d := range result.Details {
|
||||
if strings.Contains(d, "invalid JSON") {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("expected details to mention invalid JSON, got %v", result.Details)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudeSettingsCheck_FixDeletesStaleFile(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
// Create stale settings in wrong location (easy to test - just delete, no recreate)
|
||||
rigName := "testrig"
|
||||
wrongSettings := filepath.Join(tmpDir, rigName, "witness", ".claude", "settings.json")
|
||||
createValidSettings(t, wrongSettings)
|
||||
|
||||
check := NewClaudeSettingsCheck()
|
||||
ctx := &CheckContext{TownRoot: tmpDir}
|
||||
|
||||
// Run to detect
|
||||
result := check.Run(ctx)
|
||||
if result.Status != StatusError {
|
||||
t.Fatalf("expected StatusError before fix, got %v", result.Status)
|
||||
}
|
||||
|
||||
// Apply fix
|
||||
if err := check.Fix(ctx); err != nil {
|
||||
t.Fatalf("Fix failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify file was deleted
|
||||
if _, err := os.Stat(wrongSettings); !os.IsNotExist(err) {
|
||||
t.Error("expected wrong location settings to be deleted")
|
||||
}
|
||||
|
||||
// Verify check passes (no settings files means OK)
|
||||
result = check.Run(ctx)
|
||||
if result.Status != StatusOK {
|
||||
t.Errorf("expected StatusOK after fix, got %v", result.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudeSettingsCheck_SkipsNonRigDirectories(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
// Create directories that should be skipped
|
||||
for _, skipDir := range []string{"mayor", "deacon", "daemon", ".git", "docs", ".hidden"} {
|
||||
dir := filepath.Join(tmpDir, skipDir, "witness", "rig", ".claude")
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// These should NOT be detected as rig witness settings
|
||||
settingsPath := filepath.Join(dir, "settings.json")
|
||||
createStaleSettings(t, settingsPath, "PATH")
|
||||
}
|
||||
|
||||
check := NewClaudeSettingsCheck()
|
||||
ctx := &CheckContext{TownRoot: tmpDir}
|
||||
|
||||
_ = check.Run(ctx)
|
||||
|
||||
// Should only find mayor and deacon settings in their specific locations
|
||||
// The witness settings in these dirs should be ignored
|
||||
// Since we didn't create valid mayor/deacon settings, those will be stale
|
||||
// But the ones in "mayor/witness/rig/.claude" should be ignored
|
||||
|
||||
// Count how many stale files were found - should be 0 since none of the
|
||||
// skipped directories have their settings detected
|
||||
if len(check.staleSettings) != 0 {
|
||||
t.Errorf("expected 0 stale files (skipped dirs), got %d", len(check.staleSettings))
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudeSettingsCheck_MixedValidAndStale(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
rigName := "testrig"
|
||||
|
||||
// Create valid mayor settings
|
||||
mayorSettings := filepath.Join(tmpDir, ".claude", "settings.json")
|
||||
createValidSettings(t, mayorSettings)
|
||||
|
||||
// Create stale witness settings (missing PATH)
|
||||
witnessSettings := filepath.Join(tmpDir, rigName, "witness", "rig", ".claude", "settings.json")
|
||||
createStaleSettings(t, witnessSettings, "PATH")
|
||||
|
||||
// Create valid refinery settings
|
||||
refinerySettings := filepath.Join(tmpDir, rigName, "refinery", "rig", ".claude", "settings.json")
|
||||
createValidSettings(t, refinerySettings)
|
||||
|
||||
check := NewClaudeSettingsCheck()
|
||||
ctx := &CheckContext{TownRoot: tmpDir}
|
||||
|
||||
result := check.Run(ctx)
|
||||
|
||||
if result.Status != StatusError {
|
||||
t.Errorf("expected StatusError for mixed valid/stale, got %v", result.Status)
|
||||
}
|
||||
if !strings.Contains(result.Message, "1 stale") {
|
||||
t.Errorf("expected message about 1 stale file, got %q", result.Message)
|
||||
}
|
||||
// Should only report the witness settings as stale
|
||||
if len(result.Details) != 1 {
|
||||
t.Errorf("expected 1 detail, got %d: %v", len(result.Details), result.Details)
|
||||
}
|
||||
}
|
||||
@@ -383,11 +383,6 @@ func (m *Manager) AddRig(opts AddRigOptions) (*Rig, error) {
|
||||
if err := m.createRoleCLAUDEmd(refineryRigPath, "refinery", opts.Name, ""); err != nil {
|
||||
return nil, fmt.Errorf("creating refinery CLAUDE.md: %w", err)
|
||||
}
|
||||
// Create refinery hooks for patrol triggering (at refinery/ level, not rig/)
|
||||
refineryPath := filepath.Dir(refineryRigPath)
|
||||
if err := m.createPatrolHooks(refineryPath); err != nil {
|
||||
fmt.Printf(" Warning: Could not create refinery hooks: %v\n", err)
|
||||
}
|
||||
|
||||
// Create empty crew directory with README (crew members added via gt crew add)
|
||||
crewPath := filepath.Join(rigPath, "crew")
|
||||
@@ -422,10 +417,6 @@ Use crew for your own workspace. Polecats are for batch work dispatch.
|
||||
if err := os.MkdirAll(witnessPath, 0755); err != nil {
|
||||
return nil, fmt.Errorf("creating witness dir: %w", err)
|
||||
}
|
||||
// Create witness hooks for patrol triggering
|
||||
if err := m.createPatrolHooks(witnessPath); err != nil {
|
||||
fmt.Printf(" Warning: Could not create witness hooks: %v\n", err)
|
||||
}
|
||||
|
||||
// Create polecats directory (empty)
|
||||
polecatsPath := filepath.Join(rigPath, "polecats")
|
||||
@@ -822,58 +813,6 @@ func (m *Manager) createRoleCLAUDEmd(workspacePath string, role string, rigName
|
||||
return os.WriteFile(claudePath, []byte(content), 0644)
|
||||
}
|
||||
|
||||
// createPatrolHooks creates .claude/settings.json with hooks for patrol roles.
|
||||
// These hooks trigger gt prime on session start and inject mail, enabling
|
||||
// autonomous patrol execution for Witness and Refinery roles.
|
||||
func (m *Manager) createPatrolHooks(workspacePath string) error {
|
||||
claudeDir := filepath.Join(workspacePath, ".claude")
|
||||
if err := os.MkdirAll(claudeDir, 0755); err != nil {
|
||||
return fmt.Errorf("creating .claude dir: %w", err)
|
||||
}
|
||||
|
||||
// Standard patrol hooks - same as deacon
|
||||
hooksJSON := `{
|
||||
"hooks": {
|
||||
"SessionStart": [
|
||||
{
|
||||
"matcher": "",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "gt prime && gt mail check --inject"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PreCompact": [
|
||||
{
|
||||
"matcher": "",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "gt prime"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"UserPromptSubmit": [
|
||||
{
|
||||
"matcher": "",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "gt mail check --inject"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
`
|
||||
settingsPath := filepath.Join(claudeDir, "settings.json")
|
||||
return os.WriteFile(settingsPath, []byte(hooksJSON), 0600)
|
||||
}
|
||||
|
||||
// seedPatrolMolecules creates patrol molecule prototypes in the rig's beads database.
|
||||
// These molecules define the work loops for Deacon, Witness, and Refinery roles.
|
||||
func (m *Manager) seedPatrolMolecules(rigPath string) error {
|
||||
|
||||
Reference in New Issue
Block a user