ajhahn.de
← eeco
Go 1218 lines
// Package hooks wires and unwires eeco's two opt-in, reversible
// integration points — the only touches outside the gitignored
// workspace (Constraint 2).
//
//   - a local .git/hooks/pre-commit that runs leak-guard, installed
//     only when no pre-commit hook exists and removed only when the
//     on-disk script is byte-identical to what eeco wrote;
//   - one namespaced entry in the AI CLI's user-global JSON settings
//     file that emits a one-line queue reminder at session start.
//
// Every action is recorded in a ledger inside the workspace
// (<workspace>/state/hooks.json) so it is cleanly undoable, and the
// settings-file edit is backed up (into the workspace) and re-validated
// after the write, restoring the backup if the result is not valid
// JSON. Nothing here ever commits, pushes, or writes the tracked tree.
package hooks

import (
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"errors"
	"fmt"
	"os"
	"path/filepath"
	"strings"
	"time"

	"github.com/ajhahnde/eeco/internal/config"
)

// Hook names accepted by Toggle and reported by Status.
const (
	PreCommit    = "pre-commit"
	PostMerge    = "post-merge"
	SessionStart = "session-start"
	CommitMsg    = "commit-msg"
	CommitGuard  = "commit-guard"
)

// Names lists the toggleable hook names in report order.
var Names = []string{PreCommit, PostMerge, SessionStart, CommitMsg, CommitGuard}

// ledgerName is the reversibility record inside <workspace>/state.
const ledgerName = "hooks.json"

// backupSubdir is where the original settings file is copied before an
// edit, inside <workspace>/state (never beside the user's own file).
const backupSubdir = "backups"

// preCommitMarker is a unique line embedded in eeco's pre-commit
// script. It is the exact-match fallback identifier when the ledger
// hash is unavailable; an unrelated hook never carries it.
const preCommitMarker = "eeco-managed-pre-commit-v1"

// postMergeMarker is the exact-match fallback identifier in eeco's
// post-merge script, the analog of preCommitMarker.
const postMergeMarker = "eeco-managed-post-merge-v1"

// sessionToken is the path-independent namespace marker carried in the
// session-start hook command. Removal matches on this token, so a moved
// eeco binary is still cleanly removable.
const sessionToken = "hooks session-emit"

// commitGuardToken is the path-independent namespace marker carried in
// the commit-guard PreToolUse hook command — the analog of sessionToken
// for the harness PreToolUse channel, so a moved eeco binary stays
// cleanly removable.
const commitGuardToken = "hooks commit-guard-check"

// ErrSessionNotConfigured is returned by the session-start operations
// when neither delivery channel is configured. It is a clean, expected
// condition (not a failure): nothing is touched. No brand path is baked
// in, per Constraint 4 — the operator points eeco at the file or files.
var ErrSessionNotConfigured = errors.New(
	"session-start not configured: set session_settings_path (or " +
		"EECO_SESSION_SETTINGS) for an AI CLI that reads a JSON settings " +
		"file, and/or set session_files in config.local for an assistant " +
		"that reads a plain text/markdown file")

// ErrCommitGuardNotConfigured is returned by the commit-guard operations
// when no settings file is configured. Like ErrSessionNotConfigured it is
// a clean, expected condition (not a failure): nothing is touched. The
// commit-guard installs a PreToolUse group into the same Claude settings
// file the session-start JSON channel uses.
var ErrCommitGuardNotConfigured = errors.New(
	"commit-guard not configured: set session_settings_path (or " +
		"EECO_SESSION_SETTINGS) to the AI CLI's JSON settings file so eeco " +
		"can install the PreToolUse hook")

// record is one hook's reversibility state. Files is set only on the
// session-start record and only when the file-delivery channel
// (`session_files`) wired one or more targets — additive, so older
// ledgers without the field still load.
type record struct {
	Installed bool         `json:"installed"`
	Path      string       `json:"path,omitempty"`
	SHA256    string       `json:"sha256,omitempty"`
	Backup    string       `json:"backup,omitempty"`
	At        string       `json:"at,omitempty"`
	Files     []fileRecord `json:"files,omitempty"`
}

// ledger is the persisted state of the hooks. PostMerge, CommitMsg, and
// CockpitMachinery are additive; an older ledger.json without the key still
// loads (zero record = off).
type ledger struct {
	PreCommit        record `json:"pre_commit"`
	PostMerge        record `json:"post_merge"`
	SessionStart     record `json:"session_start"`
	CommitMsg        record `json:"commit_msg"`
	CommitGuard      record `json:"commit_guard"`
	CockpitMachinery record `json:"cockpit_machinery"`
}

func ledgerPath(cfg *config.Config) string {
	return filepath.Join(cfg.Workspace, "state", ledgerName)
}

