Skill: lanyasheng/autoloop-controller (version 1.1.1)

--- name: autoloop-controller description: "When continuous automated improvement of a Skill is needed. Wraps improvement-orchestrator in a persistent loop with convergence detection (plateau/oscillation), cost control, and cross-session state persistence. Not for single-shot improvement (use improvement-orchestrator) or quality scoring (use improvement-learner)." license: MIT triggers: - continuous improvement - autoloop - keep improving - 持续改进 - 自动循环 - 一直优化 --- # Autoloop Controller Wraps improvement-orchestrator in a persistent loop with convergence detection and cost control. Each iteration runs the full 5-stage pipeline (generate, discriminate, evaluate, execute, gate), then checks five termination conditions before deciding whether to continue. State is persisted to disk after every iteration, so the loop survives crashes and can resume across sessions. ## When to Use - Continuously improve a skill over multiple iterations until scores plateau - Run overnight improvement (Karpathy autoresearch style) where you start the loop, walk away, and review results next morning - Schedule periodic improvement cycles via system cron (scheduled mode exits after each run, cron triggers the next) - Resume a previously interrupted improvement run from saved state - Apply budget-constrained batch improvement when you want to spend at most $N improving a skill - Drive a skill from EMERGING to SOLID quality tier through compounding gains across rounds - Detect and halt on oscillation patterns (keep-reject-keep-reject) that waste resources without convergence - Compare improvement velocity across skills by examining iteration_log.jsonl outputs ## When NOT to Use - **Single-shot improvement** -- use `improvement-orchestrator` directly; the autoloop overhead (state persistence, convergence checks) adds no value for one-off runs - **Only want quality scores** -- use `improvement-learner`; the autoloop controller calls the orchestrator which does more than just scoring - **Only want baseline/benchmark data** -- use `benchmark-store`; autoloop does not interact with the Pareto front database directly - **Manual interactive improvement** -- autoloop is designed for unattended operation; if you want to review each candidate before applying, run orchestrator manually ## Why Continuous Loop with Convergence Detection **Problem**: A single improvement-orchestrator run typically raises 1-2 quality dimensions by 0.05-0.15 points. Moving a skill from EMERGING (weighted score < 0.60) to SOLID (> 0.80) requires 4-8 compounding rounds because each round exposes new weaknesses that were masked by more severe ones. Running these rounds manually means remembering to re-invoke, tracking which iteration you are on, and monitoring for diminishing returns. **Tradeoff**: Automated looping captures compounding gains that single-shot improvement misses -- each round fixes issues revealed by the previous round's improvements. But unbounded loops waste money: after 3-5 rounds, most skills hit a plateau where further iterations produce reject decisions or marginal gains below noise. The default $50 cost cap and 3-round plateau window balance thoroughness against waste. Because the loop persists state to disk after every iteration, a crash at iteration 4 of 10 loses zero progress -- the next invocation picks up at iteration 5 with full score history intact. **Because** convergence detection uses two independent signals (plateau: no score improvement over N rounds; oscillation: alternating keep/reject decisions), the controller avoids both the false-stop problem (plateau alone would halt on a temporary dip followed by recovery) and the infinite-loop problem (oscillation alone would not catch gradual flatlining). The circuit breaker (consecutive errors) adds a third safety net for infrastructure failures. ## 3 Modes | Mode | Trigger | Behavior | Best For | |------|---------|----------|----------| | single-run | CLI one-shot | Runs all iterations in sequence, then exits | Batch improvement during work hours | | continuous | CLI long-running | Loop with configurable cooldown between iterations | Overnight unattended runs | | scheduled | System cron | Exits after one iteration; cron triggers the next run | Production recurring improvement | In **single-run** mode, the controller loops through all iterations within a single process invocation, checking termination conditions between each. In **continuous** mode, it inserts a cooldown period (default 30 minutes) between iterations to spread LLM API load. In **scheduled** mode, the process exits after each iteration and relies on an external scheduler (cron) to invoke the next run; the persisted state file ensures continuity. ## Termination (5 conditions, OR logic) The controller evaluates all five conditions after each iteration. Any single condition triggers a stop. 1. **max_iterations reached** -- Hard cap on total pipeline runs. Default: 5. Set via `--max-iterations`. The controller compares `iterations_completed` against this cap. Use higher values (10-15) for skills starting at EMERGING tier; lower values (3-5) for skills already at SOLID. 2. **cost_cap exceeded** -- Cumulative estimated cost across all iterations. Default: $50. Set via `--max-cost`. Cost is estimated at ~$0.10/minute of pipeline execution time. When the running total meets or exceeds the cap, the loop halts with status `stopped_cost`. Always set this explicitly in continuous mode to avoid surprise bills. 3. **score plateau detected** -- No weighted score improvement over the last N consecutive rounds (default N=3, set via `--plateau-window`). The detector compares the best score in the most recent N rounds against the historical best before that window. A plateau means the skill has reached a local optimum for the current improvement strategy. 4. **oscillation detected** -- Alternating keep/reject decisions over the last 4 rounds (e.g., keep-reject-keep-reject). This pattern indicates the generator is producing changes that pass the gate on one round but cause regressions caught on the next. Oscillation wastes cost without net progress. 5. **consecutive errors exceeded** -- N consecutive pipeline failures (default N=3, set via `--max-consecutive-errors`). Acts as a circuit breaker for infrastructure issues (LLM rate limits, network failures, disk full). The counter resets to zero after any successful iteration. ```python # Termination check logic (simplified from autoloop.py) def should_stop(state) -> tuple[bool, str]: if state.iterations_completed >= state.max_iterations: return True, "max_iterations reached" if state.total_cost_usd >= state.max_cost_usd: return True, "cost_cap exceeded" if detect_plateau(state.score_history, window=state.plateau_window): return True, "plateau detected" if detect_oscillation(state.score_history, window=4): return True, "oscillation detected" if state.consecutive_errors >= state.max_consecutive_errors: return True, "consecutive_errors exceeded" return False, "" ``` <example> Correct: Run 5 iterations with a $20 budget cap $ python3 scripts/autoloop.py \ --target ./skills/my-skill \ --state-root /tmp/autoloop-state \ --max-iterations 5 \ --max-cost 20.0 \ --mode single-run -> Runs the orchestrator up to 5 times -> Stops early if plateau detected (3 consecutive rounds with no improvement) -> Stops early if cumulative cost reaches $20 -> State saved to /tmp/autoloop-state/autoloop_state.json for resumption </example> <anti-example> Running continuous mode without explicit cost cap: $ python3 scripts/autoloop.py --target ./skills/my-skill --state-root /tmp/state --mode continuous -> DANGEROUS: no --max-cost flag means the default $50 cap applies -> In continuous mode with 30-minute cooldown, this could run for hours -> Always set --max-cost explicitly when using continuous mode </anti-example> ## CLI Full argument reference: ```bash python3 scripts/autoloop.py \ --target <skill_path> # Required. Path to the skill directory --state-root <dir> # Required. Directory for state persistence --max-iterations 5 # Max pipeline runs (default: 5) --max-cost 50.0 # Budget ceiling in USD (default: 50.0) --plateau-window 3 # Rounds without improvement before stop (default: 3) --cooldown-minutes 30 # Minutes between iterations in continuous mode (default: 30) --max-consecutive-errors 3 # Circuit breaker threshold (default: 3) --mode single-run # single-run | continuous | scheduled --dry-run # Simulate without calling orchestrator ``` ### Scheduling with Cron (scheduled mode) ```bash # Add to crontab: run every 4 hours at minute 17 17 */4 * * * /path/to/scripts/run-eval.sh /path/to/skill /path/to/state # run-eval.sh wraps autoloop.py in --mode single-run with logging # Each cron invocation picks up state from the previous run ``` ## State Recovery After Crash The controller writes `autoloop_state.json` after every iteration. If the process crashes mid-iteration (OOM, network timeout, Ctrl-C), the state file reflects the last completed iteration. Restarting with the same `--state-root` resumes from that checkpoint. ```bash # Check current state after a crash cat /tmp/autoloop-state/autoloop_state.json | python3 -m json.tool # Resume from where it left off -- same command as the original run python3 scripts/autoloop.py \ --target ./skills/my-skill \ --state-root /tmp/autoloop-state \ --max-iterations 10 \ --max-cost 30.0 \ --mode single-run # -> Loads existing state, sees iterations_completed=4, continues from iteration 5 ``` State fields that carry across sessions: `iterations_completed`, `total_cost_usd`, `score_history`, `plateau_counter`, `current_scores`, `consecutive_errors`. The `status` field is reset to `running` on resume. CLI arguments (`--max-iterations`, `--max-cost`, etc.) override persisted values, so you can tighten or relax limits between sessions. ## Handoff Document Format When the autoloop completes (any termination condition), it prints a summary to stdout. For integration with other tools or human review, parse the state file: ```json { "schema_version": "1.0", "target": "./skills/my-skill", "iterations_completed": 5, "max_iterations": 10, "total_cost_usd": 18.42, "max_cost_usd": 30.0, "status": "stopped_plateau", "current_scores": { "accuracy": 0.87, "coverage": 0.92, "trigger_quality": 0.85, "knowledge_density": 0.78 }, "score_history": [ {"iteration": 1, "weighted_score": 0.65, "decision": "keep"}, {"iteration": 2, "weighted_score": 0.72, "decision": "keep"}, {"iteration": 3, "weighted_score": 0.78, "decision": "keep"}, {"iteration": 4, "weighted_score": 0.78, "decision": "reject"}, {"iteration": 5, "weighted_score": 0.79, "decision": "keep"} ], "plateau_counter": 3, "plateau_window": 3 } ``` The companion `iteration_log.jsonl` file contains one JSON object per line with per-iteration timing, cost, and artifact references. Use it for post-hoc analysis of improvement velocity. ## Output Artifacts | Artifact | Path | Format | Description | |----------|------|--------|-------------| | Loop state | `<state-root>/autoloop_state.json` | JSON | Full controller state including scores, history, termination reason, and resume data. Updated after every iteration. | | Iteration log | `<state-root>/iteration_log.jsonl` | JSONL | Append-only log with one entry per completed iteration: timing, cost, decision, candidate ID, and artifact path. | | Orchestrator outputs | `<state-root>/` (subdirectories) | Mixed | Each iteration produces orchestrator-level artifacts (candidates, gate results, diffs) in the state root. | | Console summary | stdout | Text | Human-readable summary printed on termination: iteration count, total cost, best score, and stop reason. | The state file uses `schema_version: "1.0"` for forward compatibility. Unknown fields are ignored on load, so older controllers can read state files written by newer versions. ## Related Skills - **improvement-orchestrator**: The single-pipeline runner that autoloop wraps. Each autoloop iteration invokes one full orchestrator run (generate, discriminate, evaluate, execute, gate). Use orchestrator directly for one-off improvements. - **improvement-learner**: Provides the 9-dimension quality scores that autoloop uses for convergence detection. The learner's weighted score feeds into plateau and oscillation detectors. - **improvement-discriminator**: Multi-reviewer panel scoring within each orchestrator run. Autoloop does not call the discriminator directly; it is invoked by the orchestrator. - **improvement-generator**: Produces candidate proposals within each orchestrator run. When autoloop detects oscillation, this often indicates the generator needs a different strategy. - **benchmark-store**: Pareto front data and quality tier thresholds. Autoloop does not write to benchmark-store, but the scores it tracks are comparable to benchmark baselines.

