feat(loop): expose HypothesisLedgerPort + ConvergenceCheckPort via ao loop

Follows the ao loop append/history wiring shape (loop_append.go): each
command pairs an options struct with an injectable *Fn seam and a
*ViaPort production path.

- ao loop hypothesis {list,append} — typed BC3 HypothesisLedgerPort
  surface over .agents/evolve/hypotheses.jsonl (productionHypothesisLedger,
  soc-y5vh.6). append rejects empty/duplicate IDs; list emits one JSON
  HypothesisRecord per line.
- ao loop converged — pure BC3 ConvergenceCheckPort STOP predicate
  (productionConvergenceCheck, soc-y5vh.7). Takes caller-supplied
  evidence (green streak, unconsumed HIGH+MEDIUM, fitness-baseline),
  emits {converged, ci_green_streak, ..., reasons}.
- evolve docs (SKILL.md + convergence-mechanics.md) reference the new
  typed path instead of direct hypotheses/session-convergence reads.
- Cross-harness parity: ported convergence-mechanics.md into the codex
  evolve skill (was 9/19 reference files, now 10/19), harness-adapted
  (Step 7 while-loop STOP vs ScheduleWakeup). Broader evolve drift
  tracked in soc-an3v.
- CLI docs regenerated; cd cli go build/vet/test ./... green
  (11921 tests, 53 packages).

Tests: LoopConverged 5 cases (converged + each unmet reason + observed
streak), LoopHypothesis 4 cases (empty-id reject, stub mapping, JSONL
render, append->list round-trip).