func loadLedger(cfg *config.Config) (ledger, error) {
	var l ledger
	b, err := os.ReadFile(ledgerPath(cfg))
	if err != nil {
		if errors.Is(err, os.ErrNotExist) {
			return l, nil
		}
		return l, fmt.Errorf("read hook ledger: %w", err)
	}
	if len(b) == 0 {
		return l, nil
	}
	if err := json.Unmarshal(b, &l); err != nil {
		// A corrupt ledger must not wedge the tool: start from empty
		// state. On-disk verification still protects against deletion.
		return ledger{}, nil
	}
	return l, nil
}

func saveLedger(cfg *config.Config, l ledger) error {
	dir := filepath.Join(cfg.Workspace, "state")
	if err := os.MkdirAll(dir, 0o755); err != nil {
		return fmt.Errorf("hook ledger dir: %w", err)
	}
	b, err := json.MarshalIndent(l, "", "  ")
	if err != nil {
		return err
	}
	return os.WriteFile(ledgerPath(cfg), append(b, '\n'), 0o644)
}

// selfPath resolves the absolute path of the running eeco binary, used
// in the installed hook so the integration does not depend on PATH.
// A resolution failure degrades to the bare name. On a brew-installed
// eeco the resolved path lands inside the versioned Cellar directory;
// stableBrewBin unwinds that to the version-agnostic bin shim so the
// installed hook survives every `brew upgrade eeco`.
func selfPath() string {
	p, err := os.Executable()
	if err != nil || p == "" {
		return "eeco"
	}
	if r, rerr := filepath.EvalSymlinks(p); rerr == nil {
		p = r
	}
	if stable := stableBrewBin(p); stable != "" {
		return stable
	}
	return p
}

// stableBrewBin returns the brew bin shim for a path inside
// "<prefix>/Cellar/eeco/<version>/bin/eeco", or "" if p is not a brew
// cellar path or the bin shim is not present. The bin shim is the
// version-agnostic entry brew creates in <prefix>/bin/, so an installed
// hook keeps working after `brew upgrade eeco` reaps the old cellar
// directory.
func stableBrewBin(p string) string {
	prefix, _, ok := strings.Cut(p, "/Cellar/eeco/")
	if !ok {
		return ""
	}
	stable := filepath.Join(prefix, "bin", "eeco")
	if _, err := os.Stat(stable); err != nil {
		return ""
	}
	return stable
}

// --- pre-commit -----------------------------------------------------

// gitHooksDir returns <repo>/.git/hooks, or an error when .git is not a
// directory (for example a worktree, whose .git is a file). The
// pre-commit hook is deliberately repo-scoped and untracked.
func gitHooksDir(cfg *config.Config) (string, error) {
	gitDir := filepath.Join(cfg.RepoRoot, ".git")
	info, err := os.Stat(gitDir)
	if err != nil {
		return "", fmt.Errorf("locate .git: %w", err)
	}
	if !info.IsDir() {
		return "", errors.New(".git is not a directory (worktree?) — pre-commit wiring unsupported here")
	}
	return filepath.Join(gitDir, "hooks"), nil
}

// preCommitScript renders the hook body. workflows is the ordered list
// of builtin workflow names to invoke; the runner stops at the first
// non-zero exit via `set -e`. The eeco binary path is captured once in
// a shell variable so a relocated binary that resolves identically at
// install time stays referenced consistently across steps.
func preCommitScript(workflows []string) string {
	var b strings.Builder
	b.WriteString("#!/bin/sh\n")
	b.WriteString("# eeco managed pre-commit hook. Reversible:\n")
	b.WriteString("#   eeco hooks pre-commit off\n")
	b.WriteString("# Do not edit the next line; removal is exact-match.\n")
	b.WriteString("# " + preCommitMarker + "\n")
	b.WriteString("set -e\n")
	fmt.Fprintf(&b, "EECO=%q\n", selfPath())
	for _, w := range workflows {
		fmt.Fprintf(&b, "\"$EECO\" run %s\n", w)
	}
	return b.String()
}

func sha256hex(b []byte) string {
	sum := sha256.Sum256(b)
	return hex.EncodeToString(sum[:])
}

// EnablePreCommit installs the pre-commit hook. It refuses, without
// modifying anything, when a non-eeco pre-commit hook already exists or
// when cfg.PreCommitWorkflows is empty (the operator opted out via an
// explicit empty `pre_commit_workflows` in config.local). Re-enabling
// an already-eeco hook is a no-op even when the desired workflow set
// has changed: run `eeco hooks pre-commit off` first to refresh.
func EnablePreCommit(cfg *config.Config) (string, error) {
	if len(cfg.PreCommitWorkflows) == 0 {
		return "", errors.New("pre_commit_workflows is empty in config.local — nothing to wire")
	}
	hooksDir, err := gitHooksDir(cfg)
	if err != nil {
		return "", err
	}
	path := filepath.Join(hooksDir, "pre-commit")
	script := preCommitScript(cfg.PreCommitWorkflows)

	if existing, rerr := os.ReadFile(path); rerr == nil {
		if isEecoPreCommit(existing, "") {
			return "pre-commit already enabled", nil
		}
		return "", errors.New("a non-eeco pre-commit hook already exists — left untouched")
	} else if !errors.Is(rerr, os.ErrNotExist) {
		return "", fmt.Errorf("inspect pre-commit: %w", rerr)
	}

	if err := os.MkdirAll(hooksDir, 0o755); err != nil {
		return "", fmt.Errorf("create hooks dir: %w", err)
	}
	if err := os.WriteFile(path, []byte(script), 0o755); err != nil {
		return "", fmt.Errorf("write pre-commit: %w", err)
	}

	l, err := loadLedger(cfg)
	if err != nil {
		return "", err
	}
	l.PreCommit = record{
		Installed: true,
		Path:      path,
		SHA256:    sha256hex([]byte(script)),
		At:        time.Now().UTC().Format(time.RFC3339),
	}
	if err := saveLedger(cfg, l); err != nil {
		return "", err
	}
	return "pre-commit enabled (" + path + ")", nil
}