path: README.md
content: # autoloop-controller

Persistent improvement loop for AI skills. Wraps the improvement-orchestrator pipeline in a loop with convergence detection, cost control, and cross-session state persistence.

## What It Does

Runs the 5-stage improvement pipeline (generate, discriminate, evaluate, execute, gate) repeatedly on a target skill until one of five termination conditions fires:

1. Maximum iterations reached
2. Cost cap exceeded
3. Score plateau detected (no improvement over N rounds)
4. Oscillation detected (alternating keep/reject pattern)
5. Consecutive errors exceeded (circuit breaker)

State is written to disk after every iteration, so the loop survives crashes and resumes seamlessly.

## Quick Start

```bash
# Run up to 5 improvement rounds with a $20 budget
python3 scripts/autoloop.py \
  --target ./skills/my-skill \
  --state-root /tmp/autoloop-state \
  --max-iterations 5 \
  --max-cost 20.0 \
  --mode single-run
```

## Modes

| Mode | Description |
|------|-------------|
| `single-run` | All iterations in one process, then exit |
| `continuous` | Loop with cooldown between iterations (default 30 min) |
| `scheduled` | One iteration per invocation; pair with system cron |

## Project Structure

```
autoloop-controller/
  SKILL.md              # Skill definition (triggers, usage, reference)
  README.md             # This file
  scripts/
    autoloop.py         # Main controller logic
    convergence.py      # Plateau and oscillation detection
    cost_tracker.py     # Immutable cost tracking
    run-eval.sh         # Shell wrapper for cron scheduling
  references/
    state-format.md     # autoloop_state.json schema documentation
    scheduling-guide.md # Cron and scheduling setup guide
  tests/                # pytest test suite
```

