Skip to content

Commit 3e95b52

Browse files
authored
Merge pull request #1808 from entireio/feat/hook-config-drift-warning
feat(doctor,status): warn when Claude Code hook config is outdated
2 parents 619296a + 36aedba commit 3e95b52

6 files changed

Lines changed: 305 additions & 6 deletions

File tree

cmd/entire/cli/agent/claudecode/hooks.go

Lines changed: 90 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"os"
88
"path/filepath"
99
"slices"
10+
"strings"
1011

1112
"github.com/entireio/cli/cmd/entire/cli/agent"
1213
"github.com/entireio/cli/cmd/entire/cli/jsonutil"
@@ -400,8 +401,9 @@ func (c *ClaudeCodeAgent) UninstallHooks(ctx context.Context) error {
400401
return nil
401402
}
402403

403-
// AreHooksInstalled checks if Entire hooks are installed.
404-
func (c *ClaudeCodeAgent) AreHooksInstalled(ctx context.Context) bool {
404+
// loadClaudeSettings reads and parses .claude/settings.json from the repo root.
405+
// Returns ok=false when the file is missing or unparseable.
406+
func loadClaudeSettings(ctx context.Context) (ClaudeSettings, bool) {
405407
// Use repo root to find .claude directory when run from a subdirectory
406408
repoRoot, err := paths.WorktreeRoot(ctx)
407409
if err != nil {
@@ -410,18 +412,61 @@ func (c *ClaudeCodeAgent) AreHooksInstalled(ctx context.Context) bool {
410412
settingsPath := filepath.Join(repoRoot, ".claude", ClaudeSettingsFileName)
411413
data, err := os.ReadFile(settingsPath) //nolint:gosec // path is constructed from repo root + fixed path
412414
if err != nil {
413-
return false
415+
return ClaudeSettings{}, false
414416
}
415417

416418
var settings ClaudeSettings
417419
if err := json.Unmarshal(data, &settings); err != nil {
418-
return false
420+
return ClaudeSettings{}, false
419421
}
422+
return settings, true
423+
}
420424

425+
// AreHooksInstalled checks if Entire hooks are installed.
426+
func (c *ClaudeCodeAgent) AreHooksInstalled(ctx context.Context) bool {
427+
settings, ok := loadClaudeSettings(ctx)
428+
if !ok {
429+
return false
430+
}
421431
// Check for at least one of our hooks (new, wrapped, or legacy format)
422432
return hasEntireHook(settings.Hooks.Stop)
423433
}
424434

435+
// HookConfigState describes how Entire's Claude Code hooks compare to what
436+
// InstallHooks would write today.
437+
type HookConfigState int
438+
439+
const (
440+
// HooksAbsent means Entire hooks are not installed in this repo.
441+
HooksAbsent HookConfigState = iota
442+
// HooksCurrent means the installed hooks match the current config.
443+
HooksCurrent
444+
// HooksOutdated means Entire hooks are installed but the current tool-use
445+
// matchers no longer carry them (e.g. an older CLI wrote them under the now
446+
// non-firing "Task"/"TodoWrite" matchers). Fix: `entire enable --force`.
447+
HooksOutdated
448+
)
449+
450+
// CheckHookConfig reports whether Entire's Claude Code hooks are absent,
451+
// current, or outdated. It is a read-only diagnostic used by `entire status`
452+
// and `entire doctor`; it never modifies settings. Outdated is detected on the
453+
// positive spec: Entire is installed (Stop hook present) yet one of the current
454+
// tool-use matchers does not carry its Entire hook.
455+
func CheckHookConfig(ctx context.Context) HookConfigState {
456+
settings, ok := loadClaudeSettings(ctx)
457+
if !ok || !hasEntireHook(settings.Hooks.Stop) {
458+
return HooksAbsent
459+
}
460+
subagentTools := splitMatcherTools(subagentToolMatcher)
461+
taskTools := splitMatcherTools(taskToolMatcher)
462+
if !hasEntireHookCoveringTools(settings.Hooks.PreToolUse, subagentTools) ||
463+
!hasEntireHookCoveringTools(settings.Hooks.PostToolUse, subagentTools) ||
464+
!hasEntireHookCoveringTools(settings.Hooks.PostToolUse, taskTools) {
465+
return HooksOutdated
466+
}
467+
return HooksCurrent
468+
}
469+
425470
// Helper functions for hook management
426471

427472
func hookCommandExists(matchers []ClaudeHookMatcher, command string) bool {
@@ -446,6 +491,47 @@ func hasEntireHook(matchers []ClaudeHookMatcher) bool {
446491
return false
447492
}
448493

494+
// splitMatcherTools splits a Claude Code tool matcher into its exact tool
495+
// names. Matchers that InstallHooks writes are `|`-separated lists (Claude Code
496+
// also accepts `,`); whitespace around separators is ignored. Returns the tools
497+
// in order, dropping empties.
498+
func splitMatcherTools(matcher string) []string {
499+
parts := strings.FieldsFunc(matcher, func(r rune) bool { return r == '|' || r == ',' })
500+
tools := make([]string, 0, len(parts))
501+
for _, p := range parts {
502+
if t := strings.TrimSpace(p); t != "" {
503+
tools = append(tools, t)
504+
}
505+
}
506+
return tools
507+
}
508+
509+
// hasEntireHookCoveringTools reports whether an Entire hook is installed under a
510+
// matcher that covers every tool in want. A widened matcher still counts: a
511+
// matcher of "TaskCreate|TaskUpdate|TaskGet" covers {TaskCreate, TaskUpdate},
512+
// so users who broaden a matcher aren't falsely flagged as outdated.
513+
func hasEntireHookCoveringTools(matchers []ClaudeHookMatcher, want []string) bool {
514+
for _, matcher := range matchers {
515+
have := splitMatcherTools(matcher.Matcher)
516+
coversAll := true
517+
for _, w := range want {
518+
if !slices.Contains(have, w) {
519+
coversAll = false
520+
break
521+
}
522+
}
523+
if !coversAll {
524+
continue
525+
}
526+
for _, hook := range matcher.Hooks {
527+
if isEntireHook(hook.Command) {
528+
return true
529+
}
530+
}
531+
}
532+
return false
533+
}
534+
449535
func hookCommandExistsWithMatcher(matchers []ClaudeHookMatcher, matcherName, command string) bool {
450536
for _, matcher := range matchers {
451537
if matcher.Matcher == matcherName {

cmd/entire/cli/agent/claudecode/hooks_test.go

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -731,6 +731,82 @@ func TestInstallHooks_UsesCurrentToolMatchers(t *testing.T) {
731731
agentpkg.WrapProductionSilentHookCommand("entire hooks claude-code post-todo"), "post-todo task-list hook")
732732
}
733733

734+
func TestCheckHookConfig_Absent(t *testing.T) {
735+
tempDir := t.TempDir()
736+
t.Chdir(tempDir)
737+
if got := CheckHookConfig(context.Background()); got != HooksAbsent {
738+
t.Errorf("CheckHookConfig() = %v, want HooksAbsent", got)
739+
}
740+
}
741+
742+
func TestCheckHookConfig_Current(t *testing.T) {
743+
tempDir := t.TempDir()
744+
t.Chdir(tempDir)
745+
a := &ClaudeCodeAgent{}
746+
if _, err := a.InstallHooks(context.Background(), false, false); err != nil {
747+
t.Fatalf("InstallHooks() error = %v", err)
748+
}
749+
if got := CheckHookConfig(context.Background()); got != HooksCurrent {
750+
t.Errorf("CheckHookConfig() = %v, want HooksCurrent", got)
751+
}
752+
}
753+
754+
func TestCheckHookConfig_Outdated(t *testing.T) {
755+
tempDir := t.TempDir()
756+
t.Chdir(tempDir)
757+
758+
// Config from an older CLI version: Entire installed (Stop present) but the
759+
// tool-use hooks sit under the outdated Task/TodoWrite matchers.
760+
stop := agentpkg.WrapProductionSilentHookCommand("entire hooks claude-code stop")
761+
pre := agentpkg.WrapProductionSilentHookCommand("entire hooks claude-code pre-task")
762+
post := agentpkg.WrapProductionSilentHookCommand("entire hooks claude-code post-task")
763+
todo := agentpkg.WrapProductionSilentHookCommand("entire hooks claude-code post-todo")
764+
writeSettingsFile(t, tempDir, fmt.Sprintf(`{
765+
"hooks": {
766+
"Stop": [{"matcher": "", "hooks": [{"type": "command", "command": %q}]}],
767+
"PreToolUse": [{"matcher": "Task", "hooks": [{"type": "command", "command": %q}]}],
768+
"PostToolUse": [
769+
{"matcher": "Task", "hooks": [{"type": "command", "command": %q}]},
770+
{"matcher": "TodoWrite", "hooks": [{"type": "command", "command": %q}]}
771+
]
772+
}
773+
}`, stop, pre, post, todo))
774+
775+
if got := CheckHookConfig(context.Background()); got != HooksOutdated {
776+
t.Errorf("CheckHookConfig() = %v, want HooksOutdated", got)
777+
}
778+
}
779+
780+
// TestCheckHookConfig_SupersetMatchersAreCurrent verifies that widening a
781+
// matcher beyond what we install (still covering the required tools) is not
782+
// flagged as drift — matchers are |-lists of exact tool names, so a superset
783+
// still fires for the required tools.
784+
func TestCheckHookConfig_SupersetMatchersAreCurrent(t *testing.T) {
785+
tempDir := t.TempDir()
786+
t.Chdir(tempDir)
787+
788+
stop := agentpkg.WrapProductionSilentHookCommand("entire hooks claude-code stop")
789+
pre := agentpkg.WrapProductionSilentHookCommand("entire hooks claude-code pre-task")
790+
post := agentpkg.WrapProductionSilentHookCommand("entire hooks claude-code post-task")
791+
todo := agentpkg.WrapProductionSilentHookCommand("entire hooks claude-code post-todo")
792+
// "Agent|Foo" still covers Agent; "TaskCreate|TaskUpdate|TaskGet" still
793+
// covers TaskCreate and TaskUpdate.
794+
writeSettingsFile(t, tempDir, fmt.Sprintf(`{
795+
"hooks": {
796+
"Stop": [{"matcher": "", "hooks": [{"type": "command", "command": %q}]}],
797+
"PreToolUse": [{"matcher": "Agent|Foo", "hooks": [{"type": "command", "command": %q}]}],
798+
"PostToolUse": [
799+
{"matcher": "Agent|Foo", "hooks": [{"type": "command", "command": %q}]},
800+
{"matcher": "TaskCreate|TaskUpdate|TaskGet", "hooks": [{"type": "command", "command": %q}]}
801+
]
802+
}
803+
}`, stop, pre, post, todo))
804+
805+
if got := CheckHookConfig(context.Background()); got != HooksCurrent {
806+
t.Errorf("CheckHookConfig() = %v, want HooksCurrent (superset matcher)", got)
807+
}
808+
}
809+
734810
// TestInstallHooks_Force_ReinstallsStaleToolMatchers verifies that `--force`
735811
// strips Entire hooks left under the outdated "Task"/"TodoWrite" matchers by
736812
// older CLI versions and reinstalls them under the current matchers. (A normal

cmd/entire/cli/doctor.go

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"time"
1111

1212
"charm.land/huh/v2"
13+
"github.com/entireio/cli/cmd/entire/cli/agent/claudecode"
1314
"github.com/entireio/cli/cmd/entire/cli/agent/codex"
1415
"github.com/entireio/cli/cmd/entire/cli/checkpoint"
1516
"github.com/entireio/cli/cmd/entire/cli/paths"
@@ -40,7 +41,12 @@ Checks performed:
4041
review hasn't run yet on this machine, or a newer entire release
4142
added a hook the user hasn't approved yet).
4243
43-
3. Stuck sessions: sessions stuck in ACTIVE or ENDED phase that need cleanup.
44+
When Claude Code hooks are installed:
45+
3. Claude Code hook config: warn when the installed hooks are out of
46+
date (e.g. an older release wrote tool matchers that no longer fire).
47+
Fix by re-running 'entire enable --force'.
48+
49+
4. Stuck sessions: sessions stuck in ACTIVE or ENDED phase that need cleanup.
4450
4551
A session is considered stuck if:
4652
- It is in ACTIVE phase with no interaction for over 1 hour
@@ -99,6 +105,9 @@ func runSessionsFix(cmd *cobra.Command, force bool) error {
99105
// Agent-specific: Codex hook trust state.
100106
checkCodexHookTrust(cmd)
101107

108+
// Agent-specific: Claude Code hook config drift.
109+
checkClaudeCodeHookDrift(cmd)
110+
102111
// Stuck sessions
103112
// Load all session states
104113
states, err := strategy.ListSessionStates(ctx)
@@ -437,6 +446,24 @@ func confirmDoctorFix(ctx context.Context, w io.Writer, title string) (bool, err
437446
// Both checks are structural (file/key presence). Stays silent when
438447
// this repo doesn't have codex hooks installed or when we can't
439448
// resolve the worktree root. Warn-only.
449+
// checkClaudeCodeHookDrift warns when Entire's Claude Code hooks are installed
450+
// but out of date — e.g. an older release wrote tool matchers that no longer
451+
// fire on current Claude Code. Read-only; the fix is `entire enable --force`.
452+
// Stays silent when Claude Code hooks aren't installed here.
453+
func checkClaudeCodeHookDrift(cmd *cobra.Command) {
454+
w := cmd.OutOrStdout()
455+
switch claudecode.CheckHookConfig(cmd.Context()) {
456+
case claudecode.HooksAbsent:
457+
// Not installed in this repo — nothing to report.
458+
case claudecode.HooksCurrent:
459+
fmt.Fprintln(w, "✓ Claude Code hook config: OK")
460+
case claudecode.HooksOutdated:
461+
fmt.Fprintln(w, "Claude Code hooks: OUT OF DATE")
462+
fmt.Fprintln(w, " The installed hooks use outdated tool matchers and no longer fire.")
463+
fmt.Fprintln(w, " Run `entire enable --force` to update the hooks file.")
464+
}
465+
}
466+
440467
func checkCodexHookTrust(cmd *cobra.Command) {
441468
repoRoot, err := paths.WorktreeRoot(cmd.Context())
442469
if err != nil {

cmd/entire/cli/doctor_test.go

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"testing"
1010
"time"
1111

12+
"github.com/entireio/cli/cmd/entire/cli/agent/claudecode"
1213
"github.com/entireio/cli/cmd/entire/cli/checkpoint"
1314
"github.com/entireio/cli/cmd/entire/cli/paths"
1415
"github.com/entireio/cli/cmd/entire/cli/session"
@@ -506,6 +507,61 @@ trusted_hash = "sha256:ccc"
506507
require.Contains(t, out, "Open /hooks inside Codex")
507508
}
508509

510+
// TestCheckClaudeCodeHookDrift_SilentWhenNotInstalled — doctor prints nothing
511+
// Claude-Code-related when this repo has no Entire hooks installed.
512+
func TestCheckClaudeCodeHookDrift_SilentWhenNotInstalled(t *testing.T) {
513+
dir := setupGitRepoForPhaseTest(t)
514+
t.Chdir(dir)
515+
516+
cmd, stdout := newTestCmd(t)
517+
checkClaudeCodeHookDrift(cmd)
518+
require.NotContains(t, stdout.String(), "Claude Code hook")
519+
}
520+
521+
// TestCheckClaudeCodeHookDrift_OKWhenCurrent — a fresh install writes the
522+
// current matchers, so doctor reports OK.
523+
func TestCheckClaudeCodeHookDrift_OKWhenCurrent(t *testing.T) {
524+
dir := setupGitRepoForPhaseTest(t)
525+
t.Chdir(dir)
526+
527+
if _, err := (&claudecode.ClaudeCodeAgent{}).InstallHooks(context.Background(), false, false); err != nil {
528+
t.Fatalf("InstallHooks() error = %v", err)
529+
}
530+
531+
cmd, stdout := newTestCmd(t)
532+
checkClaudeCodeHookDrift(cmd)
533+
require.Contains(t, stdout.String(), "✓ Claude Code hook config: OK")
534+
}
535+
536+
// TestCheckClaudeCodeHookDrift_WarnsWhenOutdated — a config left by an older CLI
537+
// (hooks under the stale Task/TodoWrite matchers) is reported OUT OF DATE with
538+
// the --force fix hint.
539+
func TestCheckClaudeCodeHookDrift_WarnsWhenOutdated(t *testing.T) {
540+
dir := setupGitRepoForPhaseTest(t)
541+
t.Chdir(dir)
542+
543+
claudeDir := filepath.Join(dir, ".claude")
544+
require.NoError(t, os.MkdirAll(claudeDir, 0o750))
545+
stale := `{
546+
"hooks": {
547+
"Stop": [{"matcher": "", "hooks": [{"type": "command", "command": "entire hooks claude-code stop"}]}],
548+
"PreToolUse": [{"matcher": "Task", "hooks": [{"type": "command", "command": "entire hooks claude-code pre-task"}]}],
549+
"PostToolUse": [
550+
{"matcher": "Task", "hooks": [{"type": "command", "command": "entire hooks claude-code post-task"}]},
551+
{"matcher": "TodoWrite", "hooks": [{"type": "command", "command": "entire hooks claude-code post-todo"}]}
552+
]
553+
}
554+
}`
555+
require.NoError(t, os.WriteFile(filepath.Join(claudeDir, claudecode.ClaudeSettingsFileName), []byte(stale), 0o600))
556+
557+
cmd, stdout := newTestCmd(t)
558+
checkClaudeCodeHookDrift(cmd)
559+
560+
out := stdout.String()
561+
require.Contains(t, out, "Claude Code hooks: OUT OF DATE")
562+
require.Contains(t, out, "entire enable --force")
563+
}
564+
509565
// TestCheckCodexHookTrust_FlagsStaleHooksFile — user enabled Codex on
510566
// an older release that didn't ship PostToolUse. Their hooks.json has
511567
// only the three legacy events. Doctor must surface the gap and tell

cmd/entire/cli/status.go

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414
"strings"
1515
"time"
1616

17+
"github.com/entireio/cli/cmd/entire/cli/agent/claudecode"
1718
"github.com/entireio/cli/cmd/entire/cli/gitrepo"
1819
"github.com/entireio/cli/cmd/entire/cli/paths"
1920
"github.com/entireio/cli/cmd/entire/cli/session"
@@ -191,6 +192,13 @@ func formatSettingsStatusShort(ctx context.Context, s *EntireSettings, sty statu
191192

192193
b.WriteString(strings.Join(displayNames, ", "))
193194
}
195+
196+
// Warn when installed hooks are out of date (read-only; fix is manual).
197+
if claudecode.CheckHookConfig(ctx) == claudecode.HooksOutdated {
198+
b.WriteString("\n")
199+
b.WriteString(sty.render(sty.yellow, " ! Claude Code hooks out of date"))
200+
b.WriteString(sty.render(sty.dim, " · run 'entire enable --force'"))
201+
}
194202
}
195203

196204
// Show review status for HEAD's checkpoint, if any.
@@ -589,7 +597,10 @@ type statusJSON struct {
589597
// `entire status --json` instead of the human footer. Set only on the
590598
// success path (mirrors writeAgentHelpHint, which only renders when set up).
591599
AgentHelp string `json:"agent_help,omitempty"`
592-
Error string `json:"error,omitempty"`
600+
// HooksOutdated lists agents whose installed hook config is out of date and
601+
// should be refreshed with `entire enable --force`.
602+
HooksOutdated []string `json:"hooks_outdated,omitempty"`
603+
Error string `json:"error,omitempty"`
593604
}
594605

595606
type sessionBriefJSON struct {
@@ -646,6 +657,10 @@ func runStatusJSON(ctx context.Context, w io.Writer) error {
646657
result.Agents = names
647658
}
648659

660+
if claudecode.CheckHookConfig(ctx) == claudecode.HooksOutdated {
661+
result.HooksOutdated = append(result.HooksOutdated, "claude-code")
662+
}
663+
649664
if store, err := session.NewStateStore(ctx); err == nil {
650665
if states, err := store.List(ctx); err == nil {
651666
// Finalize sessions whose agent has exited (matches the human

0 commit comments

Comments
 (0)