Skip to content

The Self-Evolution Flywheel — transparent, steerable, governed

The 24/7 propose-only Loop engine that mines the ecosystem corpus for improvements, distils them into reviewable specs, and develops the approved ones — made legible mid-flight (the operator can see what it is doing and why), steerable (the operator can reprioritize, pause, and veto), and governed (nothing lands without a human/Claude gate). Concepts: AU-KG.research.evolutionstate-live-surface-per / AU-KG.research.saturation-gauge-aggregates-four / AU-KG.research.close-distill-develop-seam / AU-OS.config.autonomous-spec-develop-off (flywheel transparency + review-veto) and AHE-3.71 / AU-AHE.harness.when-outcome-names-agent / AU-AHE.harness.callers-feed-back-per (the per-agent hardening loop), plus AU-KG.retrieval.assimilated-from-mragent / AU-KG.memory.generation-scoped-selective-reward (the memory substrate the loop reasons over).

This page is the operator-facing view of the flywheel. For the broader feedback-loop architecture it sits inside, read Self-Improving Reasoning Substrate (route → reason → measure → learn) and Failure-Driven Evolution (how production failures enter the loop as addressable gaps). This page does not re-derive those; it documents the transparency + steering + governance layer that was added on top, and the AHE-3.71/72/73 hardening cycle that makes "every exception the operator resolve hardens the system" literally true.


1. The flywheel: distill → develop → evolve

The whole flywheel is one propose-only cycle driven by LoopController.run_one_cycle in agent_utilities/knowledge_graph/research/loop_controller.py. A single controller advances every active Loop (research / develop / skill) through one hot path — there is no separate goal-runner or research-runner. The research path composes the existing primitives into one cycle that makes the KG self-improving without auto-merging anything:

intake  → active Loops (research topics with no ADDRESSED_BY; AU-KG.research.these-properties-carry)
acquire → semantically related sources per topic (research/search)
resolve → ADDRESSES edges source→topic so the loop converges
reason  → OWL/RDF reasoning over the ecosystem, harvest extrapolations (AU-KG.research.best-effort-lightweight-never)
distill → SpecDraft markdown into .specify/specs/kg-distilled/ (gated)
synth   → a TeamSpec/AgentSpec proposal persisted to the KG

Every artifact a cycle emits is a DRAFT / proposal — spec markdown under .specify/ and KG proposal nodes. No code execution, no PR merge, no edits outside .specify/. The cycle is exposed on-demand via graph_loops / graph_orchestrate (and the REST twin) and runs continuously via a throttled daemon tick / the unified scheduler's self_evolution schedule.

The distill → develop seam that was broken (and the fix)

Historically the cycle's distill stage wrote SDD markdown into .specify/ and stopped there. The develop / code path (change_publisher.governed_publish → AU-AHE.harness.promotion-governance-validator–3.24) consumes promoted proposal nodes, not those .md files — so the two tracks were disjoint: a distilled spec was a dead-end file nothing can develop.

LoopController._distill_specs now closes that seam (CONCEPT:AU-KG.research.close-distill-develop-seam). After write_spec_drafts writes the markdown, each draft is persisted as a first-class, queryable :SpecProposal node via spec_proposals.persist_spec_proposal, linked DISTILLED_FROM its source concepts so the why is traversable. The spec is then fed — only after approval — into the existing promotion pipeline as the proposal that pipeline already consumes (no new code-gen path). On the run side, LoopController._advance_develop detects a spec-bound develop Loop (one carrying a spec_id) and routes it into spec_proposals.develop_spec → governed_publish, where the merge_promotion human-approval gate (OS-5.24) and the capability-ratchet regression gate (AU-AHE.evaluation.capability-benchmark-regression-ratchet) stay on the path. So "distill specs → develop those specs" finally flows end to end, governance intact.

