Go 502 lines
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 <UserDir>/.claude/settings.json — the FlashOS pattern, where
// the harness is launched from <username>/ — 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 <UserDir>/.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: <UserDir>/.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 <UserDir>/.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
// <UserDir>/.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
}