package hooks import ( "path/filepath" "strings" "time" "github.com/ajhahnde/eeco/internal/config" "github.com/ajhahnde/eeco/internal/gitx" ) // stopNudgeThrottle is the minimum gap between handover nudges — long enough // that the Stop hook nudges at most once per working session. const stopNudgeThrottle = 6 * time.Hour // stopNudgeStampName is the throttle stamp under /state. const stopNudgeStampName = "handover-nudge.last" // StopNudge decides whether the Stop hook should surface a one-time handover // reminder. It fires when the working tree carries undocumented work — a dirty // tree, OR a commit newer than the newest handover note — and the throttle has // elapsed. On a fire it writes the throttle stamp first (so a continuation turn // cannot re-trigger) and returns the advisory reason with fire=true; otherwise // it returns fire=false and writes nothing. It never returns an error: any // uncertainty (no git, unreadable stamp) degrades to "no nudge" so a session is // never wedged. The caller must honor stop_hook_active before calling. func StopNudge(cfg *config.Config, now time.Time) (reason string, fire bool) { stamp := filepath.Join(cfg.Workspace, "state", stopNudgeStampName) if !throttleElapsed(stamp, now, stopNudgeThrottle) { return "", false } reasons := undocumentedWork(cfg) if len(reasons) == 0 { return "", false } // Stamp first so a continuation turn can't re-trigger, then advise. writeStamp(stamp, now) return "Session housekeeping (handover-nudge): undocumented work in the tree (" + joinReasons(reasons) + "). Do NOT auto-run a handover. Tell the user once: " + `"Heads-up — there is undocumented work; want me to capture a handover before we stop?" ` + "then stop normally. (Fires at most once per 6h.)", true } // undocumentedWork returns human reasons the tree looks undocumented: a dirty // working tree, and/or commits newer than the newest handover note. An empty // result means nothing to nudge about. Every check degrades to "no signal" on // error, so a repo without git or without notes simply yields fewer reasons. func undocumentedWork(cfg *config.Config) []string { var reasons []string if dirty, err := gitx.IsDirty(cfg.RepoRoot); err == nil && dirty { reasons = append(reasons, "a dirty working tree") } if commitNewerThanHandover(cfg) { reasons = append(reasons, "commits newer than the last handover note") } return reasons } // commitNewerThanHandover reports whether HEAD's commit time is later than the // newest handover note's mtime. It returns false (no signal) when there is no // commit yet or git is unavailable; when a commit exists but there is no // handover note at all, it reports true (the commit is by definition // undocumented). func commitNewerThanHandover(cfg *config.Config) bool { commitTime, ok, err := gitx.LastCommitTime(cfg.RepoRoot) if err != nil || !ok { return false } noteTime, ok := newestHandoverMtime(cfg) if !ok { return true } return commitTime.After(noteTime) } // joinReasons renders a reason list as "a, b and c" (Oxford-free) for the // nudge text. func joinReasons(reasons []string) string { switch len(reasons) { case 0: return "" case 1: return reasons[0] default: return strings.Join(reasons[:len(reasons)-1], ", ") + " and " + reasons[len(reasons)-1] } }