From 5c96a49e6429e3fcde7f5ecf5cc303095668f2d5 Mon Sep 17 00:00:00 2001 From: boshu <241868352+boshu2@users.noreply.github.com> Date: Thu, 9 Jul 2026 15:03:50 -0400 Subject: [PATCH] =?UTF-8?q?feat(yield):=20ao=20yield=20report=20=E2=80=94?= =?UTF-8?q?=20the=20on-the-loop=20governance=20surface=20(yield=20+=20ando?= =?UTF-8?q?n=20queue)=20(age-mv67)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The one command a returning operator reads after hours of autonomy, no transcript required (docs/architecture/the-flywheel.md — the human moves from in the loop to ON it): YIELD gate-verdict counts (CONFIRMED/REFUTED/ESCALATE/HOLD) since the cutoff, catches recorded (DetectCatches over the windowed ledger), beads closed in the window (tracker-agnostic). ANDON QUEUE blocked beads, ESCALATE/HOLD pawl verdicts, and any REFUTED verdict whose bead is still open (stalled slice) — id, why parked, age; deduped, oldest-parked first. --since accepts RFC3339 or a duration (default 24h); --json emits the full struct. Honest empty-states (andon queue: empty — nothing parked). Beads access reuses the ao beads exec internals (resolveTracker + canonicalizeBDReadJSON, canonical br {issues:[...]} shape) behind an injectable seam; tracker failure degrades to a reported beads_error, never fatal. Executed-red TDD: seeded production-writer ledger + stubbed beads. Flips the flywheel status-ledger row for the async governance surface to landed. --- cli/cmd/ao/cobra_writer_isolation_test.go | 4 + cli/cmd/ao/yield_report.go | 618 ++++++++++++++++++ cli/cmd/ao/yield_report_test.go | 394 +++++++++++ cli/docs/COMMANDS.md | 16 + docs/architecture/the-flywheel.md | 2 +- docs/cli-surface.json | 7 + docs/cli-surface.md | 1 + .../cli-command-surface-matrix.json | 2 +- .../fixtures/cli-command-surface-smoke.sh | 4 +- 9 files changed, 1044 insertions(+), 4 deletions(-) create mode 100644 cli/cmd/ao/yield_report.go create mode 100644 cli/cmd/ao/yield_report_test.go diff --git a/cli/cmd/ao/cobra_writer_isolation_test.go b/cli/cmd/ao/cobra_writer_isolation_test.go index 7d072b523..9657966cc 100644 --- a/cli/cmd/ao/cobra_writer_isolation_test.go +++ b/cli/cmd/ao/cobra_writer_isolation_test.go @@ -71,6 +71,10 @@ var writerResetHelpers = map[string]bool{ // out-writer (SetOut(nil)) — the SAME command every `ao membrane digest` test // sets — so a SetOut after it is guarded (age-xbmf). "setDigestProjectDir": true, + // setYieldReportState registers a t.Cleanup that resets yieldReportCmd's + // out/err writers — the SAME command every `ao yield report` test sets + // (age-mv67, mirroring the setDigestProjectDir precedent). + "setYieldReportState": true, } func writerResetHelperNames() []string { diff --git a/cli/cmd/ao/yield_report.go b/cli/cmd/ao/yield_report.go new file mode 100644 index 000000000..b4e84e4ce --- /dev/null +++ b/cli/cmd/ao/yield_report.go @@ -0,0 +1,618 @@ +// practices: [dora-metrics, andon-cord] +// +// `ao yield report` — the ON-THE-LOOP governance surface (age-mv67, flywheel +// epic age-36be). A returning operator reads ONE command after hours of +// autonomy — no transcript required — showing: +// +// 1. YIELD — what the loop banked: gate-verdict counts since the cutoff, +// the catches the membrane recorded (classed REFUTEs, via +// yieldledger.DetectCatches on the in-window events), and the beads the +// tracker closed in the window. +// 2. ANDON QUEUE — what the loop parked for a human: blocked beads, +// ESCALATE/HOLD pawl verdicts, and any REFUTED verdict whose bead is +// still open (a stalled slice). Each row: id, why parked, age. +// +// Doctrine: docs/architecture/the-flywheel.md — "The human moves from in the +// loop to on it": review asynchronously the yield and the andon queue, the two +// things the loop accumulates. +// +// Data sources: the yield ledger (.agents/yield/yield-ledger.jsonl, via +// cli/internal/yieldledger) and the resolved beads tracker (tracker-agnostic — +// the same resolveTracker/canonicalizeBDReadJSON internals `ao beads exec` +// uses, so bd and br both work; the canonical row shape is br's +// {issues:[...]}). Beads access degrades gracefully: a tracker failure is +// REPORTED (beads_error), never fabricated and never fatal — the operator +// still sees the ledger half of the surface. +package main + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "os/exec" + "sort" + "strings" + "text/tabwriter" + "time" + + "github.com/spf13/cobra" + + "github.com/boshu2/agentops/cli/internal/yieldledger" +) + +// yieldReportDefaultSince is the default lookback window: "read it the next +// morning" after an overnight autonomous run. +const yieldReportDefaultSince = 24 * time.Hour + +var ( + yieldReportSince string + yieldReportJSON bool +) + +// yieldReportNow is the report's clock, a seam so tests freeze window math and +// age rendering. Production never overrides it. +var yieldReportNow = time.Now + +// yieldReportListBeadsByStatus is the beads seam: list the tracker's issues +// with one status, decoded from the canonical (br-shaped) {issues:[...]} JSON. +// A package-level var so tests stub the tracker without spawning processes. +var yieldReportListBeadsByStatus = listReportBeadsByStatus + +var yieldReportCmd = &cobra.Command{ + Use: "report [--since ] [--json]", + Short: "The on-the-loop governance surface: the YIELD and the ANDON QUEUE since a cutoff", + Long: `Print what an autonomous loop did — and what it parked for you — without +reading any transcript (the on-the-loop review surface; +docs/architecture/the-flywheel.md). + +Sections: + YIELD gate-verdict counts (CONFIRMED/REFUTED/ESCALATE/HOLD) since the + cutoff, the catches recorded (classed membrane REFUTEs), and the + beads closed in the window. + ANDON QUEUE what needs a human: blocked beads, ESCALATE/HOLD pawl verdicts, + and any REFUTED verdict whose bead is still open (a stalled + slice). Each row: id, why parked, age. + +Data sources: the yield ledger (.agents/yield/yield-ledger.jsonl) and the +resolved beads tracker (bd or br — the same tracker-agnostic resolution +'ao beads exec' uses). A tracker failure is reported, never fatal: the ledger +half still prints. + +--since accepts an RFC3339 instant or a Go duration (e.g. 8h, 90m); default 24h. + + ao yield report + ao yield report --since 8h + ao yield report --since 2026-07-08T00:00:00Z --json`, + Args: cobra.NoArgs, + RunE: runYieldReport, +} + +func init() { + yieldReportCmd.Flags().StringVar(&yieldReportSince, "since", "", + "cutoff: an RFC3339 instant or a duration lookback like 8h (default 24h)") + yieldReportCmd.Flags().BoolVar(&yieldReportJSON, "json", false, + "emit the full report struct as JSON") + yieldCmd.AddCommand(yieldReportCmd) +} + +// reportBead is the subset of a canonical tracker issue row the report reads +// (br's {issues:[...]} element; bd rows are reshaped to it upstream). +type reportBead struct { + ID string `json:"id"` + Title string `json:"title"` + Status string `json:"status"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + ClosedAt string `json:"closed_at"` +} + +// yieldReportVerdicts are the gate-verdict counts in the window, by disposition. +type yieldReportVerdicts struct { + Confirmed int `json:"confirmed"` + Refuted int `json:"refuted"` + Escalate int `json:"escalate"` + Hold int `json:"hold"` +} + +// yieldReportCatch is one recorded catch class (a membrane REFUTED with real +// domain+reason) detected in the window. +type yieldReportCatch struct { + ClassKey string `json:"class_key"` + Domain string `json:"domain"` + Reason string `json:"reason"` + Hits int `json:"hits"` + Beads []string `json:"beads"` +} + +// yieldReportClosedBead is one bead the tracker closed in the window. +type yieldReportClosedBead struct { + ID string `json:"id"` + Title string `json:"title"` + ClosedAt string `json:"closed_at"` +} + +// yieldReportYield is the YIELD section: what the loop banked. +type yieldReportYield struct { + Verdicts yieldReportVerdicts `json:"verdicts"` + Catches []yieldReportCatch `json:"catches"` + ClosedBeads []yieldReportClosedBead `json:"closed_beads"` +} + +// Andon row kinds — why a row is parked. +const ( + andonKindBlocked = "blocked" + andonKindEscalate = "escalate" + andonKindHold = "hold" + andonKindStalled = "stalled" +) + +// yieldReportAndonRow is one parked item awaiting a human: the bead, why it is +// parked, and how long it has been waiting. +type yieldReportAndonRow struct { + ID string `json:"id"` + Kind string `json:"kind"` // blocked | escalate | hold | stalled + Why string `json:"why"` + Age string `json:"age"` + Since string `json:"since,omitempty"` // RFC3339 of when it parked, when known + Title string `json:"title,omitempty"` +} + +// yieldReportDoc is the full --json struct. +type yieldReportDoc struct { + Since string `json:"since"` + GeneratedAt string `json:"generated_at"` + Yield yieldReportYield `json:"yield"` + AndonQueue []yieldReportAndonRow `json:"andon_queue"` + BeadsError string `json:"beads_error,omitempty"` +} + +// runYieldReport wires the cobra invocation to the report core. +func runYieldReport(cmd *cobra.Command, _ []string) error { + root, err := resolveProjectDir() + if err != nil { + return err + } + now := yieldReportNow().UTC() + since, err := parseReportSince(yieldReportSince, now) + if err != nil { + return err + } + ledger, err := yieldledger.Load(root) + if err != nil { + return err + } + doc := buildYieldReport(ledger, root, since, now) + if yieldReportJSON { + enc := json.NewEncoder(cmd.OutOrStdout()) + enc.SetIndent("", " ") + return enc.Encode(doc) + } + return writeYieldReportText(cmd.OutOrStdout(), doc, now) +} + +// parseReportSince resolves the --since value against now: empty means the +// default 24h lookback, a Go duration (8h, 90m) means now-dur, and an RFC3339 +// instant is used as-is. Anything else — including a non-positive duration — +// is an error, never a silent default. +func parseReportSince(raw string, now time.Time) (time.Time, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return now.Add(-yieldReportDefaultSince), nil + } + if ts, err := time.Parse(time.RFC3339, raw); err == nil { + return ts.UTC(), nil + } + if d, err := time.ParseDuration(raw); err == nil { + if d <= 0 { + return time.Time{}, fmt.Errorf("--since duration must be positive, got %q", raw) + } + return now.Add(-d), nil + } + return time.Time{}, fmt.Errorf("invalid --since %q (want RFC3339 like 2026-07-08T00:00:00Z or a duration like 8h)", raw) +} + +// buildYieldReport assembles the full report document from the ledger and the +// tracker. Tracker failures degrade to BeadsError; the ledger sections always +// compute. +func buildYieldReport(ledger *yieldledger.Ledger, root string, since, now time.Time) yieldReportDoc { + doc := yieldReportDoc{ + Since: since.Format(time.RFC3339), + GeneratedAt: now.Format(time.RFC3339), + } + + window := windowedLedger(ledger, since) + doc.Yield.Verdicts = countReportVerdicts(window) + doc.Yield.Catches = reportCatches(window) + + beads, beadsErr := fetchReportBeads(root) + if beadsErr != nil { + doc.BeadsError = beadsErr.Error() + } + doc.Yield.ClosedBeads = reportClosedBeads(beads["closed"], since) + doc.AndonQueue = buildAndonQueue(window, beads, since, now) + return doc +} + +// windowedLedger returns a sub-ledger holding only the events at or after the +// cutoff, so every ledger-derived section (counts, catches) shares one filter. +func windowedLedger(l *yieldledger.Ledger, since time.Time) *yieldledger.Ledger { + out := &yieldledger.Ledger{SchemaVersion: yieldledger.SchemaVersion} + if l == nil { + return out + } + out.GeneratedAt = l.GeneratedAt + for _, ev := range l.Events { + ts, err := time.Parse(time.RFC3339, ev.TS) + if err != nil || ts.Before(since) { + continue + } + out.Events = append(out.Events, ev) + } + return out +} + +// countReportVerdicts tallies the windowed gate-verdicts by disposition. +func countReportVerdicts(window *yieldledger.Ledger) yieldReportVerdicts { + var v yieldReportVerdicts + for _, ev := range window.Events { + if ev.Event != yieldledger.EventGateVerdict || ev.GateVerdict == nil { + continue + } + switch ev.GateVerdict.Disposition { + case yieldledger.DispositionConfirmed: + v.Confirmed++ + case yieldledger.DispositionRefuted: + v.Refuted++ + case yieldledger.DispositionEscalate: + v.Escalate++ + case yieldledger.DispositionHold: + v.Hold++ + } + } + return v +} + +// reportCatches projects DetectCatches over the windowed events into the +// report's catch rows, most-hit classes first (stable tie-break by class key). +func reportCatches(window *yieldledger.Ledger) []yieldReportCatch { + catches := yieldledger.DetectCatches(window) + out := make([]yieldReportCatch, 0, len(catches)) + for _, c := range catches { + out = append(out, yieldReportCatch{ + ClassKey: c.ClassKey, + Domain: c.Domain, + Reason: c.Reason, + Hits: c.HitCount, + Beads: append([]string{}, c.Beads...), + }) + } + sort.SliceStable(out, func(i, j int) bool { + if out[i].Hits != out[j].Hits { + return out[i].Hits > out[j].Hits + } + return out[i].ClassKey < out[j].ClassKey + }) + return out +} + +// reportBeadStatuses are the tracker statuses the report queries: closed feeds +// the yield section; blocked feeds the andon queue; open + in_progress resolve +// whether a REFUTED bead is still open (stalled). +var reportBeadStatuses = []string{"closed", "blocked", "open", "in_progress"} + +// fetchReportBeads queries the tracker once per status of interest. The first +// failure aborts the fetch and is returned for honest reporting (no partial +// silent results); whatever was fetched before the failure is still returned. +func fetchReportBeads(root string) (map[string][]reportBead, error) { + out := make(map[string][]reportBead, len(reportBeadStatuses)) + for _, status := range reportBeadStatuses { + rows, err := yieldReportListBeadsByStatus(root, status) + if err != nil { + return out, fmt.Errorf("beads list --status %s: %w", status, err) + } + out[status] = rows + } + return out, nil +} + +// reportClosedBeads filters the closed rows to those closed at/after the +// cutoff (closed_at, falling back to updated_at for trackers that omit it), +// most recent first. +func reportClosedBeads(closed []reportBead, since time.Time) []yieldReportClosedBead { + out := []yieldReportClosedBead{} + for _, b := range closed { + closedAt := firstParseableTime(b.ClosedAt, b.UpdatedAt) + if closedAt.IsZero() || closedAt.Before(since) { + continue + } + out = append(out, yieldReportClosedBead{ + ID: b.ID, + Title: b.Title, + ClosedAt: closedAt.UTC().Format(time.RFC3339), + }) + } + sort.SliceStable(out, func(i, j int) bool { return out[i].ClosedAt > out[j].ClosedAt }) + return out +} + +// buildAndonQueue assembles the parked-for-a-human rows from the three +// sources, deduped per bead (blocked wins over a verdict row, ESCALATE/HOLD +// wins over stalled), oldest-parked first. +func buildAndonQueue(window *yieldledger.Ledger, beads map[string][]reportBead, since, now time.Time) []yieldReportAndonRow { + rows := []yieldReportAndonRow{} + seen := map[string]bool{} + add := func(row yieldReportAndonRow) { + if row.ID == "" || seen[row.ID] { + return + } + seen[row.ID] = true + rows = append(rows, row) + } + + // 1) Blocked beads — parked regardless of when (a queue, not a window). + for _, b := range beads["blocked"] { + parkedAt := firstParseableTime(b.UpdatedAt, b.CreatedAt) + add(yieldReportAndonRow{ + ID: b.ID, + Kind: andonKindBlocked, + Why: "bead blocked — needs unblocking", + Age: fmtReportAge(now.Sub(parkedAt)), + Since: rfc3339OrEmpty(parkedAt), + Title: b.Title, + }) + } + + // 2) ESCALATE / HOLD verdicts in the window — the loop asked for a human. + // 3) REFUTED verdicts whose bead is still open — a stalled slice. + titles := beadTitleIndex(beads) + stillOpen := beadStatusSet(beads, "open", "in_progress") + for _, v := range latestVerdictPerBead(window) { + ts, _ := time.Parse(time.RFC3339, v.ts) + switch v.disposition { + case yieldledger.DispositionEscalate: + add(yieldReportAndonRow{ + ID: v.bead, Kind: andonKindEscalate, + Why: "pawl ESCALATE — awaiting human decision", + Age: fmtReportAge(now.Sub(ts)), Since: rfc3339OrEmpty(ts), Title: titles[v.bead], + }) + case yieldledger.DispositionHold: + add(yieldReportAndonRow{ + ID: v.bead, Kind: andonKindHold, + Why: "pawl HOLD — held for a human", + Age: fmtReportAge(now.Sub(ts)), Since: rfc3339OrEmpty(ts), Title: titles[v.bead], + }) + case yieldledger.DispositionRefuted: + if !stillOpen[v.bead] { + continue + } + add(yieldReportAndonRow{ + ID: v.bead, Kind: andonKindStalled, + Why: "REFUTED, bead still open — stalled slice", + Age: fmtReportAge(now.Sub(ts)), Since: rfc3339OrEmpty(ts), Title: titles[v.bead], + }) + } + } + + sort.SliceStable(rows, func(i, j int) bool { + // Oldest parked first: an empty Since (unknown) sorts last. + si, sj := rows[i].Since, rows[j].Since + if (si == "") != (sj == "") { + return sj == "" + } + if si != sj { + return si < sj + } + return rows[i].ID < rows[j].ID + }) + return rows +} + +// beadVerdict is the latest-verdict projection buildAndonQueue reads. +type beadVerdict struct { + bead string + disposition string + ts string +} + +// latestVerdictPerBead collapses the windowed gate-verdicts to the LATEST one +// per bead (append order — review rounds supersede), preserving first-seen bead +// order for determinism. +func latestVerdictPerBead(window *yieldledger.Ledger) []beadVerdict { + byBead := map[string]*beadVerdict{} + order := []string{} + for _, ev := range window.Events { + if ev.Event != yieldledger.EventGateVerdict || ev.GateVerdict == nil { + continue + } + v, ok := byBead[ev.BeadID] + if !ok { + v = &beadVerdict{bead: ev.BeadID} + byBead[ev.BeadID] = v + order = append(order, ev.BeadID) + } + v.disposition = ev.GateVerdict.Disposition + v.ts = ev.TS + } + out := make([]beadVerdict, 0, len(order)) + for _, id := range order { + out = append(out, *byBead[id]) + } + return out +} + +// beadTitleIndex maps bead id -> title across every fetched status list. +func beadTitleIndex(beads map[string][]reportBead) map[string]string { + out := map[string]string{} + for _, rows := range beads { + for _, b := range rows { + if b.Title != "" { + out[b.ID] = b.Title + } + } + } + return out +} + +// beadStatusSet returns the set of bead ids present in any of the named status +// lists. +func beadStatusSet(beads map[string][]reportBead, statuses ...string) map[string]bool { + out := map[string]bool{} + for _, s := range statuses { + for _, b := range beads[s] { + out[b.ID] = true + } + } + return out +} + +// firstParseableTime returns the first candidate that parses as RFC3339, or the +// zero time. +func firstParseableTime(candidates ...string) time.Time { + for _, c := range candidates { + if strings.TrimSpace(c) == "" { + continue + } + if ts, err := time.Parse(time.RFC3339, c); err == nil { + return ts + } + } + return time.Time{} +} + +// rfc3339OrEmpty formats a time, rendering the zero time (unknown) as "". +func rfc3339OrEmpty(t time.Time) string { + if t.IsZero() { + return "" + } + return t.UTC().Format(time.RFC3339) +} + +// fmtReportAge renders a parked-duration compactly: minutes under an hour, +// hours under two days, whole days beyond. Negative (clock skew) and unknown +// clamp to "0m". +func fmtReportAge(d time.Duration) string { + if d < 0 { + d = 0 + } + switch { + case d < time.Hour: + return fmt.Sprintf("%dm", int(d.Minutes())) + case d < 48*time.Hour: + return fmt.Sprintf("%dh", int(d.Hours())) + default: + return fmt.Sprintf("%dd", int(d.Hours()/24)) + } +} + +// writeYieldReportText renders the two-section human report with honest +// empty-states — a blank section is never printed as silence. +func writeYieldReportText(out io.Writer, doc yieldReportDoc, now time.Time) error { + fmt.Fprintf(out, "Yield report — since %s (generated %s)\n\n", doc.Since, doc.GeneratedAt) + + fmt.Fprintln(out, "YIELD — what the loop banked") + v := doc.Yield.Verdicts + if v.Confirmed+v.Refuted+v.Escalate+v.Hold == 0 { + fmt.Fprintln(out, " verdicts: none in window") + } else { + fmt.Fprintf(out, " verdicts: %d CONFIRMED · %d REFUTED · %d ESCALATE · %d HOLD\n", + v.Confirmed, v.Refuted, v.Escalate, v.Hold) + } + + if len(doc.Yield.Catches) == 0 { + fmt.Fprintln(out, " catches: none") + } else { + fmt.Fprintf(out, " catches (classed membrane REFUTEs): %d\n", len(doc.Yield.Catches)) + tw := tabwriter.NewWriter(out, 0, 4, 2, ' ', 0) + fmt.Fprintln(tw, " HITS\tDOMAIN\tREASON\tBEADS") + for _, c := range doc.Yield.Catches { + fmt.Fprintf(tw, " %d\t%s\t%s\t%s\n", + c.Hits, c.Domain, truncateReportText(c.Reason, 60), strings.Join(c.Beads, ",")) + } + if err := tw.Flush(); err != nil { + return err + } + } + + if len(doc.Yield.ClosedBeads) == 0 { + fmt.Fprintln(out, " beads closed: none") + } else { + fmt.Fprintf(out, " beads closed: %d\n", len(doc.Yield.ClosedBeads)) + tw := tabwriter.NewWriter(out, 0, 4, 2, ' ', 0) + fmt.Fprintln(tw, " ID\tCLOSED\tTITLE") + for _, b := range doc.Yield.ClosedBeads { + fmt.Fprintf(tw, " %s\t%s\t%s\n", b.ID, b.ClosedAt, truncateReportText(b.Title, 60)) + } + if err := tw.Flush(); err != nil { + return err + } + } + + fmt.Fprintln(out, "\nANDON QUEUE — what the loop parked for you") + if doc.BeadsError != "" { + fmt.Fprintf(out, " ⚠ beads unavailable: %s (queue may be incomplete)\n", doc.BeadsError) + } + if len(doc.AndonQueue) == 0 { + fmt.Fprintln(out, " andon queue: empty — nothing parked") + return nil + } + tw := tabwriter.NewWriter(out, 0, 4, 2, ' ', 0) + fmt.Fprintln(tw, " ID\tWHY\tAGE\tTITLE") + for _, r := range doc.AndonQueue { + fmt.Fprintf(tw, " %s\t%s\t%s\t%s\n", r.ID, r.Why, r.Age, truncateReportText(r.Title, 48)) + } + return tw.Flush() +} + +// truncateReportText caps s at max runes with an ellipsis so table rows stay +// scannable. +func truncateReportText(s string, max int) string { + s = strings.TrimSpace(s) + r := []rune(s) + if len(r) <= max { + return s + } + return string(r[:max-1]) + "…" +} + +// listReportBeadsByStatus is the production beads seam: it resolves the +// tracker exactly as `ao beads exec` does, runs ` list --json +// --status `, reshapes a bd payload to the canonical br +// {issues:[...]} envelope, and decodes the rows. +func listReportBeadsByStatus(cwd, status string) ([]reportBead, error) { + res, err := resolveTracker(cwd, os.Environ()) + if err != nil { + return nil, err + } + args := []string{"list", "--json", "--status", status} + c := exec.Command(res.Binary, args...) // #nosec G204 -- res.Binary is resolved by resolveTracker (bd|br); args are a fixed read-only list query. + c.Env = beadsExecChildEnv(res, cwd) + c.Dir = beadsExecChildDir(res, cwd) + var stdout, stderr bytes.Buffer + c.Stdout = &stdout + c.Stderr = &stderr + if err := c.Run(); err != nil { + var exitErr *exec.ExitError + if msg := strings.TrimSpace(stderr.String()); msg != "" && errors.As(err, &exitErr) { + return nil, fmt.Errorf("%s list --json --status %s: %s", res.Tracker, status, msg) + } + return nil, fmt.Errorf("%s list --json --status %s: %w", res.Tracker, status, err) + } + raw := stdout.Bytes() + if res.Tracker == trackerBD { + canonical, cerr := canonicalizeBDReadJSON("list", raw) + if cerr != nil { + return nil, fmt.Errorf("reshape bd list --json: %w", cerr) + } + raw = canonical + } + var envelope struct { + Issues []reportBead `json:"issues"` + } + if err := json.Unmarshal(bytes.TrimSpace(raw), &envelope); err != nil { + return nil, fmt.Errorf("parse %s list --json: %w", res.Tracker, err) + } + return envelope.Issues, nil +} diff --git a/cli/cmd/ao/yield_report_test.go b/cli/cmd/ao/yield_report_test.go new file mode 100644 index 000000000..731802099 --- /dev/null +++ b/cli/cmd/ao/yield_report_test.go @@ -0,0 +1,394 @@ +// Tests for `ao yield report` — the on-the-loop governance surface (age-mv67). +// +// Executed-red TDD: seeded ledger (via the production yieldledger.Writer, per the +// fixture-fidelity rule in .claude/rules/go.md) + a stubbed beads seam +// (yieldReportListBeadsByStatus) → assert section counts, andon rows, honest +// empty-states, and the --json shape. +package main + +import ( + "bytes" + "encoding/json" + "strings" + "testing" + "time" + + "github.com/boshu2/agentops/cli/internal/yieldledger" +) + +// reportTestNow is the frozen clock every `ao yield report` test runs under so +// window math (default --since 24h) and age strings are deterministic. +var reportTestNow = time.Date(2026, 7, 9, 12, 0, 0, 0, time.UTC) + +// setYieldReportState points the report command at root, freezes its clock, +// stubs the beads seam to empty, and restores ALL shared package globals — +// including the shared cobra command's out/err writers — via t.Cleanup +// (.claude/rules/go.md test-isolation rule; mirrors setDigestProjectDir). +func setYieldReportState(t *testing.T, root string) { + t.Helper() + origProjectDir := testProjectDir + testProjectDir = root + origSince, origJSON := yieldReportSince, yieldReportJSON + yieldReportSince, yieldReportJSON = "", false + origList := yieldReportListBeadsByStatus + yieldReportListBeadsByStatus = func(cwd, status string) ([]reportBead, error) { + return nil, nil + } + origNow := yieldReportNow + yieldReportNow = func() time.Time { return reportTestNow } + t.Cleanup(func() { + testProjectDir = origProjectDir + yieldReportSince, yieldReportJSON = origSince, origJSON + yieldReportListBeadsByStatus = origList + yieldReportNow = origNow + yieldReportCmd.SetOut(nil) + yieldReportCmd.SetErr(nil) + }) +} + +// stubReportBeads points the beads seam at a canned per-status map (the canonical +// br {issues:[...]} elements, already decoded). Restore is registered by +// setYieldReportState. +func stubReportBeads(t *testing.T, byStatus map[string][]reportBead) { + t.Helper() + yieldReportListBeadsByStatus = func(cwd, status string) ([]reportBead, error) { + return byStatus[status], nil + } +} + +// seedReportVerdict appends one gate-verdict through the production writer so +// the fixture is the real persisted shape. +func seedReportVerdict(t *testing.T, root, bead, disposition, domain, reason string, ts time.Time) { + t.Helper() + w := yieldledger.Writer{} + if _, err := w.AppendGateVerdict(root, yieldledger.GateVerdictInput{ + BeadID: bead, RunID: "run-report-test", TS: ts, + Difficulty: 1, + PawlVerdictRef: yieldledger.PawlVerdictRef{BeadID: bead, HeadSHA: "abcdef0"}, + Disposition: disposition, HeadSHA: "abcdef0", Attempt: 1, + AuthorContextID: "ctx-report-test", AuthorFamily: "claude", + Domain: domain, Reason: reason, + }); err != nil { + t.Fatalf("seedReportVerdict(%s %s): %v", bead, disposition, err) + } +} + +// runReport executes runYieldReport on the shared command and returns stdout. +func runReport(t *testing.T) string { + t.Helper() + var buf bytes.Buffer + yieldReportCmd.SetOut(&buf) + t.Cleanup(func() { yieldReportCmd.SetOut(nil) }) // age-ztf8: shared cobra command — reset at the set-site + if err := runYieldReport(yieldReportCmd, nil); err != nil { + t.Fatalf("runYieldReport: %v", err) + } + return buf.String() +} + +// decodeReport executes the command with --json and unmarshals the full struct. +func decodeReport(t *testing.T) yieldReportDoc { + t.Helper() + yieldReportJSON = true + out := runReport(t) + var doc yieldReportDoc + if err := json.Unmarshal([]byte(out), &doc); err != nil { + t.Fatalf("unmarshal report JSON: %v\noutput:\n%s", err, out) + } + return doc +} + +// TestRunYieldReport_VerdictCountsSinceCutoff seeds verdicts inside and outside +// the default 24h window and asserts only the in-window ones are counted, per +// disposition. +func TestRunYieldReport_VerdictCountsSinceCutoff(t *testing.T) { + root := t.TempDir() + setYieldReportState(t, root) + + in := reportTestNow.Add(-2 * time.Hour) + old := reportTestNow.Add(-30 * time.Hour) // before the 24h cutoff + seedReportVerdict(t, root, "age-a", yieldledger.DispositionConfirmed, "", "", in) + seedReportVerdict(t, root, "age-b", yieldledger.DispositionConfirmed, "", "", in) + seedReportVerdict(t, root, "age-c", yieldledger.DispositionRefuted, "go", "missing cleanup restore of shared global", in) + seedReportVerdict(t, root, "age-d", yieldledger.DispositionEscalate, "", "", in) + seedReportVerdict(t, root, "age-old", yieldledger.DispositionConfirmed, "", "", old) + seedReportVerdict(t, root, "age-old2", yieldledger.DispositionHold, "", "", old) + + doc := decodeReport(t) + v := doc.Yield.Verdicts + if v.Confirmed != 2 || v.Refuted != 1 || v.Escalate != 1 || v.Hold != 0 { + t.Errorf("verdict counts = C%d R%d E%d H%d, want C2 R1 E1 H0", v.Confirmed, v.Refuted, v.Escalate, v.Hold) + } + if doc.Since != reportTestNow.Add(-24*time.Hour).Format(time.RFC3339) { + t.Errorf("default since = %q, want now-24h", doc.Since) + } +} + +// TestRunYieldReport_CatchesSinceCutoff asserts catches are detected from +// in-window classifiable REFUTEs only, with class fields carried through. +func TestRunYieldReport_CatchesSinceCutoff(t *testing.T) { + root := t.TempDir() + setYieldReportState(t, root) + + in := reportTestNow.Add(-3 * time.Hour) + old := reportTestNow.Add(-72 * time.Hour) + seedReportVerdict(t, root, "age-1", yieldledger.DispositionRefuted, "shell", "unguarded cmdsub aborts under set -e", in) + seedReportVerdict(t, root, "age-2", yieldledger.DispositionRefuted, "shell", "unguarded cmdsub aborts under set -e", in) + seedReportVerdict(t, root, "age-3", yieldledger.DispositionRefuted, "docs", "stale retired surface in shipped docs", old) + + doc := decodeReport(t) + if len(doc.Yield.Catches) != 1 { + t.Fatalf("catches = %d, want exactly 1 (the out-of-window class must be excluded); got %+v", + len(doc.Yield.Catches), doc.Yield.Catches) + } + c := doc.Yield.Catches[0] + if c.Domain != "shell" || c.Hits != 2 { + t.Errorf("catch = domain %q hits %d, want shell/2", c.Domain, c.Hits) + } + if len(c.Beads) != 2 { + t.Errorf("catch beads = %v, want the 2 distinct beads", c.Beads) + } + if c.Reason != "unguarded cmdsub aborts under set -e" { + t.Errorf("catch reason = %q", c.Reason) + } +} + +// TestRunYieldReport_ClosedBeadsWindow asserts the closed-beads section filters +// by closed_at against the cutoff and falls back to updated_at when closed_at is +// absent (tracker-agnostic: bd may omit it). +func TestRunYieldReport_ClosedBeadsWindow(t *testing.T) { + root := t.TempDir() + setYieldReportState(t, root) + + inWin := reportTestNow.Add(-1 * time.Hour).Format(time.RFC3339) + outWin := reportTestNow.Add(-50 * time.Hour).Format(time.RFC3339) + stubReportBeads(t, map[string][]reportBead{ + "closed": { + {ID: "age-new", Title: "landed increment", Status: "closed", ClosedAt: inWin}, + {ID: "age-ancient", Title: "old work", Status: "closed", ClosedAt: outWin}, + {ID: "age-fallback", Title: "closed_at-less tracker row", Status: "closed", UpdatedAt: inWin}, + }, + }) + + doc := decodeReport(t) + got := map[string]bool{} + for _, b := range doc.Yield.ClosedBeads { + got[b.ID] = true + } + if !got["age-new"] || !got["age-fallback"] || got["age-ancient"] { + t.Errorf("closed beads = %+v, want age-new + age-fallback only", doc.Yield.ClosedBeads) + } + if len(doc.Yield.ClosedBeads) != 2 { + t.Errorf("closed beads count = %d, want 2", len(doc.Yield.ClosedBeads)) + } +} + +// TestRunYieldReport_AndonRows asserts the three andon sources land as rows — +// blocked beads, ESCALATE/HOLD verdicts, and a REFUTED verdict whose bead is +// still open (a stalled slice) — that a REFUTED bead already closed is NOT +// parked, and that a bead is never listed twice (blocked wins over stalled). +func TestRunYieldReport_AndonRows(t *testing.T) { + root := t.TempDir() + setYieldReportState(t, root) + + in := reportTestNow.Add(-5 * time.Hour) + seedReportVerdict(t, root, "age-esc", yieldledger.DispositionEscalate, "", "", in) + seedReportVerdict(t, root, "age-hold", yieldledger.DispositionHold, "", "", in) + seedReportVerdict(t, root, "age-stall", yieldledger.DispositionRefuted, "go", "acceptance not proven", in) + seedReportVerdict(t, root, "age-done", yieldledger.DispositionRefuted, "go", "fixed then landed", in) + seedReportVerdict(t, root, "age-both", yieldledger.DispositionRefuted, "go", "refuted then blocked", in) + + parkedAt := reportTestNow.Add(-26 * time.Hour).Format(time.RFC3339) + stubReportBeads(t, map[string][]reportBead{ + "blocked": { + {ID: "age-blk", Title: "needs a credential decision", Status: "blocked", UpdatedAt: parkedAt}, + {ID: "age-both", Title: "refuted then blocked", Status: "blocked", UpdatedAt: parkedAt}, + }, + "open": { + {ID: "age-stall", Title: "stalled slice", Status: "open", UpdatedAt: parkedAt}, + }, + "closed": { + {ID: "age-done", Title: "closed after fix", Status: "closed", ClosedAt: parkedAt}, + }, + }) + + doc := decodeReport(t) + kinds := map[string]string{} + for _, row := range doc.AndonQueue { + if prev, dup := kinds[row.ID]; dup { + t.Errorf("bead %s parked twice (%s and %s) — andon rows must dedup", row.ID, prev, row.Kind) + } + kinds[row.ID] = row.Kind + } + want := map[string]string{ + "age-blk": "blocked", + "age-both": "blocked", // blocked wins over stalled + "age-esc": "escalate", + "age-hold": "hold", + "age-stall": "stalled", + } + for id, kind := range want { + if kinds[id] != kind { + t.Errorf("andon[%s] kind = %q, want %q (rows: %+v)", id, kinds[id], kind, doc.AndonQueue) + } + } + if _, parked := kinds["age-done"]; parked { + t.Errorf("age-done is closed — a REFUTED verdict on a closed bead must NOT be parked") + } + if len(doc.AndonQueue) != len(want) { + t.Errorf("andon rows = %d, want %d: %+v", len(doc.AndonQueue), len(want), doc.AndonQueue) + } + // Each row carries why + age. + for _, row := range doc.AndonQueue { + if strings.TrimSpace(row.Why) == "" || strings.TrimSpace(row.Age) == "" { + t.Errorf("andon row %+v missing why/age", row) + } + } +} + +// TestRunYieldReport_TextSections asserts the plain-text rendering carries both +// section headers, the verdict counts line, and the andon table rows. +func TestRunYieldReport_TextSections(t *testing.T) { + root := t.TempDir() + setYieldReportState(t, root) + + in := reportTestNow.Add(-2 * time.Hour) + seedReportVerdict(t, root, "age-a", yieldledger.DispositionConfirmed, "", "", in) + seedReportVerdict(t, root, "age-b", yieldledger.DispositionRefuted, "go", "assertion missing", in) + stubReportBeads(t, map[string][]reportBead{ + "closed": {{ID: "age-c", Title: "shipped thing", Status: "closed", + ClosedAt: reportTestNow.Add(-1 * time.Hour).Format(time.RFC3339)}}, + "blocked": {{ID: "age-p", Title: "parked on human", Status: "blocked", + UpdatedAt: reportTestNow.Add(-3 * time.Hour).Format(time.RFC3339)}}, + "open": {{ID: "age-b", Title: "still open", Status: "open"}}, + }) + + out := runReport(t) + for _, want := range []string{ + "YIELD", + "ANDON QUEUE", + "1 CONFIRMED", + "1 REFUTED", + "beads closed: 1", + "age-c", + "age-p", + "blocked", + "age-b", + "stalled", + } { + if !strings.Contains(out, want) { + t.Errorf("text report missing %q; output:\n%s", want, out) + } + } +} + +// TestRunYieldReport_EmptyStates asserts honest empty-states: an empty ledger and +// empty tracker produce explicit "none"/"empty" wording, never blank sections. +func TestRunYieldReport_EmptyStates(t *testing.T) { + root := t.TempDir() + setYieldReportState(t, root) + + out := runReport(t) + for _, want := range []string{ + "verdicts: none in window", + "catches: none", + "beads closed: none", + "andon queue: empty — nothing parked", + } { + if !strings.Contains(out, want) { + t.Errorf("empty-state %q missing; output:\n%s", want, out) + } + } + + // JSON shape stays structured on empty: zero counts, empty (non-null) arrays. + doc := decodeReport(t) + if doc.Yield.Verdicts.Confirmed != 0 || doc.Yield.Verdicts.Refuted != 0 { + t.Errorf("empty report verdicts = %+v, want zeros", doc.Yield.Verdicts) + } + if doc.Yield.Catches == nil || doc.Yield.ClosedBeads == nil || doc.AndonQueue == nil { + t.Errorf("empty report arrays must be [] not null: %+v", doc) + } +} + +// TestRunYieldReport_SinceFlag asserts --since accepts a duration and an RFC3339 +// instant, and rejects junk. +func TestRunYieldReport_SinceFlag(t *testing.T) { + root := t.TempDir() + setYieldReportState(t, root) + seedReportVerdict(t, root, "age-a", yieldledger.DispositionConfirmed, "", "", + reportTestNow.Add(-30*time.Hour)) + + // 48h window catches the 30h-old verdict the default 24h window would drop. + yieldReportSince = "48h" + doc := decodeReport(t) + if doc.Yield.Verdicts.Confirmed != 1 { + t.Errorf("--since 48h confirmed = %d, want 1", doc.Yield.Verdicts.Confirmed) + } + + yieldReportSince = reportTestNow.Add(-31 * time.Hour).Format(time.RFC3339) + doc = decodeReport(t) + if doc.Yield.Verdicts.Confirmed != 1 { + t.Errorf("--since confirmed = %d, want 1", doc.Yield.Verdicts.Confirmed) + } + + yieldReportSince = "not-a-window" + yieldReportCmd.SetOut(&bytes.Buffer{}) + t.Cleanup(func() { yieldReportCmd.SetOut(nil) }) // age-ztf8: shared cobra command — reset at the set-site + if err := runYieldReport(yieldReportCmd, nil); err == nil { + t.Errorf("junk --since must error") + } +} + +// TestParseReportSince is the table-driven L1 for the window parser. +func TestParseReportSince(t *testing.T) { + now := reportTestNow + cases := []struct { + name string + raw string + want time.Time + wantErr bool + }{ + {name: "default 24h", raw: "", want: now.Add(-24 * time.Hour)}, + {name: "duration", raw: "8h", want: now.Add(-8 * time.Hour)}, + {name: "minutes", raw: "90m", want: now.Add(-90 * time.Minute)}, + {name: "rfc3339", raw: "2026-07-08T00:00:00Z", want: time.Date(2026, 7, 8, 0, 0, 0, 0, time.UTC)}, + {name: "junk", raw: "yesterdayish", wantErr: true}, + {name: "negative duration", raw: "-4h", wantErr: true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := parseReportSince(tc.raw, now) + if tc.wantErr { + if err == nil { + t.Fatalf("parseReportSince(%q) = %v, want error", tc.raw, got) + } + return + } + if err != nil { + t.Fatalf("parseReportSince(%q): %v", tc.raw, err) + } + if !got.Equal(tc.want) { + t.Errorf("parseReportSince(%q) = %v, want %v", tc.raw, got, tc.want) + } + }) + } +} + +// TestFmtReportAge pins the compact age rendering the andon table uses. +func TestFmtReportAge(t *testing.T) { + cases := []struct { + d time.Duration + want string + }{ + {30 * time.Second, "0m"}, + {45 * time.Minute, "45m"}, + {5 * time.Hour, "5h"}, + {26 * time.Hour, "26h"}, + {72 * time.Hour, "3d"}, + {-time.Hour, "0m"}, // clock skew never renders negative + } + for _, tc := range cases { + if got := fmtReportAge(tc.d); got != tc.want { + t.Errorf("fmtReportAge(%v) = %q, want %q", tc.d, got, tc.want) + } + } +} diff --git a/cli/docs/COMMANDS.md b/cli/docs/COMMANDS.md index 8fc6ead5f..163f17e6a 100644 --- a/cli/docs/COMMANDS.md +++ b/cli/docs/COMMANDS.md @@ -4432,6 +4432,22 @@ ao yield gauge --run [--json] [--c-delta ] [flags] --run string factory run/cycle id to compute gauges for (required) ``` +#### `ao yield report` + +Print what an autonomous loop did — and what it parked for you — without + +``` +ao yield report [--since ] [--json] [flags] +``` + +**Flags:** + +``` + -h, --help help for report + --json emit the full report struct as JSON + --since string cutoff: an RFC3339 instant or a duration lookback like 8h (default 24h) +``` + #### `ao yield tokens` Parse a Claude Code or Codex session transcript and sum the real token diff --git a/docs/architecture/the-flywheel.md b/docs/architecture/the-flywheel.md index 9cb37c1b6..62176b6b3 100644 --- a/docs/architecture/the-flywheel.md +++ b/docs/architecture/the-flywheel.md @@ -150,7 +150,7 @@ a defect class *before* it reaches the pawl. Detail + honest status: | Small-batch-by-Gherkin enforcement | **specced** | `age-74yi` | | Goal-crafting skill | **specced** | `age-znst` | | Close-time learning checkpoint | **landed** | crank Land Loop "Close checkpoint" + implement close rule (`age-cysr`) | -| Async governance surface (yield + andon) | **specced** | `age-mv67` | +| Async governance surface (yield + andon) | **landed** | `ao yield report` (`age-mv67`) | | gc as autonomous substrate | **exists**, composition proven | the gc adoption arc | This doc will be wrong the moment a slice teaches us something — which is the point. It is a diff --git a/docs/cli-surface.json b/docs/cli-surface.json index fab9b8471..dc306638e 100644 --- a/docs/cli-surface.json +++ b/docs/cli-surface.json @@ -1800,6 +1800,13 @@ "kind": "leaf", "reason": "Covered by release smoke tests, direct command tests, or command handler tests." }, + { + "category": "public-tested", + "command": "yield report", + "coverage_status": "covered", + "kind": "leaf", + "reason": "Covered by release smoke tests, direct command tests, or command handler tests." + }, { "category": "public-tested", "command": "yield tokens", diff --git a/docs/cli-surface.md b/docs/cli-surface.md index 888e011ae..f8500aa0e 100644 --- a/docs/cli-surface.md +++ b/docs/cli-surface.md @@ -261,4 +261,5 @@ | `ao worktree gc` | `public-tested` | `covered` | Covered by release smoke tests, direct command tests, or command handler tests. | | `ao yield emit` | `public-stateful-fixture-needed` | `allowlisted` | Parent of accept/gate-verdict/usage; writes the yield ledger and needs a bead+run fixture. Pre-existing gap surfaced by an unrelated cli/cmd/ao change (ag-62jrm). | | `ao yield gauge` | `public-tested` | `covered` | Covered by release smoke tests, direct command tests, or command handler tests. | +| `ao yield report` | `public-tested` | `covered` | Covered by release smoke tests, direct command tests, or command handler tests. | | `ao yield tokens` | `public-tested` | `covered` | Covered by release smoke tests, direct command tests, or command handler tests. | diff --git a/evals/agentops-core/cli-command-surface-matrix.json b/evals/agentops-core/cli-command-surface-matrix.json index e03ed022e..11ebc8392 100644 --- a/evals/agentops-core/cli-command-surface-matrix.json +++ b/evals/agentops-core/cli-command-surface-matrix.json @@ -41,7 +41,7 @@ }, "expectations": [ {"type": "exit_code", "value": 0}, - {"type": "stdout_contains", "value": "cli-command-headings: top=74 sub=201 all=275"}, + {"type": "stdout_contains", "value": "cli-command-headings: top=74 sub=202 all=276"}, {"type": "stdout_contains", "value": "cli-help-matrix-ok"} ], "dimensions": ["correctness", "runtime_compatibility", "artifact_quality"], diff --git a/evals/agentops-core/fixtures/cli-command-surface-smoke.sh b/evals/agentops-core/fixtures/cli-command-surface-smoke.sh index 6459b7e2c..893c3a37f 100755 --- a/evals/agentops-core/fixtures/cli-command-surface-smoke.sh +++ b/evals/agentops-core/fixtures/cli-command-surface-smoke.sh @@ -17,7 +17,7 @@ top_count="$(rg -c '^### `ao ' "$DOCS_PATH")" sub_count="$(rg -c '^#### `ao ' "$DOCS_PATH")" all_count="$(rg -c '^#{3,4} `ao ' "$DOCS_PATH")" -if [[ "$top_count" != "74" || "$sub_count" != "201" || "$all_count" != "275" ]]; then +if [[ "$top_count" != "74" || "$sub_count" != "202" || "$all_count" != "276" ]]; then printf 'unexpected command heading counts: top=%s sub=%s all=%s\n' "$top_count" "$sub_count" "$all_count" >&2 exit 1 fi @@ -25,7 +25,7 @@ fi # shellcheck disable=SC2016 # literal backticks delimit generated Markdown command headings. mapfile -t commands < <(rg '^#{3,4} `ao ' "$DOCS_PATH" | sed -E 's/^.*`([^`]+)`.*/\1/') -if [[ "${#commands[@]}" -ne 275 ]]; then +if [[ "${#commands[@]}" -ne 276 ]]; then printf 'unexpected command matrix size: %s\n' "${#commands[@]}" >&2 exit 1 fi