// DisablePreCommit removes the pre-commit hook only when the on-disk
// script is byte-identical to what eeco wrote (the recorded hash, with
// a marker-line fallback). A foreign or hand-edited hook is left in
// place and reported.
func DisablePreCommit(cfg *config.Config) (string, error) {
	hooksDir, err := gitHooksDir(cfg)
	if err != nil {
		return "", err
	}
	path := filepath.Join(hooksDir, "pre-commit")
	l, lerr := loadLedger(cfg)
	if lerr != nil {
		return "", lerr
	}

	b, rerr := os.ReadFile(path)
	if errors.Is(rerr, os.ErrNotExist) {
		l.PreCommit = record{}
		if err := saveLedger(cfg, l); err != nil {
			return "", err
		}
		return "pre-commit not enabled", nil
	}
	if rerr != nil {
		return "", fmt.Errorf("inspect pre-commit: %w", rerr)
	}

	if !isEecoPreCommit(b, l.PreCommit.SHA256) {
		return "", errors.New("pre-commit hook is present but not eeco's — left untouched")
	}
	if err := os.Remove(path); err != nil {
		return "", fmt.Errorf("remove pre-commit: %w", err)
	}
	l.PreCommit = record{}
	if err := saveLedger(cfg, l); err != nil {
		return "", err
	}
	return "pre-commit disabled", nil
}

// RefreshPreCommit rewrites the on-disk pre-commit script when its
// embedded eeco binary path (or workflow set) no longer matches what the
// running binary would write today — the self-heal for a `brew upgrade
// eeco` that moved the cellar directory out from under a previously
// installed hook, or an `eeco migrate v1` workspace move (the stableBrewBin
// path is reused). No-op when no eeco-managed pre-commit hook exists or
// when the on-disk script already matches the desired bytes.
func RefreshPreCommit(cfg *config.Config) (string, error) {
	hooksDir, err := gitHooksDir(cfg)
	if err != nil {
		return "", err
	}
	path := filepath.Join(hooksDir, "pre-commit")
	l, lerr := loadLedger(cfg)
	if lerr != nil {
		return "", lerr
	}
	b, rerr := os.ReadFile(path)
	if errors.Is(rerr, os.ErrNotExist) {
		return "pre-commit not enabled", nil
	}
	if rerr != nil {
		return "", fmt.Errorf("inspect pre-commit: %w", rerr)
	}
	if !isEecoManaged(b, l.PreCommit.SHA256, preCommitMarker) {
		return "", errors.New("pre-commit hook is present but not eeco's — left untouched")
	}
	desired := preCommitScript(cfg.PreCommitWorkflows)
	if string(b) == desired {
		return "pre-commit already current", nil
	}
	if err := os.WriteFile(path, []byte(desired), 0o755); err != nil {
		return "", fmt.Errorf("write pre-commit: %w", err)
	}
	l.PreCommit = record{
		Installed: true,
		Path:      path,
		SHA256:    sha256hex([]byte(desired)),
		At:        time.Now().UTC().Format(time.RFC3339),
	}
	if err := saveLedger(cfg, l); err != nil {
		return "", err
	}
	return "pre-commit refreshed (" + path + ")", nil
}

// isEecoPreCommit reports whether content is eeco's pre-commit script.
func isEecoPreCommit(content []byte, recordedSHA string) bool {
	return isEecoManaged(content, recordedSHA, preCommitMarker)
}

// isEecoManaged reports whether content is an eeco-managed hook script:
// byte-identical to the recorded hash when one is known, otherwise
// carrying the unique marker line. A foreign hook carries neither.
func isEecoManaged(content []byte, recordedSHA, marker string) bool {
	if recordedSHA != "" && sha256hex(content) == recordedSHA {
		return true
	}
	if recordedSHA != "" {
		return false
	}
	return strings.Contains(string(content), marker)
}

// --- post-merge -----------------------------------------------------

