Go 371 lines
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 <workspace>/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 <workspace>/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 `<!--` line and every line through the matching
// `-->` 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, "<!--") {
// A comment opens on this line. If it also closes on the
// same line, the whole line is comment-only; either way the
// awk port skips the line. Multi-line comments stay in
// inComment until a line carries `-->`.
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()
}