## Output Files

- `autoloop_state.json` -- Full loop state (scores, history, resume data)
- `iteration_log.jsonl` -- Append-only per-iteration log (timing, cost, decisions)

## Dependencies

- Python 3.10+
- `lib.common` from the repository root (shared JSON utilities)
- `improvement-orchestrator` skill (invoked as subprocess)

## License

MIT


path: references/scheduling-guide.md
content: # Scheduling Guide

## Recommended: Shell Script + System Cron

### Setup
1. Create run script:
   ~/.claude/skills/autoloop-controller/scripts/run-eval.sh

2. Add to crontab:
   17 */4 * * * ~/.claude/skills/autoloop-controller/scripts/run-eval.sh /path/to/skill /path/to/state

### Advantages
- No 7-day expiry
- Runs regardless of REPL state
- Production-grade reliability

## Alternative: CronCreate (session-based)
- 7-day auto-expiry (must re-register)
- Only fires when REPL is idle
- Good for ad-hoc experimentation

## Alternative: Continuous Mode
- Runs in current session with cooldown between iterations
- Best for supervised overnight runs
- Will stop on termination conditions


path: references/state-format.md
content: # autoloop_state.json Schema

Persisted at `<state-root>/autoloop_state.json`. Created on first run, updated after each iteration.

## Fields

| Field | Type | Description |
|-------|------|-------------|
| `schema_version` | string | Always `"1.0"`. Used for forward-compatible migrations. |
| `target` | string | Absolute path to the skill being improved. |
| `started_at` | string | ISO 8601 UTC timestamp of the first iteration. |
| `iterations_completed` | int | Number of pipeline runs finished so far. |
| `max_iterations` | int | Hard cap on total iterations (from CLI `--max-iterations`). |
| `total_cost_usd` | float | Cumulative estimated cost across all iterations. |
| `max_cost_usd` | float | Budget ceiling (from CLI `--max-cost`). |
| `current_scores` | dict | Most recent dimension scores (e.g. `{"clarity": 0.85, "coverage": 0.72}`). |
| `score_history` | list[dict] | Per-iteration records: `{iteration, weighted_score, decision, scores, timestamp}`. |
| `plateau_counter` | int | Consecutive iterations without improvement. Reset on score gain. |
| `plateau_window` | int | Threshold; plateau detected when `plateau_counter >= plateau_window`. |
| `status` | string | One of: `running`, `completed`, `stopped_plateau`, `stopped_cost`, `stopped_max_iter`, `stopped_oscillation`, `error`. |
| `last_failure_trace` | string\|null | Traceback from most recent failed iteration, or `null`. |
| `cooldown_minutes` | int | Minutes to sleep between iterations in continuous mode. |
| `next_run_at` | string\|null | ISO 8601 timestamp for next scheduled run (continuous/scheduled mode). |

## Example

```json
{
  "schema_version": "1.0",
  "target": "/Users/sly/.claude/skills/coding-standards",
  "started_at": "2026-04-03T08:00:00Z",
  "iterations_completed": 3,
  "max_iterations": 5,
  "total_cost_usd": 12.50,
  "max_cost_usd": 50.0,
  "current_scores": {"clarity": 0.85, "coverage": 0.72, "actionability": 0.90},
  "score_history": [
    {"iteration": 1, "weighted_score": 0.78, "decision": "keep", "scores": {"clarity": 0.80, "coverage": 0.65, "actionability": 0.88}, "timestamp": "2026-04-03T08:01:00Z"},
    {"iteration": 2, "weighted_score": 0.80, "decision": "keep", "scores": {"clarity": 0.82, "coverage": 0.68, "actionability": 0.89}, "timestamp": "2026-04-03T08:35:00Z"},
    {"iteration": 3, "weighted_score": 0.82, "decision": "keep", "scores": {"clarity": 0.85, "coverage": 0.72, "actionability": 0.90}, "timestamp": "2026-04-03T09:10:00Z"}
  ],
  "plateau_counter": 0,
  "plateau_window": 3,
  "status": "running",
  "last_failure_trace": null,
  "cooldown_minutes": 30,
  "next_run_at": "2026-04-03T09:40:00Z"
}
```