// postMergeScript renders the post-merge hook body. Unlike the
// pre-commit script it does NOT use `set -e` and swallows each step's
// exit (`|| true`): the merge has already completed, so a drift finding
// (exit 1) or a missing-tool block (exit 2) must surface as queue items
// and workflow output, never as a hook failure that alarms the user
// after a successful `git pull`. The eeco binary path is captured once.
func postMergeScript(workflows []string) string {
	var b strings.Builder
	b.WriteString("#!/bin/sh\n")
	b.WriteString("# eeco managed post-merge hook. Reversible:\n")
	b.WriteString("#   eeco hooks post-merge off\n")
	b.WriteString("# Do not edit the next line; removal is exact-match.\n")
	b.WriteString("# " + postMergeMarker + "\n")
	fmt.Fprintf(&b, "EECO=%q\n", selfPath())
	for _, w := range workflows {
		fmt.Fprintf(&b, "\"$EECO\" run %s || true\n", w)
	}
	return b.String()
}

// EnablePostMerge installs the post-merge hook. It refuses, without
// modifying anything, when a non-eeco post-merge hook already exists or
// when cfg.PostMergeWorkflows is empty (the operator opted out via an
// explicit empty `post_merge_workflows`). Re-enabling an already-eeco
// hook is a no-op even when the desired workflow set has changed: run
// `eeco hooks post-merge off` first to refresh.
func EnablePostMerge(cfg *config.Config) (string, error) {
	if len(cfg.PostMergeWorkflows) == 0 {
		return "", errors.New("post_merge_workflows is empty in config.local — nothing to wire")
	}
	hooksDir, err := gitHooksDir(cfg)
	if err != nil {
		return "", err
	}
	path := filepath.Join(hooksDir, "post-merge")
	script := postMergeScript(cfg.PostMergeWorkflows)

	if existing, rerr := os.ReadFile(path); rerr == nil {
		if isEecoManaged(existing, "", postMergeMarker) {
			return "post-merge already enabled", nil
		}
		return "", errors.New("a non-eeco post-merge hook already exists — left untouched")
	} else if !errors.Is(rerr, os.ErrNotExist) {
		return "", fmt.Errorf("inspect post-merge: %w", rerr)
	}

	if err := os.MkdirAll(hooksDir, 0o755); err != nil {
		return "", fmt.Errorf("create hooks dir: %w", err)
	}
	if err := os.WriteFile(path, []byte(script), 0o755); err != nil {
		return "", fmt.Errorf("write post-merge: %w", err)
	}

	l, err := loadLedger(cfg)
	if err != nil {
		return "", err
	}
	l.PostMerge = record{
		Installed: true,
		Path:      path,
		SHA256:    sha256hex([]byte(script)),
		At:        time.Now().UTC().Format(time.RFC3339),
	}
	if err := saveLedger(cfg, l); err != nil {
		return "", err
	}
	return "post-merge enabled (" + path + ")", nil
}

// DisablePostMerge removes the post-merge hook only when the on-disk
// script is byte-identical to what eeco wrote (the recorded hash, with a
// marker-line fallback). A foreign or hand-edited hook is left in place
// and reported.
func DisablePostMerge(cfg *config.Config) (string, error) {
	hooksDir, err := gitHooksDir(cfg)
	if err != nil {
		return "", err
	}
	path := filepath.Join(hooksDir, "post-merge")
	l, lerr := loadLedger(cfg)
	if lerr != nil {
		return "", lerr
	}

	b, rerr := os.ReadFile(path)
	if errors.Is(rerr, os.ErrNotExist) {
		l.PostMerge = record{}
		if err := saveLedger(cfg, l); err != nil {
			return "", err
		}
		return "post-merge not enabled", nil
	}
	if rerr != nil {
		return "", fmt.Errorf("inspect post-merge: %w", rerr)
	}

	if !isEecoManaged(b, l.PostMerge.SHA256, postMergeMarker) {
		return "", errors.New("post-merge hook is present but not eeco's — left untouched")
	}
	if err := os.Remove(path); err != nil {
		return "", fmt.Errorf("remove post-merge: %w", err)
	}
	l.PostMerge = record{}
	if err := saveLedger(cfg, l); err != nil {
		return "", err
	}
	return "post-merge disabled", nil
}

// RefreshPostMerge rewrites the on-disk post-merge script when its
// embedded eeco binary path (or workflow set) no longer matches what the
// running binary would write today — the self-heal for a `brew upgrade
// eeco` that moved the cellar directory out from under a previously
// installed hook, or an `eeco migrate v1` workspace move (the stableBrewBin
// path is reused). No-op when no eeco-managed post-merge hook exists or
// when the on-disk script already matches the desired bytes.
func RefreshPostMerge(cfg *config.Config) (string, error) {
	hooksDir, err := gitHooksDir(cfg)
	if err != nil {
		return "", err
	}
	path := filepath.Join(hooksDir, "post-merge")
	l, lerr := loadLedger(cfg)
	if lerr != nil {
		return "", lerr
	}
	b, rerr := os.ReadFile(path)
	if errors.Is(rerr, os.ErrNotExist) {
		return "post-merge not enabled", nil
	}
	if rerr != nil {
		return "", fmt.Errorf("inspect post-merge: %w", rerr)
	}
	if !isEecoManaged(b, l.PostMerge.SHA256, postMergeMarker) {
		return "", errors.New("post-merge hook is present but not eeco's — left untouched")
	}
	desired := postMergeScript(cfg.PostMergeWorkflows)
	if string(b) == desired {
		return "post-merge already current", nil
	}
	if err := os.WriteFile(path, []byte(desired), 0o755); err != nil {
		return "", fmt.Errorf("write post-merge: %w", err)
	}
	l.PostMerge = record{
		Installed: true,
		Path:      path,
		SHA256:    sha256hex([]byte(desired)),
		At:        time.Now().UTC().Format(time.RFC3339),
	}
	if err := saveLedger(cfg, l); err != nil {
		return "", err
	}
	return "post-merge refreshed (" + path + ")", nil
}

