Skip to content

Commit fe11b9e

Browse files
jrwoolleyclaude
andauthored
fix: make hooks sync support non-Claude agents
Make gt hooks sync and gt doctor hooks-sync agent-agnostic. Add SyncForRole, DiscoverRoleLocations, DiscoverWorktrees. Subsume #3074 Gemini-specific code into generic infrastructure. Co-Authored-By: Jeff Woolley <jrwoolley@users.noreply.github.com> Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent a461142 commit fe11b9e

File tree

8 files changed

+1102
-219
lines changed

8 files changed

+1102
-219
lines changed

internal/cmd/hooks_sync.go

Lines changed: 88 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"strings"
88

99
"github.com/spf13/cobra"
10+
"github.com/steveyegge/gastown/internal/config"
1011
"github.com/steveyegge/gastown/internal/hooks"
1112
"github.com/steveyegge/gastown/internal/style"
1213
"github.com/steveyegge/gastown/internal/workspace"
@@ -16,18 +17,23 @@ var hooksSyncDryRun bool
1617

1718
var hooksSyncCmd = &cobra.Command{
1819
Use: "sync",
19-
Short: "Regenerate all .claude/settings.json files",
20-
Long: `Regenerate all .claude/settings.json files from the base config and overrides.
20+
Short: "Regenerate all agent hook/settings files",
21+
Long: `Regenerate hook and settings files for all agents across the workspace.
2122
22-
For each target (mayor, deacon, rig/crew, rig/witness, etc.):
23+
For Claude agents (settings.json merge):
2324
1. Load base config
2425
2. Apply role override (if exists)
2526
3. Apply rig+role override (if exists)
2627
4. Merge hooks section into existing settings.json (preserving all fields)
2728
5. Write updated settings.json
2829
30+
For template-based agents (OpenCode, Gemini, Copilot, etc.):
31+
1. Resolve the agent configured for each role
32+
2. Compare deployed hook file against current template
33+
3. Overwrite if content differs
34+
2935
Examples:
30-
gt hooks sync # Regenerate all settings.json files
36+
gt hooks sync # Regenerate all hook/settings files
3137
gt hooks sync --dry-run # Show what would change without writing`,
3238
RunE: runHooksSync,
3339
}
@@ -108,6 +114,84 @@ func runHooksSync(cmd *cobra.Command, args []string) error {
108114
}
109115
}
110116

