Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions .agents/evolve/preferences.yaml.template
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# /evolve per-repo preferences (template)
#
# Copy this file to `.agents/evolve/preferences.yaml` and edit. The actual
# preferences file is gitignored — each repo and operator picks their own
# physics for the autonomous /evolve loop.
#
# Resolution order:
# 1. defaults (built-in Go constants)
# 2. this file overrides defaults
# 3. CLI flag overrides this file (caller-applied)
#
# Invalid keys, types, or out-of-range values produce a startup error with
# file:line:column context. No silent fallback.
#
# Inspect the resolved state with: `ao evolve config --show`
# Or as JSON: `ao evolve config --show --json`

# Schema version. Must equal 1 for this template.
schema_version: 1

# Default mode when /evolve is invoked without a mode flag.
# burst — single supervised cycle, then exit (default).
# loop — keep cycling until the queue stabilizes or a halt signal fires.
mode_default: burst

# When does /evolve narrow from explore (scout) to exploit (productive)?
scope_filter:
# Number of productive cycles required before scope narrows. Range [1..100].
productive_threshold: 5
# Halt the loop on a streak of pure-scout (no productive change) cycles.
scout_streak_halt: true

# If true, treat a missing or stale `recommended` pointer as an error rather
# than a soft warning.
recommended_pointer_strict: true

# Filesystem flags that halt the loop. Relative paths from the repo root.
halt_signals:
- .agents/evolve/STOP
- .agents/evolve/KILL

# When true, the generator skill applies layered templates during cycles.
generator_layers_enabled: true
8 changes: 8 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -158,5 +158,13 @@ packs/

# AgentOps session artifacts
.agents/
# Re-include the per-repo evolve preferences template (soc-6svt). The template
# ships in the repo as a starter; the actual preferences.yaml stays per-operator
# and remains gitignored. We have to re-allowlist each ancestor directory after
# the unanchored `.agents/` re-exclude above — git won't recurse into an
# excluded directory to evaluate file-level negations.
!/.agents/
!/.agents/evolve/
!/.agents/evolve/preferences.yaml.template
evals/workbench/scorecard-latest.json
.doctor/
74 changes: 74 additions & 0 deletions cli/cmd/ao/evolve_config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package main

import (
"encoding/json"
"fmt"
"io"

"github.com/boshu2/agentops/cli/internal/evolve"
"github.com/spf13/cobra"
"gopkg.in/yaml.v3"
)

var (
evolveConfigShow bool
evolveConfigJSON bool
)

var evolveConfigCmd = &cobra.Command{
Use: "config",
Short: "Show per-repo /evolve preferences",
Long: `Display the resolved per-repo /evolve preferences.

Reads .agents/evolve/preferences.yaml (gitignored per-repo) on top of the
built-in defaults. A missing file is not an error — defaults are shown. A
malformed file exits 1 with file:line:column context for operator triage.

Resolution order (caller applies step 3):
1. defaults (built-in Go constants)
2. .agents/evolve/preferences.yaml
3. CLI flag overrides

Examples:
ao evolve config --show # YAML output (default when --show is set)
ao evolve config --show --json # JSON output`,
RunE: runEvolveConfig,
}

func init() {
evolveConfigCmd.Flags().BoolVar(&evolveConfigShow, "show", false, "Print the resolved preferences (defaults + preferences.yaml)")
evolveConfigCmd.Flags().BoolVar(&evolveConfigJSON, "json", false, "Emit JSON instead of YAML")
evolveCmd.AddCommand(evolveConfigCmd)
}

// runEvolveConfig loads preferences and prints them in YAML or JSON.
func runEvolveConfig(cmd *cobra.Command, _ []string) error {
if !evolveConfigShow {
return fmt.Errorf("ao evolve config: pass --show to print preferences")
}
prefs, err := evolve.Load(cmd.Context())
if err != nil {
return err
}
return writeEvolvePrefs(cmd.OutOrStdout(), prefs, evolveConfigJSON)
}

// writeEvolvePrefs serializes prefs to w as YAML or JSON depending on asJSON.
func writeEvolvePrefs(w io.Writer, prefs *evolve.Prefs, asJSON bool) error {
if asJSON {
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
if err := enc.Encode(prefs); err != nil {
return fmt.Errorf("encode json: %w", err)
}
return nil
}
data, err := yaml.Marshal(prefs)
if err != nil {
return fmt.Errorf("encode yaml: %w", err)
}
if _, err := w.Write(data); err != nil {
return fmt.Errorf("write yaml: %w", err)
}
return nil
}
205 changes: 205 additions & 0 deletions cli/cmd/ao/evolve_config_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
package main