// --- session-start --------------------------------------------------

// sessionCommand is the command string written into the settings file.
// It carries the namespace token so removal is exact and
// path-independent, and --if-initialized so the bundled hook stays
// silent in any repo that is not an initialized eeco workspace.
func sessionCommand() string {
	return fmt.Sprintf("%q %s --if-initialized", selfPath(), sessionToken)
}

// sessionGroup is the SessionStart group eeco appends. It is built as a
// generic map so the surrounding settings document round-trips with
// unknown fields preserved.
func sessionGroup() map[string]any {
	return map[string]any{
		"hooks": []any{
			map[string]any{
				"type":    "command",
				"command": sessionCommand(),
			},
		},
	}
}

// EnableSessionStart wires the session-start hook across both delivery
// channels: a JSON-settings file (Claude-shaped, keyed by
// SessionSettingsPath) and one or more text/markdown files
// (marker-block delivery, keyed by SessionFiles). Either channel alone
// is enough; both compose. When neither is configured the function
// returns ErrSessionNotConfigured and touches nothing.
func EnableSessionStart(cfg *config.Config) (string, error) {
	if cfg.SessionSettingsPath == "" && len(cfg.SessionFiles) == 0 {
		return "", ErrSessionNotConfigured
	}

	l, lerr := loadLedger(cfg)
	if lerr != nil {
		return "", lerr
	}

	var (
		jsonMsg     string
		backup      string
		fileRecords []fileRecord
	)

	if cfg.SessionSettingsPath != "" {
		m, b, err := enableSessionJSON(cfg)
		if err != nil {
			return "", err
		}
		jsonMsg = m
		backup = b
	}

	var fileNotes []string
	if len(cfg.SessionFiles) > 0 {
		records, errs := enableSessionFiles(cfg)
		if len(errs) > 0 {
			var msgs []string
			for _, e := range errs {
				msgs = append(msgs, e.Error())
			}
			return "", fmt.Errorf("session_files: %s", strings.Join(msgs, "; "))
		}
		fileRecords = records
		for _, r := range records {
			fileNotes = append(fileNotes, r.Path)
		}
	}

	l.SessionStart = record{
		Installed: true,
		Path:      cfg.SessionSettingsPath,
		Backup:    backup,
		At:        time.Now().UTC().Format(time.RFC3339),
		Files:     fileRecords,
	}
	if err := saveLedger(cfg, l); err != nil {
		return "", err
	}

	var parts []string
	if jsonMsg != "" {
		parts = append(parts, jsonMsg)
	}
	if len(fileNotes) > 0 {
		parts = append(parts, "files "+strings.Join(fileNotes, ", "))
	}
	if len(parts) == 0 {
		return "session-start enabled", nil
	}
	return "session-start enabled (" + strings.Join(parts, "; ") + ")", nil
}

// enableSessionJSON applies the JSON-settings-file half of the
// session-start hook. Returns a per-channel message and the backup
// path (empty when the settings file did not exist before).
func enableSessionJSON(cfg *config.Config) (msg, backup string, err error) {
	path := cfg.SessionSettingsPath
	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 sessionInstalled(root) {
		return path + " already enabled", "", nil
	}
	backup, berr := backupOriginal(cfg, orig, existed)
	if berr != nil {
		return "", "", berr
	}
	addSessionGroup(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)
	}
	msg = path
	if backup != "" {
		msg += ", backup " + backup
	}
	return msg, backup, nil
}

