mirror of
https://github.com/boshu2/agentops.git
synced 2026-09-14 15:08:13 +08:00
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:
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,6 +2,6 @@
|
||||
"generator": "manual-maintained",
|
||||
"source_skill": "skills/evolve",
|
||||
"layout": "modular",
|
||||
"source_hash": "aa08eade20fc1ddf623db30ccfcf636732247e15c89aca9d01490f6443017b67",
|
||||
"generated_hash": "271b6842ee85283a68665a1ec96d13627dd5a4c508d3876952dffa1671ab458f"
|
||||
"source_hash": "8a3d90fba7d3dce6e5625bde488edac2cee30c549d8ce1817f91b16ec1112cb8",
|
||||
"generated_hash": "b716294f19ce3af4d87423cc5cca8c056bdfa1fb28e8c98873bc2cc42e7a9830"
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user