117+
// Sync template-based (non-Claude) agents at each role location.
118+
// These agents use SyncForRole (content-aware comparison) instead of the
119+
// JSON merge path used for Claude targets above.
120+
locations, locErr := hooks.DiscoverRoleLocations(townRoot)
121+
if locErr != nil {
122+
fmt.Printf(" %s discovering role locations: %v\n", style.Error.Render("✖"), locErr)
123+
errors++
124+
} else {
125+
for _, loc := range locations {
126+
rigPath := ""
127+
if loc.Rig != "" {
128+
rigPath = filepath.Join(townRoot, loc.Rig)
129+
}
130+
rc := config.ResolveRoleAgentConfig(loc.Role, townRoot, rigPath)
131+
if rc == nil || rc.Hooks == nil || rc.Hooks.Provider == "" {
132+
continue
133+
}
134+
// Claude targets are already handled by DiscoverTargets + syncTarget above.
135+
if rc.Hooks.Provider == "claude" {
136+
continue
137+
}
138+
139+
preset := config.GetAgentPresetByName(rc.Hooks.Provider)
140+
useSettingsDir := preset != nil && preset.HooksUseSettingsDir
141+
142+
// Determine sync targets.
143+
// - Town-level roles (mayor, deacon): the role dir IS the working directory.
144+
// - Rig roles with useSettingsDir: one shared file in the role parent.
145+
// - Rig roles without useSettingsDir (OpenCode, etc.): need files in each
146+
// individual worktree subdirectory.
147+
var syncDirs []string
148+
if loc.Rig == "" || useSettingsDir {
149+
syncDirs = []string{loc.Dir}
150+
} else {
151+
syncDirs = hooks.DiscoverWorktrees(loc.Dir)
152+
}
153+
154+
for _, dir := range syncDirs {
155+
targetPath := filepath.Join(dir, rc.Hooks.Dir, rc.Hooks.SettingsFile)
156+
relPath, pathErr := filepath.Rel(townRoot, targetPath)
157+
if pathErr != nil {
158+
relPath = targetPath
159+
}
160+
161+
if hooksSyncDryRun {
162+
if _, statErr := os.Stat(targetPath); statErr == nil {
163+
fmt.Printf(" %s %s %s\n", style.Warning.Render("~"), relPath, style.Dim.Render("(would check "+rc.Hooks.Provider+")"))
164+
} else {
165+
fmt.Printf(" %s %s %s\n", style.Warning.Render("~"), relPath, style.Dim.Render("(would create "+rc.Hooks.Provider+")"))
166+
created++
167+
}
168+
continue
169+
}
170+
171+
result, syncErr := hooks.SyncForRole(rc.Hooks.Provider, dir, dir, loc.Role,
172+
rc.Hooks.Dir, rc.Hooks.SettingsFile, useSettingsDir)
173+
if syncErr != nil {
174+
fmt.Printf(" %s %s (%s): %v\n", style.Error.Render("✖"), relPath, rc.Hooks.Provider, syncErr)
175+
errors++
176+
failedTargets = append(failedTargets, relPath)
177+
continue
178+
}
179+
180+
switch result {
181+
case hooks.SyncCreated:
182+
fmt.Printf(" %s %s %s\n", style.Success.Render("✓"), relPath, style.Dim.Render("(created "+rc.Hooks.Provider+")"))
183+
created++
184+
case hooks.SyncUpdated:
185+
fmt.Printf(" %s %s %s\n", style.Success.Render("✓"), relPath, style.Dim.Render("(updated "+rc.Hooks.Provider+")"))
186+
updated++
187+
case hooks.SyncUnchanged:
188+
fmt.Printf(" %s %s %s\n", style.Dim.Render("·"), relPath, style.Dim.Render("(unchanged "+rc.Hooks.Provider+")"))
189+
unchanged++
190+
}
191+
}
192+
}
193+
}
194+
111195
// Summary
112196
fmt.Println()
113197
total := updated + unchanged + created + errors

internal/cmd/hooks_sync_test.go

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"strings"
77
"testing"
88

9+
"github.com/steveyegge/gastown/internal/config"
910
"github.com/steveyegge/gastown/internal/hooks"
1011
)
1112