// DisableSessionStart undoes both delivery channels: removes the eeco
// SessionStart group from the JSON-settings file when configured, and
// removes the marker block from every file recorded in the ledger (or
// in cfg.SessionFiles, when no ledger files survived). Foreign edits
// inside a marker block leave that file untouched with a per-file note.
func DisableSessionStart(cfg *config.Config) (string, error) {
	if cfg.SessionSettingsPath == "" && len(cfg.SessionFiles) == 0 {
		return "", ErrSessionNotConfigured
	}

	l, lerr := loadLedger(cfg)
	if lerr != nil {
		return "", lerr
	}

	var parts []string

	if cfg.SessionSettingsPath != "" {
		m, b, err := disableSessionJSON(cfg)
		if err != nil {
			return "", err
		}
		if m != "" {
			if b != "" {
				m += " (backup " + b + ")"
			}
			parts = append(parts, m)
		}
	}

	// Take the recorded files, fall back to the current configured list
	// when the ledger has no entries (older eeco wired the channel; the
	// file paths may still be there to clean up).
	fileRecs := l.SessionStart.Files
	if len(fileRecs) == 0 && len(cfg.SessionFiles) > 0 {
		for _, entry := range cfg.SessionFiles {
			fileRecs = append(fileRecs, fileRecord{Path: resolveSessionFile(cfg, entry)})
		}
	}
	if len(fileRecs) > 0 {
		notes, errs := disableSessionFiles(fileRecs)
		if len(errs) > 0 {
			var msgs []string
			for _, e := range errs {
				msgs = append(msgs, e.Error())
			}
			return "", fmt.Errorf("session_files: %s", strings.Join(msgs, "; "))
		}
		if len(notes) > 0 {
			parts = append(parts, "files left untouched: "+strings.Join(notes, "; "))
		} else {
			var ps []string
			for _, r := range fileRecs {
				ps = append(ps, r.Path)
			}
			parts = append(parts, "files "+strings.Join(ps, ", "))
		}
	}

	l.SessionStart = record{}
	if err := saveLedger(cfg, l); err != nil {
		return "", err
	}

	if len(parts) == 0 {
		return "session-start not enabled", nil
	}
	return "session-start disabled (" + strings.Join(parts, "; ") + ")", nil
}

// disableSessionJSON removes the JSON-settings half. Returns "" for msg
// when the settings file did not have an eeco entry (the caller treats
// this as a no-op for the JSON channel).
func disableSessionJSON(cfg *config.Config) (msg, backup string, err error) {
	path := cfg.SessionSettingsPath
	orig, existed, perm, rerr := readSettings(path)
	if rerr != nil {
		return "", "", rerr
	}
	if !existed {
		return "", "", 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 !sessionInstalled(root) {
		return "", "", nil
	}
	backup, berr := backupOriginal(cfg, orig, existed)
	if berr != nil {
		return "", "", berr
	}
	removeSessionGroups(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)
	}
	return path, backup, nil
}

// RefreshSessionStart re-derives both delivery channels from current
// project state. For the file-delivery channel it re-renders the marker
// block in every configured session_files entry (picking up a new queue
// item, an emptied mailbox, a fresh `roadmap*.md` match). For the
// JSON-settings channel it rewrites the eeco SessionStart command when
// the embedded binary path no longer matches what selfPath() produces
// — the self-heal for a brew upgrade that moved the cellar directory
// out from under a previously-installed hook. Refresh is safe to run
// repeatedly; the file outputs are byte-deterministic for a given
// project state, and the JSON rewrite is a no-op when the command is
// already current (idempotent).
func RefreshSessionStart(cfg *config.Config) (string, error) {
	if cfg.SessionSettingsPath == "" && len(cfg.SessionFiles) == 0 {
		return "", ErrSessionNotConfigured
	}

	var parts []string
	jsonRefreshed := false

	if cfg.SessionSettingsPath != "" {
		jsonPath, jerr := refreshSessionJSON(cfg)
		if jerr != nil {
			return "", jerr
		}
		if jsonPath != "" {
			parts = append(parts, jsonPath)
			jsonRefreshed = true
		}
	}

	var fileRecords []fileRecord
	if len(cfg.SessionFiles) > 0 {
		records, errs := refreshSessionFiles(cfg)
		if len(errs) > 0 {
			var msgs []string
			for _, e := range errs {
				msgs = append(msgs, e.Error())
			}
			return "", fmt.Errorf("session_files: %s", strings.Join(msgs, "; "))
		}
		fileRecords = records
	}

	if jsonRefreshed || len(fileRecords) > 0 {
		l, lerr := loadLedger(cfg)
		if lerr != nil {
			return "", lerr
		}
		if len(fileRecords) > 0 {
			// Preserve Created across refreshes: a file that existed
			// before the first enable must not become "created" by a
			// later refresh.
			preserved := map[string]bool{}
			for _, prev := range l.SessionStart.Files {
				preserved[prev.Path] = prev.Created
			}
			for i := range fileRecords {
				if c, ok := preserved[fileRecords[i].Path]; ok {
					fileRecords[i].Created = c
				}
			}
			l.SessionStart.Files = fileRecords
		}
		l.SessionStart.At = time.Now().UTC().Format(time.RFC3339)
		if !l.SessionStart.Installed {
			l.SessionStart.Installed = true
		}
		if err := saveLedger(cfg, l); err != nil {
			return "", err
		}
	}

	for _, r := range fileRecords {
		parts = append(parts, r.Path)
	}

	if len(parts) == 0 {
		return "nothing to refresh (the JSON channel is already current)", nil
	}
	return "session-start refreshed (" + strings.Join(parts, ", ") + ")", nil
}