## iteration_log.jsonl

Append-only log. One JSON object per line, written after each iteration completes.

```json
{"iteration": 1, "started_at": "2026-04-03T08:00:00Z", "finished_at": "2026-04-03T08:01:00Z", "decision": "keep", "weighted_score": 0.78, "cost_usd": 4.20, "duration_seconds": 60.0, "candidate_id": "abc123", "artifact_path": "/tmp/state/artifacts/abc123.json"}
```


path: scripts/autoloop.py
content: #!/usr/bin/env python3
"""Autoloop controller — wraps improvement-orchestrator in a persistent loop.

Supports three modes:
  single-run  — one iteration then exit
  continuous  — loop with cooldown until termination condition
  scheduled   — exit after each run (cron triggers next)

Termination conditions (OR logic):
  1. max_iterations reached
  2. cost_cap exceeded
  3. score plateau detected
  4. oscillation detected
  5. consecutive errors exceeded (circuit breaker)
"""

from __future__ import annotations

import argparse
import json
import subprocess
import sys
import time
import traceback
from dataclasses import asdict, dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Any

# ---------------------------------------------------------------------------
# Shared-lib import (same pattern as orchestrate.py)
# ---------------------------------------------------------------------------

_REPO_ROOT = Path(__file__).resolve().parents[3]
if str(_REPO_ROOT) not in sys.path:
    sys.path.insert(0, str(_REPO_ROOT))

from lib.common import read_json, utc_now_iso, write_json

# Sibling modules
_SCRIPT_DIR = Path(__file__).resolve().parent
if str(_SCRIPT_DIR) not in sys.path:
    sys.path.insert(0, str(_SCRIPT_DIR))

from convergence import compute_weighted_score, detect_oscillation, detect_plateau
# cost_tracker available for future use

# ---------------------------------------------------------------------------
# Orchestrator path
# ---------------------------------------------------------------------------

ORCHESTRATOR_SCRIPT = _REPO_ROOT / "skills" / "improvement-orchestrator" / "scripts" / "orchestrate.py"

# ---------------------------------------------------------------------------
# State
# ---------------------------------------------------------------------------

SCHEMA_VERSION = "1.0"


@dataclass
class AutoloopState:
    """Serializable state for cross-session persistence."""

    schema_version: str = SCHEMA_VERSION
    target: str = ""
    started_at: str = ""
    iterations_completed: int = 0
    max_iterations: int = 5
    total_cost_usd: float = 0.0
    max_cost_usd: float = 50.0
    current_scores: dict[str, float] = field(default_factory=dict)
    score_history: list[dict[str, Any]] = field(default_factory=list)
    plateau_counter: int = 0
    plateau_window: int = 3
    status: str = "running"
    last_failure_trace: str | None = None
    consecutive_errors: int = 0
    max_consecutive_errors: int = 3
    cooldown_minutes: int = 30
    next_run_at: str | None = None

    # -- persistence --------------------------------------------------------

    @classmethod
    def load(cls, path: Path) -> "AutoloopState":
        """Load from JSON or create a fresh instance if file is missing."""
        if path.exists():
            data = read_json(path)
            known_fields = {f.name for f in cls.__dataclass_fields__.values()}
            filtered = {k: v for k, v in data.items() if k in known_fields}
            return cls(**filtered)
        return cls(started_at=utc_now_iso())

    def save(self, path: Path) -> None:
        write_json(path, asdict(self))


# ---------------------------------------------------------------------------
# Termination logic
# ---------------------------------------------------------------------------


def should_stop(state: AutoloopState) -> tuple[bool, str]:
    """Check all termination conditions. Returns (should_stop, reason)."""
    if state.iterations_completed >= state.max_iterations:
        return True, f"max_iterations reached ({state.iterations_completed}/{state.max_iterations})"

    if state.total_cost_usd >= state.max_cost_usd:
        return True, f"cost_cap exceeded (${state.total_cost_usd:.2f} >= ${state.max_cost_usd:.2f})"

    if detect_plateau(state.score_history, window=state.plateau_window):
        return True, f"plateau detected (no improvement in last {state.plateau_window} iterations)"

    if detect_oscillation(state.score_history, window=4):
        return True, "oscillation detected (keep-reject alternating pattern)"

    if state.consecutive_errors >= state.max_consecutive_errors:
        return True, f"consecutive_errors exceeded ({state.consecutive_errors}/{state.max_consecutive_errors})"

    return False, ""


# ---------------------------------------------------------------------------
# Single iteration
# ---------------------------------------------------------------------------


