package hooks import ( "encoding/json" "fmt" "os" "path/filepath" "strings" "time" "github.com/ajhahnde/eeco/internal/config" ) // The cockpit machinery is the auto-firing deterministic layer the cockpit // program (C4) emits as harness config. It lands in the per-project Claude // settings file /.claude/settings.json — the FlashOS pattern, where // the harness is launched from / — which is distinct from the // machine-wide SessionSettingsPath the session-start and commit-guard channels // edit. Token-identified groups never collide, so the two settings files (and // the two PreToolUse guards) coexist. // // It manages four hook events as ONE reversible unit (one CockpitMachinery // ledger record, one backup pointer): // - PreToolUse(Bash): the git-write guard (deny an unauthorized commit/tag) — C4a. // - SessionStart: orient + drift inject + sentinel clear (reuses session-emit). // - Stop: a throttled handover nudge. // - PostToolUse(Edit|Write|…): contract-watch (flag a cockpit-input edit). // // Reuses the same atomic-write / backup / validate / restore machinery as the // commit-guard channel (hooks.go). Explicit opt-in, reversible: `eeco cockpit // machinery on` installs every group, `off` removes only eeco's groups, and // foreign groups + unknown keys are preserved. Each group carries a // path-independent namespace token so removal is exact and survives a moved // eeco binary. const ( // cockpitMachineryToken is the PreToolUse git-write-guard marker (C4a). // Distinct from commitGuardToken so the two PreToolUse guards are // independently installable / removable. cockpitMachineryToken = "hooks git-write-guard-check" // cockpitSessionToken marks the machinery's SessionStart orient group. It // reuses the session-emit runner; the per-project settings file keeps it // distinct from the machine-wide session-start channel (a different file). cockpitSessionToken = "hooks session-emit" // stopNudgeToken marks the Stop handover-nudge group. stopNudgeToken = "hooks stop-nudge-check" // contractWatchToken marks the PostToolUse contract-watch group. contractWatchToken = "hooks contract-watch-check" // contractWatchMatcher is the tool matcher for the contract-watch group: // the file-writing tools whose edits can touch a cockpit input. contractWatchMatcher = "Edit|Write|MultiEdit|NotebookEdit" ) // errCockpitMachineryUserDir is returned when the per-user dir is unknown, so // there is no /.claude/settings.json to write. A clean, expected // condition (not a failure): nothing is touched. var errCockpitMachineryUserDir = fmt.Errorf( "cockpit machinery not configured: no per-user directory resolved (run inside an initialized eeco workspace)") // cockpitSettingsPath is the per-project Claude settings file the machinery // edits: /.claude/settings.json. func cockpitSettingsPath(cfg *config.Config) string { return filepath.Join(cfg.UserDir, ".claude", "settings.json") } func gitWriteGuardCommand() string { return fmt.Sprintf("%q %s", selfPath(), cockpitMachineryToken) } func cockpitSessionCommand() string { return fmt.Sprintf("%q %s --if-initialized", selfPath(), cockpitSessionToken) } func stopNudgeCommand() string { return fmt.Sprintf("%q %s", selfPath(), stopNudgeToken) } func contractWatchCommand() string { return fmt.Sprintf("%q %s", selfPath(), contractWatchToken) } // machineryHook describes one auto-firing hook the cockpit machinery installs // into /.claude/settings.json. Event is the Claude settings hook key; // Token is the path-independent namespace marker carried in the command (so // removal is exact and survives a moved binary); Matcher is the tool matcher // for tool events ("" for SessionStart/Stop, which are not tool-scoped); // Command builds the full command string from the current binary path; Desc is // the human status label. type machineryHook struct { Event string Token string Matcher string Command func() string Desc string } // machineryHookSet returns the full set the machinery manages as one unit, // recorded under the single CockpitMachinery ledger record. Order is the // install + status report order. (A fresh slice each call: callers never mutate // it, but the function shape mirrors the Default*Workflows pattern.) func machineryHookSet() []machineryHook { return []machineryHook{ {Event: "PreToolUse", Token: cockpitMachineryToken, Matcher: "Bash", Command: gitWriteGuardCommand, Desc: "git-write guard (deny unauthorized commit/tag)"}, {Event: "SessionStart", Token: cockpitSessionToken, Matcher: "", Command: cockpitSessionCommand, Desc: "orient + drift inject + sentinel clear"}, {Event: "Stop", Token: stopNudgeToken, Matcher: "", Command: stopNudgeCommand, Desc: "handover nudge"}, {Event: "PostToolUse", Token: contractWatchToken, Matcher: contractWatchMatcher, Command: contractWatchCommand, Desc: "contract-watch (flag cockpit-input edits)"}, } } // machineryGroup builds the settings group for one machinery hook. Tool events // carry a matcher; SessionStart/Stop do not. func machineryGroup(h machineryHook) map[string]any { group := map[string]any{ "hooks": []any{ map[string]any{"type": "command", "command": h.Command()}, }, } if h.Matcher != "" { group["matcher"] = h.Matcher } return group } // eventGroups returns the group list under root.hooks[event], or nil. func eventGroups(root map[string]any, event string) []any { h, ok := root["hooks"].(map[string]any) if !ok { return nil } groups, _ := h[event].([]any) return groups } // groupCarriesToken reports whether a settings group has a hook command // containing token. func groupCarriesToken(group any, token string) bool { gm, ok := group.(map[string]any) if !ok { return false } hs, ok := gm["hooks"].([]any) if !ok { return false } for _, h := range hs { hm, ok := h.(map[string]any) if !ok { continue } if cmd, ok := hm["command"].(string); ok && strings.Contains(cmd, token) { return true } } return false } // hookPresent reports whether root carries the group for one machinery hook. func hookPresent(root map[string]any, h machineryHook) bool { for _, g := range eventGroups(root, h.Event) { if groupCarriesToken(g, h.Token) { return true } } return false } // machineryInstalled reports whether root contains ANY machinery group across // the four events. One present group reads as on, so a partially-installed // state still reports installed and Enable tops up the rest. func machineryInstalled(root map[string]any) bool { for _, h := range machineryHookSet() { if hookPresent(root, h) { return true } } return false } // machineryFullyInstalled reports whether every machinery group is present, so // Enable can no-op cleanly when nothing needs topping up. func machineryFullyInstalled(root map[string]any) bool { for _, h := range machineryHookSet() { if !hookPresent(root, h) { return false } } return true } // addMachineryGroups appends every machinery group not already present, keyed // by event. Returns true if it added at least one. func addMachineryGroups(root map[string]any) bool { h, ok := root["hooks"].(map[string]any) if !ok { h = map[string]any{} root["hooks"] = h } added := false for _, mh := range machineryHookSet() { if hookPresent(root, mh) { continue } groups, _ := h[mh.Event].([]any) h[mh.Event] = append(groups, machineryGroup(mh)) added = true } return added } // removeMachineryGroups strips every machinery group across the four events, // dropping an event key (and the hooks object) left empty, while preserving // foreign groups and unknown keys. func removeMachineryGroups(root map[string]any) { h, ok := root["hooks"].(map[string]any) if !ok { return } for _, mh := range machineryHookSet() { groups, ok := h[mh.Event].([]any) if !ok { continue } kept := make([]any, 0, len(groups)) for _, g := range groups { if groupCarriesToken(g, mh.Token) { continue } kept = append(kept, g) } if len(kept) == 0 { delete(h, mh.Event) } else { h[mh.Event] = kept } } if len(h) == 0 { delete(root, "hooks") } } // rewriteMachineryCommands rewrites any machinery command whose value differs // from the current builder output (a moved binary path). Returns true if any // command was changed. func rewriteMachineryCommands(root map[string]any) bool { changed := false for _, mh := range machineryHookSet() { want := mh.Command() for _, g := range eventGroups(root, mh.Event) { gm, ok := g.(map[string]any) if !ok { continue } hs, ok := gm["hooks"].([]any) if !ok { continue } for _, hk := range hs { hm, ok := hk.(map[string]any) if !ok { continue } cmd, ok := hm["command"].(string) if !ok || !strings.Contains(cmd, mh.Token) || cmd == want { continue } hm["command"] = want changed = true } } } return changed } // EnableCockpitMachinery installs every machinery hook group into // /.claude/settings.json, creating the .claude dir if needed. It is a // no-op when all groups are already present, tops up any missing group // otherwise, refuses (touching nothing) when the settings file is present but // not valid JSON, and restores the original on a post-edit validation failure. func EnableCockpitMachinery(cfg *config.Config) (string, error) { if cfg.UserDir == "" { return "", errCockpitMachineryUserDir } path := cockpitSettingsPath(cfg) if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { return "", fmt.Errorf("create .claude dir: %w", err) } orig, existed, perm, rerr := readSettings(path) if rerr != nil { return "", rerr } root := map[string]any{} if existed { if jerr := json.Unmarshal(orig, &root); jerr != nil { return "", fmt.Errorf("settings file %s is not valid JSON — left untouched", path) } } if machineryFullyInstalled(root) { return "cockpit machinery already enabled (" + path + ")", nil } backup, berr := backupOriginal(cfg, orig, existed) if berr != nil { return "", berr } addMachineryGroups(root) if werr := writeJSONAtomic(path, root, perm); werr != nil { return "", werr } if verr := validateJSON(path); verr != nil { _ = restoreOriginal(path, orig, existed) return "", fmt.Errorf("settings file failed validation after edit, restored: %w", verr) } l, lerr := loadLedger(cfg) if lerr != nil { return "", lerr } rec := record{ Installed: true, Path: path, Backup: backup, At: time.Now().UTC().Format(time.RFC3339), } // Preserve the first-enable Backup across a top-up: a missing Backup on an // existing record means enable created the file (Disable's created-by-us // path relies on that signal), so a later top-up must not overwrite it. if l.CockpitMachinery.Installed { rec.Backup = l.CockpitMachinery.Backup } l.CockpitMachinery = rec if err := saveLedger(cfg, l); err != nil { return "", err } msg := "cockpit machinery enabled (" + path if rec.Backup != "" { msg += ", backup " + rec.Backup } return msg + ")", nil } // DisableCockpitMachinery removes eeco's machinery groups across all four // events, preserving foreign groups and unknown keys. It is a no-op when not // installed, and restores the original on a post-edit validation failure. func DisableCockpitMachinery(cfg *config.Config) (string, error) { if cfg.UserDir == "" { return "", errCockpitMachineryUserDir } path := cockpitSettingsPath(cfg) l, lerr := loadLedger(cfg) if lerr != nil { return "", lerr } orig, existed, perm, rerr := readSettings(path) if rerr != nil { return "", rerr } notEnabled := func() (string, error) { l.CockpitMachinery = record{} if err := saveLedger(cfg, l); err != nil { return "", err } return "cockpit machinery not enabled", nil } if !existed { return notEnabled() } root := map[string]any{} if jerr := json.Unmarshal(orig, &root); jerr != nil { return "", fmt.Errorf("settings file %s is not valid JSON — left untouched", path) } if !machineryInstalled(root) { return notEnabled() } backup, berr := backupOriginal(cfg, orig, existed) if berr != nil { return "", berr } // A missing Backup on the install record means enable found no pre-existing // settings file — eeco created it. The cockpit settings file is eeco-owned // and per-project (unlike the shared machine-wide commit-guard channel), so // when our groups were its only content, restore the original absent state // byte-for-byte rather than leaving a {} shell. removeMachineryGroups(root) createdByUs := l.CockpitMachinery.Installed && l.CockpitMachinery.Backup == "" if createdByUs && len(root) == 0 { if rerr := os.Remove(path); rerr != nil && !os.IsNotExist(rerr) { return "", fmt.Errorf("remove settings: %w", rerr) } } else { if werr := writeJSONAtomic(path, root, perm); werr != nil { return "", werr } if verr := validateJSON(path); verr != nil { _ = restoreOriginal(path, orig, existed) return "", fmt.Errorf("settings file failed validation after edit, restored: %w", verr) } } l.CockpitMachinery = record{} if err := saveLedger(cfg, l); err != nil { return "", err } msg := "cockpit machinery disabled (" + path if backup != "" { msg += ", backup " + backup } return msg + ")", nil } // RefreshCockpitMachinery rewrites every machinery command whose embedded // binary path no longer matches selfPath() — the self-heal for a `brew upgrade // eeco` that moved the cellar directory. No-op when not installed or already // current. func RefreshCockpitMachinery(cfg *config.Config) (string, error) { if cfg.UserDir == "" { return "", errCockpitMachineryUserDir } path := cockpitSettingsPath(cfg) orig, existed, perm, rerr := readSettings(path) if rerr != nil { return "", rerr } if !existed { return "cockpit machinery not enabled", nil } root := map[string]any{} if jerr := json.Unmarshal(orig, &root); jerr != nil { return "", fmt.Errorf("settings file %s is not valid JSON — left untouched", path) } if !machineryInstalled(root) { return "cockpit machinery not enabled", nil } if !rewriteMachineryCommands(root) { return "cockpit machinery already current", nil } if _, berr := backupOriginal(cfg, orig, existed); berr != nil { return "", berr } if werr := writeJSONAtomic(path, root, perm); werr != nil { return "", werr } if verr := validateJSON(path); verr != nil { _ = restoreOriginal(path, orig, existed) return "", fmt.Errorf("settings file failed validation after edit, restored: %w", verr) } l, lerr := loadLedger(cfg) if lerr != nil { return "", lerr } l.CockpitMachinery.Installed = true l.CockpitMachinery.Path = path l.CockpitMachinery.At = time.Now().UTC().Format(time.RFC3339) if err := saveLedger(cfg, l); err != nil { return "", err } return "cockpit machinery refreshed (" + path + ")", nil } // CockpitMachineryStatus reports the machinery state, one line per managed hook // event, reflecting on-disk reality so a hand-removed group reads as off. It // changes nothing. Fidelity is honest: these runtime hooks fire only on Claude // (the one target with real hook channels); advisory targets carry the policy // as prose only — see cockpit.MachineryFidelity / the cmd layer's per-target // fidelity print. func CockpitMachineryStatus(cfg *config.Config) []string { if cfg.UserDir == "" { return []string{"cockpit-machinery: not configured (no per-user directory)"} } path := cockpitSettingsPath(cfg) present := map[string]bool{} on := false orig, existed, _, err := readSettings(path) if err == nil && existed { root := map[string]any{} if json.Unmarshal(orig, &root) == nil { for _, h := range machineryHookSet() { if hookPresent(root, h) { present[h.Event] = true on = true } } } } state := "off" if on { state = "on (" + path + ")" } lines := []string{ "cockpit-machinery: " + state + " (claude — enforced; other targets advisory prose only)", } for _, h := range machineryHookSet() { mark := "off" if present[h.Event] { mark = "on" } lines = append(lines, fmt.Sprintf(" %s: %s — %s", h.Event, mark, h.Desc)) } return lines }