// refreshSessionJSON rewrites the eeco SessionStart command in the
// settings file when it carries the namespace token but its current
// command string differs from sessionCommand(). Returns the settings
// path on a successful rewrite, "" when there is nothing to do (no
// settings file, no eeco group present, or the command is already
// current). Atomic via writeJSONAtomic and revalidated like the enable
// path; an existing backup of the pre-refresh bytes is captured under
// <workspace>/state/backups/.
func refreshSessionJSON(cfg *config.Config) (string, error) {
	path := cfg.SessionSettingsPath
	if path == "" {
		return "", nil
	}
	orig, existed, perm, rerr := readSettings(path)
	if rerr != nil {
		return "", rerr
	}
	if !existed {
		return "", 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 !sessionInstalled(root) {
		return "", nil
	}
	want := sessionCommand()
	if !rewriteSessionCommand(root, want) {
		return "", 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)
	}
	return path, nil
}

// rewriteSessionCommand walks every SessionStart group in root and
// replaces any command containing eeco's namespace token whose current
// value differs from want. Returns true if any command was changed.
func rewriteSessionCommand(root map[string]any, want string) bool {
	changed := false
	for _, g := range sessionGroups(root) {
		gm, ok := g.(map[string]any)
		if !ok {
			continue
		}
		hs, ok := gm["hooks"].([]any)
		if !ok {
			continue
		}
		for _, h := range hs {
			hm, ok := h.(map[string]any)
			if !ok {
				continue
			}
			cmd, ok := hm["command"].(string)
			if !ok {
				continue
			}
			if !strings.Contains(cmd, sessionToken) {
				continue
			}
			if cmd == want {
				continue
			}
			hm["command"] = want
			changed = true
		}
	}
	return changed
}

// sessionInstalled reports whether root already contains an eeco
// SessionStart group (identified by the namespace token).
func sessionInstalled(root map[string]any) bool {
	for _, g := range sessionGroups(root) {
		if groupHasToken(g) {
			return true
		}
	}
	return false
}

// sessionGroups returns the SessionStart group list, or nil.
func sessionGroups(root map[string]any) []any {
	hooks, ok := root["hooks"].(map[string]any)
	if !ok {
		return nil
	}
	groups, _ := hooks["SessionStart"].([]any)
	return groups
}

// groupHasToken reports whether a SessionStart group carries a hook
// command containing eeco's namespace token.
func groupHasToken(group any) 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, sessionToken) {
			return true
		}
	}
	return false
}

func addSessionGroup(root map[string]any) {
	hooks, ok := root["hooks"].(map[string]any)
	if !ok {
		hooks = map[string]any{}
		root["hooks"] = hooks
	}
	groups, _ := hooks["SessionStart"].([]any)
	hooks["SessionStart"] = append(groups, sessionGroup())
}

func removeSessionGroups(root map[string]any) {
	hooks, ok := root["hooks"].(map[string]any)
	if !ok {
		return
	}
	groups, ok := hooks["SessionStart"].([]any)
	if !ok {
		return
	}
	kept := make([]any, 0, len(groups))
	for _, g := range groups {
		if groupHasToken(g) {
			continue
		}
		kept = append(kept, g)
	}
	if len(kept) == 0 {
		// Leave no empty SessionStart array behind: drop the key, and
		// the hooks object too if eeco's edit left it empty.
		delete(hooks, "SessionStart")
		if len(hooks) == 0 {
			delete(root, "hooks")
		}
		return
	}
	hooks["SessionStart"] = kept
}

// readSettings reads the settings file. A missing file is not an error:
// existed is false and perm defaults to 0o644.
func readSettings(path string) (data []byte, existed bool, perm os.FileMode, err error) {
	info, serr := os.Stat(path)
	if errors.Is(serr, os.ErrNotExist) {
		return nil, false, 0o644, nil
	}
	if serr != nil {
		return nil, false, 0, fmt.Errorf("stat settings: %w", serr)
	}
	b, rerr := os.ReadFile(path)
	if rerr != nil {
		return nil, false, 0, fmt.Errorf("read settings: %w", rerr)
	}
	return b, true, info.Mode().Perm(), nil
}

// backupOriginal copies the pre-edit bytes into the workspace (never
// beside the user's file). When the file did not exist there is nothing
// to back up and it returns "".
func backupOriginal(cfg *config.Config, orig []byte, existed bool) (string, error) {
	if !existed {
		return "", nil
	}
	dir := filepath.Join(cfg.Workspace, "state", backupSubdir)
	if err := os.MkdirAll(dir, 0o755); err != nil {
		return "", fmt.Errorf("backup dir: %w", err)
	}
	name := "session-settings-" + time.Now().UTC().Format("20060102T150405.000000000Z") + ".json"
	bp := filepath.Join(dir, name)
	if err := os.WriteFile(bp, orig, 0o644); err != nil {
		return "", fmt.Errorf("write backup: %w", err)
	}
	return bp, nil
}

