feat(yield): ao yield report — the on-the-loop governance surface (yield + andon queue) (age-mv67)

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.
This commit is contained in:
boshu
2026-07-09 15:03:50 -04:00
parent 125d351410
commit 5c96a49e64
9 changed files with 1044 additions and 4 deletions
@@ -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 {
+618
View File
@@ -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 <RFC3339|duration>] [--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 `<tracker> list --json
// --status <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
}
+394
View File
@@ -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 <RFC3339> 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)
}
}
}
+16
View File
@@ -4432,6 +4432,22 @@ ao yield gauge --run <id> [--json] [--c-delta <float>] [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 <RFC3339|duration>] [--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
+1 -1
View File
@@ -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
+7
View File
@@ -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",
+1
View File
@@ -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. |
@@ -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"],
@@ -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