package hooks import ( "os" "path/filepath" "time" "github.com/ajhahnde/eeco/internal/cockpit" "github.com/ajhahnde/eeco/internal/config" ) // The contract-watch / doc-drift-nudge pair is the cockpit machinery's // event-driven drift signal: a PostToolUse edit to a cockpit input drops a // flag (ContractWatch), and the next SessionStart consumes it into a one-time // doc-drift nudge (DocDriftNudge) — so the drift check fires on real change, // not a blind timer (a weekly backstop still catches silent drift). Both ends // live here so the flag names stay in one place. const ( // contractChangedFlag marks that a watched cockpit input changed since the // last session; DocDriftNudge consumes it. contractChangedFlag = "contract-changed" // cockpitDirtyFlag is the companion marker for the cockpit specifically, // dropped alongside contractChangedFlag. cockpitDirtyFlag = "cockpit-dirty" // docDriftStampName throttles the doc-drift nudge's time backstop. docDriftStampName = "doc-drift.last" ) // docDriftBackstop is the time backstop for the doc-drift nudge when no // contract-changed flag is present: docs drift on code change, but a weekly // backstop still catches drift no edit announced. const docDriftBackstop = 7 * 24 * time.Hour // ClearGitWriteSentinels removes both one-shot git-write authorization // sentinels under /state, so no new session inherits a standing // authorization left over from a prior one (security-critical — it pairs with // the C4a git-write guard). Called from runSessionEmit before the pure Emit. // Missing sentinels are not an error. func ClearGitWriteSentinels(cfg *config.Config) { stateDir := filepath.Join(cfg.Workspace, "state") for _, kind := range []string{"commit", "tag"} { _ = os.Remove(filepath.Join(stateDir, "git-"+kind+"-authorized")) } } // DocDriftNudge decides whether session start should nudge a doc/cockpit-drift // check. It fires when a cockpit input changed since the last session (the // contract-watch PostToolUse hook dropped the contract-changed flag) OR the // weekly backstop elapsed. On a fire it writes the throttle stamp and clears // the flags (so the nudge is one-shot per change), returning the nudge line. // It performs WRITES, so runSessionEmit calls it around the pure Emit, never // from inside Emit. It never errors. func DocDriftNudge(cfg *config.Config, now time.Time) (line string, fire bool) { stateDir := filepath.Join(cfg.Workspace, "state") flag := filepath.Join(stateDir, contractChangedFlag) stamp := filepath.Join(stateDir, docDriftStampName) flagged := fileExists(flag) if !flagged && !throttleElapsed(stamp, now, docDriftBackstop) { return "", false } // A backstop-only trigger (no explicit contract change) stays silent unless // the cockpit is actually generated here — otherwise there is nothing to // drift-check, and session start must not nag an unrelated repo. (The flag // path needs no such gate: the contract-changed flag can only be dropped by // the machinery's PostToolUse hook, which implies the cockpit is in use.) if !flagged && !cockpit.IsGenerated(cfg) { return "", false } trigger := "the weekly backstop is due" if flagged { trigger = "a cockpit input (cockpit.json / config.local) changed since the last session" } writeStamp(stamp, now) _ = os.Remove(flag) _ = os.Remove(filepath.Join(stateDir, cockpitDirtyFlag)) return "[eeco maintenance] run a doc/cockpit drift check (`eeco cockpit verify`): " + trigger + ". Report the verdict (one line if clean), then carry on.", true } // ContractWatch is the PostToolUse side-effect: when the edited file is a // cockpit input (the selection store /cockpit.json or // /config.local), it drops the contract-changed + cockpit-dirty // flags under /state so the next SessionStart orient nudges a drift // check. filePath is the absolute path the tool wrote (from the PostToolUse // event); a blank or non-matching path is a no-op. It never blocks and never // errors — a flag write that fails is simply skipped. Returns whether a flag // was dropped (for tests / the runner's accounting). func ContractWatch(cfg *config.Config, filePath string) bool { if filePath == "" || !isWatchedInput(cfg, filePath) { return false } stateDir := filepath.Join(cfg.Workspace, "state") if err := os.MkdirAll(stateDir, 0o755); err != nil { return false } wrote := false for _, name := range []string{contractChangedFlag, cockpitDirtyFlag} { if err := os.WriteFile(filepath.Join(stateDir, name), nil, 0o644); err == nil { wrote = true } } return wrote } // isWatchedInput reports whether path (the tool's edited file) is a cockpit // input whose change should trigger a drift re-check: the selection store or // config.local. Matching is on the cleaned absolute path so a relative or loose // form still resolves. func isWatchedInput(cfg *config.Config, path string) bool { abs := path if !filepath.IsAbs(abs) { if a, err := filepath.Abs(abs); err == nil { abs = a } } abs = filepath.Clean(abs) watched := []string{ cockpit.SelectionPath(cfg), filepath.Join(cfg.Workspace, "config.local"), } for _, w := range watched { if filepath.Clean(w) == abs { return true } } return false }