flowchart LR
    subgraph cycle["LoopController.run_one_cycle (propose-only)"]
      intake["intake / acquire / resolve"] --> reason["reason (OWL/RDF, AU-KG.research.best-effort-lightweight-never)"]
      reason --> distill["distill → SpecDraft .md"]
      distill --> persist["persist_spec_proposal\n:SpecProposal (pending_review)\nDISTILLED_FROM concepts\n(AU-KG.research.close-distill-develop-seam)"]
    end
    persist --> gate{"spec-review\ncheckpoint\n(AU-OS.config.autonomous-spec-develop-off)"}
    gate -- "approve" --> dev["develop_spec → governed_publish\n(merge_promotion gate +\ncapability ratchet)"]
    gate -- "reject / veto" --> dead["rejected (terminal)"]
    gate -- "edit" --> persist
    dev --> branch["reviewable branch\n(never auto-merged)"]

Wave 6 — the unified Gap→SDD→Implement→Promote→Close spine (operator surface)

CONCEPT:AU-AHE.harness.canonical-gap-lifecycle. Before Wave 6 the platform had three unrelated notions of "gap" (a transient failure_gap :Concept dict — see Failure-Driven Evolution for that legacy shape — a dead-end sdd_plan node, an in-memory SkillGap), plus a fourth (code-audit findings), joined by 7+ disjoint id schemes. knowledge_graph/research/gaps.py repurposes the existing KnowledgeGapNode model as the single gap representation every discovery track now folds into:

flowchart LR
    failure["failure_analyzer\n(production failures)"] -->|submit_gap| gap
    research["plan_synthesis\n(research/OSS)"] -->|submit_gap| gap
    skill["skill_evolver\n(skill coverage)"] -->|submit_gap| gap
    audit["audit_gap_detector\n(code-correctness/security)"] -->|submit_gap| gap
    manual["operator\n(graph_loops action=submit_gap)"] -->|submit_gap| gap
    gap((":Gap\nopen")) -->|SPECIFIED_BY| spec[":SpecProposal\n(distill/review gate above)"]
    spec -->|develop_spec| loop["develop-Loop\nRESOLVES the origin gap"]
    loop -->|governed_publish| resolved["gap.status = resolved\n(the visible END)"]

Every hop is queryable and steerable through the SAME graph_loops MCP tool (+ REST twin) the rest of this page documents — no separate gap-management surface:

  • graph_loops(action="gaps") — the open backlog, highest priority (lowest priority_bucket, derived from severity) first.
  • graph_loops(action="submit_gap", data_json={...}) — file one by hand (an operator-triaged issue) through the exact same gaps.submit_gap the four autonomous discovery tracks call — idempotent on the canonical gap:<source>:<signature> id, and leased as a first-class WorkItem so it is schedulable like any other unit of work.
  • graph_loops(action="gap", loop_id="<gap id>") — one gap plus its D6 provenance chain: the SpecProposal it was SPECIFIED_BY (once distilled — see the seam above) and the develop-Loop that RESOLVES it (once bound), so "which commit closed this gap" is a traversal, not an archaeology exercise.
  • The gap's derived develop-Loop is driven exactly like any other Loop (run/drive); spec_proposals.develop_spec binds a RESOLVES edge back to the origin gap, and gaps.resolve_gaps_for_loop flips it to resolved when that Loop's governed_publish lands — the same merge_promotion human-approval gate and capability-ratchet regression gate as every other promoted change.

The read-only per-graph RBAC-ownership disposition pass (W2.8, CONCEPT:AU-KG.audit.graph-ownership-disposition — a different, catalog-level governance concern, not the Gap content lifecycle above) is reachable the same way, on graph_governance: ownership_report runs the disposition pass; ownership_apply previews (always dry-run) the UNAMBIGUOUS grant plan + its rollback. The live mutating grant apply stays a separate, human-gated CLI (scripts/apply_graph_ownership_grants.py --apply-unambiguous, HG-4) — deliberately never reachable from MCP/REST.


2. Transparency: live EvolutionState + the saturation gauge

"The operator cannot steer what the operator cannot see." The observation plane every steering action hangs off. Concepts: AU-KG.research.evolutionstate-live-surface-per (live state + per-stage beacon), AU-KG.research.saturation-gauge-aggregates-four (saturation gauge). Module: agent_utilities/knowledge_graph/research/evolution_state.py.

The per-stage progress beacon (AU-KG.research.evolutionstate-live-surface-per)

