// 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 // (/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 /state. const ledgerName = "hooks.json" // backupSubdir is where the original settings file is copied before an // edit, inside /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 // "/Cellar/eeco//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 /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 /.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 // /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 ":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 }