ajhahn.de
← eeco
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()
}