diff --git a/cli/cmd/ao/cobra_commands_test.go b/cli/cmd/ao/cobra_commands_test.go index e1b53bd3c..a6f017b80 100644 --- a/cli/cmd/ao/cobra_commands_test.go +++ b/cli/cmd/ao/cobra_commands_test.go @@ -446,7 +446,7 @@ func TestCobraCommandTreeRegistration(t *testing.T) { // Verify all top-level commands are registered (flat namespace) expectedCmds := []string{ "agents", "anti-patterns", "autodev", "badge", "batch-feedback", "beads", "capabilities", "ci", "citation", "claim", "completion", "config", - "constraint", "context", "codex", "compile", "contradict", "corpus", "curate", "dedup", + "constraint", "context", "codex", "compile", "contradict", "corpus", "cron", "curate", "dedup", "daemon", "defrag", "demo", "doctor", "eval", "evolve", "extract", "factory", "feedback", "feedback-loop", "findings", "flywheel", "forge", "gate", "goals", "handoff", "harness", "harvest", "hooks", "index", "init", "inject", "knowledge", "lookup", "loop", "maturity", @@ -481,6 +481,7 @@ func TestCobraCommandTreeRegistration(t *testing.T) { "flywheel": {"status", "nudge", "gate", "compare", "close-loop"}, "constraint": {"activate", "retire", "review", "list"}, "corpus": {"fitness"}, + "cron": {"self-adjust"}, "patterns": {"repair-filenames"}, "pool": {"list", "ingest"}, "store": {"rebuild", "search"}, @@ -507,7 +508,7 @@ func TestCobraExpectedCmdsMatchRegistration(t *testing.T) { // Same list as TestCobraCommandTreeRegistration expectedCmds := []string{ "agents", "anti-patterns", "autodev", "badge", "batch-feedback", "beads", "capabilities", "ci", "citation", "claim", "completion", "config", - "constraint", "context", "codex", "compile", "contradict", "corpus", "curate", "dedup", + "constraint", "context", "codex", "compile", "contradict", "corpus", "cron", "curate", "dedup", "daemon", "defrag", "demo", "doctor", "eval", "evolve", "extract", "factory", "feedback", "feedback-loop", "findings", "flywheel", "forge", "gate", "goals", "handoff", "harness", "harvest", "hooks", "index", "init", "inject", "knowledge", "lookup", "loop", "maturity", diff --git a/cli/cmd/ao/cron.go b/cli/cmd/ao/cron.go new file mode 100644 index 000000000..e5dc57eda --- /dev/null +++ b/cli/cmd/ao/cron.go @@ -0,0 +1,31 @@ +// practices: [dora-metrics, lean-startup] +package main + +import ( + "github.com/spf13/cobra" +) + +// cronCmd is the parent command for cron-loop helpers used by the /evolve +// loop's cron-fire continuity primitive. Today it carries `self-adjust` +// (soc-un0m); future subcommands belong on this same parent so the operator +// surface stays consistent ("manage the cron contract"). +var cronCmd = &cobra.Command{ + Use: "cron", + Short: "Cron-fire loop helpers (used by /evolve --mode=loop)", + Long: `Helpers for the /evolve --mode=loop cron-fire continuity primitive. + +The /evolve loop runs as a recurring cron-fire that the agent re-arms each +cycle. These subcommands are the mechanical surfaces the agent calls to +participate in that contract: + + ao cron self-adjust ... Render the next cycle's cron prompt from the + versioned template + last-cycle context, and emit + a JSON spec the harness uses to re-arm the cron. + +See docs/plans/2026-05-21-evolve-loop-epic-design.md §A4 for the full design.`, +} + +func init() { + cronCmd.GroupID = "workflow" + rootCmd.AddCommand(cronCmd) +} diff --git a/cli/cmd/ao/cron_self_adjust.go b/cli/cmd/ao/cron_self_adjust.go new file mode 100644 index 000000000..0397b9c3c --- /dev/null +++ b/cli/cmd/ao/cron_self_adjust.go @@ -0,0 +1,276 @@ +// practices: [dora-metrics, lean-startup] +package main + +import ( + "bufio" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "time" + + "github.com/boshu2/agentops/cli/internal/evolve" + "github.com/spf13/cobra" +) + +// cronSelfAdjust (soc-un0m) renders the next /evolve loop-mode cron prompt +// from the versioned template and emits a JSON spec the harness consumes to +// orchestrate CronCreate. The CLI never calls CronCreate itself — that boundary +// is intentional. See docs/plans/2026-05-21-evolve-loop-epic-design.md §A4. +// +// Audit trail: every render appends a row to .agents/evolve/cron-history.jsonl +// so operators can reconstruct the loop's prompt evolution. + +const ( + cronSelfAdjustDefaultTemplate = ".agents/evolve/cron-template.md" + cronSelfAdjustHistoryRel = ".agents/evolve/cron-history.jsonl" +) + +var ( + cronSelfAdjustOn string + cronSelfAdjustTemplate string + cronSelfAdjustShipped string + cronSelfAdjustNext string + cronSelfAdjustSubBeads string + cronSelfAdjustTestsDelta string + cronSelfAdjustClock func() time.Time +) + +var cronSelfAdjustCmd = &cobra.Command{ + Use: "self-adjust", + Short: "Render the next loop-mode cron prompt and emit a CronCreate spec", + Long: `Render the next /evolve loop-mode cron prompt and emit JSON for the harness. + +This subcommand is the mechanical primitive the /evolve loop calls at the end +of every cycle. It: + + 1. Reads the versioned cron template (default .agents/evolve/cron-template.md) + 2. Verifies VERBATIM-PRESERVE marker hashes (refuses on drift) + 3. Renders the template with the supplied shipped/next/sub-beads/tests-delta + 4. Appends one row to .agents/evolve/cron-history.jsonl + 5. Emits a JSON spec on stdout: {"new_cron_prompt": "", "schedule_hint": "..."} + +The CLI does NOT call CronCreate. The harness reads the JSON spec and +orchestrates CronList/Delete/Create itself; the CLI's responsibility ends at +emitting the spec. + +--shipped accepts one or more ":[#]" entries, +comma-separated. + +Example: + ao cron self-adjust --on cycle-close \ + --template .agents/evolve/cron-template.md \ + --shipped abc123:soc-x,def456:soc-y#scen \ + --next soc-z --sub-beads soc-q,soc-r \ + --tests-delta "+3 passing, 0 new failures"`, + Args: cobra.NoArgs, + RunE: runCronSelfAdjust, +} + +func init() { + cronSelfAdjustClock = func() time.Time { return time.Now().UTC() } + cronSelfAdjustCmd.Flags().StringVar(&cronSelfAdjustOn, "on", "cycle-close", "Trigger marker: 'cycle-close' for default loop usage") + cronSelfAdjustCmd.Flags().StringVar(&cronSelfAdjustTemplate, "template", cronSelfAdjustDefaultTemplate, "Path to the cron-loop-mode template") + cronSelfAdjustCmd.Flags().StringVar(&cronSelfAdjustShipped, "shipped", "", "Comma-separated commit:bead entries shipped this cycle") + cronSelfAdjustCmd.Flags().StringVar(&cronSelfAdjustNext, "next", "", "Optional recommended next bead") + cronSelfAdjustCmd.Flags().StringVar(&cronSelfAdjustSubBeads, "sub-beads", "", "Comma-separated bead ids filed this cycle") + cronSelfAdjustCmd.Flags().StringVar(&cronSelfAdjustTestsDelta, "tests-delta", "", "Human-readable tests delta summary") + cronCmd.AddCommand(cronSelfAdjustCmd) +} + +// cronSelfAdjustSpec is the JSON spec written to stdout for the harness. +type cronSelfAdjustSpec struct { + NewCronPrompt string `json:"new_cron_prompt"` + ScheduleHint string `json:"schedule_hint"` +} + +// cronSelfAdjustHistoryRow is one row of cron-history.jsonl. +type cronSelfAdjustHistoryRow struct { + Timestamp string `json:"timestamp"` + CronIDBefore string `json:"cron_id_before"` + CronIDAfter string `json:"cron_id_after"` + Shipped []string `json:"shipped"` + Next string `json:"next,omitempty"` + SubBeadsFiled []string `json:"sub_beads_filed"` + TestsDelta string `json:"tests_delta,omitempty"` + RenderedTemplate string `json:"rendered_template_path,omitempty"` +} + +func runCronSelfAdjust(cmd *cobra.Command, _ []string) error { + cwd, err := os.Getwd() + if err != nil { + return fmt.Errorf("get working directory: %w", err) + } + templatePath := cronSelfAdjustTemplate + if !filepath.IsAbs(templatePath) { + templatePath = filepath.Join(cwd, templatePath) + } + + // Verify VERBATIM-PRESERVE markers before rendering. evolve.VerifyMarkers + // returns a typed error listing each drifted marker; surface unchanged. + if err := evolve.VerifyMarkers(templatePath); err != nil { + return err + } + + shipped := parseShippedCommits(cronSelfAdjustShipped) + subBeads := splitCronCSV(cronSelfAdjustSubBeads) + + counter := countCronHistoryRows(filepath.Join(cwd, cronSelfAdjustHistoryRel)) + 1 + rendered, err := evolve.Render(templatePath, evolve.CronContext{ + ShippedCommits: shipped, + NextRecommendedBead: cronSelfAdjustNext, + SubBeadsFiledThisCycle: subBeads, + TestsDelta: cronSelfAdjustTestsDelta, + CronSelfAdjustCounter: counter, + }) + if err != nil { + return err + } + + now := cronSelfAdjustClock() + row := cronSelfAdjustHistoryRow{ + Timestamp: now.Format(time.RFC3339), + CronIDBefore: "", + CronIDAfter: "", + Shipped: shippedAsStrings(shipped), + Next: cronSelfAdjustNext, + SubBeadsFiled: subBeads, + TestsDelta: cronSelfAdjustTestsDelta, + RenderedTemplate: templatePath, + } + if err := appendCronHistoryRow(filepath.Join(cwd, cronSelfAdjustHistoryRel), row); err != nil { + return err + } + + spec := cronSelfAdjustSpec{ + NewCronPrompt: rendered, + ScheduleHint: cronSelfAdjustOn, + } + return writeCronSelfAdjustSpec(cmd.OutOrStdout(), spec) +} + +// parseShippedCommits parses the comma-separated --shipped value into a slice +// of evolve.ShippedCommit. Each entry is ":[#]". +func parseShippedCommits(in string) []evolve.ShippedCommit { + if strings.TrimSpace(in) == "" { + return nil + } + parts := strings.Split(in, ",") + out := make([]evolve.ShippedCommit, 0, len(parts)) + for _, p := range parts { + p = strings.TrimSpace(p) + if p == "" { + continue + } + sha, rest, ok := strings.Cut(p, ":") + if !ok { + // No colon — treat the whole token as the bead id. + out = append(out, evolve.ShippedCommit{Bead: p}) + continue + } + bead, scenario, hasScen := strings.Cut(rest, "#") + commit := evolve.ShippedCommit{Sha: sha, Bead: bead} + if hasScen { + commit.Scenario = scenario + } + out = append(out, commit) + } + return out +} + +// shippedAsStrings rebuilds the canonical ":[#]" form for +// logging. +func shippedAsStrings(in []evolve.ShippedCommit) []string { + if len(in) == 0 { + return []string{} + } + out := make([]string, 0, len(in)) + for _, c := range in { + s := c.Sha + ":" + c.Bead + if c.Scenario != "" { + s += "#" + c.Scenario + } + out = append(out, s) + } + return out +} + +// splitCronCSV is the local trim-aware splitter (the standard library's +// strings.Split keeps empty trailing tokens; we want a clean slice). +func splitCronCSV(in string) []string { + if strings.TrimSpace(in) == "" { + return []string{} + } + parts := strings.Split(in, ",") + out := make([]string, 0, len(parts)) + for _, p := range parts { + p = strings.TrimSpace(p) + if p == "" { + continue + } + out = append(out, p) + } + return out +} + +// appendCronHistoryRow writes one JSONL row to path, creating the dir if +// needed. +func appendCronHistoryRow(path string, row cronSelfAdjustHistoryRow) error { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return fmt.Errorf("create history dir: %w", err) + } + f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) + if err != nil { + return fmt.Errorf("open history %s: %w", path, err) + } + defer f.Close() + data, err := json.Marshal(row) + if err != nil { + return fmt.Errorf("marshal history row: %w", err) + } + if _, err := f.Write(append(data, '\n')); err != nil { + return fmt.Errorf("write history row: %w", err) + } + return nil +} + +// writeCronSelfAdjustSpec emits the harness spec. +func writeCronSelfAdjustSpec(w io.Writer, spec cronSelfAdjustSpec) error { + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + if err := enc.Encode(spec); err != nil { + return fmt.Errorf("encode spec: %w", err) + } + return nil +} + +// cronHistoryReadRows decodes path into typed rows. Missing file returns an +// empty slice. Exported for tests in this package only. +func cronHistoryReadRows(path string) ([]cronSelfAdjustHistoryRow, error) { + f, err := os.Open(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + return nil, fmt.Errorf("open %s: %w", path, err) + } + defer f.Close() + scanner := bufio.NewScanner(f) + scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) + var rows []cronSelfAdjustHistoryRow + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + var row cronSelfAdjustHistoryRow + if err := json.Unmarshal([]byte(line), &row); err != nil { + return nil, fmt.Errorf("decode history row: %w", err) + } + rows = append(rows, row) + } + return rows, scanner.Err() +} diff --git a/cli/cmd/ao/cron_self_adjust_test.go b/cli/cmd/ao/cron_self_adjust_test.go new file mode 100644 index 000000000..82126f761 --- /dev/null +++ b/cli/cmd/ao/cron_self_adjust_test.go @@ -0,0 +1,289 @@ +// practices: [dora-metrics, lean-startup] +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/boshu2/agentops/cli/internal/evolve" +) + +// minimalCronTemplate returns a self-contained template with a single +// VERBATIM-PRESERVE marker whose SHA is computed inline. Used by tests that +// don't need the real production template. +func minimalCronTemplate(t *testing.T) string { + t.Helper() + inner := "\nload-bearing\n" + sha := evolve.ComputeMarkerSHA(inner) + return strings.Join([]string{ + "---", + "template_version: 1", + "verbatim_markers:", + " test-marker: " + sha, + "---", + "", + "# Cycle {{.CronSelfAdjustCounter}}", + "", + "Shipped: {{range .ShippedCommits}}{{.Sha}}:{{.Bead}}{{if .Scenario}}#{{.Scenario}}{{end}} {{end}}", + "Next: {{.NextRecommendedBead}}", + "Sub-beads: {{range .SubBeadsFiledThisCycle}}- {{.}} {{end}}", + "Tests: {{.TestsDelta}}", + "", + "" + inner + "", + }, "\n") +} + +// withFixedCronClock pins the clock for deterministic timestamps. +func withFixedCronClock(t *testing.T, ts time.Time) { + t.Helper() + prev := cronSelfAdjustClock + cronSelfAdjustClock = func() time.Time { return ts } + t.Cleanup(func() { cronSelfAdjustClock = prev }) +} + +// TestCronSelfAdjust_RoundTrip exercises the happy path: render a template +// with shipped/next/sub-beads, verify stdout JSON and history row. +func TestCronSelfAdjust_RoundTrip(t *testing.T) { + dir := chdirTemp(t) + withFixedCronClock(t, time.Date(2026, 5, 21, 12, 0, 0, 0, time.UTC)) + + templatePath := filepath.Join(dir, ".agents/evolve/cron-template.md") + if err := os.MkdirAll(filepath.Dir(templatePath), 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(templatePath, []byte(minimalCronTemplate(t)), 0o644); err != nil { + t.Fatalf("seed template: %v", err) + } + + out, err := executeCommand( + "cron", "self-adjust", + "--on", "cycle-close", + "--template", ".agents/evolve/cron-template.md", + "--shipped", "abc123:soc-x,def456:soc-y#scen", + "--next", "soc-z", + "--sub-beads", "soc-q,soc-r", + "--tests-delta", "+3 passing", + ) + if err != nil { + t.Fatalf("err: %v\nout=%s", err, out) + } + jsonStart := strings.Index(out, "{") + if jsonStart < 0 { + t.Fatalf("no JSON in output: %q", out) + } + var spec cronSelfAdjustSpec + if err := json.Unmarshal([]byte(out[jsonStart:]), &spec); err != nil { + t.Fatalf("decode spec: %v\nout=%s", err, out) + } + if !strings.Contains(spec.NewCronPrompt, "Cycle 1") { + t.Errorf("prompt missing cycle counter: %q", spec.NewCronPrompt) + } + if !strings.Contains(spec.NewCronPrompt, "abc123:soc-x") { + t.Errorf("prompt missing shipped commit: %q", spec.NewCronPrompt) + } + if !strings.Contains(spec.NewCronPrompt, "soc-y#scen") { + t.Errorf("prompt missing scenario: %q", spec.NewCronPrompt) + } + if !strings.Contains(spec.NewCronPrompt, "Next: soc-z") { + t.Errorf("prompt missing next: %q", spec.NewCronPrompt) + } + if !strings.Contains(spec.NewCronPrompt, "- soc-q") || !strings.Contains(spec.NewCronPrompt, "- soc-r") { + t.Errorf("prompt missing sub-beads: %q", spec.NewCronPrompt) + } + if !strings.Contains(spec.NewCronPrompt, "+3 passing") { + t.Errorf("prompt missing tests delta: %q", spec.NewCronPrompt) + } + if spec.ScheduleHint != "cycle-close" { + t.Errorf("schedule hint = %q, want cycle-close", spec.ScheduleHint) + } + + rows, err := cronHistoryReadRows(filepath.Join(dir, cronSelfAdjustHistoryRel)) + if err != nil { + t.Fatalf("read history: %v", err) + } + if len(rows) != 1 { + t.Fatalf("history rows = %d, want 1", len(rows)) + } + got := rows[0] + want := cronSelfAdjustHistoryRow{ + Timestamp: "2026-05-21T12:00:00Z", + CronIDBefore: "", + CronIDAfter: "", + Shipped: []string{"abc123:soc-x", "def456:soc-y#scen"}, + Next: "soc-z", + SubBeadsFiled: []string{"soc-q", "soc-r"}, + TestsDelta: "+3 passing", + RenderedTemplate: templatePath, + } + if got.Timestamp != want.Timestamp || + got.Next != want.Next || + got.TestsDelta != want.TestsDelta || + got.RenderedTemplate != want.RenderedTemplate { + t.Errorf("history scalars mismatch:\n got=%+v\nwant=%+v", got, want) + } + if strings.Join(got.Shipped, "|") != strings.Join(want.Shipped, "|") { + t.Errorf("shipped mismatch: got=%v want=%v", got.Shipped, want.Shipped) + } + if strings.Join(got.SubBeadsFiled, "|") != strings.Join(want.SubBeadsFiled, "|") { + t.Errorf("sub-beads mismatch: got=%v want=%v", got.SubBeadsFiled, want.SubBeadsFiled) + } +} + +// TestCronSelfAdjust_RefusesOnMarkerDrift confirms VerifyMarkers gating wires +// through; tampering with marker content trips an exit-1 error. +func TestCronSelfAdjust_RefusesOnMarkerDrift(t *testing.T) { + dir := chdirTemp(t) + + templatePath := filepath.Join(dir, ".agents/evolve/cron-template.md") + if err := os.MkdirAll(filepath.Dir(templatePath), 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + // Use the minimal template, but tamper with the marker's inner content + // after generating its SHA — drift should be detected. + tpl := minimalCronTemplate(t) + tampered := strings.Replace(tpl, "load-bearing", "tampered-content", 1) + if err := os.WriteFile(templatePath, []byte(tampered), 0o644); err != nil { + t.Fatalf("write template: %v", err) + } + + out, err := executeCommand( + "cron", "self-adjust", + "--template", ".agents/evolve/cron-template.md", + "--shipped", "abc:soc-x", + ) + if err == nil { + t.Fatalf("expected drift error\nout=%s", out) + } + if !strings.Contains(err.Error(), "drift") { + t.Errorf("error message: %v", err) + } +} + +// TestCronSelfAdjust_EmptyShippedTolerated covers the corner case where the +// cycle shipped nothing — render should still succeed and emit a valid spec. +func TestCronSelfAdjust_EmptyShippedTolerated(t *testing.T) { + dir := chdirTemp(t) + withFixedCronClock(t, time.Date(2026, 5, 21, 12, 0, 0, 0, time.UTC)) + + templatePath := filepath.Join(dir, ".agents/evolve/cron-template.md") + if err := os.MkdirAll(filepath.Dir(templatePath), 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(templatePath, []byte(minimalCronTemplate(t)), 0o644); err != nil { + t.Fatalf("seed: %v", err) + } + + out, err := executeCommand( + "cron", "self-adjust", + "--template", ".agents/evolve/cron-template.md", + ) + if err != nil { + t.Fatalf("err: %v\nout=%s", err, out) + } + jsonStart := strings.Index(out, "{") + if jsonStart < 0 { + t.Fatalf("no JSON: %q", out) + } + var spec cronSelfAdjustSpec + if err := json.Unmarshal([]byte(out[jsonStart:]), &spec); err != nil { + t.Fatalf("decode: %v", err) + } + if spec.NewCronPrompt == "" { + t.Errorf("expected non-empty prompt") + } +} + +// TestCronSelfAdjust_CounterIncrementsAcrossInvocations verifies cycle counter +// advances each call. +func TestCronSelfAdjust_CounterIncrementsAcrossInvocations(t *testing.T) { + dir := chdirTemp(t) + withFixedCronClock(t, time.Date(2026, 5, 21, 12, 0, 0, 0, time.UTC)) + + templatePath := filepath.Join(dir, ".agents/evolve/cron-template.md") + if err := os.MkdirAll(filepath.Dir(templatePath), 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(templatePath, []byte(minimalCronTemplate(t)), 0o644); err != nil { + t.Fatalf("seed: %v", err) + } + + // First call → counter 1. + out, err := executeCommand("cron", "self-adjust", "--template", ".agents/evolve/cron-template.md") + if err != nil { + t.Fatalf("call 1: %v\nout=%s", err, out) + } + if !strings.Contains(out, "Cycle 1") { + t.Errorf("call 1 counter: %q", out) + } + + // Second call → counter 2. + out, err = executeCommand("cron", "self-adjust", "--template", ".agents/evolve/cron-template.md") + if err != nil { + t.Fatalf("call 2: %v\nout=%s", err, out) + } + if !strings.Contains(out, "Cycle 2") { + t.Errorf("call 2 counter: %q", out) + } +} + +// TestParseShippedCommits covers the comma-separated --shipped parser. +func TestParseShippedCommits(t *testing.T) { + cases := []struct { + in string + want []evolve.ShippedCommit + }{ + {"", nil}, + {"abc:soc-x", []evolve.ShippedCommit{{Sha: "abc", Bead: "soc-x"}}}, + {"abc:soc-x,def:soc-y", []evolve.ShippedCommit{ + {Sha: "abc", Bead: "soc-x"}, + {Sha: "def", Bead: "soc-y"}, + }}, + {"abc:soc-x#scen", []evolve.ShippedCommit{{Sha: "abc", Bead: "soc-x", Scenario: "scen"}}}, + {"soc-only", []evolve.ShippedCommit{{Bead: "soc-only"}}}, + } + for _, tc := range cases { + got := parseShippedCommits(tc.in) + if len(got) != len(tc.want) { + t.Errorf("parseShippedCommits(%q) len=%d, want %d", tc.in, len(got), len(tc.want)) + continue + } + for i := range got { + if got[i] != tc.want[i] { + t.Errorf("parseShippedCommits(%q)[%d] = %+v, want %+v", tc.in, i, got[i], tc.want[i]) + } + } + } +} + +// TestCronSelfAdjust_RegisteredOnCron confirms the subcommand is reachable +// via `ao cron self-adjust`. +func TestCronSelfAdjust_RegisteredOnCron(t *testing.T) { + var found bool + for _, sub := range cronCmd.Commands() { + if sub.Name() == "self-adjust" { + found = true + break + } + } + if !found { + t.Fatal("cron self-adjust should be registered on cronCmd") + } +} + +// TestCron_RegisteredOnRoot confirms `ao cron` is reachable. +func TestCron_RegisteredOnRoot(t *testing.T) { + var found bool + for _, sub := range rootCmd.Commands() { + if sub.Name() == "cron" { + found = true + break + } + } + if !found { + t.Fatal("cron should be registered on rootCmd") + } +} diff --git a/cli/cmd/ao/evolve_blocked.go b/cli/cmd/ao/evolve_blocked.go new file mode 100644 index 000000000..a9665d2f3 --- /dev/null +++ b/cli/cmd/ao/evolve_blocked.go @@ -0,0 +1,372 @@ +// practices: [dora-metrics, lean-startup] +package main + +import ( + "bufio" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "time" + + "github.com/boshu2/agentops/cli/internal/evolve" + "github.com/spf13/cobra" +) + +// evolveBlocked subcommand (soc-g34d) records typed blocked-events that the +// agent emits when the next-work ladder is exhausted (or any other blocking +// condition). The /evolve loop contract is "log, don't halt": agents append a +// structured record to .agents/evolve/blocked.jsonl rather than writing a +// STOP/DORMANT marker. Operators triage from the log. +// +// See docs/plans/2026-05-21-evolve-loop-epic-design.md §A6. + +const ( + evolveBlockedRelDir = ".agents/evolve" + evolveBlockedLogName = "blocked.jsonl" + evolveCronHistoryRel = ".agents/evolve/cron-history.jsonl" + evolveBlockedDefTail = 10 +) + +var ( + evolveBlockedReason string + evolveBlockedBead string + evolveBlockedNeededContext string + evolveBlockedLadderStep int + evolveBlockedList bool + evolveBlockedTail int + evolveBlockedJSON bool + evolveBlockedClearCycleID string + evolveBlockedClear bool + evolveBlockedCycleOverride string + evolveBlockedTimestampClock func() time.Time +) + +var evolveBlockedCmd = &cobra.Command{ + Use: "blocked", + Short: "Log or list typed blocked-events from the /evolve loop", + Long: `Record or inspect typed blocked-events emitted by the /evolve loop. + +The loop contract is "log, don't halt": when the agent can't make progress +(empty next-work ladder, missing context, ambiguous acceptance), it appends a +structured record to .agents/evolve/blocked.jsonl rather than writing a STOP +or DORMANT marker. Operators triage the log between cycles. + +Three modes (mutually exclusive): + + Write: ao evolve blocked --reason '' [--bead ] [--needed-context ''] + Read: ao evolve blocked --list [--tail N] [--json] + Clear: ao evolve blocked --clear (operator-only) + +Examples: + ao evolve blocked --reason 'ladder exhausted' --bead soc-mlbm --needed-context 'undefined step 4 semantics' + ao evolve blocked --list --tail 20 --json + ao evolve blocked --clear 2026-05-21-cycle-42`, + Args: cobra.NoArgs, + RunE: runEvolveBlocked, +} + +func init() { + evolveBlockedTimestampClock = func() time.Time { return time.Now().UTC() } + evolveBlockedCmd.Flags().StringVar(&evolveBlockedReason, "reason", "", "Reason text (write mode)") + evolveBlockedCmd.Flags().StringVar(&evolveBlockedBead, "bead", "", "Bead id the agent was working on (write mode, optional)") + evolveBlockedCmd.Flags().StringVar(&evolveBlockedNeededContext, "needed-context", "", "Missing context description (write mode, optional)") + evolveBlockedCmd.Flags().IntVar(&evolveBlockedLadderStep, "ladder-step-failed", 0, "Ladder step that failed (write mode, optional)") + evolveBlockedCmd.Flags().BoolVar(&evolveBlockedList, "list", false, "Read mode: list blocked events") + evolveBlockedCmd.Flags().IntVar(&evolveBlockedTail, "tail", evolveBlockedDefTail, "Read mode: show last N entries") + evolveBlockedCmd.Flags().BoolVar(&evolveBlockedJSON, "json", false, "Read mode: emit JSON instead of human-readable text") + evolveBlockedCmd.Flags().StringVar(&evolveBlockedClearCycleID, "clear", "", "Clear mode: delete entries for the given cycle id (operator-only)") + evolveBlockedCmd.Flags().StringVar(&evolveBlockedCycleOverride, "cycle", "", "Override cycle-id (write mode; defaults to date-derived counter)") + evolveCmd.AddCommand(evolveBlockedCmd) +} + +// BlockedEvent is the schema for one row in .agents/evolve/blocked.jsonl. All +// fields except cycle/timestamp/reason are optional; the JSONL reader rejects +// records missing those three. +type BlockedEvent struct { + Cycle string `json:"cycle"` + Timestamp string `json:"timestamp"` + Bead string `json:"bead,omitempty"` + Reason string `json:"reason"` + NeededContext string `json:"needed_context,omitempty"` + LadderStepFailed int `json:"ladder_step_failed,omitempty"` +} + +func runEvolveBlocked(cmd *cobra.Command, _ []string) error { + mode, err := resolveBlockedMode(cmd) + if err != nil { + return err + } + cwd, err := os.Getwd() + if err != nil { + return fmt.Errorf("get working directory: %w", err) + } + switch mode { + case "write": + return runEvolveBlockedWrite(cmd, cwd) + case "list": + return runEvolveBlockedList(cmd, cwd) + case "clear": + return runEvolveBlockedClear(cmd, cwd) + default: + return errors.New("ao evolve blocked: must pass one of --reason, --list, or --clear") + } +} + +// resolveBlockedMode picks which of the three modes the operator requested, +// rejecting combinations of mutually-exclusive flags. +func resolveBlockedMode(cmd *cobra.Command) (string, error) { + writeFlag := cmd.Flags().Changed("reason") + listFlag := evolveBlockedList || cmd.Flags().Changed("list") + clearFlag := evolveBlockedClearCycleID != "" + + set := 0 + if writeFlag { + set++ + } + if listFlag { + set++ + } + if clearFlag { + set++ + } + if set == 0 { + return "", errors.New("ao evolve blocked: must pass one of --reason, --list, or --clear") + } + if set > 1 { + return "", errors.New("ao evolve blocked: --reason, --list, and --clear are mutually exclusive") + } + switch { + case writeFlag: + return "write", nil + case listFlag: + return "list", nil + default: + return "clear", nil + } +} + +func runEvolveBlockedWrite(cmd *cobra.Command, cwd string) error { + if strings.TrimSpace(evolveBlockedReason) == "" { + return errors.New("ao evolve blocked --reason: reason cannot be empty") + } + cycle := evolveBlockedCycleOverride + if cycle == "" { + cycle = nextBlockedCycleID(cwd, evolveBlockedTimestampClock()) + } + event := BlockedEvent{ + Cycle: cycle, + Timestamp: evolveBlockedTimestampClock().Format(time.RFC3339), + Bead: evolveBlockedBead, + Reason: evolveBlockedReason, + NeededContext: evolveBlockedNeededContext, + LadderStepFailed: evolveBlockedLadderStep, + } + path := filepath.Join(cwd, evolveBlockedRelDir, evolveBlockedLogName) + if err := appendBlockedEvent(path, event); err != nil { + return err + } + fmt.Fprintf(cmd.OutOrStdout(), "Logged blocked event for cycle %s\n", event.Cycle) + return nil +} + +func runEvolveBlockedList(cmd *cobra.Command, cwd string) error { + path := filepath.Join(cwd, evolveBlockedRelDir, evolveBlockedLogName) + events, err := readBlockedEvents(path) + if err != nil { + return err + } + tail := evolveBlockedTail + if tail <= 0 { + tail = evolveBlockedDefTail + } + if len(events) > tail { + events = events[len(events)-tail:] + } + if evolveBlockedJSON { + return writeBlockedEventsJSON(cmd.OutOrStdout(), events) + } + return writeBlockedEventsHuman(cmd.OutOrStdout(), events) +} + +func runEvolveBlockedClear(cmd *cobra.Command, cwd string) error { + // Operators may use --clear under any mode, but warn if mode_default=loop. + prefs, prefsErr := evolve.LoadFromDir(cmd.Context(), cwd) + if prefsErr == nil && prefs != nil && prefs.ModeDefault == evolveModeLoop { + fmt.Fprintln(cmd.ErrOrStderr(), "ao evolve blocked --clear: warning — preferences indicate --mode=loop; clearing is operator-only") + } + path := filepath.Join(cwd, evolveBlockedRelDir, evolveBlockedLogName) + events, err := readBlockedEvents(path) + if err != nil { + return err + } + kept := make([]BlockedEvent, 0, len(events)) + removed := 0 + for _, ev := range events { + if ev.Cycle == evolveBlockedClearCycleID { + removed++ + continue + } + kept = append(kept, ev) + } + if removed == 0 { + fmt.Fprintf(cmd.OutOrStdout(), "No blocked events matched cycle %s\n", evolveBlockedClearCycleID) + return nil + } + if err := rewriteBlockedEvents(path, kept); err != nil { + return err + } + fmt.Fprintf(cmd.OutOrStdout(), "Cleared %d blocked event(s) for cycle %s\n", removed, evolveBlockedClearCycleID) + return nil +} + +// appendBlockedEvent serializes event as one JSON line and appends to path, +// creating the directory if needed. +func appendBlockedEvent(path string, event BlockedEvent) error { + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("create blocked log dir %s: %w", dir, err) + } + f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) + if err != nil { + return fmt.Errorf("open blocked log %s: %w", path, err) + } + defer f.Close() + data, err := json.Marshal(event) + if err != nil { + return fmt.Errorf("marshal blocked event: %w", err) + } + if _, err := f.Write(append(data, '\n')); err != nil { + return fmt.Errorf("append blocked event: %w", err) + } + return nil +} + +// readBlockedEvents loads all JSONL records from path. Missing file returns +// an empty slice (not an error). Malformed lines are rejected with a typed +// error referencing the line number. +func readBlockedEvents(path string) ([]BlockedEvent, error) { + f, err := os.Open(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return []BlockedEvent{}, nil + } + return nil, fmt.Errorf("open blocked log %s: %w", path, err) + } + defer f.Close() + scanner := bufio.NewScanner(f) + scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) + var events []BlockedEvent + lineNo := 0 + for scanner.Scan() { + lineNo++ + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + var ev BlockedEvent + if err := json.Unmarshal([]byte(line), &ev); err != nil { + return nil, fmt.Errorf("%s:%d: malformed JSONL: %w", path, lineNo, err) + } + if ev.Cycle == "" || ev.Timestamp == "" || ev.Reason == "" { + return nil, fmt.Errorf("%s:%d: missing required field(s) cycle/timestamp/reason", path, lineNo) + } + events = append(events, ev) + } + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("scan blocked log: %w", err) + } + return events, nil +} + +// rewriteBlockedEvents writes events back to path, atomically (tmp + rename). +func rewriteBlockedEvents(path string, events []BlockedEvent) error { + tmp := path + ".tmp" + f, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644) + if err != nil { + return fmt.Errorf("open tmp %s: %w", tmp, err) + } + for _, ev := range events { + data, err := json.Marshal(ev) + if err != nil { + f.Close() + os.Remove(tmp) + return fmt.Errorf("marshal blocked event: %w", err) + } + if _, err := f.Write(append(data, '\n')); err != nil { + f.Close() + os.Remove(tmp) + return fmt.Errorf("write tmp: %w", err) + } + } + if err := f.Close(); err != nil { + return fmt.Errorf("close tmp: %w", err) + } + if err := os.Rename(tmp, path); err != nil { + return fmt.Errorf("rename %s -> %s: %w", tmp, path, err) + } + return nil +} + +// writeBlockedEventsJSON emits a JSON array of events. +func writeBlockedEventsJSON(w io.Writer, events []BlockedEvent) error { + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + if err := enc.Encode(events); err != nil { + return fmt.Errorf("encode json: %w", err) + } + return nil +} + +// writeBlockedEventsHuman emits a human-readable summary. +func writeBlockedEventsHuman(w io.Writer, events []BlockedEvent) error { + if len(events) == 0 { + fmt.Fprintln(w, "(no blocked events)") + return nil + } + for _, ev := range events { + fmt.Fprintf(w, "%s cycle=%s", ev.Timestamp, ev.Cycle) + if ev.Bead != "" { + fmt.Fprintf(w, " bead=%s", ev.Bead) + } + if ev.LadderStepFailed > 0 { + fmt.Fprintf(w, " step=%d", ev.LadderStepFailed) + } + fmt.Fprintf(w, "\n reason: %s\n", ev.Reason) + if ev.NeededContext != "" { + fmt.Fprintf(w, " needed-context: %s\n", ev.NeededContext) + } + } + return nil +} + +// nextBlockedCycleID derives the default cycle id from the date plus a counter +// read from .agents/evolve/cron-history.jsonl. The counter is the number of +// rows in cron-history.jsonl; if the file is absent we use 0. The format is +// "-cycle-". +func nextBlockedCycleID(cwd string, now time.Time) string { + counter := countCronHistoryRows(filepath.Join(cwd, evolveCronHistoryRel)) + return fmt.Sprintf("%s-cycle-%d", now.UTC().Format("2006-01-02"), counter) +} + +// countCronHistoryRows returns the number of non-blank lines in path; 0 on +// missing or unreadable file (the cycle counter is a soft default). +func countCronHistoryRows(path string) int { + f, err := os.Open(path) + if err != nil { + return 0 + } + defer f.Close() + scanner := bufio.NewScanner(f) + scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) + n := 0 + for scanner.Scan() { + if strings.TrimSpace(scanner.Text()) != "" { + n++ + } + } + return n +} diff --git a/cli/cmd/ao/evolve_blocked_test.go b/cli/cmd/ao/evolve_blocked_test.go new file mode 100644 index 000000000..c9075ab6f --- /dev/null +++ b/cli/cmd/ao/evolve_blocked_test.go @@ -0,0 +1,219 @@ +// practices: [dora-metrics, lean-startup] +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// withFixedBlockedClock pins the timestamp clock used by the blocked subcommand +// for deterministic assertions. +func withFixedBlockedClock(t *testing.T, ts time.Time) { + t.Helper() + prev := evolveBlockedTimestampClock + evolveBlockedTimestampClock = func() time.Time { return ts } + t.Cleanup(func() { evolveBlockedTimestampClock = prev }) +} + +// TestEvolveBlocked_WriteThenListRoundTrip writes a blocked event, reads it +// back via --list --json, and asserts structural equality on the parsed JSON. +func TestEvolveBlocked_WriteThenListRoundTrip(t *testing.T) { + dir := chdirTemp(t) + fixed := time.Date(2026, 5, 21, 16, 30, 0, 0, time.UTC) + withFixedBlockedClock(t, fixed) + + // Write. + writeOut, err := executeCommand( + "evolve", "blocked", + "--reason", "ladder exhausted", + "--bead", "soc-mlbm", + "--needed-context", "undefined ladder step 4 semantics", + "--ladder-step-failed", "4", + "--cycle", "2026-05-21-cycle-42", + ) + if err != nil { + t.Fatalf("write: %v\nout=%s", err, writeOut) + } + if !strings.Contains(writeOut, "Logged blocked event for cycle 2026-05-21-cycle-42") { + t.Errorf("write stdout: want confirmation, got %q", writeOut) + } + + logPath := filepath.Join(dir, ".agents/evolve/blocked.jsonl") + data, err := os.ReadFile(logPath) + if err != nil { + t.Fatalf("read log: %v", err) + } + lines := strings.Split(strings.TrimSpace(string(data)), "\n") + if len(lines) != 1 { + t.Fatalf("expected 1 record, got %d", len(lines)) + } + + listOut, err := executeCommand("evolve", "blocked", "--list", "--json") + if err != nil { + t.Fatalf("list: %v\nout=%s", err, listOut) + } + // Strip leading non-JSON noise; output starts at first '['. + jsonStart := strings.Index(listOut, "[") + if jsonStart < 0 { + t.Fatalf("list output missing JSON array: %q", listOut) + } + var got []BlockedEvent + if err := json.Unmarshal([]byte(listOut[jsonStart:]), &got); err != nil { + t.Fatalf("decode list json: %v\noutput=%s", err, listOut) + } + want := BlockedEvent{ + Cycle: "2026-05-21-cycle-42", + Timestamp: fixed.Format(time.RFC3339), + Bead: "soc-mlbm", + Reason: "ladder exhausted", + NeededContext: "undefined ladder step 4 semantics", + LadderStepFailed: 4, + } + if len(got) != 1 || got[0] != want { + t.Errorf("round trip mismatch:\n got = %+v\nwant = %+v", got, want) + } +} + +// TestEvolveBlocked_ClearRemovesMatchingCycle seeds two events, clears one, +// then re-reads. +func TestEvolveBlocked_ClearRemovesMatchingCycle(t *testing.T) { + dir := chdirTemp(t) + fixed := time.Date(2026, 5, 21, 16, 30, 0, 0, time.UTC) + + path := filepath.Join(dir, ".agents/evolve/blocked.jsonl") + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + a := BlockedEvent{Cycle: "cycle-A", Timestamp: fixed.Format(time.RFC3339), Reason: "alpha"} + b := BlockedEvent{Cycle: "cycle-B", Timestamp: fixed.Format(time.RFC3339), Reason: "beta"} + for _, ev := range []BlockedEvent{a, b} { + raw, _ := json.Marshal(ev) + f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) + if err != nil { + t.Fatalf("open: %v", err) + } + if _, err := f.Write(append(raw, '\n')); err != nil { + t.Fatalf("write seed: %v", err) + } + f.Close() + } + + out, err := executeCommand("evolve", "blocked", "--clear", "cycle-A") + if err != nil { + t.Fatalf("clear: %v\nout=%s", err, out) + } + if !strings.Contains(out, "Cleared 1 blocked event(s) for cycle cycle-A") { + t.Errorf("clear stdout: %q", out) + } + + remaining, err := readBlockedEvents(path) + if err != nil { + t.Fatalf("re-read: %v", err) + } + if len(remaining) != 1 || remaining[0].Cycle != "cycle-B" { + t.Errorf("after clear: %+v", remaining) + } +} + +// TestEvolveBlocked_MalformedJSONLRejected confirms readBlockedEvents returns +// a typed error referencing the line number for malformed rows. +func TestEvolveBlocked_MalformedJSONLRejected(t *testing.T) { + tmp := t.TempDir() + path := filepath.Join(tmp, "blocked.jsonl") + if err := os.WriteFile(path, []byte("not json\n"), 0o644); err != nil { + t.Fatalf("seed: %v", err) + } + _, err := readBlockedEvents(path) + if err == nil { + t.Fatalf("expected error for malformed line") + } + if !strings.Contains(err.Error(), ":1:") { + t.Errorf("error missing line context: %v", err) + } +} + +// TestEvolveBlocked_MissingRequiredFieldsRejected confirms records that parse +// as JSON but omit required fields fail readBlockedEvents. +func TestEvolveBlocked_MissingRequiredFieldsRejected(t *testing.T) { + tmp := t.TempDir() + path := filepath.Join(tmp, "blocked.jsonl") + row := `{"bead":"x"}` + "\n" + if err := os.WriteFile(path, []byte(row), 0o644); err != nil { + t.Fatalf("seed: %v", err) + } + _, err := readBlockedEvents(path) + if err == nil { + t.Fatalf("expected error for missing fields") + } + if !strings.Contains(err.Error(), "missing required field") { + t.Errorf("error message: %v", err) + } +} + +// TestEvolveBlocked_MutuallyExclusiveFlags confirms resolveBlockedMode rejects +// combinations of --reason + --list. +func TestEvolveBlocked_MutuallyExclusiveFlags(t *testing.T) { + chdirTemp(t) + + out, err := executeCommand("evolve", "blocked", "--reason", "x", "--list") + if err == nil { + t.Fatalf("expected mutual-exclusion error\nout=%s", out) + } + combined := err.Error() + "\n" + out + if !strings.Contains(combined, "mutually exclusive") { + t.Errorf("error: %v\nout=%s", err, out) + } +} + +// TestEvolveBlocked_DefaultCycleIDUsesCronHistoryCounter confirms the default +// cycle id is derived as -cycle-. +func TestEvolveBlocked_DefaultCycleIDUsesCronHistoryCounter(t *testing.T) { + dir := chdirTemp(t) + fixed := time.Date(2026, 5, 21, 0, 0, 0, 0, time.UTC) + withFixedBlockedClock(t, fixed) + + historyPath := filepath.Join(dir, evolveCronHistoryRel) + if err := os.MkdirAll(filepath.Dir(historyPath), 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(historyPath, []byte("{\"a\":1}\n{\"a\":2}\n{\"a\":3}\n"), 0o644); err != nil { + t.Fatalf("seed: %v", err) + } + + out, err := executeCommand("evolve", "blocked", "--reason", "test") + if err != nil { + t.Fatalf("write: %v\nout=%s", err, out) + } + if !strings.Contains(out, "2026-05-21-cycle-3") { + t.Errorf("default cycle: %q", out) + } +} + +// TestEvolveBlocked_RegisteredOnEvolve confirms the subcommand is reachable via +// `ao evolve blocked`. +func TestEvolveBlocked_RegisteredOnEvolve(t *testing.T) { + var found bool + for _, sub := range evolveCmd.Commands() { + if sub.Name() == "blocked" { + found = true + break + } + } + if !found { + t.Fatal("evolve blocked subcommand should be registered on evolveCmd") + } +} + +// TestEvolveBlocked_NextCycleIDFormat covers the cycle id derivation helper. +func TestEvolveBlocked_NextCycleIDFormat(t *testing.T) { + tmp := t.TempDir() + now := time.Date(2026, 5, 21, 0, 0, 0, 0, time.UTC) + got := nextBlockedCycleID(tmp, now) + if got != "2026-05-21-cycle-0" { + t.Errorf("nextBlockedCycleID() = %q, want %q", got, "2026-05-21-cycle-0") + } +} diff --git a/cli/cmd/ao/evolve_next_work.go b/cli/cmd/ao/evolve_next_work.go new file mode 100644 index 000000000..b9748d2aa --- /dev/null +++ b/cli/cmd/ao/evolve_next_work.go @@ -0,0 +1,169 @@ +// practices: [dora-metrics, lean-startup] +package main + +import ( + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "time" + + "github.com/boshu2/agentops/cli/internal/evolve/ladder" + "github.com/spf13/cobra" +) + +// evolveNextWork subcommand (soc-mlbm) is the programmatic next-work ladder +// the /evolve loop consults each cycle. It traverses ready beads through the +// 5-step ladder in cli/internal/evolve/ladder, logs the decision, and emits +// either human-readable text or JSON for downstream consumers. +// +// See docs/plans/2026-05-21-evolve-loop-epic-design.md §A5. + +const ( + evolveNextWorkLogRel = ".agents/evolve/next-work-decisions.jsonl" +) + +var ( + evolveNextWorkMode string + evolveNextWorkIncludeOperator bool + evolveNextWorkJSON bool + evolveNextWorkBDBinary string + evolveNextWorkRunnerOverride ladder.BeadRunner + evolveNextWorkGrepOverride ladder.GrepRunner + evolveNextWorkClock func() time.Time +) + +var evolveNextWorkCmd = &cobra.Command{ + Use: "next-work", + Short: "Recommend the next bead to claim via the 5-step ladder", + Long: `Run the 5-step next-work ladder and recommend a bead to claim. + +The ladder filters operator-shape beads, enriches with sibling-pattern grep +hits, gates with the 3-question Primitive Test, falls back to cross-hop +discovered-from chains, and finally to the smallest bug. On exhaustion the +recommended bead is empty and the rationale tells the agent to call +'ao evolve blocked' instead of halting. + +The full ladder spec lives at docs/plans/2026-05-21-evolve-loop-epic-design.md +§A5. Each cycle's decision is appended to .agents/evolve/next-work-decisions.jsonl. + +Examples: + ao evolve next-work + ao evolve next-work --json + ao evolve next-work --include-operator-shape + ao evolve next-work --mode=loop`, + Args: cobra.NoArgs, + RunE: runEvolveNextWork, +} + +func init() { + evolveNextWorkClock = func() time.Time { return time.Now().UTC() } + evolveNextWorkCmd.Flags().StringVar(&evolveNextWorkMode, "mode", evolveModeBurst, "Execution contract: 'burst' (default) or 'loop'") + evolveNextWorkCmd.Flags().BoolVar(&evolveNextWorkIncludeOperator, "include-operator-shape", false, "Do not filter operator-shape beads at step 1") + evolveNextWorkCmd.Flags().BoolVar(&evolveNextWorkJSON, "json", false, "Emit JSON instead of human-readable text") + evolveNextWorkCmd.Flags().StringVar(&evolveNextWorkBDBinary, "bd-binary", "", "Override path to the 'bd' binary (default: resolves via PATH)") + evolveCmd.AddCommand(evolveNextWorkCmd) +} + +// nextWorkDecisionLogRow is the schema for one row in +// .agents/evolve/next-work-decisions.jsonl. The fields mirror the ladder +// Recommendation plus a timestamp. +type nextWorkDecisionLogRow struct { + Timestamp string `json:"timestamp"` + LadderStepMatched int `json:"ladder_step_matched"` + RecommendedBead string `json:"recommended_bead"` + Alternatives []string `json:"alternatives,omitempty"` +} + +func runEvolveNextWork(cmd *cobra.Command, _ []string) error { + if err := validateEvolveMode(evolveNextWorkMode); err != nil { + return err + } + cwd, err := os.Getwd() + if err != nil { + return fmt.Errorf("get working directory: %w", err) + } + + br := evolveNextWorkRunnerOverride + if br == nil { + br = ladder.ExecBeadRunner{BinaryPath: evolveNextWorkBDBinary} + } + gr := evolveNextWorkGrepOverride + if gr == nil { + gr = ladder.ExecGrepRunner{} + } + + rec, err := ladder.Run(cmd.Context(), br, gr, ladder.Config{ + IncludeOperatorShape: evolveNextWorkIncludeOperator, + RepoRoot: cwd, + }) + if err != nil { + return fmt.Errorf("ladder run: %w", err) + } + + if err := appendNextWorkDecision(cwd, rec, evolveNextWorkClock()); err != nil { + // Logging failure should not block recommendation surfacing; surface + // to stderr but proceed with stdout output per the principle that the + // recommendation is the load-bearing artifact. + fmt.Fprintf(cmd.ErrOrStderr(), "warning: append next-work-decisions log: %v\n", err) + } + + return writeNextWorkRecommendation(cmd.OutOrStdout(), rec, evolveNextWorkJSON) +} + +// appendNextWorkDecision writes one row to the per-cycle decision log. +func appendNextWorkDecision(cwd string, rec ladder.Recommendation, now time.Time) error { + row := nextWorkDecisionLogRow{ + Timestamp: now.Format(time.RFC3339), + LadderStepMatched: rec.LadderStepMatched, + RecommendedBead: rec.RecommendedBead, + Alternatives: rec.Alternatives, + } + path := filepath.Join(cwd, evolveNextWorkLogRel) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return fmt.Errorf("mkdir %s: %w", filepath.Dir(path), err) + } + f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) + if err != nil { + return fmt.Errorf("open %s: %w", path, err) + } + defer f.Close() + data, err := json.Marshal(row) + if err != nil { + return fmt.Errorf("marshal log row: %w", err) + } + if _, err := f.Write(append(data, '\n')); err != nil { + return fmt.Errorf("write log row: %w", err) + } + return nil +} + +// writeNextWorkRecommendation emits rec to w in JSON or human form. +func writeNextWorkRecommendation(w io.Writer, rec ladder.Recommendation, asJSON bool) error { + if asJSON { + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + if err := enc.Encode(rec); err != nil { + return fmt.Errorf("encode json: %w", err) + } + return nil + } + if rec.RecommendedBead == "" { + fmt.Fprintf(w, "next-work: (none) — %s\n", rec.Rationale) + return nil + } + fmt.Fprintf(w, "next-work: %s (step %d)\n", rec.RecommendedBead, rec.LadderStepMatched) + fmt.Fprintf(w, " rationale: %s\n", rec.Rationale) + if len(rec.Alternatives) > 0 { + fmt.Fprintf(w, " alternatives: ") + for i, a := range rec.Alternatives { + if i > 0 { + fmt.Fprintf(w, ", ") + } + fmt.Fprintf(w, "%s", a) + } + fmt.Fprintln(w) + } + return nil +} diff --git a/cli/cmd/ao/evolve_next_work_test.go b/cli/cmd/ao/evolve_next_work_test.go new file mode 100644 index 000000000..8e5b9d602 --- /dev/null +++ b/cli/cmd/ao/evolve_next_work_test.go @@ -0,0 +1,250 @@ +// practices: [dora-metrics, lean-startup] +package main + +import ( + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/boshu2/agentops/cli/internal/evolve/ladder" +) + +// fakeBeadRunner is a test double implementing ladder.BeadRunner. +type fakeBeadRunner struct { + ReadyList []ladder.Bead + ReadyByTypeMap map[string][]ladder.Bead + ShowMap map[string]ladder.Bead + InProgressList []ladder.Bead +} + +func (f *fakeBeadRunner) Ready(_ context.Context) ([]ladder.Bead, error) { + return f.ReadyList, nil +} + +func (f *fakeBeadRunner) ReadyByType(_ context.Context, t string) ([]ladder.Bead, error) { + return f.ReadyByTypeMap[t], nil +} + +func (f *fakeBeadRunner) Show(_ context.Context, id string) (ladder.Bead, error) { + b, ok := f.ShowMap[id] + if !ok { + return ladder.Bead{}, errors.New("not found") + } + return b, nil +} + +func (f *fakeBeadRunner) InProgress(_ context.Context) ([]ladder.Bead, error) { + return f.InProgressList, nil +} + +// fakeGrep mocks the grep enrichment so tests stay hermetic. +type fakeGrep struct{} + +func (fakeGrep) Grep(_ context.Context, _ string, _ []string) ([]string, error) { + return nil, nil +} + +// withFakeNextWorkRunners installs the supplied fakes for the duration of the +// test and restores production runners on cleanup. +func withFakeNextWorkRunners(t *testing.T, br ladder.BeadRunner, gr ladder.GrepRunner) { + t.Helper() + prevBR, prevGR := evolveNextWorkRunnerOverride, evolveNextWorkGrepOverride + evolveNextWorkRunnerOverride = br + evolveNextWorkGrepOverride = gr + t.Cleanup(func() { + evolveNextWorkRunnerOverride = prevBR + evolveNextWorkGrepOverride = prevGR + }) +} + +// withFixedNextWorkClock pins the timestamp clock. +func withFixedNextWorkClock(t *testing.T, ts time.Time) { + t.Helper() + prev := evolveNextWorkClock + evolveNextWorkClock = func() time.Time { return ts } + t.Cleanup(func() { evolveNextWorkClock = prev }) +} + +// TestEvolveNextWork_Step1Pick exercises the happy path: shape-compatible +// bead picked at step 1 with JSON output. +func TestEvolveNextWork_Step1Pick(t *testing.T) { + dir := chdirTemp(t) + withFixedNextWorkClock(t, time.Date(2026, 5, 21, 12, 0, 0, 0, time.UTC)) + withFakeNextWorkRunners(t, &fakeBeadRunner{ + ReadyList: []ladder.Bead{ + { + ID: "soc-x", + Title: "implement next-work", + Description: "Edit cli/foo.go. ## Scenarios when X then Y. Follows soc-prev.", + }, + {ID: "soc-alt-a"}, + {ID: "soc-alt-b"}, + }, + }, fakeGrep{}) + + out, err := executeCommand("evolve", "next-work", "--json") + if err != nil { + t.Fatalf("err: %v\nout=%s", err, out) + } + start := strings.Index(out, "{") + if start < 0 { + t.Fatalf("no JSON in output: %q", out) + } + var rec ladder.Recommendation + if err := json.Unmarshal([]byte(out[start:]), &rec); err != nil { + t.Fatalf("decode: %v\nout=%s", err, out) + } + if rec.RecommendedBead != "soc-x" { + t.Errorf("bead = %q, want soc-x", rec.RecommendedBead) + } + if rec.LadderStepMatched != 1 { + t.Errorf("step = %d, want 1", rec.LadderStepMatched) + } + + // Decision log should have one row. + logPath := filepath.Join(dir, evolveNextWorkLogRel) + data, err := os.ReadFile(logPath) + if err != nil { + t.Fatalf("read log: %v", err) + } + lines := strings.Split(strings.TrimSpace(string(data)), "\n") + if len(lines) != 1 { + t.Fatalf("log rows = %d, want 1", len(lines)) + } + var row nextWorkDecisionLogRow + if err := json.Unmarshal([]byte(lines[0]), &row); err != nil { + t.Fatalf("decode row: %v", err) + } + if row.RecommendedBead != "soc-x" || row.LadderStepMatched != 1 { + t.Errorf("log row = %+v", row) + } +} + +// TestEvolveNextWork_PrimitiveTestFailsRecommendsScout exercises the step-3 +// scout-mode rationale. +func TestEvolveNextWork_PrimitiveTestFailsRecommendsScout(t *testing.T) { + chdirTemp(t) + withFakeNextWorkRunners(t, &fakeBeadRunner{ + ReadyList: []ladder.Bead{ + {ID: "soc-vague", Title: "vague", Description: "make better"}, + }, + }, fakeGrep{}) + + out, err := executeCommand("evolve", "next-work", "--json") + if err != nil { + t.Fatalf("err: %v\nout=%s", err, out) + } + start := strings.Index(out, "{") + if start < 0 { + t.Fatalf("no JSON in output: %q", out) + } + var rec ladder.Recommendation + if err := json.Unmarshal([]byte(out[start:]), &rec); err != nil { + t.Fatalf("decode: %v", err) + } + if rec.LadderStepMatched != 3 { + t.Errorf("step = %d, want 3", rec.LadderStepMatched) + } + if !strings.Contains(rec.Rationale, "scout-mode") { + t.Errorf("rationale: %q", rec.Rationale) + } +} + +// TestEvolveNextWork_LadderExhaustionEmitsBlockedHint covers the terminal +// "ladder exhausted" recommendation. +func TestEvolveNextWork_LadderExhaustionEmitsBlockedHint(t *testing.T) { + chdirTemp(t) + withFakeNextWorkRunners(t, &fakeBeadRunner{}, fakeGrep{}) + + out, err := executeCommand("evolve", "next-work", "--json") + if err != nil { + t.Fatalf("err: %v\nout=%s", err, out) + } + start := strings.Index(out, "{") + if start < 0 { + t.Fatalf("no JSON in output: %q", out) + } + var rec ladder.Recommendation + if err := json.Unmarshal([]byte(out[start:]), &rec); err != nil { + t.Fatalf("decode: %v", err) + } + if rec.RecommendedBead != "" { + t.Errorf("bead = %q, want empty", rec.RecommendedBead) + } + if !strings.Contains(rec.Rationale, "ao evolve blocked") { + t.Errorf("rationale missing blocked hint: %q", rec.Rationale) + } +} + +// TestEvolveNextWork_HumanReadableFallback covers the non-JSON output path. +func TestEvolveNextWork_HumanReadableFallback(t *testing.T) { + chdirTemp(t) + withFakeNextWorkRunners(t, &fakeBeadRunner{ + ReadyList: []ladder.Bead{ + { + ID: "soc-h", + Title: "human", + Description: "Edit cli/x.go. when X then Y. Follows soc-prev.", + }, + }, + }, fakeGrep{}) + + out, err := executeCommand("evolve", "next-work") + if err != nil { + t.Fatalf("err: %v\nout=%s", err, out) + } + if !strings.Contains(out, "next-work: soc-h (step 1)") { + t.Errorf("human output: %q", out) + } +} + +// TestEvolveNextWork_RegisteredOnEvolve confirms registration under evolveCmd. +func TestEvolveNextWork_RegisteredOnEvolve(t *testing.T) { + var found bool + for _, sub := range evolveCmd.Commands() { + if sub.Name() == "next-work" { + found = true + break + } + } + if !found { + t.Fatal("evolve next-work subcommand should be registered on evolveCmd") + } +} + +// TestEvolveNextWork_IncludeOperatorShape exercises the flag-driven step-1 +// override. +func TestEvolveNextWork_IncludeOperatorShape(t *testing.T) { + chdirTemp(t) + withFakeNextWorkRunners(t, &fakeBeadRunner{ + ReadyList: []ladder.Bead{ + { + ID: "soc-ops", + Title: "operator scaffold", + Description: "Edit cli/x.go. when X then Y. Follows soc-prev.", + Labels: []string{"operator-shape"}, + }, + }, + }, fakeGrep{}) + + out, err := executeCommand("evolve", "next-work", "--include-operator-shape", "--json") + if err != nil { + t.Fatalf("err: %v\nout=%s", err, out) + } + start := strings.Index(out, "{") + if start < 0 { + t.Fatalf("no JSON: %q", out) + } + var rec ladder.Recommendation + if err := json.Unmarshal([]byte(out[start:]), &rec); err != nil { + t.Fatalf("decode: %v", err) + } + if rec.RecommendedBead != "soc-ops" { + t.Errorf("bead = %q, want soc-ops", rec.RecommendedBead) + } +} diff --git a/cli/cmd/ao/init_test.go b/cli/cmd/ao/init_test.go index 4f588e35e..5f0fb2513 100644 --- a/cli/cmd/ao/init_test.go +++ b/cli/cmd/ao/init_test.go @@ -261,6 +261,7 @@ func TestNestedGitignoreContent(t *testing.T) { initStealth = false initHooks = false + initWithSchedule = false // reset: prior tests in this package may have left it true if err := runInit(initCmd, nil); err != nil { t.Fatalf("runInit: %v", err) } diff --git a/cli/docs/COMMANDS.md b/cli/docs/COMMANDS.md index 0f8d71bc4..0f8bc3f7f 100644 --- a/cli/docs/COMMANDS.md +++ b/cli/docs/COMMANDS.md @@ -1309,6 +1309,38 @@ ao codex stop [flags] --- +### `ao cron` + +Helpers for the /evolve --mode=loop cron-fire continuity primitive. + +``` +ao cron [command] +``` + +**Subcommands:** + +#### `ao cron self-adjust` + +Render the next /evolve loop-mode cron prompt and emit JSON for the harness. + +``` +ao cron self-adjust [flags] +``` + +**Flags:** + +``` + -h, --help help for self-adjust + --next string Optional recommended next bead + --on string Trigger marker: 'cycle-close' for default loop usage (default "cycle-close") + --shipped string Comma-separated commit:bead entries shipped this cycle + --sub-beads string Comma-separated bead ids filed this cycle + --template string Path to the cron-loop-mode template (default ".agents/evolve/cron-template.md") + --tests-delta string Human-readable tests delta summary +``` + +--- + ### `ao daemon` Run and inspect the AgentOps daemon @@ -1828,6 +1860,29 @@ ao evolve [command] **Subcommands:** +#### `ao evolve blocked` + +Record or inspect typed blocked-events emitted by the /evolve loop. + +``` +ao evolve blocked [flags] +``` + +**Flags:** + +``` + --bead string Bead id the agent was working on (write mode, optional) + --clear string Clear mode: delete entries for the given cycle id (operator-only) + --cycle string Override cycle-id (write mode; defaults to date-derived counter) + -h, --help help for blocked + --json Read mode: emit JSON instead of human-readable text + --ladder-step-failed int Ladder step that failed (write mode, optional) + --list Read mode: list blocked events + --needed-context string Missing context description (write mode, optional) + --reason string Reason text (write mode) + --tail int Read mode: show last N entries (default 10) +``` + #### `ao evolve config` Display the resolved per-repo /evolve preferences. @@ -1844,6 +1899,24 @@ ao evolve config [flags] --show Print the resolved preferences (defaults + preferences.yaml) ``` +#### `ao evolve next-work` + +Run the 5-step next-work ladder and recommend a bead to claim. + +``` +ao evolve next-work [flags] +``` + +**Flags:** + +``` + --bd-binary string Override path to the 'bd' binary (default: resolves via PATH) + -h, --help help for next-work + --include-operator-shape Do not filter operator-shape beads at step 1 + --json Emit JSON instead of human-readable text + --mode string Execution contract: 'burst' (default) or 'loop' (default "burst") +``` + #### `ao evolve write-stop-marker` Write a DORMANT, STOP, or KILL marker under .agents/evolve/. diff --git a/cli/internal/evolve/ladder/ladder.go b/cli/internal/evolve/ladder/ladder.go new file mode 100644 index 000000000..0465c412e --- /dev/null +++ b/cli/internal/evolve/ladder/ladder.go @@ -0,0 +1,540 @@ +// Package ladder implements the five-step "next-work" decision ladder for +// /evolve. The ladder traverses ready beads and adjacent context to recommend +// what the agent should claim next; when the ladder is exhausted the agent +// is expected to call `ao evolve blocked` (see soc-g34d) rather than halt. +// +// Each step is a small function with a stable signature so callers can run +// them individually for testing. Step ownership: +// +// step1_shape_filter → filter operator-shape beads from `bd ready` +// step2_grep_siblings → enrich rationale with sibling-pattern grep matches +// step3_primitive_test → apply the 3-question Primitive Test +// step4_cross_hop_pickup → traverse in-progress beads' discovered-from chains +// step5_bug_fallback → final fallback: smallest-surface bug from `bd ready` +// +// The package shells out to `bd ready --json` and `bd show --json` via +// the BeadRunner interface; tests inject fakes to avoid depending on a real +// bd installation. See cli/cmd/ao/evolve_next_work.go for the CLI wiring. +package ladder + +import ( + "context" + "encoding/json" + "fmt" + "os/exec" + "path/filepath" + "regexp" + "sort" + "strings" +) + +// Bead is the trimmed projection of `bd ready --json` / `bd show --json` that +// the ladder needs. Additional fields from the source JSON are ignored. +type Bead struct { + ID string `json:"id"` + Title string `json:"title"` + Description string `json:"description"` + Type string `json:"issue_type"` + Status string `json:"status"` + Labels []string `json:"labels"` + // Dependencies is the raw dependency list as returned by bd. + Dependencies []Dependency `json:"dependencies"` +} + +// Dependency mirrors the bd `discovered-from` / `blocks` shape. +type Dependency struct { + Type string `json:"type"` + IssueID string `json:"issue_id"` + Relation string `json:"relation"` +} + +// Recommendation is the result of running the ladder. +type Recommendation struct { + RecommendedBead string `json:"recommended_bead"` + Rationale string `json:"rationale"` + Alternatives []string `json:"alternatives"` + LadderStepMatched int `json:"ladder_step_matched"` +} + +// BeadRunner abstracts the bd CLI for testability. Production callers pass +// ExecBeadRunner; tests pass a fake. +type BeadRunner interface { + Ready(ctx context.Context) ([]Bead, error) + ReadyByType(ctx context.Context, issueType string) ([]Bead, error) + Show(ctx context.Context, id string) (Bead, error) + InProgress(ctx context.Context) ([]Bead, error) +} + +// GrepRunner abstracts the read-only filesystem grep used by step 2. Tests +// inject a fake to assert call shapes without touching the repo. +type GrepRunner interface { + Grep(ctx context.Context, pattern string, roots []string) ([]string, error) +} + +// Config captures the operator-tunable behavior of the ladder. +type Config struct { + IncludeOperatorShape bool + RepoRoot string +} + +// operatorShapeLabels marks beads that are scaffolding/orchestration work the +// agent should not claim unless --include-operator-shape is set. +var operatorShapeLabels = map[string]struct{}{ + "operator-shape": {}, + "meta-runtime": {}, + "human-coordinate": {}, +} + +// Run executes the ladder in order and returns the first non-empty result. +// On full exhaustion (step 5 also returns nothing) the recommendation is the +// "ladder exhausted" sentinel. +func Run(ctx context.Context, br BeadRunner, gr GrepRunner, cfg Config) (Recommendation, error) { + if br == nil { + return Recommendation{}, fmt.Errorf("ladder: nil BeadRunner") + } + + // Step 1: shape filter. + candidate, alts, err := Step1ShapeFilter(ctx, br, cfg) + if err != nil { + return Recommendation{}, fmt.Errorf("step1: %w", err) + } + if candidate.ID != "" { + // Step 2: sibling-pattern grep enrichment. + patterns := siblingPatterns(candidate) + var grepHits []string + if len(patterns) > 0 && gr != nil { + grepHits = Step2GrepSiblings(ctx, gr, cfg.RepoRoot, patterns) + } + // Step 3: Primitive Test. + passes, failureSummary := Step3PrimitiveTest(candidate) + if !passes { + rec := Recommendation{ + RecommendedBead: candidate.ID, + Rationale: "scout-mode: " + candidate.ID + " needs decomposition; primitive test failed (" + failureSummary + ")", + Alternatives: alts, + LadderStepMatched: 3, + } + return rec, nil + } + rationale := fmt.Sprintf("shape-compatible ready bead at step 1") + if len(grepHits) > 0 { + rationale += "; sibling refs: " + strings.Join(grepHits, ", ") + } + return Recommendation{ + RecommendedBead: candidate.ID, + Rationale: rationale, + Alternatives: alts, + LadderStepMatched: 1, + }, nil + } + + // Step 4: cross-hop pickup. + siblingCand, siblingAlts, err := Step4CrossHopPickup(ctx, br) + if err != nil { + return Recommendation{}, fmt.Errorf("step4: %w", err) + } + if siblingCand.ID != "" { + return Recommendation{ + RecommendedBead: siblingCand.ID, + Rationale: "cross-hop pickup from in-progress bead's discovered-from chain", + Alternatives: siblingAlts, + LadderStepMatched: 4, + }, nil + } + + // Step 5: bug fallback. + bug, bugAlts, err := Step5BugFallback(ctx, br) + if err != nil { + return Recommendation{}, fmt.Errorf("step5: %w", err) + } + if bug.ID != "" { + return Recommendation{ + RecommendedBead: bug.ID, + Rationale: "bug-fallback: smallest surface-area bug from bd ready", + Alternatives: bugAlts, + LadderStepMatched: 5, + }, nil + } + + return Recommendation{ + RecommendedBead: "", + Rationale: "ladder exhausted; agent should call 'ao evolve blocked' instead of halting", + Alternatives: nil, + LadderStepMatched: 0, + }, nil +} + +// Step1ShapeFilter returns the first ready bead whose labels do not match the +// operator-shape skip set (unless cfg.IncludeOperatorShape is true). The +// remaining ready beads (up to 5) are returned as alternatives. +func Step1ShapeFilter(ctx context.Context, br BeadRunner, cfg Config) (Bead, []string, error) { + beads, err := br.Ready(ctx) + if err != nil { + return Bead{}, nil, err + } + var kept []Bead + for _, b := range beads { + if !cfg.IncludeOperatorShape && hasOperatorShapeLabel(b) { + continue + } + kept = append(kept, b) + } + if len(kept) == 0 { + return Bead{}, nil, nil + } + first := kept[0] + alts := make([]string, 0, len(kept)-1) + for i := 1; i < len(kept) && i < 6; i++ { + alts = append(alts, kept[i].ID) + } + return first, alts, nil +} + +// Step2GrepSiblings runs the supplied grep against the repo's skills/ and cli/ +// roots for each pattern, returning up to 3 distinct file paths (file:line). +// Pure read-only enrichment — never causes the ladder to skip a step. +func Step2GrepSiblings(ctx context.Context, gr GrepRunner, repoRoot string, patterns []string) []string { + roots := []string{ + filepath.Join(repoRoot, "skills"), + filepath.Join(repoRoot, "cli"), + } + seen := map[string]struct{}{} + var hits []string + for _, p := range patterns { + matches, err := gr.Grep(ctx, p, roots) + if err != nil { + continue + } + for _, m := range matches { + if _, dup := seen[m]; dup { + continue + } + seen[m] = struct{}{} + hits = append(hits, m) + if len(hits) >= 3 { + return hits + } + } + } + return hits +} + +// Step3PrimitiveTest applies the 3-question Primitive Test to a candidate +// bead. Returns (passes, summary). passes is true iff at most one question +// answers "no"; summary is a short string describing which questions failed +// when passes is false. +func Step3PrimitiveTest(b Bead) (bool, string) { + q1Names := primitiveQ1NamesFiles(b) + q2Observable := primitiveQ2HasObservableAcceptance(b) + q3Sibling := primitiveQ3CitesSibling(b) + + misses := []string{} + if !q1Names { + misses = append(misses, "Q1 names-files") + } + if !q2Observable { + misses = append(misses, "Q2 observable-acceptance") + } + if !q3Sibling { + misses = append(misses, "Q3 sibling-cited") + } + if len(misses) >= 2 { + return false, strings.Join(misses, ", ") + } + return true, "" +} + +// Step4CrossHopPickup walks the in-progress beads' discovered-from chains for +// sibling ready beads. Returns the first match or the zero Bead. +func Step4CrossHopPickup(ctx context.Context, br BeadRunner) (Bead, []string, error) { + inProgress, err := br.InProgress(ctx) + if err != nil { + // in-progress lookup is best-effort; fall through silently. + return Bead{}, nil, nil + } + seen := map[string]struct{}{} + var candidates []Bead + for _, ip := range inProgress { + full, err := br.Show(ctx, ip.ID) + if err != nil { + continue + } + for _, dep := range full.Dependencies { + if dep.Relation != "discovered-from" && dep.Type != "discovered-from" { + continue + } + id := dep.IssueID + if id == "" || id == ip.ID { + continue + } + if _, dup := seen[id]; dup { + continue + } + seen[id] = struct{}{} + sib, err := br.Show(ctx, id) + if err != nil || sib.Status != "ready" { + continue + } + candidates = append(candidates, sib) + } + } + if len(candidates) == 0 { + return Bead{}, nil, nil + } + alts := make([]string, 0, len(candidates)-1) + for i := 1; i < len(candidates) && i < 6; i++ { + alts = append(alts, candidates[i].ID) + } + return candidates[0], alts, nil +} + +// Step5BugFallback returns the ready bug with the smallest surface area +// (heuristic: distinct file paths mentioned in description). +func Step5BugFallback(ctx context.Context, br BeadRunner) (Bead, []string, error) { + bugs, err := br.ReadyByType(ctx, "bug") + if err != nil { + return Bead{}, nil, err + } + if len(bugs) == 0 { + return Bead{}, nil, nil + } + sort.SliceStable(bugs, func(i, j int) bool { + return surfaceArea(bugs[i]) < surfaceArea(bugs[j]) + }) + alts := make([]string, 0, len(bugs)-1) + for i := 1; i < len(bugs) && i < 6; i++ { + alts = append(alts, bugs[i].ID) + } + return bugs[0], alts, nil +} + +// siblingPatterns returns the trigger phrases the bead's text contains. The +// returned slice contains the exact substrings to grep for. +func siblingPatterns(b Bead) []string { + corpus := strings.ToLower(b.Title + "\n" + b.Description) + triggers := []string{ + "wiring", + "with_x builder", + "hop c shape", + "sibling pattern", + } + var out []string + for _, t := range triggers { + if strings.Contains(corpus, t) { + out = append(out, t) + } + } + return out +} + +func hasOperatorShapeLabel(b Bead) bool { + for _, l := range b.Labels { + if _, ok := operatorShapeLabels[strings.ToLower(l)]; ok { + return true + } + } + return false +} + +// primitiveQ1NamesFiles answers "does the bead description name files?". A +// "file" is a token that looks like a path with an extension and at least one +// slash, OR a known top-level dir name like cli/, skills/, scripts/, docs/. +var filePathRegex = regexp.MustCompile(`(?m)\b[A-Za-z0-9_./-]+\.(?:go|md|json|yaml|yml|sh|py|ts|tsx|js|feature|bats)\b`) + +func primitiveQ1NamesFiles(b Bead) bool { + return filePathRegex.MatchString(b.Description) +} + +// primitiveQ2HasObservableAcceptance answers "does it have observable +// acceptance?" — looks for testable assertion vocabulary or a Scenarios block. +func primitiveQ2HasObservableAcceptance(b Bead) bool { + corpus := strings.ToLower(b.Description) + markers := []string{ + "## scenarios", + "## acceptance", + "acceptance:", + "when ", + "then ", + "assert ", + "asserts ", + "asserted", + "observable", + "verifies that", + "verify that", + "check that", + "exit 1", + "exit 0", + "returns ", + "returns:", + } + for _, m := range markers { + if strings.Contains(corpus, m) { + return true + } + } + return false +} + +// primitiveQ3CitesSibling answers "does it cite a sibling or predecessor +// pattern?" — looks for explicit pattern-citation vocabulary. +func primitiveQ3CitesSibling(b Bead) bool { + corpus := strings.ToLower(b.Title + "\n" + b.Description) + markers := []string{ + "sibling", + "predecessor", + "follows ", + "mirrors ", + "as in ", + "pattern from", + "see soc-", + "see also", + "based on soc-", + } + for _, m := range markers { + if strings.Contains(corpus, m) { + return true + } + } + return false +} + +// surfaceArea counts distinct file-path-ish tokens in the description. +func surfaceArea(b Bead) int { + matches := filePathRegex.FindAllString(b.Description, -1) + seen := map[string]struct{}{} + for _, m := range matches { + seen[m] = struct{}{} + } + return len(seen) +} + +// ExecBeadRunner is the production BeadRunner that shells out to `bd`. +type ExecBeadRunner struct { + // BinaryPath optionally pins the bd binary; empty resolves via $PATH. + BinaryPath string +} + +// Ready returns all ready beads. +func (e ExecBeadRunner) Ready(ctx context.Context) ([]Bead, error) { + return e.runReady(ctx, nil) +} + +// ReadyByType returns ready beads filtered to a single issue type. +func (e ExecBeadRunner) ReadyByType(ctx context.Context, issueType string) ([]Bead, error) { + return e.runReady(ctx, []string{"--type=" + issueType}) +} + +func (e ExecBeadRunner) runReady(ctx context.Context, extra []string) ([]Bead, error) { + args := append([]string{"ready", "--json"}, extra...) + bin := e.BinaryPath + if bin == "" { + bin = "bd" + } + cmd := exec.CommandContext(ctx, bin, args...) + out, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("bd ready: %w", err) + } + return decodeBeadList(out) +} + +// Show returns the full Bead detail for id. +func (e ExecBeadRunner) Show(ctx context.Context, id string) (Bead, error) { + bin := e.BinaryPath + if bin == "" { + bin = "bd" + } + cmd := exec.CommandContext(ctx, bin, "show", id, "--json") + out, err := cmd.Output() + if err != nil { + return Bead{}, fmt.Errorf("bd show %s: %w", id, err) + } + var b Bead + if err := json.Unmarshal(out, &b); err != nil { + return Bead{}, fmt.Errorf("decode bd show %s: %w", id, err) + } + return b, nil +} + +// InProgress returns beads whose status is "in_progress". +func (e ExecBeadRunner) InProgress(ctx context.Context) ([]Bead, error) { + bin := e.BinaryPath + if bin == "" { + bin = "bd" + } + cmd := exec.CommandContext(ctx, bin, "list", "--status=in_progress", "--json") + out, err := cmd.Output() + if err != nil { + // Some bd versions use different status names; treat missing as empty. + return nil, nil + } + return decodeBeadList(out) +} + +func decodeBeadList(out []byte) ([]Bead, error) { + out = trimByteOrderMark(out) + if len(out) == 0 { + return nil, nil + } + // bd ready --json sometimes emits a wrapper {"issues":[...]} and sometimes a + // raw [...] depending on subcommand. Try both. + var direct []Bead + if err := json.Unmarshal(out, &direct); err == nil { + return direct, nil + } + var wrap struct { + Issues []Bead `json:"issues"` + Items []Bead `json:"items"` + Data []Bead `json:"data"` + } + if err := json.Unmarshal(out, &wrap); err != nil { + return nil, fmt.Errorf("decode bd json: %w", err) + } + switch { + case len(wrap.Issues) > 0: + return wrap.Issues, nil + case len(wrap.Items) > 0: + return wrap.Items, nil + case len(wrap.Data) > 0: + return wrap.Data, nil + } + return nil, nil +} + +func trimByteOrderMark(b []byte) []byte { + if len(b) >= 3 && b[0] == 0xEF && b[1] == 0xBB && b[2] == 0xBF { + return b[3:] + } + return b +} + +// ExecGrepRunner shells out to `grep -rn -l`-style ripgrep equivalent (using +// plain grep for portability). Returns up to 3 file:line hits per pattern. +type ExecGrepRunner struct{} + +// Grep runs grep -rn against the given roots for pattern and returns up to +// 3 file:line strings. +func (ExecGrepRunner) Grep(ctx context.Context, pattern string, roots []string) ([]string, error) { + args := []string{"-rn", "--max-count=3", pattern} + args = append(args, roots...) + cmd := exec.CommandContext(ctx, "grep", args...) + out, _ := cmd.Output() // non-zero exit when no matches; ignore. + lines := strings.Split(strings.TrimSpace(string(out)), "\n") + var hits []string + for _, l := range lines { + l = strings.TrimSpace(l) + if l == "" { + continue + } + // "file:line:content" → keep "file:line". + parts := strings.SplitN(l, ":", 3) + if len(parts) < 2 { + continue + } + hits = append(hits, parts[0]+":"+parts[1]) + if len(hits) >= 3 { + break + } + } + return hits, nil +} diff --git a/cli/internal/evolve/ladder/ladder_test.go b/cli/internal/evolve/ladder/ladder_test.go new file mode 100644 index 000000000..d46dab740 --- /dev/null +++ b/cli/internal/evolve/ladder/ladder_test.go @@ -0,0 +1,388 @@ +// practices: [dora-metrics, lean-startup] +package ladder + +import ( + "context" + "errors" + "reflect" + "strings" + "testing" +) + +// fakeBeadRunner is a test double implementing BeadRunner. +type fakeBeadRunner struct { + ReadyList []Bead + ReadyErr error + ReadyByTypeMap map[string][]Bead + ReadyByTypeErr error + ShowMap map[string]Bead + ShowErr error + InProgressList []Bead + InProgressErr error +} + +func (f *fakeBeadRunner) Ready(ctx context.Context) ([]Bead, error) { + return f.ReadyList, f.ReadyErr +} + +func (f *fakeBeadRunner) ReadyByType(ctx context.Context, t string) ([]Bead, error) { + return f.ReadyByTypeMap[t], f.ReadyByTypeErr +} + +func (f *fakeBeadRunner) Show(ctx context.Context, id string) (Bead, error) { + if f.ShowErr != nil { + return Bead{}, f.ShowErr + } + b, ok := f.ShowMap[id] + if !ok { + return Bead{}, errors.New("not found: " + id) + } + return b, nil +} + +func (f *fakeBeadRunner) InProgress(ctx context.Context) ([]Bead, error) { + return f.InProgressList, f.InProgressErr +} + +// fakeGrep returns canned hits per pattern. +type fakeGrep struct { + Hits map[string][]string +} + +func (g fakeGrep) Grep(ctx context.Context, pattern string, roots []string) ([]string, error) { + return g.Hits[pattern], nil +} + +// TestStep1ShapeFilter exercises the operator-shape skip behavior. +func TestStep1ShapeFilter(t *testing.T) { + tests := []struct { + name string + beads []Bead + includeOps bool + wantID string + wantAlts []string + }{ + { + name: "filters operator-shape by default", + beads: []Bead{ + {ID: "soc-a", Labels: []string{"operator-shape"}}, + {ID: "soc-b"}, + {ID: "soc-c"}, + }, + wantID: "soc-b", + wantAlts: []string{"soc-c"}, + }, + { + name: "includes operator-shape when flag set", + beads: []Bead{ + {ID: "soc-a", Labels: []string{"operator-shape"}}, + {ID: "soc-b"}, + }, + includeOps: true, + wantID: "soc-a", + wantAlts: []string{"soc-b"}, + }, + { + name: "empty when nothing ready", + beads: nil, + wantID: "", + }, + { + name: "all filtered returns empty", + beads: []Bead{ + {ID: "soc-a", Labels: []string{"operator-shape"}}, + {ID: "soc-b", Labels: []string{"meta-runtime"}}, + }, + wantID: "", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + br := &fakeBeadRunner{ReadyList: tc.beads} + got, alts, err := Step1ShapeFilter(context.Background(), br, Config{IncludeOperatorShape: tc.includeOps}) + if err != nil { + t.Fatalf("err: %v", err) + } + if got.ID != tc.wantID { + t.Errorf("ID = %q, want %q", got.ID, tc.wantID) + } + if len(tc.wantAlts) > 0 && !reflect.DeepEqual(alts, tc.wantAlts) { + t.Errorf("alts = %v, want %v", alts, tc.wantAlts) + } + }) + } +} + +// TestStep2GrepSiblings exercises sibling pattern enrichment. +func TestStep2GrepSiblings(t *testing.T) { + tests := []struct { + name string + patterns []string + hits map[string][]string + want []string + }{ + { + name: "no patterns yields no hits", + patterns: nil, + want: nil, + }, + { + name: "merges hits across patterns", + patterns: []string{"wiring", "sibling pattern"}, + hits: map[string][]string{ + "wiring": {"cli/foo.go:10", "skills/bar/SKILL.md:5"}, + "sibling pattern": {"docs/x.md:1"}, + }, + want: []string{"cli/foo.go:10", "skills/bar/SKILL.md:5", "docs/x.md:1"}, + }, + { + name: "dedupes across patterns", + patterns: []string{"wiring", "sibling pattern"}, + hits: map[string][]string{ + "wiring": {"a:1", "b:2", "c:3", "d:4"}, + "sibling pattern": {"a:1"}, + }, + want: []string{"a:1", "b:2", "c:3"}, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := Step2GrepSiblings(context.Background(), fakeGrep{Hits: tc.hits}, "/repo", tc.patterns) + if !reflect.DeepEqual(got, tc.want) { + t.Errorf("hits = %v, want %v", got, tc.want) + } + }) + } +} + +// TestStep3PrimitiveTest covers the 3-question gating. +func TestStep3PrimitiveTest(t *testing.T) { + tests := []struct { + name string + bead Bead + wantPass bool + wantMissL int + }{ + { + name: "all three pass", + bead: Bead{ + Title: "implement foo", + Description: "Edit cli/foo.go and skills/bar.md. ## Scenarios when X then Y. Follows soc-1234.", + }, + wantPass: true, + }, + { + name: "one miss is acceptable", + bead: Bead{ + Title: "x", + Description: "Edit cli/foo.go. when X then Y.", + }, + wantPass: true, + }, + { + name: "two misses fails", + bead: Bead{ + Title: "vague work", + Description: "make it better", + }, + wantPass: false, + wantMissL: 3, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + pass, summary := Step3PrimitiveTest(tc.bead) + if pass != tc.wantPass { + t.Errorf("pass = %v, want %v (summary=%s)", pass, tc.wantPass, summary) + } + if !pass && summary == "" { + t.Errorf("expected failure summary for failed primitive test") + } + }) + } +} + +// TestStep4CrossHopPickup exercises sibling traversal from in-progress beads. +func TestStep4CrossHopPickup(t *testing.T) { + br := &fakeBeadRunner{ + InProgressList: []Bead{{ID: "soc-ip1"}}, + ShowMap: map[string]Bead{ + "soc-ip1": { + ID: "soc-ip1", + Dependencies: []Dependency{ + {Type: "discovered-from", IssueID: "soc-sib1", Relation: "discovered-from"}, + {Type: "blocks", IssueID: "soc-sib2"}, // wrong relation + }, + }, + "soc-sib1": {ID: "soc-sib1", Status: "ready"}, + "soc-sib2": {ID: "soc-sib2", Status: "ready"}, + }, + } + got, _, err := Step4CrossHopPickup(context.Background(), br) + if err != nil { + t.Fatalf("err: %v", err) + } + if got.ID != "soc-sib1" { + t.Errorf("ID = %q, want soc-sib1", got.ID) + } +} + +// TestStep4CrossHopPickup_SkipsNonReady confirms non-ready siblings are excluded. +func TestStep4CrossHopPickup_SkipsNonReady(t *testing.T) { + br := &fakeBeadRunner{ + InProgressList: []Bead{{ID: "soc-ip1"}}, + ShowMap: map[string]Bead{ + "soc-ip1": { + ID: "soc-ip1", + Dependencies: []Dependency{ + {Type: "discovered-from", IssueID: "soc-closed", Relation: "discovered-from"}, + }, + }, + "soc-closed": {ID: "soc-closed", Status: "closed"}, + }, + } + got, _, err := Step4CrossHopPickup(context.Background(), br) + if err != nil { + t.Fatalf("err: %v", err) + } + if got.ID != "" { + t.Errorf("expected no candidate, got %q", got.ID) + } +} + +// TestStep5BugFallback orders bugs by surface area. +func TestStep5BugFallback(t *testing.T) { + br := &fakeBeadRunner{ + ReadyByTypeMap: map[string][]Bead{ + "bug": { + {ID: "big", Description: "Edit cli/a.go and cli/b.go and cli/c.go"}, + {ID: "small", Description: "Edit cli/x.go only"}, + {ID: "mid", Description: "Edit a.go and b.go"}, + }, + }, + } + got, alts, err := Step5BugFallback(context.Background(), br) + if err != nil { + t.Fatalf("err: %v", err) + } + if got.ID != "small" { + t.Errorf("smallest bug = %q, want small", got.ID) + } + if len(alts) == 0 { + t.Errorf("expected alternatives") + } +} + +// TestRun_ExhaustionEmitsBlockedHint covers the terminal recommendation. +func TestRun_ExhaustionEmitsBlockedHint(t *testing.T) { + br := &fakeBeadRunner{ + ReadyByTypeMap: map[string][]Bead{}, + } + got, err := Run(context.Background(), br, nil, Config{}) + if err != nil { + t.Fatalf("err: %v", err) + } + if got.RecommendedBead != "" { + t.Errorf("bead = %q, want empty", got.RecommendedBead) + } + if !strings.Contains(got.Rationale, "ladder exhausted") { + t.Errorf("rationale: %q", got.Rationale) + } + if !strings.Contains(got.Rationale, "ao evolve blocked") { + t.Errorf("rationale missing blocked hint: %q", got.Rationale) + } +} + +// TestRun_Step1_PrimitivePass returns step 1 when bead passes primitive test. +func TestRun_Step1_PrimitivePass(t *testing.T) { + br := &fakeBeadRunner{ + ReadyList: []Bead{ + { + ID: "soc-x", + Title: "do work", + Description: "Edit cli/x.go. ## Scenarios when X then Y. Follows soc-prev.", + }, + }, + } + got, err := Run(context.Background(), br, nil, Config{}) + if err != nil { + t.Fatalf("err: %v", err) + } + if got.RecommendedBead != "soc-x" || got.LadderStepMatched != 1 { + t.Errorf("got=%+v, want bead=soc-x step=1", got) + } +} + +// TestRun_Step3_DecompositionRecommendation covers the scout-mode path. +func TestRun_Step3_DecompositionRecommendation(t *testing.T) { + br := &fakeBeadRunner{ + ReadyList: []Bead{ + {ID: "soc-vague", Title: "improve things", Description: "make it better"}, + }, + } + got, err := Run(context.Background(), br, nil, Config{}) + if err != nil { + t.Fatalf("err: %v", err) + } + if got.LadderStepMatched != 3 { + t.Errorf("step = %d, want 3", got.LadderStepMatched) + } + if !strings.Contains(got.Rationale, "scout-mode") { + t.Errorf("rationale: %q", got.Rationale) + } +} + +// TestRun_Step5_BugFallbackChosen exercises the bug-fallback hop. +func TestRun_Step5_BugFallbackChosen(t *testing.T) { + br := &fakeBeadRunner{ + ReadyList: nil, + ReadyByTypeMap: map[string][]Bead{ + "bug": { + {ID: "buggy", Description: "Edit cli/a.go to fix Y"}, + }, + }, + } + got, err := Run(context.Background(), br, nil, Config{}) + if err != nil { + t.Fatalf("err: %v", err) + } + if got.LadderStepMatched != 5 || got.RecommendedBead != "buggy" { + t.Errorf("got=%+v, want bead=buggy step=5", got) + } +} + +// TestSiblingPatterns covers the trigger-phrase extraction. +func TestSiblingPatterns(t *testing.T) { + b := Bead{ + Title: "wiring up X", + Description: "Uses Hop C shape and With_X builder pattern; see also sibling pattern from before.", + } + got := siblingPatterns(b) + want := []string{"wiring", "with_x builder", "hop c shape", "sibling pattern"} + if !reflect.DeepEqual(got, want) { + t.Errorf("siblingPatterns = %v, want %v", got, want) + } +} + +// TestDecodeBeadList covers wrap vs raw shapes. +func TestDecodeBeadList(t *testing.T) { + cases := []struct { + in string + want int + }{ + {`[{"id":"a"},{"id":"b"}]`, 2}, + {`{"issues":[{"id":"a"}]}`, 1}, + {`{"items":[{"id":"a"},{"id":"b"},{"id":"c"}]}`, 3}, + {``, 0}, + } + for _, tc := range cases { + got, err := decodeBeadList([]byte(tc.in)) + if err != nil { + t.Errorf("decode %q: %v", tc.in, err) + continue + } + if len(got) != tc.want { + t.Errorf("decode %q: got %d, want %d", tc.in, len(got), tc.want) + } + } +} diff --git a/docs/cli-skills-map.md b/docs/cli-skills-map.md index b90acae1d..d6a60a086 100644 --- a/docs/cli-skills-map.md +++ b/docs/cli-skills-map.md @@ -2,7 +2,7 @@ > Which `ao` commands are called by which skills and hooks — and vice versa. -Auto-audited 2026-04-24; targeted runtime-proof update 2026-04-28. 73 generated CLI command headings, 69 source skills, 12 runtime hook event sections. +Auto-audited 2026-04-24; targeted runtime-proof update 2026-04-28. 74 generated CLI command headings, 69 source skills, 12 runtime hook event sections. Source-of-truth note: `hooks/hooks.json` currently declares the full Claude runtime event surface. `hooks/codex-hooks.json` declares the Codex-native subset that runtime can support. diff --git a/evals/agentops-core/cli-command-surface-matrix.json b/evals/agentops-core/cli-command-surface-matrix.json index ea7741d45..9ed25eb4b 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=73 sub=199 all=272"}, + {"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 fa172f115..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" != "73" || "$sub_count" != "199" || "$all_count" != "272" ]]; 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 272 ]]; then +if [[ "${#commands[@]}" -ne 276 ]]; then printf 'unexpected command matrix size: %s\n' "${#commands[@]}" >&2 exit 1 fi diff --git a/registry.json b/registry.json index 9628c349a..f51ac3203 100644 --- a/registry.json +++ b/registry.json @@ -1,13 +1,13 @@ { "schema_version": 1, - "generated_at": "2026-05-21T15:57:24Z", + "generated_at": "2026-05-21T17:43:30Z", "summary": { "skills": 80, "hooks": 44, "knowledge_stores": 5, "job_types": 14, "eval_files": 62, - "cli_commands": 176 + "cli_commands": 179 }, "surfaces": { "skills": [ @@ -1334,6 +1334,10 @@ "name": "corpus_snapshot", "path": "cli/cmd/ao/corpus_snapshot.go" }, + { + "name": "cron_self_adjust", + "path": "cli/cmd/ao/cron_self_adjust.go" + }, { "name": "curate", "path": "cli/cmd/ao/curate.go" @@ -1386,10 +1390,18 @@ "name": "evolve", "path": "cli/cmd/ao/evolve.go" }, + { + "name": "evolve_blocked", + "path": "cli/cmd/ao/evolve_blocked.go" + }, { "name": "evolve_config", "path": "cli/cmd/ao/evolve_config.go" }, + { + "name": "evolve_next_work", + "path": "cli/cmd/ao/evolve_next_work.go" + }, { "name": "evolve_write_stop_marker", "path": "cli/cmd/ao/evolve_write_stop_marker.go"