ajhahn.de
← eeco
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
}