package hooks import ( "fmt" "io" "os" "path/filepath" "strings" "time" "github.com/ajhahnde/eeco/internal/cockpit" "github.com/ajhahnde/eeco/internal/config" "github.com/ajhahnde/eeco/internal/gitx" "github.com/ajhahnde/eeco/internal/memory" "github.com/ajhahnde/eeco/internal/notes" "github.com/ajhahnde/eeco/internal/playbooks" "github.com/ajhahnde/eeco/internal/queue" ) // autoDetectDocs is the ordered list of repo-relative paths the bundled // session-start hook checks when SessionStartDocs is empty. Only the // entries that exist on disk are surfaced; the rest are silently // skipped. The order is the read order — frozen contract docs first, // then architecture, then changelog, then the more general entry // points. var autoDetectDocs = []string{ "docs/PUBLIC_API.md", "docs/ARCHITECTURE.md", "CHANGELOG.md", "ARCHITECTURE.md", "docs/USAGE.md", "README.md", } // Emit writes any non-empty session-start blocks to w, separated by // blank lines, in the order: reading routine, mailbox, queue reminder, // pinned memory bodies, live state, cockpit drift. The pinned block fires // only when cfg.SessionStartPinnedBodies is true and at least one `pin: true` // memory fact exists. The live-state block (version + newest handover note) // and the cockpit-drift block (live cockpit.Sync findings) are the cockpit // machinery's SessionStart orientation; both are silent on a repo that carries // no tags / notes / generated cockpit, so non-cockpit output is unchanged. // // Emit is strictly READ-ONLY (best-effort: missing or unreadable state is // treated as absent, and it never returns an error) so it is safe to wire // directly into a session-start hook. Any session-start WRITE — clearing the // one-shot git-write sentinels, advancing a throttle stamp — lives in the cmd // runner (runSessionEmit), never here. func Emit(cfg *config.Config, w io.Writer) { if cfg == nil { return } var blocks []string if r := readingRoutine(cfg); r != "" { blocks = append(blocks, r) } if m := mailboxBlock(cfg); m != "" { blocks = append(blocks, m) } if q := queueLine(cfg); q != "" { blocks = append(blocks, q) } if cfg.SessionStartPinnedBodies { if p := pinnedMemoriesBlock(cfg); p != "" { blocks = append(blocks, p) } } if ls := liveStateBlock(cfg); ls != "" { blocks = append(blocks, ls) } if d := driftBlock(cfg); d != "" { blocks = append(blocks, d) } if len(blocks) == 0 { return } fmt.Fprintln(w, strings.Join(blocks, "\n\n")) } // liveStateBlock composes the "live state" orientation block: the newest // semver-shaped git tag (the current version) and the newest handover / resume // note (the session's resume point). The handover note is the most-recently // -modified handover_glob match when that config key is set, else the newest // note under /notes/. Returns "" when neither is available. // Best-effort and read-only: any error degrades to a missing field or "". func liveStateBlock(cfg *config.Config) string { version, _ := gitx.LatestSemverTag(cfg.RepoRoot) handover := newestHandover(cfg) if version == "" && handover == "" { return "" } var b strings.Builder b.WriteString("[eeco live state]") if version != "" { b.WriteString("\n version: ") b.WriteString(version) } if handover != "" { b.WriteString("\n newest handover: ") b.WriteString(handover) } return b.String() } // driftBlock composes the live cockpit-drift orientation block: it runs the // read-only cockpit.Sync over every registered playbook and prints each // finding's Detail, one per line. Returns "" when the cockpit was never // generated here (Sync's empty-ledger gate makes this the common, silent // case), when there is no drift, or on any error — best-effort, never disrupts // session start. This is the SessionStart "drift inject" the C3 slice deferred // (C3 surfaced drift only via the one-line queue reminder). func driftBlock(cfg *config.Config) string { report, err := cockpit.Sync(cfg, playbooks.All()) if err != nil || report.Clean { return "" } var b strings.Builder b.WriteString("[eeco cockpit drift] regenerate or reconcile:") for _, f := range report.Findings { b.WriteString("\n - ") b.WriteString(f.Detail) } return b.String() } // newestHandover returns a short label for the newest handover note: the // repo-relative path of the most-recently-modified handover_glob match when // that key is set, otherwise the one-line summary (falling back to the // filename) of the newest note under /notes/. Returns "" when // neither yields anything. func newestHandover(cfg *config.Config) string { if rel, _, ok := newestGlobMatch(cfg.RepoRoot, cfg.HandoverGlob); ok { return rel } ns, err := notes.List(filepath.Join(cfg.Workspace, "notes")) if err != nil || len(ns) == 0 { return "" } if s := strings.TrimSpace(ns[0].Summary); s != "" { return s } return filepath.Base(ns[0].Path) } // newestHandoverMtime returns the modification time of the newest handover // note — the handover_glob match when configured, else the newest workspace // note. ok is false when no handover note exists. Used by the Stop nudge to // compare against the last commit time. func newestHandoverMtime(cfg *config.Config) (time.Time, bool) { if _, mod, ok := newestGlobMatch(cfg.RepoRoot, cfg.HandoverGlob); ok { return mod, true } ns, err := notes.List(filepath.Join(cfg.Workspace, "notes")) if err != nil || len(ns) == 0 { return time.Time{}, false } return ns[0].When, true } // pinnedMemoriesBlock composes the fourth session-start block: every // `pin: true` memory fact's name, description, and full body separated // by markdown dividers. Returns "" when the workspace has no memory // store, the store has no pinned facts, or pinned-body emission is // otherwise unavailable — Emit's best-effort posture applies; the // session-start hook never disrupts startup over a missing block. func pinnedMemoriesBlock(cfg *config.Config) string { store, err := memory.Open(cfg) if err != nil { return "" } facts, err := store.LoadAll() if err != nil { return "" } var pinned []*memory.Fact for _, f := range facts { if f.Pin && !f.Disabled { pinned = append(pinned, f) } } if len(pinned) == 0 { return "" } var b strings.Builder b.WriteString("[eeco pinned memories — read these before substantive work]") for i, f := range pinned { if i > 0 { b.WriteString("\n\n---") } b.WriteString("\n\n## ") b.WriteString(f.Name) if f.Description != "" { b.WriteString("\n") b.WriteString(f.Description) } body := strings.TrimSpace(f.Body) if body != "" { b.WriteString("\n\n") b.WriteString(body) } } return b.String() } // readingRoutine composes the "before substantive work, read these" // block. When SessionStartDocs is set in config it is used verbatim // (filtered to existing files only); otherwise autoDetectDocs is // scanned and existing entries are included. The live planning surface // (most-recently-modified match of SessionStartRoadmapGlob) is appended // last when discovery is enabled. Returns "" when no docs surface. func readingRoutine(cfg *config.Config) string { var docs []string if len(cfg.SessionStartDocs) > 0 { for _, rel := range cfg.SessionStartDocs { if fileExists(filepath.Join(cfg.RepoRoot, rel)) { docs = append(docs, rel) } } } else { for _, rel := range autoDetectDocs { if fileExists(filepath.Join(cfg.RepoRoot, rel)) { docs = append(docs, rel) } } } roadmap := liveRoadmap(cfg) if len(docs) == 0 && roadmap == "" { return "" } var b strings.Builder b.WriteString("[eeco session start] Before substantive work, read these for current state and contracts:") for _, rel := range docs { b.WriteString("\n - ") b.WriteString(rel) } if roadmap != "" { b.WriteString("\n - ") b.WriteString(roadmap) b.WriteString(" (live planning surface)") } return b.String() } // liveRoadmap returns the repo-relative path of the most // recently-modified match for the configured roadmap glob, or "" when // discovery is disabled or no match exists. func liveRoadmap(cfg *config.Config) string { rel, _, _ := newestGlobMatch(cfg.RepoRoot, cfg.SessionStartRoadmapGlob) return rel } // newestGlobMatch returns the most-recently-modified file matching pattern // (joined under repoRoot): its repo-relative, slash-separated path, its // modification time, and ok. ok is false when the pattern is empty, invalid, // or matches no regular file. Errors from filepath.Glob (bad pattern) are // treated as no-match: the hook stays silent rather than fail. Directories are // skipped. func newestGlobMatch(repoRoot, pattern string) (rel string, mod time.Time, ok bool) { if pattern == "" { return "", time.Time{}, false } matches, err := filepath.Glob(filepath.Join(repoRoot, pattern)) if err != nil || len(matches) == 0 { return "", time.Time{}, false } var bestPath string var bestMod time.Time for _, m := range matches { info, serr := os.Stat(m) if serr != nil || info.IsDir() { continue } if bestPath == "" || info.ModTime().After(bestMod) { bestPath, bestMod = m, info.ModTime() } } if bestPath == "" { return "", time.Time{}, false } r, rerr := filepath.Rel(repoRoot, bestPath) if rerr != nil { return "", time.Time{}, false } return filepath.ToSlash(r), bestMod, true } // mailboxBlock returns the "unprocessed ideas" instruction when the // configured mailbox file is present and has content beyond its header // + commented template. Returns "" when the mailbox is disabled // (SessionStartMailbox empty), the file is missing, or the file // contains only the empty template. func mailboxBlock(cfg *config.Config) string { if cfg.SessionStartMailbox == "" { return "" } path := filepath.Join(cfg.RepoRoot, cfg.SessionStartMailbox) b, err := os.ReadFile(path) if err != nil { return "" } if !mailboxHasContent(b) { return "" } name := cfg.SessionStartMailbox return fmt.Sprintf( "[Ideas mailbox] %s has unprocessed ideas. Read it and file each idea where it belongs — "+ "feature/fix/cross-cut into the roadmap planning doc, durable preference or fact into an "+ "auto-memory, anything unclear raise with the operator. Report what went where, then reset "+ "%s to its empty template. Never remove an idea until it is durably filed — if %s is "+ "gitignored, a cleaned-but-unfiled idea is lost.", name, name, name) } // mailboxHasContent ports the awk logic from the legacy bash hook: // skip the first line (the header), elide HTML comment blocks // (including multi-line ones), and report whether any non-blank line // remains. The opening `` are all treated as comment. func mailboxHasContent(b []byte) bool { lines := strings.Split(string(b), "\n") inComment := false for i, line := range lines { if i == 0 { continue } if inComment { if strings.Contains(line, "-->") { inComment = false } continue } if strings.Contains(line, "`. if !strings.Contains(line, "-->") { inComment = true } continue } if strings.TrimSpace(line) != "" { return true } } return false } // queueLine preserves the legacy one-line queue reminder produced by // the hidden `session-emit` subcommand. Returns "" when the queue is // empty or unreadable so the block is omitted from the composed output. func queueLine(cfg *config.Config) string { n, err := queue.Count(filepath.Join(cfg.Workspace, "state")) if err != nil || n <= 0 { return "" } noun := "items" if n == 1 { noun = "item" } return fmt.Sprintf("eeco: %d %s awaiting a decision — run `eeco` to review", n, noun) } func fileExists(path string) bool { info, err := os.Stat(path) return err == nil && !info.IsDir() }