import (
"bytes"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"

"gopkg.in/yaml.v3"
)

// resetEvolveConfigFlags resets the package-level flags between subtests so
// state from a prior run doesn't leak.
func resetEvolveConfigFlags(t *testing.T) {
t.Helper()
t.Cleanup(func() {
evolveConfigShow = false
evolveConfigJSON = false
})
}

// writeEvolvePrefsFile writes contents to <dir>/.agents/evolve/preferences.yaml.
func writeEvolvePrefsFile(t *testing.T, dir, contents string) {
t.Helper()
full := filepath.Join(dir, ".agents", "evolve")
if err := os.MkdirAll(full, 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
path := filepath.Join(full, "preferences.yaml")
if err := os.WriteFile(path, []byte(contents), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
}

// runEvolveConfigCmd executes `ao evolve config <args...>` with a fresh
// stdout/stderr buffer and returns (stdout, stderr, err).
func runEvolveConfigCmd(t *testing.T, args ...string) (string, string, error) {
t.Helper()
var stdout, stderr bytes.Buffer
rootCmd.SetOut(&stdout)
rootCmd.SetErr(&stderr)
full := append([]string{"evolve", "config"}, args...)
rootCmd.SetArgs(full)
err := rootCmd.Execute()
rootCmd.SetOut(nil)
rootCmd.SetErr(nil)
return stdout.String(), stderr.String(), err
}

func TestEvolveConfig_MissingFile_PrintsDefaults_YAML(t *testing.T) {
dir := chdirTemp(t)
_ = dir
resetEvolveConfigFlags(t)

stdout, _, err := runEvolveConfigCmd(t, "--show")
if err != nil {
t.Fatalf("evolve config: %v", err)
}

// Round-trip the YAML back into a map and assert the canonical default values.
var got map[string]any
if uerr := yaml.Unmarshal([]byte(stdout), &got); uerr != nil {
t.Fatalf("unmarshal yaml: %v\n--- output ---\n%s", uerr, stdout)
}
if v, _ := got["schema_version"].(int); v != 1 {
t.Fatalf("schema_version: want 1, got %v", got["schema_version"])
}
if v, _ := got["mode_default"].(string); v != "burst" {
t.Fatalf("mode_default: want burst, got %v", got["mode_default"])
}
sf, ok := got["scope_filter"].(map[string]any)
if !ok {
t.Fatalf("scope_filter missing or wrong type: %v", got["scope_filter"])
}
if v, _ := sf["productive_threshold"].(int); v != 5 {
t.Fatalf("scope_filter.productive_threshold: want 5, got %v", sf["productive_threshold"])
}
if v, _ := sf["scout_streak_halt"].(bool); v != true {
t.Fatalf("scope_filter.scout_streak_halt: want true, got %v", sf["scout_streak_halt"])
}
if v, _ := got["recommended_pointer_strict"].(bool); v != true {
t.Fatalf("recommended_pointer_strict: want true, got %v", got["recommended_pointer_strict"])
}
if v, _ := got["generator_layers_enabled"].(bool); v != true {
t.Fatalf("generator_layers_enabled: want true, got %v", got["generator_layers_enabled"])
}
signals, ok := got["halt_signals"].([]any)
if !ok || len(signals) != 2 {
t.Fatalf("halt_signals: want list of len 2, got %v", got["halt_signals"])
}
if s, _ := signals[0].(string); s != ".agents/evolve/STOP" {
t.Fatalf("halt_signals[0]: want .agents/evolve/STOP, got %v", signals[0])
}
}

func TestEvolveConfig_ValidFile_OverridesDefaults_YAML(t *testing.T) {
dir := chdirTemp(t)
resetEvolveConfigFlags(t)
writeEvolvePrefsFile(t, dir, `schema_version: 1
mode_default: loop
scope_filter:
productive_threshold: 17
scout_streak_halt: false
recommended_pointer_strict: false
halt_signals:
- .agents/evolve/STOP
generator_layers_enabled: false
`)

stdout, _, err := runEvolveConfigCmd(t, "--show")
if err != nil {
t.Fatalf("evolve config: %v", err)
}

var got map[string]any
if uerr := yaml.Unmarshal([]byte(stdout), &got); uerr != nil {
t.Fatalf("unmarshal yaml: %v\n--- output ---\n%s", uerr, stdout)
}
if v, _ := got["mode_default"].(string); v != "loop" {
t.Fatalf("mode_default: want loop, got %v", got["mode_default"])
}
sf := got["scope_filter"].(map[string]any)
if v, _ := sf["productive_threshold"].(int); v != 17 {
t.Fatalf("productive_threshold: want 17, got %v", sf["productive_threshold"])
}
if v, _ := sf["scout_streak_halt"].(bool); v != false {
t.Fatalf("scout_streak_halt: want false, got %v", sf["scout_streak_halt"])
}
if v, _ := got["recommended_pointer_strict"].(bool); v != false {
t.Fatalf("recommended_pointer_strict: want false, got %v", got["recommended_pointer_strict"])
}
if v, _ := got["generator_layers_enabled"].(bool); v != false {
t.Fatalf("generator_layers_enabled: want false, got %v", got["generator_layers_enabled"])
}
}

func TestEvolveConfig_JSONFlag_ProducesValidJSON(t *testing.T) {
dir := chdirTemp(t)
_ = dir
resetEvolveConfigFlags(t)

stdout, _, err := runEvolveConfigCmd(t, "--show", "--json")
if err != nil {
t.Fatalf("evolve config --json: %v", err)
}

var got map[string]any
if jerr := json.Unmarshal([]byte(stdout), &got); jerr != nil {
t.Fatalf("json.Unmarshal: %v\n--- output ---\n%s", jerr, stdout)
}
// JSON numbers come back as float64.
if v, _ := got["schema_version"].(float64); v != 1 {
t.Fatalf("schema_version: want 1, got %v", got["schema_version"])
}
if v, _ := got["mode_default"].(string); v != "burst" {
t.Fatalf("mode_default: want burst, got %v", got["mode_default"])
}
sf, ok := got["scope_filter"].(map[string]any)
if !ok {
t.Fatalf("scope_filter missing or wrong type: %v", got["scope_filter"])
}
if v, _ := sf["productive_threshold"].(float64); v != 5 {
t.Fatalf("productive_threshold: want 5, got %v", sf["productive_threshold"])
}
}

func TestEvolveConfig_MalformedFile_ErrorsWithFileLineContext(t *testing.T) {
dir := chdirTemp(t)
resetEvolveConfigFlags(t)
writeEvolvePrefsFile(t, dir, `schema_version: 1
scope_filter:
productive_threshold: "abc"
`)

_, _, err := runEvolveConfigCmd(t, "--show")
if err == nil {
t.Fatal("expected error from malformed preferences.yaml, got nil")
}
msg := err.Error()
if !strings.Contains(msg, "preferences.yaml:") {
t.Errorf("error %q missing preferences.yaml: prefix (file:line context)", msg)
}
if !strings.Contains(msg, "scope_filter.productive_threshold") {
t.Errorf("error %q missing field name", msg)
}
if !strings.Contains(msg, "expected int") {
t.Errorf("error %q missing type-mismatch description", msg)
}
}

func TestEvolveConfig_WithoutShowFlag_Errors(t *testing.T) {
dir := chdirTemp(t)
_ = dir
resetEvolveConfigFlags(t)

_, _, err := runEvolveConfigCmd(t)
if err == nil {
t.Fatal("expected error when --show is not set, got nil")
}
if !strings.Contains(err.Error(), "--show") {
t.Errorf("error %q missing --show hint", err.Error())
}
}
20 changes: 19 additions & 1 deletion cli/docs/COMMANDS.md
Original file line number Diff line number Diff line change
Expand Up @@ -1781,7 +1781,7 @@ ao eval task show <task-id> [flags]
Run the v2 autonomous improvement loop.

```
ao evolve [goal] [flags]
ao evolve [command]
```

**Flags:**
Expand Down Expand Up @@ -1825,6 +1825,24 @@ ao evolve [goal] [flags]
--supervisor Enable autonomous supervisor mode (lease lock, self-heal, retries, gates, cleanup) (default true)
```

**Subcommands:**

#### `ao evolve config`

Display the resolved per-repo /evolve preferences.

```
ao evolve config [flags]
```

**Flags:**

```
-h, --help help for config
--json Emit JSON instead of YAML
--show Print the resolved preferences (defaults + preferences.yaml)
```

---

### `ao factory`
Expand Down
Loading
Loading