def run_single_iteration(
    state: AutoloopState,
    target: str,
    state_root: str,
    dry_run: bool = False,
) -> tuple[AutoloopState, dict[str, Any]]:
    """Run one improvement-orchestrator pipeline iteration.

    Returns updated state and the pipeline result dict.
    """
    iteration = state.iterations_completed + 1
    iter_start = time.monotonic()
    started_at = utc_now_iso()

    result: dict[str, Any]

    if dry_run:
        # Simulate a successful keep with dummy scores
        result = {
            "target": target,
            "attempts": 1,
            "max_retries": 3,
            "final_decision": "keep",
            "final_candidate_id": f"dry-run-{iteration}",
            "final_artifact_path": None,
        }
        duration = 0.1
        cost = 0.0
    else:
        # Subprocess isolation — do not import orchestrate directly
        cmd = [
            sys.executable,
            str(ORCHESTRATOR_SCRIPT),
            "--target", str(target),
            "--state-root", str(state_root),
            "--auto",
        ]
        try:
            proc = subprocess.run(cmd, capture_output=True, text=True, timeout=3600)
        except subprocess.TimeoutExpired:
            state.last_failure_trace = "Orchestrator subprocess timed out after 3600s"
            state.status = "error"
            state.consecutive_errors += 1
            result = {
                "target": target,
                "attempts": 0,
                "max_retries": 0,
                "final_decision": "error",
                "final_candidate_id": None,
                "final_artifact_path": None,
                "error": state.last_failure_trace,
            }
            return state, result

        duration = time.monotonic() - iter_start

        if proc.returncode != 0:
            state.last_failure_trace = proc.stderr.strip() or proc.stdout.strip()
            state.status = "error"
            state.consecutive_errors += 1
            result = {
                "target": target,
                "attempts": 0,
                "max_retries": 0,
                "final_decision": "error",
                "final_candidate_id": None,
                "final_artifact_path": None,
                "error": state.last_failure_trace,
            }
            return state, result

        # Parse the pipeline result — orchestrate.py prints summary to stdout
        # Try to find JSON output; fall back to parsing text
        result = _parse_orchestrator_output(proc.stdout, target)
        cost = _estimate_cost(duration)

    decision = result.get("final_decision", "reject")

    # Compute weighted score from learner output if available
    scores = _load_latest_scores(state_root)
    weighted = compute_weighted_score(scores) if scores else 0.0

    # Update score history
    history_entry = {
        "iteration": iteration,
        "weighted_score": weighted,
        "decision": decision,
        "scores": scores,
        "timestamp": utc_now_iso(),
    }
    state.score_history.append(history_entry)
    state.current_scores = scores

    # Update plateau counter
    if len(state.score_history) >= 2:
        prev_best = max(h["weighted_score"] for h in state.score_history[:-1])
        if weighted > prev_best:
            state.plateau_counter = 0
        else:
            state.plateau_counter += 1
    else:
        state.plateau_counter = 0

    # Update cost
    cost_val = cost if not dry_run else 0.0
    state.total_cost_usd += cost_val
    state.iterations_completed = iteration
    state.last_failure_trace = None
    state.consecutive_errors = 0

    # Append to iteration log
    log_entry = {
        "iteration": iteration,
        "started_at": started_at,
        "finished_at": utc_now_iso(),
        "decision": decision,
        "weighted_score": weighted,
        "cost_usd": round(cost_val, 4),
        "duration_seconds": round(duration, 1),
        "candidate_id": result.get("final_candidate_id"),
        "artifact_path": result.get("final_artifact_path"),
    }
    _append_iteration_log(state_root, log_entry)

    return state, result


def _parse_orchestrator_output(stdout: str, target: str) -> dict[str, Any]:
    """Best-effort parse of orchestrate.py stdout."""
    # Try to extract structured data from the summary lines
    lines = stdout.strip().split("\n")
    result: dict[str, Any] = {"target": target}

    for line in lines:
        stripped = line.strip()
        if stripped.startswith("Final Decision:"):
            result["final_decision"] = stripped.split(":", 1)[1].strip()
        elif stripped.startswith("Candidate:"):
            val = stripped.split(":", 1)[1].strip()
            result["final_candidate_id"] = val if val != "N/A" else None
        elif stripped.startswith("Artifact:"):
            val = stripped.split(":", 1)[1].strip()
            result["final_artifact_path"] = val if val != "N/A" else None
        elif stripped.startswith("Attempts:"):
            parts = stripped.split(":", 1)[1].strip().split("/")
            if len(parts) == 2:
                result["attempts"] = int(parts[0])
                result["max_retries"] = int(parts[1])

    result.setdefault("final_decision", "reject")
    result.setdefault("final_candidate_id", None)
    result.setdefault("final_artifact_path", None)
    result.setdefault("attempts", 0)
    result.setdefault("max_retries", 3)
    return result


def _estimate_cost(duration_seconds: float) -> float:
    """Rough cost estimate based on duration. Placeholder heuristic."""
    # ~$0.10 per minute of LLM time as rough estimate
    return round(duration_seconds / 60.0 * 0.10, 4)


def _load_latest_scores(state_root: str) -> dict[str, float]:
    """Try to load latest learner scores from the state directory."""
    state_dir = Path(state_root)
    # Look for the most recent learner output
    learner_dir = state_dir / "learner"
    if not learner_dir.exists():
        return {}
    candidates = sorted(learner_dir.glob("*.json"), key=lambda p: p.stat().st_mtime, reverse=True)
    if not candidates:
        return {}
    try:
        data = read_json(candidates[0])
        # Extract dimension scores from learner output (final_scores or legacy dimensions)
        dimensions = data.get("final_scores", data.get("dimensions", {}))
        return {k: v.get("score", 0.0) if isinstance(v, dict) else float(v) for k, v in dimensions.items()}
    except (json.JSONDecodeError, KeyError, TypeError, ValueError):
        return {}


def _append_iteration_log(state_root: str, entry: dict) -> None:
    """Append one JSON line to iteration_log.jsonl."""
    log_path = Path(state_root) / "iteration_log.jsonl"
    log_path.parent.mkdir(parents=True, exist_ok=True)
    with log_path.open("a", encoding="utf-8") as f:
        f.write(json.dumps(entry, ensure_ascii=False) + "\n")


# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------


