package workflow import ( "errors" "fmt" "os" "path/filepath" "sort" "strings" "time" "github.com/ajhahnde/eeco/internal/gitx" "github.com/ajhahnde/eeco/internal/queue" ) // handoverDir is the per-note directory inside /docs/. const handoverDir = "handover" // handoverRefresh writes a dated session-handover note plus a "what // changed since the last one" summary, then queues it for the // maintainer to review. It never overwrites: every note is a fresh // uniquely-stamped file, so prior handovers stay intact. The note lives // in the gitignored workspace (write-scope floor invariant). git is // used read-only for the change summary; when git is unavailable the // note is still written (without the summary) rather than blocking. type handoverRefresh struct{} func (handoverRefresh) Name() string { return "handover-refresh" } func (handoverRefresh) Summary() string { return "dated handover note + change-since-last summary; queued, never overwrites" } func (handoverRefresh) Run(env Env) (Result, error) { cfg := env.Config dir := filepath.Join(cfg.Workspace, "docs", handoverDir) if err := os.MkdirAll(dir, 0o755); err != nil { return Result{}, fmt.Errorf("handover-refresh: %w", err) } prevBase := lastHandoverHead(dir) head := "" changeSummary := "(git unavailable — no change summary)" if sha, err := gitx.HeadSHA(cfg.RepoRoot); err == nil { head = sha log, stat, cerr := gitx.ChangesSince(cfg.RepoRoot, prevBase) switch { case cerr != nil: changeSummary = "(change summary unavailable: " + cerr.Error() + ")" case prevBase == "": changeSummary = "first handover — no prior baseline.\n\n" + nonEmpty(log, "(no commits)") default: changeSummary = nonEmpty(log, "(no new commits)") if stat != "" { changeSummary += "\n\n" + stat } } } else if !errors.Is(err, gitx.ErrUnavailable) { changeSummary = "(change summary unavailable: " + err.Error() + ")" } stamp := time.Now().UTC() path := uniquePath(dir, "handover-"+stamp.Format("20060102T150405.000000000Z")+".md") note := buildHandoverNote(stamp, prevBase, head, changeSummary) if err := os.WriteFile(path, []byte(note), 0o644); err != nil { return Result{}, fmt.Errorf("handover-refresh: write note: %w", err) } rel := path if r, err := filepath.Rel(cfg.RepoRoot, path); err == nil { rel = filepath.ToSlash(r) } if err := queue.Append(filepath.Join(cfg.Workspace, "state"), queue.Item{ Kind: "handover", Title: "Handover note ready for review", Project: filepath.Base(cfg.RepoRoot), Detail: "wrote " + rel + " — review and carry forward what is still relevant", Date: stamp, }); err != nil { return Result{}, fmt.Errorf("handover-refresh: queue: %w", err) } return Result{ Code: CodeClean, Summary: "handover note written and queued for review (" + rel + ")", }, nil } // lastHandoverHead returns the head SHA recorded in the most recent // existing note, or "" when there is none. The timestamped filenames // sort lexically in chronological order. func lastHandoverHead(dir string) string { ents, err := os.ReadDir(dir) if err != nil { return "" } var names []string for _, e := range ents { if !e.IsDir() && strings.HasPrefix(e.Name(), "handover-") && strings.HasSuffix(e.Name(), ".md") { names = append(names, e.Name()) } } if len(names) == 0 { return "" } sort.Strings(names) b, err := os.ReadFile(filepath.Join(dir, names[len(names)-1])) if err != nil { return "" } for _, line := range strings.Split(string(b), "\n") { if v, ok := strings.CutPrefix(strings.TrimSpace(line), "head:"); ok { return strings.TrimSpace(v) } } return "" } // uniquePath returns base inside dir, or base with a numeric suffix if // it already exists, so a note is never overwritten. func uniquePath(dir, base string) string { path := filepath.Join(dir, base) if _, err := os.Stat(path); os.IsNotExist(err) { return path } ext := filepath.Ext(base) stem := strings.TrimSuffix(base, ext) for i := 2; ; i++ { cand := filepath.Join(dir, fmt.Sprintf("%s-%d%s", stem, i, ext)) if _, err := os.Stat(cand); os.IsNotExist(err) { return cand } } } func nonEmpty(s, fallback string) string { if strings.TrimSpace(s) == "" { return fallback } return s } func buildHandoverNote(stamp time.Time, base, head, changes string) string { if base == "" { base = "none" } if head == "" { head = "unknown" } return fmt.Sprintf(`# Handover — %s Written by eeco run handover-refresh. This note is a draft for the maintainer; nothing here is committed. base: %s head: %s ## Changes since last handover %s ## Open threads (carry forward what is still relevant; delete the rest) `, stamp.Format(time.RFC3339), base, head, changes) }