// writeJSONAtomic marshals root and replaces path via a same-directory
// temp file and rename, so a crash mid-write cannot leave a truncated
// settings file.
func writeJSONAtomic(path string, root map[string]any, perm os.FileMode) error {
	b, err := json.MarshalIndent(root, "", "  ")
	if err != nil {
		return fmt.Errorf("encode settings: %w", err)
	}
	b = append(b, '\n')
	dir := filepath.Dir(path)
	tmp, err := os.CreateTemp(dir, ".eeco-settings-*")
	if err != nil {
		return fmt.Errorf("temp settings: %w", err)
	}
	tmpName := tmp.Name()
	defer os.Remove(tmpName)
	if _, werr := tmp.Write(b); werr != nil {
		tmp.Close()
		return fmt.Errorf("write temp settings: %w", werr)
	}
	if cerr := tmp.Close(); cerr != nil {
		return fmt.Errorf("close temp settings: %w", cerr)
	}
	if perm == 0 {
		perm = 0o644
	}
	if cherr := os.Chmod(tmpName, perm); cherr != nil {
		return fmt.Errorf("chmod temp settings: %w", cherr)
	}
	if rerr := os.Rename(tmpName, path); rerr != nil {
		return fmt.Errorf("replace settings: %w", rerr)
	}
	return nil
}

// validateJSON re-reads path and confirms it parses as JSON.
func validateJSON(path string) error {
	b, err := os.ReadFile(path)
	if err != nil {
		return err
	}
	var v any
	return json.Unmarshal(b, &v)
}

// restoreOriginal puts the pre-edit state back: the original bytes, or
// removal when the file did not exist before the edit.
func restoreOriginal(path string, orig []byte, existed bool) error {
	if !existed {
		return os.Remove(path)
	}
	return os.WriteFile(path, orig, 0o644)
}

// --- status ---------------------------------------------------------

// Status returns one human-readable line per hook, reflecting both the
// ledger and on-disk reality (so a hand-removed hook reads as off). It
// changes nothing.
func Status(cfg *config.Config) []string {
	l, _ := loadLedger(cfg)
	return []string{
		PreCommit + ": " + preCommitStatus(cfg, l),
		PostMerge + ": " + postMergeStatus(cfg, l),
		SessionStart + ": " + sessionStatus(cfg),
		CommitMsg + ": " + commitMsgStatus(cfg, l),
		CommitGuard + ": " + commitGuardStatus(cfg),
	}
}

func preCommitStatus(cfg *config.Config, l ledger) string {
	return managedHookStatus(cfg, "pre-commit", l.PreCommit.SHA256, preCommitMarker)
}

func postMergeStatus(cfg *config.Config, l ledger) string {
	return managedHookStatus(cfg, "post-merge", l.PostMerge.SHA256, postMergeMarker)
}

// managedHookStatus reports on/off for a repo-scoped managed git hook,
// reflecting on-disk reality so a hand-removed hook reads as off and a
// foreign hook of the same name reads as off-with-note.
func managedHookStatus(cfg *config.Config, hookName, recordedSHA, marker string) string {
	hooksDir, err := gitHooksDir(cfg)
	if err != nil {
		return "unavailable (" + err.Error() + ")"
	}
	b, rerr := os.ReadFile(filepath.Join(hooksDir, hookName))
	if errors.Is(rerr, os.ErrNotExist) {
		return "off"
	}
	if rerr != nil {
		return "unknown (" + rerr.Error() + ")"
	}
	if isEecoManaged(b, recordedSHA, marker) {
		return "on"
	}
	return "off (a non-eeco " + hookName + " hook is present)"
}

func sessionStatus(cfg *config.Config) string {
	if cfg.SessionSettingsPath == "" && len(cfg.SessionFiles) == 0 {
		return "not configured"
	}
	jsonOn := false
	if cfg.SessionSettingsPath != "" {
		orig, existed, _, err := readSettings(cfg.SessionSettingsPath)
		switch {
		case err != nil || !existed:
			jsonOn = false
		default:
			root := map[string]any{}
			if json.Unmarshal(orig, &root) != nil {
				return "unknown (settings file is not valid JSON)"
			}
			jsonOn = sessionInstalled(root)
		}
	}
	filesOn := false
	for _, entry := range cfg.SessionFiles {
		path := resolveSessionFile(cfg, entry)
		b, rerr := os.ReadFile(path)
		if rerr != nil {
			continue
		}
		if _, _, found, ferr := findSessionBlock(b); ferr == nil && found {
			filesOn = true
			break
		}
	}
	if jsonOn || filesOn {
		return "on"
	}
	return "off"
}

// ShortState is the compact "<name>:on/off" pair for the status digest.
func ShortState(cfg *config.Config) string {
	l, _ := loadLedger(cfg)
	pc := "off"
	if strings.HasPrefix(preCommitStatus(cfg, l), "on") {
		pc = "on"
	}
	pm := "off"
	if strings.HasPrefix(postMergeStatus(cfg, l), "on") {
		pm = "on"
	}
	ss := sessionStatus(cfg)
	if ss != "on" {
		ss = "off"
	}
	cm := "off"
	if strings.HasPrefix(commitMsgStatus(cfg, l), "on") {
		cm = "on"
	}
	cg := "off"
	if commitGuardStatus(cfg) == "on" {
		cg = "on"
	}
	return "pre-commit:" + pc + " post-merge:" + pm + " session:" + ss + " commit-msg:" + cm + " commit-guard:" + cg
}