Closes soc-y5vh.8
This commit is contained in:
Boden Fuller
2026-05-16 10:56:23 -04:00
parent b5de6fe59e
commit c85b27f439
11 changed files with 738 additions and 15 deletions
+131
View File
@@ -0,0 +1,131 @@
// practices: [hexagonal-architecture, ddd-bounded-context]
package main
import (
"context"
"encoding/json"
"fmt"
"io"
"os"
"github.com/spf13/cobra"
"github.com/boshu2/agentops/cli/internal/ports"
)
// loopConvergedCmd exposes the pure BC3 ConvergenceCheckPort
// (productionConvergenceCheck, soc-y5vh.7) via the CLI. It is the
// typed replacement for the hand-rolled bash predicate that reads
// .agents/evolve/session-convergence.json directly. soc-y5vh.8.
//
// ConvergenceCheckPort.Check is deliberately pure — it does not fetch
// CI, scan findings, or read fitness files. This command therefore
// takes caller-supplied evidence (the /evolve loop already has
// `ao ci recent` and the findings count) and runs the predicate.
var loopConvergedCmd = &cobra.Command{
Use: "converged",
Short: "Evaluate the evolve convergence STOP predicate via the BC3 ConvergenceCheckPort",
Long: `Evaluate the evolve loop's convergence STOP predicate via the typed
BC3 ConvergenceCheckPort. Emits a JSON object: converged, ci_green_streak,
unconsumed_high_medium, fitness_baseline_captured, reasons.
The default criteria are green CI streak >= 3, unconsumed HIGH+MEDIUM
findings <= 1, and a captured fitness baseline. The predicate is pure —
supply the evidence as flags; this command does not fetch CI itself.
Exit status is always 0 (this is a query). Callers branch on the
"converged" field, e.g. ao loop converged ... | jq -e .converged.
Examples:
ao loop converged --green-streak 3 --unconsumed-high-medium 0 --fitness-baseline
ao loop converged --green-streak 2 --unconsumed-high-medium 4`,
RunE: runLoopConverged,
}
type loopConvergedOptions struct {
greenStreak int
unconsumedHighMedium int
fitnessBaseline bool
writer io.Writer
checkFn func(ctx context.Context, opts loopConvergedOptions) (ports.ConvergenceResult, error)
}
// convergedReport is the snake_case JSON shape emitted to stdout — a
// stable, script-friendly projection of ports.ConvergenceResult.
type convergedReport struct {
Converged bool `json:"converged"`
CIGreenStreak int `json:"ci_green_streak"`
UnconsumedHighMedium int `json:"unconsumed_high_medium"`
FitnessBaselineCaptured bool `json:"fitness_baseline_captured"`
Reasons []string `json:"reasons"`
}
func init() {
loopConvergedCmd.Flags().Int("green-streak", 0, "current leading green CI streak (caller-supplied evidence)")
loopConvergedCmd.Flags().Int("unconsumed-high-medium", 0, "current unconsumed HIGH+MEDIUM finding count")
loopConvergedCmd.Flags().Bool("fitness-baseline", false, "a fitness baseline artifact has been captured")
loopCmd.AddCommand(loopConvergedCmd)
}
func runLoopConverged(cmd *cobra.Command, _ []string) error {
greenStreak, _ := cmd.Flags().GetInt("green-streak")
unconsumed, _ := cmd.Flags().GetInt("unconsumed-high-medium")
fitnessBaseline, _ := cmd.Flags().GetBool("fitness-baseline")
return loopConvergedRun(cmd.Context(), loopConvergedOptions{
greenStreak: greenStreak,
unconsumedHighMedium: unconsumed,
fitnessBaseline: fitnessBaseline,
writer: cmd.OutOrStdout(),
})
}
func loopConvergedRun(ctx context.Context, opts loopConvergedOptions) error {
fn := opts.checkFn
if fn == nil {
fn = loopConvergedViaPort
}
result, err := fn(ctx, opts)
if err != nil {
return fmt.Errorf("loop converged: %w", err)
}
if opts.writer == nil {
opts.writer = os.Stdout
}
report := convergedReport{
Converged: result.Converged,
CIGreenStreak: result.CIGreenStreak,
UnconsumedHighMedium: result.UnconsumedHighMedium,
FitnessBaselineCaptured: result.FitnessBaselineCaptured,
Reasons: result.Reasons,
}
if report.Reasons == nil {
report.Reasons = []string{}
}
if err := json.NewEncoder(opts.writer).Encode(report); err != nil {
return fmt.Errorf("loop converged encode: %w", err)
}
return nil
}
// loopConvergedViaPort runs productionConvergenceCheck against
// caller-supplied evidence. ConvergenceCheckPort.Check counts the
// leading green streak from RecentCIRuns, so the command synthesizes
// opts.greenStreak completed/success runs to express the streak.
func loopConvergedViaPort(ctx context.Context, opts loopConvergedOptions) (ports.ConvergenceResult, error) {
n := opts.greenStreak
if n < 0 {
n = 0
}
runs := make([]ports.CIRun, 0, n)
for i := 0; i < n; i++ {
runs = append(runs, ports.CIRun{
Status: ports.CIRunStatusCompleted,
Conclusion: ports.CIRunConclusionSuccess,
})
}
return newProductionConvergenceCheck().Check(ctx, ports.ConvergenceInput{
RecentCIRuns: runs,
UnconsumedHighMedium: opts.unconsumedHighMedium,
FitnessBaselineCaptured: opts.fitnessBaseline,
})
}
+81
View File
@@ -0,0 +1,81 @@
// practices: [tdd]
package main
import (
"bytes"
"context"
"strings"
"testing"
)
// soc-y5vh.8: `ao loop converged` exposes the pure BC3 ConvergenceCheckPort.
// The default criteria are green streak >=3, HIGH+MEDIUM <=1, baseline
// captured.
func runConverged(t *testing.T, opts loopConvergedOptions) string {
t.Helper()
var buf bytes.Buffer
opts.writer = &buf
if err := loopConvergedRun(context.Background(), opts); err != nil {
t.Fatalf("loopConvergedRun: %v", err)
}
return buf.String()
}
func TestLoopConverged_AllCriteriaMet(t *testing.T) {
out := runConverged(t, loopConvergedOptions{
greenStreak: 3,
unconsumedHighMedium: 0,
fitnessBaseline: true,
})
if !strings.Contains(out, `"converged":true`) {
t.Fatalf("expected converged:true, got %q", out)
}
}
func TestLoopConverged_GreenStreakBelowThreshold(t *testing.T) {
out := runConverged(t, loopConvergedOptions{
greenStreak: 2,
unconsumedHighMedium: 0,
fitnessBaseline: true,
})
if !strings.Contains(out, `"converged":false`) {
t.Fatalf("expected converged:false, got %q", out)
}
if !strings.Contains(out, "ci-green-streak-below-threshold") {
t.Fatalf("expected ci-green-streak reason, got %q", out)
}
}
func TestLoopConverged_UnconsumedAboveThreshold(t *testing.T) {
out := runConverged(t, loopConvergedOptions{
greenStreak: 5,
unconsumedHighMedium: 4,
fitnessBaseline: true,
})
if !strings.Contains(out, "unconsumed-high-medium-above-threshold") {
t.Fatalf("expected unconsumed-high-medium reason, got %q", out)
}
}
func TestLoopConverged_MissingBaseline(t *testing.T) {
out := runConverged(t, loopConvergedOptions{
greenStreak: 3,
unconsumedHighMedium: 0,
fitnessBaseline: false,
})
if !strings.Contains(out, "fitness-baseline-missing") {
t.Fatalf("expected fitness-baseline-missing reason, got %q", out)
}
}
func TestLoopConverged_ReportsObservedStreak(t *testing.T) {
out := runConverged(t, loopConvergedOptions{
greenStreak: 7,
unconsumedHighMedium: 0,
fitnessBaseline: true,
})
if !strings.Contains(out, `"ci_green_streak":7`) {
t.Fatalf("expected observed streak 7 in output, got %q", out)
}
}
+198
View File
@@ -0,0 +1,198 @@
// practices: [hexagonal-architecture, ddd-bounded-context]
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"github.com/spf13/cobra"
"github.com/boshu2/agentops/cli/internal/ports"
)
// loopHypothesisCmd is the BC3 Loop hypothesis-ledger CLI group. It
// exposes productionHypothesisLedger (soc-y5vh.6) so /evolve's
// hypothesis tracking (convergence-mechanics.md Mechanism 3) flows
// through the typed HypothesisLedgerPort instead of direct
// .agents/evolve/hypotheses.jsonl shell reads. soc-y5vh.8.
//
// Sibling pattern: ao loop append/history (loop_append.go, loop.go).
var loopHypothesisCmd = &cobra.Command{
Use: "hypothesis",
Short: "BC3 Loop hypothesis-ledger operations (list, append)",
Long: `Operations on the /evolve hypothesis ledger (.agents/evolve/hypotheses.jsonl) via the typed BC3 HypothesisLedgerPort.`,
}
var loopHypothesisListCmd = &cobra.Command{
Use: "list",
Short: "List evolve hypotheses via the BC3 HypothesisLedgerPort",
Long: `Read .agents/evolve/hypotheses.jsonl via the typed BC3
HypothesisLedgerPort. Emits one JSON HypothesisRecord per line in
append order — a typed replacement for inline jq over the raw ledger.`,
RunE: runLoopHypothesisList,
}
var loopHypothesisAppendCmd = &cobra.Command{
Use: "append --id <id> --hypothesis <h> --measure <m> [flags]",
Short: "Append a hypothesis record via the BC3 HypothesisLedgerPort",
Long: `Append a falsifiable hypothesis to .agents/evolve/hypotheses.jsonl
via the typed BC3 HypothesisLedgerPort. --id is required and must be
unique; a patch names what landed, --check-at-cycle names the future
cycle that evaluates the measure.
Example:
ao loop hypothesis append --id H210.1 --cycle-landed 210 --check-at-cycle 225 \
--patch "Step 1.5 typed CI probe" --hypothesis "removes gh shell-outs" \
--measure "grep -c gh in evolve hot path"`,
RunE: runLoopHypothesisAppend,
}
type loopHypothesisListOptions struct {
writer io.Writer
listFn func(ctx context.Context, opts loopHypothesisListOptions) ([]ports.HypothesisRecord, error)
}
type loopHypothesisAppendOptions struct {
id string
patch string
hypothesis string
measure string
verdict string
cycleLanded int
checkAtCycle int
writer io.Writer
appendFn func(ctx context.Context, opts loopHypothesisAppendOptions) (ports.HypothesisRecord, error)
}
func init() {
loopHypothesisAppendCmd.Flags().String("id", "", "unique hypothesis ID, e.g. H210.1 (required)")
loopHypothesisAppendCmd.Flags().String("patch", "", "one-line description of what landed")
loopHypothesisAppendCmd.Flags().String("hypothesis", "", "expected effect of the patch")
loopHypothesisAppendCmd.Flags().String("measure", "", "how the effect is verified")
loopHypothesisAppendCmd.Flags().String("verdict", "PENDING", "verdict: PENDING|VERIFIED|FALSIFIED")
loopHypothesisAppendCmd.Flags().Int("cycle-landed", 0, "cycle the patch landed")
loopHypothesisAppendCmd.Flags().Int("check-at-cycle", 0, "future cycle that evaluates the measure")
_ = loopHypothesisAppendCmd.MarkFlagRequired("id")
loopHypothesisCmd.AddCommand(loopHypothesisListCmd)
loopHypothesisCmd.AddCommand(loopHypothesisAppendCmd)
loopCmd.AddCommand(loopHypothesisCmd)
}
// evolveHypothesesPath resolves the project-local hypothesis ledger path.
func evolveHypothesesPath() (string, error) {
cwd, err := resolveProjectDir()
if err != nil {
return "", err
}
return filepath.Join(cwd, ".agents", "evolve", "hypotheses.jsonl"), nil
}
// appendHypothesisAt appends one record to the ledger at path, creating
// the parent directory. Path-explicit so tests exercise the real
// production adapter against a temp ledger.
func appendHypothesisAt(ctx context.Context, path string, rec ports.HypothesisRecord) (ports.HypothesisRecord, error) {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return ports.HypothesisRecord{}, fmt.Errorf("mkdir: %w", err)
}
return newProductionHypothesisLedger(path).Append(ctx, rec)
}
// listHypothesesAt reads all records from the ledger at path.
func listHypothesesAt(ctx context.Context, path string) ([]ports.HypothesisRecord, error) {
return newProductionHypothesisLedger(path).List(ctx)
}
func runLoopHypothesisList(cmd *cobra.Command, _ []string) error {
return loopHypothesisListRun(cmd.Context(), loopHypothesisListOptions{writer: cmd.OutOrStdout()})
}
func loopHypothesisListRun(ctx context.Context, opts loopHypothesisListOptions) error {
fn := opts.listFn
if fn == nil {
fn = loopHypothesisListViaPort
}
records, err := fn(ctx, opts)
if err != nil {
return fmt.Errorf("loop hypothesis list: %w", err)
}
if opts.writer == nil {
opts.writer = os.Stdout
}
enc := json.NewEncoder(opts.writer)
for _, rec := range records {
if err := enc.Encode(rec); err != nil {
return fmt.Errorf("loop hypothesis list encode: %w", err)
}
}
return nil
}
func loopHypothesisListViaPort(ctx context.Context, _ loopHypothesisListOptions) ([]ports.HypothesisRecord, error) {
path, err := evolveHypothesesPath()
if err != nil {
return nil, err
}
return listHypothesesAt(ctx, path)
}
func runLoopHypothesisAppend(cmd *cobra.Command, _ []string) error {
id, _ := cmd.Flags().GetString("id")
patch, _ := cmd.Flags().GetString("patch")
hypothesis, _ := cmd.Flags().GetString("hypothesis")
measure, _ := cmd.Flags().GetString("measure")
verdict, _ := cmd.Flags().GetString("verdict")
cycleLanded, _ := cmd.Flags().GetInt("cycle-landed")
checkAtCycle, _ := cmd.Flags().GetInt("check-at-cycle")
return loopHypothesisAppendRun(cmd.Context(), loopHypothesisAppendOptions{
id: id,
patch: patch,
hypothesis: hypothesis,
measure: measure,
verdict: verdict,
cycleLanded: cycleLanded,
checkAtCycle: checkAtCycle,
writer: cmd.OutOrStdout(),
})
}
func loopHypothesisAppendRun(ctx context.Context, opts loopHypothesisAppendOptions) error {
if opts.id == "" {
return errors.New("loop hypothesis append: --id required")
}
fn := opts.appendFn
if fn == nil {
fn = loopHypothesisAppendViaPort
}
rec, err := fn(ctx, opts)
if err != nil {
return fmt.Errorf("loop hypothesis append: %w", err)
}
if opts.writer == nil {
opts.writer = os.Stdout
}
fmt.Fprintf(opts.writer, "appended hypothesis id=%q verdict=%q check_at_cycle=%d\n",
rec.ID, rec.Verdict, rec.CheckAtCycle)
return nil
}
func loopHypothesisAppendViaPort(ctx context.Context, opts loopHypothesisAppendOptions) (ports.HypothesisRecord, error) {
path, err := evolveHypothesesPath()
if err != nil {
return ports.HypothesisRecord{}, err
}
return appendHypothesisAt(ctx, path, ports.HypothesisRecord{
ID: opts.id,
Patch: opts.patch,
Hypothesis: opts.hypothesis,
Measure: opts.measure,
Verdict: ports.HypothesisVerdict(opts.verdict),
CycleLanded: opts.cycleLanded,
CheckAtCycle: opts.checkAtCycle,
})
}
+112
View File
@@ -0,0 +1,112 @@
// practices: [tdd]
package main
import (
"bytes"
"context"
"path/filepath"
"strings"
"testing"
"github.com/boshu2/agentops/cli/internal/ports"
)
// soc-y5vh.8: `ao loop hypothesis {list,append}` exposes the BC3
// HypothesisLedgerPort.
func TestLoopHypothesisAppend_EmptyIDRejected(t *testing.T) {
err := loopHypothesisAppendRun(context.Background(), loopHypothesisAppendOptions{
hypothesis: "raises pass rate",
measure: "count cycles",
})
if err == nil {
t.Fatal("expected error on empty --id")
}
if !strings.Contains(err.Error(), "--id required") {
t.Fatalf("error not informative: %v", err)
}
}
func TestLoopHypothesisAppend_StubCalledWithRecord(t *testing.T) {
var got ports.HypothesisRecord
stub := func(_ context.Context, opts loopHypothesisAppendOptions) (ports.HypothesisRecord, error) {
got = ports.HypothesisRecord{
ID: opts.id,
Patch: opts.patch,
Hypothesis: opts.hypothesis,
Measure: opts.measure,
CycleLanded: opts.cycleLanded,
CheckAtCycle: opts.checkAtCycle,
Verdict: ports.HypothesisVerdict(opts.verdict),
}
return got, nil
}
var buf bytes.Buffer
err := loopHypothesisAppendRun(context.Background(), loopHypothesisAppendOptions{
id: "H210.1",
patch: "Step 1.5 typed CI probe",
hypothesis: "removes gh shell-outs",
measure: "grep -c gh evolve hot path",
cycleLanded: 210,
checkAtCycle: 225,
verdict: "PENDING",
writer: &buf,
appendFn: stub,
})
if err != nil {
t.Fatal(err)
}
if got.ID != "H210.1" || got.CheckAtCycle != 225 || got.Hypothesis != "removes gh shell-outs" {
t.Fatalf("record mis-mapped: %+v", got)
}
if !strings.Contains(buf.String(), "H210.1") {
t.Fatalf("append output missing id: %q", buf.String())
}
}
func TestLoopHypothesisList_RendersRecordsAsJSONL(t *testing.T) {
stub := func(_ context.Context, _ loopHypothesisListOptions) ([]ports.HypothesisRecord, error) {
return []ports.HypothesisRecord{
{ID: "H45.1", Verdict: ports.HypothesisVerdictPending},
{ID: "H45.2", Verdict: ports.HypothesisVerdictFalsified},
}, nil
}
var buf bytes.Buffer
err := loopHypothesisListRun(context.Background(), loopHypothesisListOptions{
writer: &buf,
listFn: stub,
})
if err != nil {
t.Fatal(err)
}
lines := strings.Count(strings.TrimSpace(buf.String()), "\n") + 1
if lines != 2 {
t.Fatalf("expected 2 JSONL lines, got %d: %q", lines, buf.String())
}
if !strings.Contains(buf.String(), `"id":"H45.1"`) || !strings.Contains(buf.String(), `"id":"H45.2"`) {
t.Fatalf("list output missing records: %q", buf.String())
}
}
// L2: append then list round-trip through the production adapter at an
// explicit ledger path.
func TestLoopHypothesis_AppendListRoundTrip(t *testing.T) {
path := filepath.Join(t.TempDir(), "hypotheses.jsonl")
rec := ports.HypothesisRecord{
ID: "H300.1",
Hypothesis: "round-trips",
Measure: "this test",
CheckAtCycle: 315,
Verdict: ports.HypothesisVerdictPending,
}
if _, err := appendHypothesisAt(context.Background(), path, rec); err != nil {
t.Fatalf("appendHypothesisAt: %v", err)
}
records, err := listHypothesesAt(context.Background(), path)
if err != nil {
t.Fatalf("listHypothesesAt: %v", err)
}
if len(records) != 1 || records[0].ID != "H300.1" || records[0].CheckAtCycle != 315 {
t.Fatalf("round-trip mismatch: %+v", records)
}
}
+54
View File
@@ -566,6 +566,23 @@ ao loop append --mode <m> --result <r> [flags]
--trace-json string XP/BDD/TDD evidence trace as a JSON object — a file path or inline JSON (optional)
```
#### `ao loop converged`
Evaluate the evolve loop's convergence STOP predicate via the typed
```
ao loop converged [flags]
```
**Flags:**
```
--fitness-baseline a fitness baseline artifact has been captured
--green-streak int current leading green CI streak (caller-supplied evidence)
-h, --help help for converged
--unconsumed-high-medium int current unconsumed HIGH+MEDIUM finding count
```
#### `ao loop history`
Read .agents/evolve/cycle-history.jsonl via the typed BC3 LoopReaderPort.
@@ -584,6 +601,43 @@ ao loop history [flags]
--start int start cycle number (inclusive; 0 = unbounded)
```
#### `ao loop hypothesis`
Operations on the /evolve hypothesis ledger (.agents/evolve/hypotheses.jsonl) via the typed BC3 HypothesisLedgerPort.
```
ao loop hypothesis [command]
```
##### `ao loop hypothesis append`
Append a falsifiable hypothesis to .agents/evolve/hypotheses.jsonl
```
ao loop hypothesis append --id <id> --hypothesis <h> --measure <m> [flags]
```
**Flags:**
```
--check-at-cycle int future cycle that evaluates the measure
--cycle-landed int cycle the patch landed
-h, --help help for append
--hypothesis string expected effect of the patch
--id string unique hypothesis ID, e.g. H210.1 (required)
--measure string how the effect is verified
--patch string one-line description of what landed
--verdict string verdict: PENDING|VERIFIED|FALSIFIED (default "PENDING")
```
##### `ao loop hypothesis list`
Read .agents/evolve/hypotheses.jsonl via the typed BC3
```
ao loop hypothesis list [flags]
```
#### `ao loop verify`
Audit .agents/evolve/cycle-history.jsonl integrity via the typed
+2 -2
View File
@@ -769,8 +769,8 @@
{
"name": "evolve",
"source_skill": "skills/evolve",
"source_hash": "aa08eade20fc1ddf623db30ccfcf636732247e15c89aca9d01490f6443017b67",
"generated_hash": "271b6842ee85283a68665a1ec96d13627dd5a4c508d3876952dffa1671ab458f"
"source_hash": "8a3d90fba7d3dce6e5625bde488edac2cee30c549d8ce1817f91b16ec1112cb8",
"generated_hash": "b716294f19ce3af4d87423cc5cca8c056bdfa1fb28e8c98873bc2cc42e7a9830"
},
{
"name": "flywheel",
+2 -2
View File
@@ -2,6 +2,6 @@
"generator": "manual-maintained",
"source_skill": "skills/evolve",
"layout": "modular",
"source_hash": "aa08eade20fc1ddf623db30ccfcf636732247e15c89aca9d01490f6443017b67",
"generated_hash": "271b6842ee85283a68665a1ec96d13627dd5a4c508d3876952dffa1671ab458f"
"source_hash": "8a3d90fba7d3dce6e5625bde488edac2cee30c549d8ce1817f91b16ec1112cb8",
"generated_hash": "b716294f19ce3af4d87423cc5cca8c056bdfa1fb28e8c98873bc2cc42e7a9830"
}
+17
View File
@@ -592,6 +592,21 @@ while true; do
done
```
**Convergence STOP.** Before re-entering the loop, evaluate the terminal
predicate through the typed BC3 `ConvergenceCheckPort` (soc-y5vh.8):
```bash
ao loop converged --green-streak "$STREAK" --unconsumed-high-medium "$HM" --fitness-baseline
# emits {converged, ci_green_streak, unconsumed_high_medium, fitness_baseline_captured, reasons}
```
Branch on `.converged` instead of hand-parsing `.agents/evolve/session-convergence.json`.
When `converged` is true (default criteria: CI green streak ≥ 3, unconsumed
HIGH+MEDIUM ≤ 1, fitness baseline captured), break the loop and run Teardown.
When a cycle edits an evolve `SKILL.md`, record the falsifiable claim through
`ao loop hypothesis append` (read it back with `ao loop hypothesis list`).
See `references/convergence-mechanics.md` for all four compounding mechanisms.
Push only when productive work has accumulated:
```bash
if [ $((PRODUCTIVE_THIS_SESSION % 5)) -eq 0 ] && [ "$PRODUCTIVE_THIS_SESSION" -gt 0 ]; then
@@ -715,6 +730,7 @@ See `references/cycle-history.md` for advanced troubleshooting.
- [references/artifacts.md](references/artifacts.md)
- [references/compounding.md](references/compounding.md)
- [references/convergence-mechanics.md](references/convergence-mechanics.md)
- [references/cycle-history.md](references/cycle-history.md)
- [references/examples.md](references/examples.md)
- [references/goals-schema.md](references/goals-schema.md)
@@ -729,6 +745,7 @@ See `references/cycle-history.md` for advanced troubleshooting.
- [references/artifacts.md](references/artifacts.md)
- [references/compounding.md](references/compounding.md)
- [references/convergence-mechanics.md](references/convergence-mechanics.md)
- [references/cycle-history.md](references/cycle-history.md)
- [references/examples.md](references/examples.md)
- [references/goals-schema.md](references/goals-schema.md)
@@ -0,0 +1,110 @@
# Convergence Mechanics — How the Loop Compounds Instead of Drifts
The $evolve loop only compounds when each cycle reads prior cycles' outcomes and lets them change behavior. Append-only ledgers that no step reads are write-only artifacts — they accumulate without compounding.
This reference documents the four feedback mechanisms that turn raw cycle output into next-cycle behavior change.
## Mechanism 1: Step 0 reads prior-failure surface
In Step 0 (Setup), after `mkdir -p .agents/evolve`, the loop reads the last 3 entries of `cycle-history.jsonl`. For any entry where `gate` field contains a FAIL marker, it extracts the failure surface (e.g. "registry-check stale", "bats-tests goals-validate") and injects the matching learning before work selection.
```bash
last3=$(scripts/evolve-read-cycle-history.sh recent 3) # routes through BC3 LoopReaderPort (soc-y5vh.4)
fail_surfaces=$(echo "$last3" | jq -r 'select(.gate | test("FAIL|FAILED|BLOCKED")) | .gate' 2>/dev/null)
if [ -n "$fail_surfaces" ]; then
# Search learnings for surface keywords; print whichever match
keywords=$(echo "$fail_surfaces" | grep -oE 'registry|bats|markdown|supergate|canary|coverage|toolchain' | sort -u)
for kw in $keywords; do
ao lookup --query "$kw failure" --limit 2 2>/dev/null || \
find .agents/learnings -name "*$kw*.md" -mtime -30 | head -2
done
fi
```
Without this, the 2026-05-07 CI-toil learning sat for 7 days while 5 cycles re-hit the same `registry.json` non-determinism. Reading the learning at Step 0 would have surfaced the `git ls-files` fix on cycle 45.
## Mechanism 2: Healing-first classifier
Before measuring fitness and selecting work, the loop classifies the cycle:
```bash
# Healing-first classifier — routes through BC2 CIStatusPort
# (cli/cmd/ao/ci_status_adapter.go, productionCIStatus) per soc-y5vh.2.
# No inline gh shell-outs.
last_ci=$(ao ci recent --limit 1 2>/dev/null | jq -r '.Conclusion // empty')
if [ "$last_ci" = "failure" ]; then
CYCLE_MODE="restorative"
# Read failure surface, search for matching learning (see Mechanism 1).
# Selection ladder downgrade: only allow harvested items typed
# bug/fix/ci-failure.
else
CYCLE_MODE="feature"
fi
```
Restorative cycles ONLY take work that reduces CI red. New PG4 promotions, feature additions, doc growth — all blocked until `last_ci=success`.
This eliminates the pattern of adding new evidence files onto a CI-red base.
## Mechanism 3: Hypothesis tracking for skill changes
When a cycle edits `skills/evolve/SKILL.md` (or `skills-codex/evolve/SKILL.md`),
it MUST append to the hypothesis ledger through the typed BC3
`HypothesisLedgerPort` (soc-y5vh.8):
```bash
ao loop hypothesis append --id "H<cycle>.<patch>" --cycle-landed N --check-at-cycle $((N+15)) \
--patch "<one-line>" --hypothesis "<expected effect>" --measure "<how to verify>"
```
This routes through `productionHypothesisLedger` instead of a raw append to
`.agents/evolve/hypotheses.jsonl`; the port rejects empty and duplicate IDs.
At `check_at_cycle`, the loop reads the ledger with `ao loop hypothesis list`
(one JSON record per line), evaluates each PENDING row's `measure`, and
writes the verdict (VERIFIED / FALSIFIED). Falsified hypotheses are
revisited: either the patch is wrong, or the measurement was wrong.
The `ao loop hypothesis` subcommands are runtime-agnostic — the same `ao`
binary serves Claude Code and Codex; only the surrounding loop driver differs.
Without this, skill-edit patches land unmeasured and silently inert — text in SKILL.md with no harness automation behind them.
## Mechanism 4: Convergence criteria with a STOP
`.agents/evolve/session-convergence.json` records the terminal state; the STOP
decision is evaluated through the typed BC3 `ConvergenceCheckPort` (soc-y5vh.8):
```bash
ao loop converged --green-streak "$STREAK" --unconsumed-high-medium "$HM" --fitness-baseline
# emits {converged, ci_green_streak, unconsumed_high_medium, fitness_baseline_captured, reasons}
```
The predicate is pure — the loop supplies the evidence it already has
(`ao ci recent` for the streak, the next-work findings count, the
fitness-baseline flag). The criteria are met when all hold:
- CI Validate green for the last 3 pushes (green streak ≥ 3)
- HIGH+MEDIUM unconsumed next-work entries ≤ 1
- a fitness baseline has been captured
When `ao loop converged` reports `converged: true`, the loop emits a teardown
report and breaks the Step 7 loop — it does NOT re-enter Step 1. The
autonomous loop is bounded by criteria, not by cycle count. `reasons` names
every unmet criterion when `converged` is false.
> Harness note: in Codex the loop is the Step 7 `while` loop, so convergence
> means breaking that loop into Teardown. In the Claude Code harness the dual
> mechanism is an end-of-turn `ScheduleWakeup` that simply is not re-armed.
> Same intent — a criteria-bounded STOP — different harness primitive.
Without an explicit STOP, the loop drifts indefinitely. With STOP, it converges.
## Anti-drift rules
1. **Restorative-only after red.** Any cycle whose `gate` field has FAIL → cycle N+1 is restorative.
2. **3 consecutive restorative without restoration → escalate.** Don't silently grind.
3. **Scope shift resets the streak.** If the operator broadens the convergence target mid-session, reset the `ci-green-streak` counter to 0.
## Why this is the load-bearing change
A loop can write ~30 KB of bookkeeping per arc (cycle-history, learning, retro, evidence, hypotheses) and still produce ~0 compounded behavior — every cycle re-deriving a lesson an earlier cycle should have surfaced. The compounding lives in the read path, not the write path. These four mechanisms make the read path real.
+1 -1
View File
@@ -203,7 +203,7 @@ CYCLE_START_SHA=$(git rev-parse HEAD)
Before fitness or work selection, classify the cycle: `ao ci recent --limit 1 2>/dev/null | jq -r '.Conclusion // empty'`. The command routes through the typed BC2 `CIStatusPort` (`cli/cmd/ao/ci_status_adapter.go`, cycle 117 productionCIStatus) — no inline `gh` shell-outs in the evolve hot path (soc-y5vh.2). If the last push CI was `failure`, this cycle is **restorative-only** — Step 3 selection MUST take only work that reduces CI red (bug-type harvested items, gate-failure-fix beads, or generator output typed bug). No PG4 promotions, feature additions, or new shape work allowed until CI is green. The cycle-history.jsonl `gate` field of any FAIL cycle automatically triggers this mode for cycle N+1. See `references/convergence-mechanics.md`.
**Convergence check:** read `.agents/evolve/session-convergence.json` if present. If ALL criteria are met (CI green streak ≥ 3, outstanding HIGH+MEDIUM next-work ≤ 1, fitness baseline), emit teardown and DO NOT re-arm wakeup.
**Convergence check:** evaluate the STOP predicate through the typed BC3 `ConvergenceCheckPort``ao loop converged --green-streak <n> --unconsumed-high-medium <n> [--fitness-baseline]` (soc-y5vh.8). It emits `{converged, ci_green_streak, unconsumed_high_medium, fitness_baseline_captured, reasons}`; branch on `.converged` instead of hand-parsing `.agents/evolve/session-convergence.json`. If `converged` is true (default criteria: CI green streak ≥ 3, outstanding HIGH+MEDIUM next-work ≤ 1, fitness baseline captured), emit teardown and DO NOT re-arm wakeup.
### Step 2: Measure Fitness
@@ -2,7 +2,7 @@
The /evolve loop only compounds when each cycle reads prior cycles' outcomes and lets them change behavior. Append-only ledgers that no step reads are write-only artifacts — they accumulate without compounding.
This reference documents the three feedback mechanisms that turn raw cycle output into next-cycle behavior change.
This reference documents the four feedback mechanisms that turn raw cycle output into next-cycle behavior change.
## Mechanism 1: Step 0 reads prior-failure surface
@@ -49,25 +49,45 @@ This eliminates the cycle-46-47 pattern where I added new evidence files onto a
## Mechanism 3: Hypothesis tracking for skill changes
When a cycle edits `skills/evolve/SKILL.md`, it MUST append to `.agents/evolve/hypotheses.jsonl` with shape:
When a cycle edits `skills/evolve/SKILL.md`, it MUST append to the hypothesis
ledger through the typed BC3 `HypothesisLedgerPort` (soc-y5vh.8):
```json
{"id":"H<cycle>.<patch>","cycle_landed":N,"check_at_cycle":N+15,"patch":"<one-line>","hypothesis":"<expected effect>","measure":"<how to verify>","verdict":"PENDING"}
```bash
ao loop hypothesis append --id "H<cycle>.<patch>" --cycle-landed N --check-at-cycle $((N+15)) \
--patch "<one-line>" --hypothesis "<expected effect>" --measure "<how to verify>"
```
At `check_at_cycle`, the loop reads `hypotheses.jsonl`, evaluates each PENDING row's `measure`, and writes the verdict (VERIFIED / FALSIFIED). Falsified hypotheses are revisited: either the patch is wrong, or the measurement was wrong.
This routes through `productionHypothesisLedger` instead of a raw append to
`.agents/evolve/hypotheses.jsonl`; the port rejects empty and duplicate IDs.
At `check_at_cycle`, the loop reads the ledger with `ao loop hypothesis list`
(one JSON record per line), evaluates each PENDING row's `measure`, and
writes the verdict (VERIFIED / FALSIFIED). Falsified hypotheses are
revisited: either the patch is wrong, or the measurement was wrong.
Without this, cycle 45's 6 patches landed unmeasured and 2 of them (H45.2 source-surface auto-rebuild, H45.3 grep-based gate parsing) were silently inert for the next 5 cycles — text in SKILL.md but no harness automation behind them.
## Mechanism 4: Convergence criteria with a STOP
`.agents/evolve/session-convergence.json` defines the terminal state. When all criteria are met:
`.agents/evolve/session-convergence.json` records the terminal state; the STOP
decision is evaluated through the typed BC3 `ConvergenceCheckPort` (soc-y5vh.8):
- CI Validate green for the last 3 pushes
- HIGH+MEDIUM unconsumed next-work entries <= 1
- Fitness score >= session baseline
```bash
ao loop converged --green-streak "$STREAK" --unconsumed-high-medium "$HM" --fitness-baseline
# emits {converged, ci_green_streak, unconsumed_high_medium, fitness_baseline_captured, reasons}
```
The loop emits a teardown report and does NOT call `ScheduleWakeup`. The wakeup chain terminates. The autonomous loop is bounded by criteria, not by wakeup count.
The predicate is pure — the loop supplies the evidence it already has
(`ao ci recent` for the streak, the next-work findings count, the
fitness-baseline flag). The criteria are met when all hold:
- CI Validate green for the last 3 pushes (green streak ≥ 3)
- HIGH+MEDIUM unconsumed next-work entries ≤ 1
- a fitness baseline has been captured
When `ao loop converged` reports `converged: true`, the loop emits a teardown
report and does NOT call `ScheduleWakeup`. The wakeup chain terminates. The
autonomous loop is bounded by criteria, not by wakeup count. `reasons` names
every unmet criterion when `converged` is false.
Without an explicit STOP, the loop drifts indefinitely. With STOP, it converges.