@@ -297,3 +298,141 @@ func TestRunHooksSyncFailsClosedOnIntegrityViolation(t *testing.T) {
297298
t.Fatalf("expected fail-closed error, got: %v", err)
298299
}
299300
}
301+
302+
func TestRunHooksSyncNonClaudeAgent(t *testing.T) {
303+
tmpDir := t.TempDir()
304+
t.Setenv("HOME", tmpDir)
305+
306+
townRoot := filepath.Join(tmpDir, "town")
307+
308+
// Scaffold workspace: mayor, deacon, and a rig with a crew worktree
309+
if err := os.MkdirAll(filepath.Join(townRoot, "mayor"), 0755); err != nil {
310+
t.Fatal(err)
311+
}
312+
if err := os.MkdirAll(filepath.Join(townRoot, "deacon"), 0755); err != nil {
313+
t.Fatal(err)
314+
}
315+
if err := os.MkdirAll(filepath.Join(townRoot, "myrig", "crew", "alice"), 0755); err != nil {
316+
t.Fatal(err)
317+
}
318+
319+
// Workspace marker
320+
if err := os.WriteFile(
321+
filepath.Join(townRoot, "mayor", "town.json"),
322+
[]byte(`{"type":"town","version":1,"name":"test"}`),
323+
0644,
324+
); err != nil {
325+
t.Fatal(err)
326+
}
327+
328+
// Configure crew role to use opencode
329+
townSettings := config.NewTownSettings()
330+
townSettings.RoleAgents = map[string]string{"crew": "opencode"}
331+
settingsDir := filepath.Join(townRoot, "settings")
332+
if err := os.MkdirAll(settingsDir, 0755); err != nil {
333+
t.Fatal(err)
334+
}
335+
if err := config.SaveTownSettings(config.TownSettingsPath(townRoot), townSettings); err != nil {
336+
t.Fatal(err)
337+
}
338+
339+
// Base hooks config (needed for Claude targets to not error)
340+
base := &hooks.HooksConfig{
341+
SessionStart: []hooks.HookEntry{
342+
{Matcher: "", Hooks: []hooks.Hook{{Type: "command", Command: "echo test"}}},
343+
},
344+
}
345+
if err := hooks.SaveBase(base); err != nil {
346+
t.Fatalf("SaveBase failed: %v", err)
347+
}
348+
349+
cwd, err := os.Getwd()
350+
if err != nil {
351+
t.Fatal(err)
352+
}
353+
defer func() { _ = os.Chdir(cwd) }()
354+
if err := os.Chdir(townRoot); err != nil {
355+
t.Fatal(err)
356+
}
357+
358+
hooksSyncDryRun = false
359+
if err := runHooksSync(nil, nil); err != nil {
360+
t.Fatalf("runHooksSync failed: %v", err)
361+
}
362+
363+
// Verify OpenCode plugin was synced to the worktree (not the parent)
364+
pluginPath := filepath.Join(townRoot, "myrig", "crew", "alice", ".opencode", "plugins", "gastown.js")
365+
if _, err := os.Stat(pluginPath); os.IsNotExist(err) {
366+
t.Error("opencode plugin not created in worktree alice")
367+
}
368+
369+
// Verify it was NOT created in the parent (crew/) since useSettingsDir=false
370+
parentPlugin := filepath.Join(townRoot, "myrig", "crew", ".opencode", "plugins", "gastown.js")
371+
if _, err := os.Stat(parentPlugin); !os.IsNotExist(err) {
372+
t.Error("opencode plugin should not be in the parent crew/ directory")
373+
}
374+
}
375+
376+
func TestRunHooksSyncNonClaudeAgentDryRun(t *testing.T) {
377+
tmpDir := t.TempDir()
378+
t.Setenv("HOME", tmpDir)
379+
380+
townRoot := filepath.Join(tmpDir, "town")
381+
382+
if err := os.MkdirAll(filepath.Join(townRoot, "mayor"), 0755); err != nil {
383+
t.Fatal(err)
384+
}
385+
if err := os.MkdirAll(filepath.Join(townRoot, "deacon"), 0755); err != nil {
386+
t.Fatal(err)
387+
}
388+
if err := os.MkdirAll(filepath.Join(townRoot, "myrig", "crew", "alice"), 0755); err != nil {
389+
t.Fatal(err)
390+
}
391+
392+
if err := os.WriteFile(
393+
filepath.Join(townRoot, "mayor", "town.json"),
394+
[]byte(`{"type":"town","version":1,"name":"test"}`),
395+
0644,
396+
); err != nil {
397+
t.Fatal(err)
398+
}
399+
400+
townSettings := config.NewTownSettings()
401+
townSettings.RoleAgents = map[string]string{"crew": "opencode"}
402+
if err := os.MkdirAll(filepath.Join(townRoot, "settings"), 0755); err != nil {
403+
t.Fatal(err)
404+
}
405+
if err := config.SaveTownSettings(config.TownSettingsPath(townRoot), townSettings); err != nil {
406+
t.Fatal(err)
407+
}
408+
409+
base := &hooks.HooksConfig{
410+
SessionStart: []hooks.HookEntry{
411+
{Matcher: "", Hooks: []hooks.Hook{{Type: "command", Command: "echo test"}}},
412+
},
413+
}
414+
if err := hooks.SaveBase(base); err != nil {
415+
t.Fatalf("SaveBase failed: %v", err)
416+
}
417+
418+
cwd, err := os.Getwd()
419+
if err != nil {
420+
t.Fatal(err)
421+
}
422+
defer func() { _ = os.Chdir(cwd) }()
423+
if err := os.Chdir(townRoot); err != nil {
424+
t.Fatal(err)
425+
}
426+
427+
hooksSyncDryRun = true
428+
defer func() { hooksSyncDryRun = false }()
429+
if err := runHooksSync(nil, nil); err != nil {
430+
t.Fatalf("runHooksSync dry-run failed: %v", err)
431+
}
432+
433+
// Dry run should NOT create the file
434+
pluginPath := filepath.Join(townRoot, "myrig", "crew", "alice", ".opencode", "plugins", "gastown.js")
435+
if _, err := os.Stat(pluginPath); !os.IsNotExist(err) {
436+
t.Error("dry-run should not create opencode plugin file")
437+
}
438+
}

0 commit comments

Comments
 (0)