def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        description="Autoloop controller — persistent improvement loop",
    )
    parser.add_argument("--target", required=True, help="Target skill/file path")
    parser.add_argument("--state-root", required=True, help="State directory root")
    parser.add_argument("--max-iterations", type=int, default=5, help="Max iterations (default: 5)")
    parser.add_argument("--max-cost", type=float, default=50.0, help="Cost cap in USD (default: 50.0)")
    parser.add_argument("--plateau-window", type=int, default=3, help="Plateau detection window (default: 3)")
    parser.add_argument("--cooldown-minutes", type=int, default=30, help="Cooldown between iterations in minutes (default: 30)")
    parser.add_argument(
        "--mode",
        choices=["single-run", "continuous", "scheduled"],
        default="single-run",
        help="Run mode (default: single-run)",
    )
    parser.add_argument("--max-consecutive-errors", type=int, default=3, help="Stop after N consecutive errors (default: 3)")
    parser.add_argument("--dry-run", action="store_true", help="Simulate without calling orchestrator")
    return parser.parse_args(argv)


# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------


def main(argv: list[str] | None = None) -> int:
    args = parse_args(argv)

    target = str(Path(args.target).expanduser().resolve())
    state_root = str(Path(args.state_root).expanduser().resolve())
    state_path = Path(state_root) / "autoloop_state.json"

    # Ensure state directory exists
    Path(state_root).mkdir(parents=True, exist_ok=True)

    # Load or create state
    state = AutoloopState.load(state_path)
    state.target = target
    state.max_iterations = args.max_iterations
    state.max_cost_usd = args.max_cost
    state.plateau_window = args.plateau_window
    state.cooldown_minutes = args.cooldown_minutes
    state.max_consecutive_errors = args.max_consecutive_errors

    if not state.started_at:
        state.started_at = utc_now_iso()

    state.status = "running"
    state.save(state_path)

    print(f"Autoloop starting: target={target}, mode={args.mode}, max_iter={args.max_iterations}, max_cost=${args.max_cost}")

    try:
        while True:
            # Check termination conditions
            stop, reason = should_stop(state)
            if stop:
                status_map = {
                    "max_iterations": "stopped_max_iter",
                    "cost_cap": "stopped_cost",
                    "plateau": "stopped_plateau",
                    "oscillation": "stopped_oscillation",
                    "consecutive_errors": "stopped_errors",
                }
                for key, status_val in status_map.items():
                    if key in reason:
                        state.status = status_val
                        break
                else:
                    state.status = "completed"
                state.save(state_path)
                print(f"\nStopped: {reason}")
                break

            # Run one iteration
            print(f"\n--- Iteration {state.iterations_completed + 1}/{state.max_iterations} ---")
            state, result = run_single_iteration(state, target, state_root, dry_run=args.dry_run)
            state.save(state_path)

            decision = result.get("final_decision", "unknown")
            print(f"  Decision: {decision}")
            if state.score_history:
                latest = state.score_history[-1]
                print(f"  Weighted score: {latest['weighted_score']:.4f}")
            print(f"  Cumulative cost: ${state.total_cost_usd:.4f}")

            if state.status == "error":
                print(f"  Error: {state.last_failure_trace}")
                break

            # Mode-specific behavior
            if args.mode == "single-run":
                # Check if we should do another iteration
                stop, reason = should_stop(state)
                if stop:
                    state.status = "completed" if "max_iterations" in reason else state.status
                    for key, status_val in {
                        "max_iterations": "stopped_max_iter",
                        "cost_cap": "stopped_cost",
                        "plateau": "stopped_plateau",
                        "oscillation": "stopped_oscillation",
                    "consecutive_errors": "stopped_errors",
                    }.items():
                        if key in reason:
                            state.status = status_val
                            break
                    state.save(state_path)
                    print(f"\nStopped: {reason}")
                    break
                # Continue to next iteration in single-run mode
                continue

            elif args.mode == "continuous":
                # Sleep between iterations
                stop, reason = should_stop(state)
                if stop:
                    for key, status_val in {
                        "max_iterations": "stopped_max_iter",
                        "cost_cap": "stopped_cost",
                        "plateau": "stopped_plateau",
                        "oscillation": "stopped_oscillation",
                    "consecutive_errors": "stopped_errors",
                    }.items():
                        if key in reason:
                            state.status = status_val
                            break
                    state.save(state_path)
                    print(f"\nStopped: {reason}")
                    break
                cooldown_sec = state.cooldown_minutes * 60
                next_run = datetime.now(timezone.utc).replace(microsecond=0)
                from datetime import timedelta
                next_run = next_run + timedelta(seconds=cooldown_sec)
                state.next_run_at = next_run.isoformat().replace("+00:00", "Z")
                state.save(state_path)
                print(f"  Cooling down {state.cooldown_minutes}m (next: {state.next_run_at})")
                time.sleep(cooldown_sec)

            elif args.mode == "scheduled":
                # Exit after one iteration; cron triggers next
                state.status = "running"
                state.save(state_path)
                print("  Scheduled mode: exiting after single iteration")
                break

    except KeyboardInterrupt:
        state.status = "completed"
        state.save(state_path)
        print("\nInterrupted by user")
    except Exception:
        state.last_failure_trace = traceback.format_exc()
        state.status = "error"
        state.save(state_path)
        print(f"\nError: {state.last_failure_trace}", file=sys.stderr)
        return 1

    # Final summary
    print(f"\n=== Autoloop Summary ===")
    print(f"  Iterations: {state.iterations_completed}/{state.max_iterations}")
    print(f"  Total cost: ${state.total_cost_usd:.4f}")
    print(f"  Status: {state.status}")
    if state.score_history:
        best = max(state.score_history, key=lambda h: h["weighted_score"])
        print(f"  Best score: {best['weighted_score']:.4f} (iteration {best['iteration']})")
    print(f"  State saved: {state_path}")

    return 0


if __name__ == "__main__":
    raise SystemExit(main())


path: scripts/convergence.py
content: """Convergence detection for autoloop."""

from __future__ import annotations