The deployed loop used to persist an EvolutionCycle audit node only at finalize — so until a cycle finished it was opaque, and an operator can not see "what is it mining / distilling / developing right now, and why". StageBeacon fixes that. One beacon is created per run_one_cycle, and beacon.enter(stage) is called at every stage boundary (_stage() in loop_controller.py calls it before running each stage), upserting a single mutable :EvolutionBeacon node (id evolution:beacon). So a mid-cycle graph_loops(action="state") reports exactly which loop and stage is live, what it is acting on, and why (the focus_query / the open gaps it is mining). Every beacon write is best-effort — instrumentation never aborts the cycle. read_beacon() reads that one node back (an O(1) read, not a scan).

The aggregated read (AU-KG.research.evolutionstate-live-surface-per)

read_evolution_state(engine) is the operator's "make it transparent so I can steer" surface. It folds into one legible read:

  • beacon — the live stage + rationale (from read_beacon).
  • velocity — the AU-AHE.sdd.recursive-improvement-instrumentation-aggregating ImprovementVelocity verdict (improving / steady / stalling / idle) from improvement_ledger.improvement_velocity: is the loop still improving, how fast, and is it emitting code or only prose?
  • open_gaps — the recent per-cycle open_gaps trend (_open_gaps_trend reads the EvolutionCycle audit nodes' metadata).
  • ingestion_coverage — the AU-OS.deployment.flagging-repos ingestion-coverage % (how much of this repository's corpus is ingested) via deployment.doctor._check_ingestion_coverage.
  • specs — the distilled-spec backlog counts + a pending sample (spec_proposals.specs_summary).
  • saturation — the gauge below.
  • steering — a built-in cheat-sheet of the exact tool calls to pause, reprioritize, review a spec, or acquire more corpus.

The saturation gauge (AU-KG.research.saturation-gauge-aggregates-four)

saturation_gauge(...) folds the signals that already exist into one 0..1 reading of "how mined-out is the current corpus":

Component Weight Meaning
velocity 0.4 AU-AHE.sdd.recursive-improvement-instrumentation-aggregating verdict: stalling→1.0, steady→0.5, improving/idle→0.0
coverage 0.3 AU-OS.deployment.flagging-repos ingestion coverage fraction (unknown ⇒ neutral 0.5)
gaps 0.3 open_gaps trend: still shrinking fast ⇒ low; flat/rising ⇒ high

gauge = 0.4·velocity + 0.3·coverage + 0.3·gaps. It is saturated when gauge ≥ SATURATION_THRESHOLD (the module constant 0.66). The gauge is computed every cycle in LoopController._finalize_metrics (the hot-loop path skips the coverage probe for speed) and on every read_evolution_state read.

Crucially, when the gauge is high and velocity is stalling, it sets request_more=True and emits a queryable :EvolutionSaturationSignal (emit_saturation_signal) carrying a recommendation — but it never auto-fetches. Acquiring more research / cloning more codebases is left as a steerable decision for Claude/the human (enable discovery, run background research, or add to the breadth corpus). This is the deliberate boundary: the loop tells the operator it is mined out; the operator decide to feed it.

How it is surfaced (the "steerable" half)

Both surfaces dispatch into the same core (the Two surfaces by default contract):

  • MCP: graph_loops(action="state") → read_evolution_state; graph_loops(action="specs", status=…) → the spec backlog; graph_loops(action="review", spec_id=…, decision=…) → the veto gate. Registered in agent_utilities/mcp/tools/state_tools.py.
  • REST: the /graph/loops twin.

So the harness can query live EvolutionState, see the current stage and the saturation reading, and steer — which is exactly what the AGENTS.md "Evolving / managing the ecosystem → review-veto" directive asks of the orchestrator.


3. Governance: spec proposals + the review/veto gate (AU-KG.research.close-distill-develop-seam / AU-OS.config.autonomous-spec-develop-off)

The propose-and-hold checkpoint that keeps a 24/7 auto-coder safe. Module: agent_utilities/knowledge_graph/research/spec_proposals.py.

A distilled :SpecProposal moves through a lifecycle, and pending_review is the default landing state — review-first, not act-first:

pending_review ──approve──▶ approved ──develop──▶ developing ──▶ published
      │  ▲                                                  └──▶ reverted (ratchet regression)
      │  └──edit (merge changes, hold again)
      └──reject / veto ──▶ rejected (terminal)

review_spec(engine, spec_id, decision, …) is the explicit human/Claude decision point:

  • approve → status approved, an ActionDecision audit is recorded under the spec_promotion ActionPolicy tier (_audit_spec_decision), and (by default) a develop Loop is bound to the spec (_bind_develop_loop submits loop:develop:<spec_id> and stamps spec_id on it) so it enters the develop pipeline and is visible + steerable in graph_loops.
  • reject / veto → status rejected — the veto terminal; it never develops.
  • edit → merge edits (title/problem/approach/value/target_file) and keep it in pending_review for another look.

The approved spec is shaped by _spec_to_proposal into exactly the dict the promotion path already consumes, so an approved spec is a valid proposal — develop_spec runs the existing code_synthesis → change_synthesis → validate_in_sandbox → change_publisher → capability_ratchet path, which itself queues a reviewable branch (never an auto-merge) behind the merge_promotion gate.

Propose-and-hold is the default; auto-advance is opt-in

The 24/7 loop only auto-advances a spec when two things are deliberately relaxed:

  1. KG_LOOP_AUTO_DEVELOP (config.kg_loop_auto_develop, default False) — when on, _distill_specs calls auto_advance_specs after persisting the drafts.
  2. The spec_promotion ActionPolicy tier — which itself defaults to approval_required in deploy/action-policy.default.yml ({kind: spec_promotion, target: "*", tier: approval_required}).

auto_advance_specs consults the OS-5.24 ActionPolicy per pending spec; an allowed verdict (an operator who relaxed that tier) approves + binds a develop Loop, otherwise the spec stays pending_review and a human approval is queued. Acquisition is never auto-run; this only governs develop. So a fresh spec sits in pending_review until a human/Claude approves it — review-first by default, exactly as a 24/7 auto-coder needs.


4. The AHE-3.71/72/73 hardening loop — one exception → a durable hardening

The mechanism that makes "every exception the operator resolve hardens the system" real, end-to-end for one agent. Modules: agent_utilities/harness/evolve_agent.py, agent_utilities/harness/program_optimization.py, agent_utilities/knowledge_graph/adaptation/feedback.py. Concepts: AHE-3.71 (gated apply + audit), AU-AHE.harness.when-outcome-names-agent (per-agent attribution), AU-AHE.harness.callers-feed-back-per (the orchestrator).

This converts the previously dormant/stubbed metric→optimize→harden substrate into one real, gated metric → native optimize → hardened-prompt cycle for a single agent's system prompt. It reuses the existing StructuredPrompt and reward machinery while the engine jobs plane owns compilation and durable evidence.

AU-AHE.harness.when-outcome-names-agent — per-agent attribution

When a delegated run produces an outcome, FeedbackService.record_action_outcome(…, agent_id=…) derives an opaque agent_ref and tags the resulting eval case with that reference (see adaptation/feedback.py). The supplied identifier is not copied into optimizer or evolution artifacts. Then:

  • FeedbackService.agent_eval_cases(agent_id) returns the eval-corpus slice attributed to that agent — its measured executions (expected vs the goal that was reached), which double as the training signal and the held-out scoring slice for its prompt.
  • FeedbackService.build_agent_trainset(agent_id) exposes provider-neutral context/task/response rows plus their opaque example_ref at the governed corpus boundary.

So an agent's own real outcomes — including the ones that failed — become the corpus that steers its prompt optimization. Attribution is by agent, not just by trace signature.

AU-AHE.harness.callers-feed-back-per — harden_agent_prompt, the orchestrator

EvolveAgent.harden_agent_prompt(agent_id, prompt_path, …) runs ONE closed cycle:

  1. Attribute — pool the agent's action_outcome cases into a per-agent trainset + eval slice (AU-AHE.harness.when-outcome-names-agent).
  2. Optimize — run_program_optimization on the system_prompt target with that trainset. The native request and result contain opaque content, demonstration, evidence, and provenance references—not raw trace examples.
  3. Build — validate a reference-only ProgramCompiledState. The prompt blueprint and proposal persist this state, while FeedbackService.resolve_program_demonstrations materializes selected examples from the governed corpus only for the current execution. The renderer bounds and JSON-encodes each value inside a fixed data delimiter and tells the model never to treat instructions inside an example as policy.
  4. Evaluate — score_prompt_against_corpus scores the baseline against that ephemeral execution rendering. Resolved example text is discarded after evaluation.
  5. Decide + apply — should_promote(baseline, candidate, min_delta), then a gated apply.

It returns a PromptHardeningOutcome (status = applied | proposed | rejected | no_data) — always with an audit trail.

AHE-3.71 — the gated apply + the audit/approve surface

EvolveAgent.apply_edits is no longer a stub. For a system_prompt edit carrying a native reference-only compiled state it calls _apply_prompt_edit, which attaches that state to its StructuredPrompt file via .save() and commits it — but only when all three hold (CONCEPT:AU-AHE.harness.unified-promotion-gate — the unified evolution matrix's governance-bypass closure; see §8 below):

  • the candidate beat baseline (edit.metadata["promote"]);
  • the KG_AGENT_AUTO_APPLY gate is on (config.kg_agent_auto_apply, default False / shadow) — this is a NECESSARY, no longer Enough, condition; and
  • the SAME unified action_policy.decide() veto every other promotion vector passes — artifact_promotion.promote(..., PromotionCandidate(artifact_kind="prompt", ..., policy_kind="promote_prompt_version")) — whose shipped default tier (approval_required) means a benchmark win + KG_AGENT_AUTO_APPLY=true ALONE still does not apply; an operator must also relax that tier (or approve the queued request). Before this closure, KG_AGENT_AUTO_APPLY alone was enough — weaker governance than a skill-markdown promotion, which already went through action_policy.decide().

Off-gate (the default, or a queue_approval/deny verdict) the cycle is propose-only: a queryable, git-diffable ProposedPromptChange audit record is written under .specify/proposals/<id>.json (plus a best-effort KG node) carrying opaque agent/component/evidence references, compiled metadata, the before/after metric, and BOTH decisions (apply_status and the action_decision verdict). It never stores raw trace examples, source-system identifiers, or a local path. The live prompt is left untouched. EvolveAgent.approve_proposed_change(proposal_ref) is the steerable reviewed apply path: a winning prompt can go live by review instead of by flipping the global gate. Approval checks the proposal integrity reference, revalidates compiled metadata, binds the opaque component reference to the in-memory registry, and rejects workspace escapes. Background failure-cluster optimization is always review-only even when the global auto-apply gate is enabled; only the metric-scored per-agent hardening path can be marked automatically eligible.

The same boundary applies to round manifests. persist_manifest projects the operational ChangeManifest into a durable schema containing opaque round/edit/component/task/evidence references, scores, booleans, and validated compiled metadata. It omits local paths, raw diffs, free-form trace text, and source identifiers; the filename uses a portable digest. Deep-analysis output is bounded, must select a registered component type, cannot nominate a path, and may reference only task IDs already present in the in-memory evidence corpus.

flowchart LR
    fail["delegated run fails /\nungrounded / escalated"] --> outcome["record_action_outcome\n→ opaque agent/example refs"]
    outcome --> attribute["agent_eval_cases +\nbuild_agent_trainset\n(governed store)"]
    attribute --> optimize["run_program_optimization\nreference-only request/result"]
    optimize --> build["validate ProgramCompiledState\n(no raw demonstrations)"]
    build --> resolve["resolve demonstration refs\nephemerally for execution"]
    resolve --> eval["score baseline vs execution render"]
    eval --> promote{"should_promote?"}
    promote -- "no" --> rejected["rejected (audit only)"]
    promote -- "yes" --> gate{"KG_AGENT_AUTO_APPLY?"}
    gate -- "off (default)" --> proposed["ProposedPromptChange\n(.specify/proposals/) — held\n→ approve_proposed_change()"]
    gate -- "on" --> policy{"action_policy.decide\nkind=promote_prompt_version"}
    policy -- "queue_approval / deny\n(shipped default)" --> proposed
    policy -- "allow (operator-relaxed)" --> applied["StructuredPrompt.save() + commit"]

This is the loop AGENTS.md points at: when the operator resolve an exception, the fix (a hardened prompt — and, by the same spine, a fixed tool binding or a new skill) becomes a durable hardening of the system so it self-handles that case next time.


5. The memory substrate the loop reasons over (AU-KG.retrieval.assimilated-from-mragent / AU-KG.memory.generation-scoped-selective-reward)

The flywheel's reasoning quality depends on what it can recall and how trustworthy that recall is. Two assimilated primitives harden that substrate. (Note the renumbering: AU-KG.retrieval.assimilated-from-mragent is MRAgent active reconstruction; the selective-erasure primitive was originally tagged 2.275 and renumbered to AU-KG.memory.generation-scoped-selective-reward to avoid the collision — both IDs are confirmed against the code below.)

AU-KG.retrieval.assimilated-from-mragent — MRAgent active Cue-Tag-Content reconstruction

Module: agent_utilities/knowledge_graph/retrieval/active_reconstruction.py (assimilated from MRAgent, arXiv:2606.06036). Instead of one-shot top-k retrieval, reconstruct() walks a Cue → Tag → Content graph in an evidence-conditioned loop:

  1. Cue → Tag — enable candidate associative tags (relation types) on the current cue frontier and keep only the tag_top_k most query-relevant (pruning the combinatorial neighbour blow-up a fixed n-hop expansion will incur).
  2. (Cue, Tag) → Content — expand content only along the selected tags, dropping neighbours below relevance_floor.
  3. Content → Cue — the best fresh content (content_top_k) becomes the next cue frontier (reverse traversal),

so the walk progressively reconstructs a query-relevant subgraph and self-stop (reusing the shared IterativeStopper, AU-KG.retrieval.adaptive-stopping-iterative-retrieval) once fresh evidence stops arriving. It is dependency-injected (callers supply neighbor_fn and score_fn) with a dependency-free lexical default (lexical_relevance — no torch) and engine-backed adapters (engine_neighbor_fn, resolve_seeds). It is wired into the live path: the entity_context "why" intent natively runs reconstruction (and falls back to the census on no seed, so zero regression) — reachable via graph_explain action=explain target='entity:why' and its REST twin. The ontology gains :ReconstructionTrajectory (the analog of MRAgent's reconstructed memory state).

AU-KG.memory.generation-scoped-selective-reward — generation-scoped selective reward erasure

Module: agent_utilities/knowledge_graph/retrieval/capability_index.py (assimilated from the Red Queen Gödel Machine, arXiv:2606.26294). The memory-maintenance quadrant forgot learned reward only by age (decay_rewards, KG-2.4) or idle/max-age reapers — never by provenance. So when an entity's generation changed (a capability redeployed, a document re-ingested with materially new content, a model/embedding regime swapped), the in-place CapabilityIndex.add() upsert replaced the vector but kept the stale reward EMA — designate() kept ranking on evidence scored under a representation that no longer existed. This is a non-stationary utility carried across a regime change.

Two surfaces fix it:

  • Native, default-on on the live ingestion upsert path: CapabilityIndex.add() auto-erases an id's stale reward when a re-embed's cosine distance exceeds _REWARD_REGEN_DISTANCE (the module constant 0.25 — one correct value, not a flag; bge-m3 minor-edit re-embeds stay well above 0.75 similarity, a material rewrite drops below). Content-stable re-adds keep their reward.
  • Explicit, provenance-scoped: CapabilityIndex.selective_erase_rewards(ids) erases exactly the records whose source/evaluator/impl was superseded (order-independent), preserving every reward not in ids, plus a reward_erasures observability counter. Surfaced over graph_feedback (MCP) and POST /graph/feedback (REST) via FeedbackService.record_correction(correction_type="selective_erasure").

The effect: the router re-climbs under the new regime from the neutral prior instead of being anchored by stale (possibly reward-hacked) utility forever. Unlike uniform time decay, this is targeted by provenance, not by age.


6. Config knobs

All evolution behavior follows Configuration discipline (a flag is a last resort). The few that exist:

Setting (env alias) Default Effect
KG_LOOP_DISTILL (config.kg_loop_distill) off enable the distill stage (writes :SpecProposal drafts)
KG_LOOP_BREADTH (config.kg_loop_breadth) — ingest the OSS/repos/docs breadth corpus each cycle (idempotent)
KG_LOOP_STANDARDIZE (config.kg_loop_standardize) — enterprise standardization pass (needs a harvested estate)
KG_LOOP_DISCOVER (config.kg_loop_discover) off opt-in research discovery/intake (external calls)
KG_LOOP_AUTO_DEVELOP (config.kg_loop_auto_develop) False let the 24/7 loop auto-advance approved specs through the spec_promotion gate (review-first otherwise)
KG_AGENT_AUTO_APPLY (config.kg_agent_auto_apply) False (shadow) NECESSARY but not Enough: lets a hardened prompt that beat baseline be CONSIDERED for a live write; the promote_prompt_version ActionPolicy tier below must ALSO allow. Off ⇒ propose-only either way.
spec_promotion ActionPolicy tier approval_required the governance tier on spec→develop promotion (deploy/action-policy.default.yml)
promote_prompt_version / promote_tool_description_version / promote_mined_claim ActionPolicy tiers approval_required the SAME unified gate (§8) every promotion vector passes; KG_AGENT_AUTO_APPLY alone can no longer apply a prompt, and graph_ops_causal's materialize_claims alone can no longer promote a mined claim

Non-flag tunables (module constants, intentionally not env vars):

  • SATURATION_THRESHOLD = 0.66 (evolution_state.py) — the saturation cutoff.
  • _REWARD_REGEN_DISTANCE = 0.25 (capability_index.py) — the re-embed distance that counts as a new generation and erases the stale reward.

7. Operating the flywheel

Watch it. Query live state — current stage + why, saturation, gaps trend, velocity, spec backlog:

graph_loops action=state          # → read_evolution_state
graph_loops action=specs status=pending_review
graph_schedules action=list       # the self_evolution schedule + run state

When state.saturation.request_more is true, the corpus looks mined out — the operator decide to acquire more (enable KG_LOOP_DISCOVER, run graph_research action=background_research, or clone more codebases into the breadth corpus). The loop will not auto-fetch.

Review / veto specs (the governance gate):

graph_loops action=review spec_id=<id> decision=approve   # binds a develop Loop
graph_loops action=review spec_id=<id> decision=reject    # veto (terminal)
graph_loops action=review spec_id=<id> decision=edit       # merge edits, hold again

Approved specs develop through governed_publish, which queues a reviewable branch behind the merge_promotion gate and the capability ratchet — never an auto-merge.

Steer the loop itself:

graph_loops action=prioritize loop_id=<id> priority=high
graph_schedules action=disable name=<evolution schedule>   # pause

Close the hardening loop. After resolving a delegated-run exception, harden the agent that failed: run EvolveAgent.harden_agent_prompt(agent_id, prompt_path). By default the winning candidate is held as a ProposedPromptChange under .specify/proposals/ — review the before/after metric and approve it with approve_proposed_change(proposal_id) (or set KG_AGENT_AUTO_APPLY and relax the shipped promote_prompt_version ActionPolicy tier to let beats-baseline candidates land automatically — KG_AGENT_AUTO_APPLY alone is no longer enough, §8). Either way an audit record and the manifest are written, so the hardening is never silent — and the next run self-handles the case the operator just fixed.


8. The unified promotion gate + evolution matrix (CONCEPT:AU-AHE.harness.unified-promotion-gate)

Every artifact-mutation vector — skill markdown, prompt, mined claim, merged spec, routing policy — funnels through the SAME contract: a provenance-carrying reward, ONE comparison-then-governance gate, and a queryable version lineage. Modules: agent_utilities/harness/reward_signal.py, agent_utilities/orchestration/ artifact_promotion.py, agent_utilities/models/knowledge_graph.py (ArtifactVersionNode), agent_utilities/knowledge_graph/research/evolution_state.py.

The contract

  • RewardSignal (value in [0, 1], source, confidence, provenance_ref, reason) names the [0, 1]-clamped reward convention every producer in the codebase already converged on independently (Langfuse blend, skill benchmark score, capability EMA, ...) and adds WHO computed it. blend() generalizes the existing 2-operand langfuse_signal.blend_reward to N confidence-weighted signals.
  • PromotionCandidate / promote() (orchestration/artifact_promotion.py) is the ONE entry point every optimizer's promotion boundary calls: (1) evaluate_promotion — the candidate-vs-incumbent comparison gate (skipped entirely when incumbent_reward=None, the shape a comparison-less vector like a mined claim or a golden-loop proposal uses — gated on quality + governance instead of a held-out score), then (2) the SAME action_policy.decide(kind=...) veto every reserved-kind call site already used, then (3) a caller-legible PromotionVerdict. It performs NO KG writes itself and never raises (a policy-consult failure fails closed).
  • ArtifactVersionNode (models/knowledge_graph.py) generalizes SkillVersionNode's propose-only lifecycle contract to every vector: artifact_kind/artifact_id/status (proposal|active|rejected)/origin/ reward/reward_source (the RewardSignal that gated this version, e.g. "internal_corpus")/task_count/notes/transfer_scores. SkillVersionNode / PromptVersionNode are thin subclasses (additive fields, zero existing-caller breakage — the strangler pattern, not a parallel v2 type).
  • The matrix query — artifact_evolution_summary(engine, artifact_kind=None, limit=200) (evolution_state.py) is the cross-vector read: scans every ArtifactVersionNode label, aggregates by status and kind, returns a drill-down sample. It is folded into read_evolution_state()'s artifact_versions key, so it is queryable from the SAME live surface every other steering read already uses — graph_loops action=state (MCP) and its REST twin — with zero new tool/route code.

Migration status (5 promotion pipelines)

Reserved kind Call site On promote()?
promote_skill_version skill_evolution.run_reflact_cycle Yes (pre-existing)
merge_promotion auto_merge.GovernedAutoMerger._consult_action_policy Yes (pre-existing)
promote_mined_claim ops_causal_tools._materialize_root_cause_claims (W2.7, closes B3) Yes
promote_mined_claim loop_controller._run_insight_validation No — direct action_policy.decide() (already governed, not a bypass; see migration shape)
route_policy_update loop_controller._run_trace_mining (via trace_pattern_miner) No — direct action_policy.decide()
spec_promotion spec_proposals.review_spec / auto_advance_specs No — direct action_policy.decide()

Two governance-bypass-closing vectors were also added directly onto the contract this wave (not part of the original 5, born from closing the KG_AGENT_AUTO_APPLY bypass, §4 above): promote_prompt_version (evolve_agent._apply_prompt_edit) and the reserved (currently uncalled — no ComponentType.TOOL_DESCRIPTION edit exists yet, so it stays unwired per Wire-First) promote_tool_description_version.

Migration shape for the 3 remaining direct-action_policy.decide() call sites — mechanical, low-risk (same reserved kind, same shipped tier, same audit trail; only the call-site plumbing changes):

  1. Wrap the vector's own quality/confidence signal as a RewardSignal (e.g. CandidateInsight.confidence → RewardSignal(value=..., source="internal_corpus")).
  2. Build a PromotionCandidate(artifact_kind=<name>, artifact_id=<target>, candidate_ref=<target>, candidate_reward=<the RewardSignal>, incumbent_reward=None, policy_kind=<the existing reserved kind string>, source="loop_engine", reason=...) — incumbent_reward=None for all three (none compares a candidate against an incumbent; each gates on quality + governance, the same shape promote_mined_claim uses).
  3. Replace the hand-built ActionRequest + action_policy.decide(...) + manual decision.decision in ("allow", "allow_notify") check with verdict = promote(engine, candidate); verdict.decision; verdict.approved.
  4. Keep every existing side effect (flywheel transitions, ProposalPublication/ ActionDecision audit writes) exactly where it is — promote() is a decision boundary, not a persistence layer; the caller still owns every write.

Not required this wave (the 2 pre-existing promote() call sites already exceed the "migrate at least 2" bar); tracked here so the remaining 3 direct call sites move onto the shared contract in a follow-up rather than accumulating a second pattern.