// Package queue is eeco's single decision channel. // // Items are appended to /state/queue.md as a Markdown // checklist. The user resolves an item by checking its box; nothing in // the engine deletes user data. Workflows and GC append; later // milestones add list/resolve helpers. package queue import ( "bytes" "errors" "fmt" "os" "path/filepath" "strings" "time" ) // Filename is the queue file name inside /state/. const Filename = "queue.md" // Item is one queue entry. Kind is a short tag ("gc-review", "evolve", // etc.). Title is a one-line summary that fits on the checklist row. // Project is a short project handle (typically the repo basename). // Detail is an optional one- or few-line elaboration printed as an // indented continuation line beneath the checklist row. type Item struct { Kind string Title string Project string Detail string Date time.Time } // Append writes item to /queue.md, creating the file and // parent directory if missing. The format follows PLAN.md: // // - [ ] **** — _(<project>, <date>)_ // <detail> // // A trailing newline is added if the existing file lacked one so the // new item starts on its own line. func Append(stateDir string, item Item) error { if err := validateItem(stateDir, &item); err != nil { return err } if err := os.MkdirAll(stateDir, 0o755); err != nil { return fmt.Errorf("queue.Append: create state dir: %w", err) } release, err := acquireLock(stateDir) if err != nil { return err } defer release() existing, err := readQueue(stateDir) if err != nil { return err } return writeAppended(stateDir, existing, item) } // AppendUnique behaves like Append but skips the write when an open // (unchecked) item with the same Kind and Title already sits in the // queue, returning appended=false. It exists so a workflow that may run // repeatedly — for example a drift check wired into a git hook — does // not pile up duplicate items for the same unresolved finding. The // dedup key is Kind+Title only: Project and Date are deliberately // excluded, so the same finding reported on two different days still // collapses to one open item. A resolved (checked) item never blocks a // re-file: if the operator ticked it off and the finding persists, // filing it again is the correct signal. func AppendUnique(stateDir string, item Item) (appended bool, err error) { if verr := validateItem(stateDir, &item); verr != nil { return false, verr } if merr := os.MkdirAll(stateDir, 0o755); merr != nil { return false, fmt.Errorf("queue.AppendUnique: create state dir: %w", merr) } release, err := acquireLock(stateDir) if err != nil { return false, err } defer release() existing, err := readQueue(stateDir) if err != nil { return false, err } if hasOpenItem(existing, item.Kind, item.Title) { return false, nil } if werr := writeAppended(stateDir, existing, item); werr != nil { return false, werr } return true, nil } // validateItem checks the shared preconditions and defaults the date. func validateItem(stateDir string, item *Item) error { if stateDir == "" { return errors.New("queue: stateDir is empty") } if item.Kind == "" || item.Title == "" { return errors.New("queue: kind and title are required") } if item.Date.IsZero() { item.Date = time.Now() } return nil } // readQueue reads the queue file, treating a missing file as empty. // Callers hold the lock. func readQueue(stateDir string) ([]byte, error) { b, err := os.ReadFile(filepath.Join(stateDir, Filename)) if err != nil && !errors.Is(err, os.ErrNotExist) { return nil, fmt.Errorf("queue: read: %w", err) } return b, nil } // writeAppended renders item onto existing and writes the queue file. // Callers hold the lock. func writeAppended(stateDir string, existing []byte, item Item) error { var buf bytes.Buffer if len(existing) == 0 { buf.WriteString("# eeco queue\n\n") } else { buf.Write(existing) if !bytes.HasSuffix(existing, []byte("\n")) { buf.WriteByte('\n') } } fmt.Fprintf(&buf, "- [ ] **%s** — %s _(%s, %s)_\n", item.Kind, item.Title, item.Project, item.Date.UTC().Format("2006-01-02")) if d := strings.TrimSpace(item.Detail); d != "" { for _, line := range strings.Split(d, "\n") { fmt.Fprintf(&buf, " %s\n", strings.TrimRight(line, " \t\r")) } } return os.WriteFile(filepath.Join(stateDir, Filename), buf.Bytes(), 0o644) } // hasOpenItem reports whether content carries an unchecked item whose // kind and title match. Resolved (checked) items are ignored. func hasOpenItem(content []byte, kind, title string) bool { for _, line := range strings.Split(string(content), "\n") { k, t, ok := parseOpenRow(line) if ok && k == kind && t == title { return true } } return false } // parseOpenRow extracts the kind and title from an open checklist row in // the frozen format `- [ ] **<kind>** — <title> _(<project>, <date>)_`. // It returns ok=false for resolved rows, detail/continuation lines, and // anything not matching the row shape. func parseOpenRow(line string) (kind, title string, ok bool) { return parseRow(line, "- [ ] **") } // parseResolvedRow is the resolved-checkbox counterpart of parseOpenRow. // It returns ok=false for unresolved rows and non-row lines. func parseResolvedRow(line string) (kind, title string, ok bool) { return parseRow(line, "- [x] **") } // parseRow is the shared row parser. prefix selects the checkbox state: // `- [ ] **` for open, `- [x] **` for resolved. func parseRow(line, prefix string) (kind, title string, ok bool) { rest := strings.TrimSpace(line) if !strings.HasPrefix(rest, prefix) { return "", "", false } rest = rest[len(prefix):] end := strings.Index(rest, "**") if end < 0 { return "", "", false } kind = rest[:end] rest = rest[end+2:] const sep = " — " if !strings.HasPrefix(rest, sep) { return "", "", false } rest = rest[len(sep):] // The title runs up to the trailing ` _(<project>, <date>)_` suffix. // Trim from the last ` _(` so a title that itself contains "_(" is // handled correctly. if cut := strings.LastIndex(rest, " _("); cut >= 0 { rest = rest[:cut] } title = rest if kind == "" || title == "" { return "", "", false } return kind, title, true } // Resolved reports whether <stateDir>/queue.md carries a resolved // (checked) item with the given kind and title. A missing queue file // is reported as not resolved with no error. Counterpart to the // internal hasOpenItem used by AppendUnique; used by the evolve // repetition ledger to reconcile its records against operator // resolution. func Resolved(stateDir, kind, title string) (bool, error) { b, err := os.ReadFile(filepath.Join(stateDir, Filename)) if err != nil { if errors.Is(err, os.ErrNotExist) { return false, nil } return false, fmt.Errorf("queue.Resolved: %w", err) } for _, line := range strings.Split(string(b), "\n") { k, t, ok := parseResolvedRow(line) if ok && k == kind && t == title { return true, nil } } return false, nil } // Count returns the number of unchecked items in <stateDir>/queue.md. // A missing file is reported as zero. func Count(stateDir string) (int, error) { path := filepath.Join(stateDir, Filename) b, err := os.ReadFile(path) if err != nil { if errors.Is(err, os.ErrNotExist) { return 0, nil } return 0, fmt.Errorf("queue.Count: %w", err) } n := 0 for _, line := range strings.Split(string(b), "\n") { if strings.HasPrefix(strings.TrimSpace(line), "- [ ]") { n++ } } return n, nil }