def detect_plateau(score_history: list[dict], window: int = 3) -> bool:
    """Consecutive `window` rounds with no weighted_score improvement.

    Returns True if the best score in the last `window` rounds
    does not exceed the historical best before that window.
    """
    if len(score_history) < window + 1:
        return False
    recent = score_history[-window:]
    earlier = score_history[:-window]
    best_before = max(h["weighted_score"] for h in earlier)
    best_recent = max(h["weighted_score"] for h in recent)
    return best_recent <= best_before


def detect_oscillation(score_history: list[dict], window: int = 4) -> bool:
    """Detect keep-reject-keep-reject oscillation pattern.

    If the last `window` decisions alternate between keep and reject,
    the loop is unlikely to converge.
    """
    if len(score_history) < window:
        return False
    recent_decisions = [h["decision"] for h in score_history[-window:]]
    # Check for alternating pattern
    pattern = ["keep", "reject"] * (window // 2)
    alt_pattern = ["reject", "keep"] * (window // 2)
    return recent_decisions == pattern or recent_decisions == alt_pattern


def compute_weighted_score(scores: dict[str, float], weights: dict[str, float] | None = None) -> float:
    """Compute weighted average score across dimensions.

    Default weights treat all dimensions equally.
    """
    if weights is None:
        # Equal weights for all dimensions
        n = len(scores)
        if n == 0:
            return 0.0
        return sum(scores.values()) / n

    total = 0.0
    weight_sum = 0.0
    for dim, score in scores.items():
        w = weights.get(dim, 0.0)
        total += score * w
        weight_sum += w

    return total / weight_sum if weight_sum > 0 else 0.0


path: scripts/cost_tracker.py
content: """Immutable cost tracking for autoloop iterations."""

from __future__ import annotations

from dataclasses import dataclass


@dataclass(frozen=True, slots=True)
class CostRecord:
    """Single cost entry from one pipeline run."""
    iteration: int
    cost_usd: float
    duration_seconds: float
    decision: str  # keep / reject / pending

    def to_dict(self) -> dict:
        return {"iteration": self.iteration, "cost_usd": self.cost_usd,
                "duration_seconds": self.duration_seconds, "decision": self.decision}


@dataclass(frozen=True, slots=True)
class CostTracker:
    """Immutable cost tracker — add() returns a new instance."""
    budget_limit: float = 50.0
    records: tuple[CostRecord, ...] = ()

    def add(self, record: CostRecord) -> "CostTracker":
        return CostTracker(
            budget_limit=self.budget_limit,
            records=(*self.records, record),
        )

    @property
    def total_cost(self) -> float:
        return sum(r.cost_usd for r in self.records)

    @property
    def total_duration(self) -> float:
        return sum(r.duration_seconds for r in self.records)

    @property
    def over_budget(self) -> bool:
        return self.total_cost >= self.budget_limit

    @property
    def iteration_count(self) -> int:
        return len(self.records)

    def summary(self) -> dict:
        return {
            "total_cost_usd": round(self.total_cost, 4),
            "total_duration_seconds": round(self.total_duration, 1),
            "budget_limit_usd": self.budget_limit,
            "over_budget": self.over_budget,
            "iterations": self.iteration_count,
            "records": [r.to_dict() for r in self.records],
        }


path: scripts/run-eval.sh
content: #!/bin/bash
set -euo pipefail

SKILL_PATH="${1:?Usage: run-eval.sh <skill_path> [state_root]}"
STATE_ROOT="${2:-/tmp/autoloop}"

echo "[$(date -Iseconds)] Starting autoloop iteration for ${SKILL_PATH}" >> "${STATE_ROOT}/autoloop.log"

python3 "$(dirname "$0")/autoloop.py" \
  --target "${SKILL_PATH}" \
  --state-root "${STATE_ROOT}" \
  --mode single-run \
  2>&1 | tee -a "${STATE_ROOT}/autoloop.log"

echo "[$(date -Iseconds)] Iteration complete" >> "${STATE_ROOT}/autoloop.log"


path: tests/conftest.py
content: """Pytest configuration — add scripts/ to sys.path for test imports."""

import sys
from pathlib import Path

_SCRIPTS_DIR = Path(__file__).resolve().parent.parent / "scripts"
if str(_SCRIPTS_DIR) not in sys.path:
    sys.path.insert(0, str(_SCRIPTS_DIR))


path: tests/test_autoloop.py
content: import json
from pathlib import Path

import pytest

from autoloop import AutoloopState, should_stop


class TestAutoloopState:
    def test_create_fresh(self, tmp_path):
        state_path = tmp_path / "autoloop_state.json"
        state = AutoloopState.load(state_path)
        assert state.schema_version == "1.0"
        assert state.iterations_completed == 0
        assert state.status == "running"
        assert state.started_at != ""

    def test_save_and_reload(self, tmp_path):
        state_path = tmp_path / "autoloop_state.json"
        state = AutoloopState.load(state_path)
        state.target = "/some/skill"
        state.iterations_completed = 3
        state.total_cost_usd = 12.5
        state.current_scores = {"clarity": 0.85}
        state.save(state_path)

        reloaded = AutoloopState.load(state_path)
        assert reloaded.target == "/some/skill"
        assert reloaded.iterations_completed == 3
        assert reloaded.total_cost_usd == 12.5
        assert reloaded.current_scores == {"clarity": 0.85}

    def test_should_stop_max_iterations(self):
        state = AutoloopState(
            iterations_completed=5,
            max_iterations=5,
        )
        stop, reason = should_stop(state)
        assert stop is True
        assert "max_iterations" in reason

    def test_should_stop_cost_cap(self):
        state = AutoloopState(
            iterations_completed=2,
            max_iterations=10,
            total_cost_usd=55.0,
            max_cost_usd=50.0,
        )
        stop, reason = should_stop(state)
        assert stop is True
        assert "cost_cap" in reason

    def test_should_stop_plateau(self):
        state = AutoloopState(
            iterations_completed=5,
            max_iterations=10,
            plateau_window=3,
            score_history=[
                {"weighted_score": 0.85, "decision": "keep"},
                {"weighted_score": 0.83, "decision": "reject"},
                {"weighted_score": 0.84, "decision": "reject"},
                {"weighted_score": 0.82, "decision": "reject"},
            ],
        )
        stop, reason = should_stop(state)
        assert stop is True
        assert "plateau" in reason

    def test_should_not_stop_early(self):
        state = AutoloopState(
            iterations_completed=1,
            max_iterations=10,
            total_cost_usd=5.0,
            max_cost_usd=50.0,
            plateau_window=3,
            score_history=[
                {"weighted_score": 0.80, "decision": "keep"},
                {"weighted_score": 0.85, "decision": "keep"},
            ],
        )
        stop, reason = should_stop(state)
        assert stop is False
        assert reason == ""

    def test_ignores_unknown_fields_on_load(self, tmp_path):
        """Unknown fields in JSON should not crash deserialization."""
        state_path = tmp_path / "autoloop_state.json"
        data = {
            "schema_version": "1.0",
            "target": "/skill",
            "iterations_completed": 2,
            "unknown_future_field": "some_value",
        }
        state_path.write_text(json.dumps(data))
        state = AutoloopState.load(state_path)
        assert state.iterations_completed == 2
        assert state.target == "/skill"


path: tests/test_convergence.py
content: import pytest
from convergence import compute_weighted_score, detect_oscillation, detect_plateau


class TestDetectPlateau:
    def test_no_plateau_improving(self):
        history = [
            {"weighted_score": 0.80, "decision": "keep"},
            {"weighted_score": 0.82, "decision": "keep"},
            {"weighted_score": 0.85, "decision": "keep"},
            {"weighted_score": 0.87, "decision": "keep"},
        ]
        assert detect_plateau(history, window=3) is False

    def test_plateau_detected(self):
        history = [
            {"weighted_score": 0.85, "decision": "keep"},
            {"weighted_score": 0.83, "decision": "reject"},
            {"weighted_score": 0.84, "decision": "reject"},
            {"weighted_score": 0.82, "decision": "reject"},
        ]
        assert detect_plateau(history, window=3) is True

    def test_too_few_entries(self):
        history = [{"weighted_score": 0.80, "decision": "keep"}]
        assert detect_plateau(history, window=3) is False

    def test_exact_boundary(self):
        # Best recent equals best before — still plateau
        history = [
            {"weighted_score": 0.85, "decision": "keep"},
            {"weighted_score": 0.85, "decision": "keep"},
            {"weighted_score": 0.85, "decision": "keep"},
            {"weighted_score": 0.85, "decision": "keep"},
        ]
        assert detect_plateau(history, window=3) is True


class TestDetectOscillation:
    def test_oscillation_detected(self):
        history = [
            {"weighted_score": 0.80, "decision": "keep"},
            {"weighted_score": 0.79, "decision": "reject"},
            {"weighted_score": 0.81, "decision": "keep"},
            {"weighted_score": 0.78, "decision": "reject"},
        ]
        assert detect_oscillation(history, window=4) is True

    def test_no_oscillation(self):
        history = [
            {"weighted_score": 0.80, "decision": "keep"},
            {"weighted_score": 0.82, "decision": "keep"},
            {"weighted_score": 0.83, "decision": "keep"},
            {"weighted_score": 0.85, "decision": "keep"},
        ]
        assert detect_oscillation(history, window=4) is False

    def test_too_few(self):
        history = [{"weighted_score": 0.80, "decision": "keep"}]
        assert detect_oscillation(history, window=4) is False


class TestComputeWeightedScore:
    def test_equal_weights(self):
        scores = {"a": 0.8, "b": 0.6}
        assert compute_weighted_score(scores) == pytest.approx(0.7)

    def test_custom_weights(self):
        scores = {"a": 1.0, "b": 0.0}
        weights = {"a": 0.7, "b": 0.3}
        assert compute_weighted_score(scores, weights) == pytest.approx(0.7)

    def test_empty_scores(self):
        assert compute_weighted_score({}) == 0.0


path: tests/test_cost_tracker.py
content: import pytest
from cost_tracker import CostRecord, CostTracker


class TestCostTracker:
    def test_initial_state(self):
        tracker = CostTracker(budget_limit=50.0)
        assert tracker.total_cost == 0.0
        assert tracker.over_budget is False
        assert tracker.iteration_count == 0

    def test_add_record(self):
        tracker = CostTracker(budget_limit=50.0)
        record = CostRecord(iteration=1, cost_usd=10.0, duration_seconds=60.0, decision="keep")
        new_tracker = tracker.add(record)
        assert new_tracker.total_cost == 10.0
        assert new_tracker.iteration_count == 1
        # Original is unchanged (immutable)
        assert tracker.total_cost == 0.0

    def test_over_budget(self):
        tracker = CostTracker(budget_limit=20.0)
        r1 = CostRecord(iteration=1, cost_usd=15.0, duration_seconds=60.0, decision="keep")
        r2 = CostRecord(iteration=2, cost_usd=10.0, duration_seconds=60.0, decision="keep")
        tracker = tracker.add(r1).add(r2)
        assert tracker.over_budget is True
        assert tracker.total_cost == 25.0

    def test_summary(self):
        tracker = CostTracker(budget_limit=50.0)
        r = CostRecord(iteration=1, cost_usd=5.0, duration_seconds=30.0, decision="keep")
        tracker = tracker.add(r)
        s = tracker.summary()
        assert s["total_cost_usd"] == 5.0
        assert s["iterations"] == 1
        assert s["over_budget"] is False


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (HIGH)