| name | evolve |
| description | OODA Meta-Orchestrator. Observes all domain states, orients by learning from past outcomes, decides the highest-priority action, and executes it. Run with /evolve or /loop 4h /evolve. |
| ooda_phase | meta |
| version | 1.4.0 |
| input | {"files":["config.json","agent/state/evolve/state.json","agent/state/evolve/confidence.json","agent/state/evolve/goals.json","agent/state/evolve/action_queue.json","agent/state/evolve/memos.json","agent/state/evolve/skill_gaps.json","agent/state/evolve/metrics.json","agent/state/evolve/episodes.json","agent/state/evolve/principles.json","agent/state/evolve/cost_ledger.json","agent/state/evolve/reflections.json"],"apis":["GitHub CLI (gh pr list, gh issue list, gh pr merge, gh pr view)","Health endpoints (config.health_endpoints)","Notification APIs (config.notifications)"],"config_keys":["domains","mission","implementation","scoring","confidence","safety","progressive_complexity","signals","memory","test_command","health_endpoints","deploy_workflow","notifications","cost"]} |
| output | {"files":["agent/state/evolve/state.json","agent/state/evolve/confidence.json","agent/state/evolve/memos.json","agent/state/evolve/action_queue.json","agent/state/evolve/metrics.json","agent/state/evolve/skill_gaps.json","agent/state/evolve/goals.json","agent/state/evolve/episodes.json","agent/state/evolve/principles.json","agent/state/evolve/cost_ledger.json","agent/state/evolve/CHANGELOG.md","agent/state/evolve/reflections.json","agent/state/*/lens.json","agent/state/*/lens_changelog.json"],"prs":"none"} |
| safety | {"halt_check":true,"read_only":true} |
| domains | [] |
| chain_triggers | [] |
evolve: OODA Meta-Orchestrator -- Autonomous Decision Engine
evolve is the brain of OODA-loop. It sits above every domain skill and runs
one full OODA loop per cycle: Observe the world, Orient by analyzing patterns
and updating beliefs, Decide which domain needs attention most, then Act by
invoking the winning skill.
This is NOT a round-robin scheduler. The Orient step differentiates evolve from
a cron job -- each cycle it reviews PR outcomes, updates confidence, applies
memos, detects urgent signals, and adjusts its world model. Over time it learns
which domains produce value and which waste cycles.
Based on John Boyd's OODA loop with an added Reflect step that extracts skill
gaps, writes memos, and feeds the next Orient -- creating a double-loop learning
system. All behavior is driven by config.json. No domain names, skills, or
routing tables are hardcoded.
Safety Rules
These rules are absolute. Violating any is a bug.
- HALT File -- Before ANY work, check
config.safety.halt_file. If it exists, print reason and stop immediately.
- Protected Paths -- PRs touching
config.safety.protected_paths MUST be Risk Tier 3 (human review). No auto-merge ever.
- PR Limit -- Max
config.safety.max_prs_per_cycle PRs per cycle (default 1).
- State-Only Direct Write -- evolve writes only to
agent/state/evolve/*. All other changes require branch + PR.
- Skill Allowlist -- Only invoke skills in
config.safety.skill_allowlist. Unlisted skill = safety violation, skip to Reflect.
- PR Size Limits -- Respect
config.safety.max_files_per_pr (20) and config.safety.max_lines_per_pr (500). Exceeding = Level 3.
File Structure
agent/state/evolve/
state.json -- cycle_count, last_cycle, decision_log (max 20)
confidence.json -- per-domain confidence scores (0.1-1.0)
goals.json -- user-defined goals with progress tracking
skill_gaps.json -- detected missing capabilities with frequency
memos.json -- cross-cycle notes and score_adjustments
action_queue.json -- RICE-scored pending/in_progress/completed actions
metrics.json -- permanent counters (cycles, PRs, costs)
cost_ledger.json -- daily API cost tracking (resets at 00:00 UTC)
episodes.json -- weekly summaries (tier 2 memory)
principles.json -- permanent learnings (tier 3 memory)
CHANGELOG.md -- cycle activity log (most recent 50)
Domain skills write their own state files. evolve reads but never writes them.
Step 0: Safety Checks
0-Pre: Config Validation
if config.json does not exist:
Print "[FATAL] config.json not found. Run /ooda-setup to create it."
EXIT immediately.
if config.json is not valid JSON:
Print "[FATAL] config.json parse error: {error_message}. Fix syntax and retry."
EXIT immediately.
0-A: HALT Check
if file exists at config.safety.halt_file:
Print "[HALT] Agent stopped. Reason: {file_content}"
Print "Remove to resume: rm {config.safety.halt_file}"
EXIT immediately.
0-B: Concurrent Execution Lock
lock_file = agent/state/evolve/.lock
if lock_file exists:
age_minutes = now - lock_file.started_at
if age_minutes < config.safety.lock_timeout_minutes: -- default 30: live lock
Print "[SKIP] Another evolve cycle is running (lock age {age_minutes}m)."
EXIT. -- do NOT delete a live lock
-- Stale lock: the previous cycle crashed or was killed mid-run.
Print "[WARN] Stale lock detected (age {age_minutes}m >= {lock_timeout_minutes}m). Removing and recovering."
Delete lock_file.
-- Fall through to 0-C, which performs the crash recovery for the cycle
-- that left this lock behind.
Create lock_file with content: {"pid": current, "started_at": "ISO 8601"}
The lock file is deleted at the end of Step 6. Every early-exit path
(min-cycle-interval skip in 0-D, HALT re-check in 4-A, HALT during execution,
dry-run, min-score skip, confidence gate with no fallback) MUST also delete the
lock file before exiting. If evolve crashes mid-run, the lock persists and
blocks live invocations only until lock_timeout_minutes elapses — then the
next invocation removes it, runs 0-C crash recovery, and proceeds. Unattended
operation therefore self-heals from crashes; manual rm is never required
(though always allowed).
0-C: Crash Recovery
if state.json.cycle_in_progress == true:
-- The crashed cycle never reached Step 6, so it has NO decision_log entry:
-- decision_log[-1] is the last COMPLETED cycle, and the crashed one is
-- cycle_count + 1. Name them correctly in the diagnostics (surfaced by the
-- v1.3.0 live soak run — the old message blamed the wrong cycle number).
crashed_cycle = state.cycle_count + 1
last = state.json.decision_log[-1] (if exists — the last completed cycle)
Print "[WARN] Cycle #{crashed_cycle} did not complete (crash/kill detected)."
Print " Last completed: #{last.cycle} — domain {last.selected_domain}, skill {last.selected_skill}, at {last.timestamp}"
Print " Resetting cycle_in_progress. Starting fresh cycle."
Set cycle_in_progress = false, write state.json.
Add memo: { type: "crash_recovery", cycle: crashed_cycle, last_completed: last.cycle }
Continue with fresh cycle (do NOT resume old one).
0-D: Min Cycle Interval
elapsed = (now - state.json.last_cycle) in minutes.
if elapsed < config.safety.min_cycle_interval_minutes:
EXCEPTION: if ANY domain state file has alerts with severity "critical":
Print "[URGENT] Critical alert detected, bypassing interval."
Continue.
Otherwise:
Print "[SKIP] Too soon. {remaining} min until next cycle."
Delete lock_file. -- 0-B created it; leaking it would block the next
-- on-time invocation until the stale timeout
EXIT.
if last_cycle is null: first cycle, proceed.
0-E: First Cycle Observe-Only
if state.json.cycle_count === 0 AND config.safety.first_cycle_observe_only:
Print "[INFO] First cycle -- observe-only mode."
Run Steps 1-3 only. Skip Steps 4-5.
Print "First cycle complete. Here's what I found:" + score table.
Print "Run /evolve again to take action."
Jump to Step 6.
In Step 6, record decision_log entry as:
{ cycle: 1, timestamp, action: "observe_only", reason: "first_cycle",
selected_domain: winner_domain, selected_skill: winner_skill,
score: winner_score, orient_summary, result: "observe_only",
score_verified: true }
cycle_count MUST increment to 1 so the next cycle proceeds normally.
Set cycle_in_progress = true in state.json before proceeding.
Record cycle_start_time = now.
Step 1: Observe
1-A: Domain State Reading
Season mode pre-apply (v1.2.0): Before iterating domains, check if
config.season_modes.enabled == true. If so, resolve the active mode:
if config.season_modes.enabled:
mode_name = config.season_modes.current_mode or "default"
mode = config.season_modes.modes[mode_name]
weight_overrides = mode.weight_overrides or {}
disabled_by_season = set(mode.disabled_domains or [])
Print "[Observe] Season mode: {mode_name} ({len(weight_overrides)} weight overrides, {len(disabled_by_season)} disabled domains)"
else:
weight_overrides = {}
disabled_by_season = set()
Apply overrides in-memory only (do NOT mutate config.json on disk). Each
domain's effective weight becomes weight_overrides.get(domain_name, domain_config.weight).
Active context pre-load (v1.2.0): Load stakeholder context blob if
configured, so skills receive it via a standard context var.
if config.active_context and config.active_context.path:
try:
active_context_blob = read JSON from config.active_context.path
active_context.path_resolved = config.active_context.path
-- Check staleness for refresh_skill policy
if config.active_context.refresh_skill:
file_age_hours = hours since file mtime
if file_age_hours >= config.active_context.refresh_interval_hours:
Schedule a chain-trigger refresh via config.active_context.refresh_skill
(recorded as a memo, consumed by Step 4-B's chain logic)
except (file missing or malformed):
Print "[Observe] active_context not loadable: {error}. Proceeding without context."
active_context_blob = null
else:
active_context_blob = null
The resolved active_context_blob is passed to every invoked skill in Step 4-B
as a context variable (opaque blob; skills interpret domain-specifically).
for each domain_name, domain_config in config.domains:
if domain_name in disabled_by_season:
log "[{domain}] Disabled by season mode '{mode_name}'. Skipping."
skip, do not score
if domain_config.status == "disabled": skip entirely, do not score
if domain_config.status == "available":
log "[{domain}] Not yet configured. Skipping."
skip, do not score
if domain_config.status == "active": proceed normally
# Legacy: if no status field, treat as "active" (backward compat)
# Season mode weight override (v1.2.0): replace the static weight in-memory
if domain_name in weight_overrides:
domain_config.weight = weight_overrides[domain_name]
# Legacy: "enabled" field is superseded by "status" but still honored as fallback
if not domain_config.enabled: skip
file = domain_config.state_file
if file missing:
mark as "never_run", hours_since_last = config.scoring.hours_if_never_run
else:
read JSON. Calculate hours_since_last from data.last_run to now.
(Legacy: if `last_run` is missing, also check `last_check` — older scan-health
versions used this key. Treat either as the last-run timestamp.)
Extract: status, run_count, alerts.
# Lens pre-init (v1.2.0): initialize the lens file here, before reading, so
# first cycles and domains with custom observe skills (which may not call the
# Step 5-E init helper) always have a valid lens file on disk. Production
# deployments observed 152 cycles with zero lens.json files created because
# the only init path was inside Step 5-E's observe-skill loop.
lens_path = agent/state/{domain_name}/lens.json
if lens_path does not exist:
mkdir -p agent/state/{domain_name}/
Write initial lens:
{
"schema_version": "1.0.0",
"domain": "{domain_name}",
"last_updated": now (ISO 8601),
"focus_items": [],
"learned_thresholds": [],
"discovered_signals": [],
"evidence": [],
"deprecated_items": []
}
Print "[Observe] Initialized lens for {domain_name}."
Also read lens file: agent/state/{domain_name}/lens.json
If lens exists and is valid JSON:
Extract focus_items where confidence >= 0.6
Extract learned_thresholds where confidence >= 0.6
Extract discovered_signals
Store as domain_config.lens for use in Step 4 skill invocation
If lens missing or corrupt: proceed without lens (base behavior)
Also read evolve self-state: state.json, confidence.json, memos.json,
goals.json, action_queue.json, skill_gaps.json, cost_ledger.json.
If cost_ledger.json is MISSING (fresh install / first run), create with initial structure:
{"schema_version": "1.0.0", "date": "<today YYYY-MM-DD>", "entries": [], "total_estimated_usd": 0.0}
If it EXISTS but is CORRUPT (unparseable JSON): fail closed — back it up to
cost_ledger.json.corrupt, create the HALT file ("cost ledger corrupt — today's
spend unknown; refusing to run without cost accounting"), and EXIT (delete the
lock first). Recreating a corrupt ledger as $0.00 would silently erase today's
spend and defeat the daily cap.
Daily reset: Compare cost_ledger.json.date to current UTC date (YYYY-MM-DD).
If different: reset total_estimated_usd to 0.0, clear entries, update date to today.
Cost accounting always uses UTC regardless of config.project.timezone.
If config.implementation.enabled: read action_queue.json for pending_count,
oldest_pending_hours, highest_rice.
1-B: GitHub Status
Run these commands. If gh fails, log warning and continue with empty data.
gh pr list --state open --json number,title,labels,createdAt,headRefName
gh pr list --state merged --limit 5 --json number,title,mergedAt,headRefName
gh pr list --state closed --limit 5 --json number,title,closedAt,headRefName
gh issue list --state open --json number,title,labels
Store as: open_prs, merged_prs, closed_prs, open_issues.
1-C: External Signals
Read agent/state/external/*.json if any files exist. Include contents in
observations under "external_signals". Missing directory = skip silently.
1-D: Observation Structuring
Assemble in-memory observation object (NOT written to file):
observation = {
timestamp, domains (1-A), github (1-B),
implementation { pending_count, oldest_pending_hours, highest_rice },
external_signals (1-C),
evolve_state { cycle_count, decision_log_length, active_goals, pending_actions }
}
Print: [Observe] Scanned {N} domains. {M} open PRs, {K} merged since last cycle.
Step 2: Orient
2-A: Pattern Analysis
Read last 5 entries from state.json.decision_log. Detect:
- Consecutive same domain: count recent consecutive selections of same domain.
- Execution-result correlation: match decision_log PR numbers to merged/closed PRs.
- Repeated failures: count entries with result "error"/"failed" for same domain. If >= 2, flag as infrastructure_issue.
- Futile loop: derive the consecutive-futile count by scanning
decision_log backwards from the newest entry (no separately persisted counter — the log IS the source of truth, so the count survives restarts): consecutive entries with the same selected_domain, result "success", and no actionable output recorded (no actions extracted, no alerts generated, no PR — e.g., plan-backlog returning "no_remote" repeatedly). If >= 3 consecutive futile cycles for the same domain, add a memo penalty of -10.0 to that domain and log [Orient] Futile loop detected: {domain} produced no output for {N} consecutive cycles. Penalizing. This prevents the staleness score from endlessly selecting an unproductive domain.
- Scope: penalty applies only to the specific futile domain, not globally.
- Lifetime: written as
score_adjustments[domain] = -10.0 in memos.json. Consumed (deleted) after one application in Step 3-A scoring. Does not persist across multiple cycles.
- Recovery: domain recovers automatically by producing a non-empty observation in any subsequent cycle (the consecutive futile counter resets to 0).
Store patterns for Steps 2-E and 5-C.
2-A2: Saturation Circuit Breaker
Track consecutive_observe_only_cycles in state.json. A cycle is "observe-only"
if it produced result "success" but: no PRs created, no actions extracted, no
new alerts generated, and no confidence changes occurred.
This check runs in Orient, BEFORE the current cycle has acted — so it evaluates
the previous completed cycle (the newest decision_log entry and what Step 6
recorded for it), never the in-flight one. If the counter field is missing from
state.json (fresh install, pre-v1.3 state), initialize it to 0 — a missing field
must not silently disable the circuit breaker.
-- Evaluate the PREVIOUS completed cycle's actionable output (from its
-- decision_log entry / recorded outcome — current cycle hasn't acted yet)
if state.consecutive_observe_only_cycles is undefined:
state.consecutive_observe_only_cycles = 0
prev = decision_log[-1] (if none: skip 2-A2 entirely — nothing to evaluate)
has_output = (prev created a PR OR extracted actions OR generated new alerts
OR changed confidence)
if has_output:
state.consecutive_observe_only_cycles = 0
else:
state.consecutive_observe_only_cycles += 1
N = state.consecutive_observe_only_cycles
warn_threshold = config.saturation.warn_threshold -- default 5
boost_threshold = config.saturation.boost_threshold -- default 10
halt_threshold = config.saturation.halt_threshold -- default 15
if N == warn_threshold:
Add memo: { type: "saturation_warning", message: "Observation saturation: {N} cycles without actionable output. Consider enabling implementation or reviewing domain configuration." }
Print "[Orient] ⚠ Saturation warning: {N} consecutive observe-only cycles."
if N == boost_threshold:
-- Boost pending actions and implementation domain to break out of observation loop
for each item in action_queue.pending:
item.effective_rice += config.saturation.implementation_boost -- default 5.0
Print "[Orient] ⚠ Saturation boost applied: action queue +{boost}, implementation domain boosted."
if N >= halt_threshold AND config.saturation.auto_halt (default true):
Create HALT file: "Observation saturation: {N} cycles without actionable output. Human review needed. Delete this file to resume."
Print "[Orient] 🛑 Saturation halt: {N} cycles. HALT file created. Review required."
2-B: Confidence Update
confidence.json exists in two shapes in the wild and BOTH must be read
correctly (surfaced by the 2026-06 framework-repo dogfood run, where live state
was nested while fixtures were flat):
- flat (fixtures, early projects):
{ "domain_name": 0.7, ... }
- nested (live deployments):
{ "domains": { "domain_name": { "score": 0.7, "last_updated": ..., "recent_outcomes": [...] } } }
Read rule: if a top-level domains key holds objects, the per-domain value is
domains[name].score; otherwise the top-level value itself. Write rule:
preserve the file's existing shape (update score in place for nested; the
bare float for flat) — never silently convert a project's state file.
for each PR in merged_prs:
match PR.headRefName to domain via config.domains.*.branch_prefix
if PR.number already in decision_log: skip (prevent double-counting)
confidence[domain] += config.confidence.merge_boost (cap at config.confidence.max)
Print "[Orient] PR #{n} merged -> {domain} confidence +{boost}"
for each PR in closed_prs (not merged):
match branch to domain via branch_prefix
if already logged: skip
confidence[domain] -= config.confidence.reject_penalty (floor at config.confidence.min)
Print "[Orient] PR #{n} rejected -> {domain} confidence -{penalty}"
for domains not yet in confidence.json:
set to config.confidence.initial (default 0.7)
If config.implementation.enabled: apply same logic to implementation PRs.
2-B1: Observation-Based Micro-Adjustments
When config.confidence.observation_micro_adjustments is true (default) AND
progressive_complexity.current_level < 3, apply observation-based confidence
updates. This prevents confidence stagnation in observation-only deployments
where no PRs are created and PR-based updates never fire.
for each domain that was executed this cycle:
if skill produced actionable findings (actions extracted > 0 or alerts found):
confidence[domain] += 0.02 (cap at config.confidence.max)
Print "[Orient] {domain} produced findings -> confidence +0.02"
else if skill produced alerts (domain state has severity warning or critical):
confidence[domain] += 0.03 (cap at config.confidence.max)
Print "[Orient] {domain} alert detected -> confidence +0.03"
else if skill produced no new data (status unchanged from previous cycle):
confidence[domain] -= 0.01 (floor at config.confidence.min)
Print "[Orient] {domain} no new data -> confidence -0.01"
At Level 3 (autonomous mode), these micro-adjustments are suppressed to avoid
double-counting with PR-based confidence updates. The PR merge/reject deltas
(+0.1/-0.2) are the primary signal at Level 3.
2-B2: Action-Queue Sync
For each action in action-queue with status "proposed" (has pr_number):
- PR merged -> status = "merged", move to completed.
- PR closed (not merged) -> status = "rejected", move to completed. Add memo.
- PR has review comments -> summarize feedback in memos.
- PR still open -> no change.
2-B3: Cross-Domain Cascade Detection
If config.domain_dependencies is defined, check for cascade events that
affect multiple domains simultaneously.
if config.domain_dependencies exists:
-- Read cascades.json (create if missing)
cascades_path = agent/state/evolve/cascades.json
if cascades_path does not exist:
write { "schema_version": "1.0.0", "cascades": [] }
-- Check for new cascade events from executed skill output
for each domain_dep in config.domain_dependencies:
source = domain_dep key
depends_on = domain_dep.depends_on (array of domain names)
cascade_events = domain_dep.cascade_events (array of event types)
-- Detect cascade: if source domain's state changed significantly
if source was executed this cycle AND state changed:
for each event_type in cascade_events:
-- Pattern match against domain state changes
if event_type matches observed change (e.g., entity_rename, schema_change):
cascade = {
id: "C-{date}-{seq}",
event_type: event_type,
source_domain: source,
affected_domains: depends_on,
details: description of what changed,
status: "pending",
created_at: now
}
cascades.append(cascade)
Print "[Orient] Cascade detected: {event_type} from {source} → affects {depends_on}"
-- Apply cascade scoring bonus to affected domains.
-- One-shot per (cascade, domain): score_adjustments are consumed by 2-D/3-A
-- the next time that domain is scored, so re-adding the bonus every cycle
-- while the cascade stays pending would compound +3.0 indefinitely. Track
-- which domains already received this cascade's bonus.
for each pending cascade:
for each affected_domain in cascade.affected_domains:
if affected_domain in (cascade.bonus_applied_to or []):
continue -- already boosted once for this cascade
memos.score_adjustments[affected_domain] = (memos.score_adjustments[affected_domain] or 0) + 3.0
cascade.bonus_applied_to = (cascade.bonus_applied_to or []) + [affected_domain]
Print "[Orient] Cascade bonus: {affected_domain} +3.0 (from {cascade.source_domain} {cascade.event_type})"
-- Check if all affected domains have run since cascade was created
all_updated = all(
domain.last_run > cascade.created_at
for domain in cascade.affected_domains
)
if all_updated:
cascade.status = "resolved"
cascade.resolved_at = now
Print "[Orient] Cascade resolved: {cascade.id}"
-- Persist: write cascades.json (new cascades, bonus_applied_to, resolutions)
-- and memos.json (the score_adjustments added above) back to disk. Without
-- this write, every cascade detected above is silently lost at cycle end.
Write cascades.json
Write memos.json
2-B4: Outcome Back-Annotation (merge & hold, v1.4.0)
The Step 6-C9 Outcome Record scores a cycle from what was knowable AT cycle end
(a freshly-opened PR scores pr_created = 0.5). The true value of that PR is
only knowable later — when a human merges it, and when it survives. This step
back-annotates the earlier outcome entry as that information arrives, so the
scorecard reflects accepted, durable value rather than mere PR creation.
for each PR in merged_prs (from 1-B):
find the outcomes.json entry where entry.pr_number == PR.number
if found and entry.result_type in ("pr_created",):
entry.result_type = "pr_merged"; entry.quality_multiplier = 0.8
Print "[Orient] Outcome back-annotated: cycle #{entry.cycle_id} PR #{n} merged (0.5 → 0.8)."
for each merged outcome entry older than 48h whose hold has not been resolved:
-- merge-and-hold check: was the merge commit reverted since?
reverted = `gh api repos/{owner}/{repo}/commits?since=...` shows a revert of PR.number
(or git log --grep "Revert" referencing the PR/merge SHA)
if reverted:
entry.result_type = "pr_rejected"; entry.quality_multiplier = 0.0
Print "[Orient] PR #{n} was REVERTED within 48h → outcome downgraded to 0.0."
else:
entry.result_type = "pr_merged_held"; entry.quality_multiplier = 1.0
Print "[Orient] PR #{n} merged and held 48h → confirmed value (1.0)."
for each PR in closed_prs (not merged):
find the outcomes.json entry where entry.pr_number == PR.number
if found: entry.result_type = "pr_rejected"; entry.quality_multiplier = 0.0
Write outcomes.json if any entry changed.
This is what makes pr_merged_held (1.0) and the merge-and-hold rate on the
scorecard real signals rather than aspirational ones. The score_outcome.py
reference already maps these result_types; this step is what SETS them over time.
2-C: Goal Progress
for each goal in goals.json where status == "active":
if goal.metric_command exists:
run command, parse numeric output as current_value
if command fails: log warning, keep previous progress
goal.progress = clamp(current_value / goal.target, 0.0, 1.0)
if progress >= 1.0: mark status "completed"
Evidence gate for artifact-dependent goals (v1.7.0). The F1 dogfood declared
its goal 99% complete by counting features it wrote for itself while the game
was broken. A goal whose domain declares a quality_rubric may NOT be credited
on a self-authored checklist alone — and a single lucky critic spike must not
permanently unlock it either:
if goal.domain has config.domains[d].quality_rubric:
-- rolling artifact mean over the last N build cycles (N = rubric.plateau_window)
roll = mean(artifact_score of last N artifact-scored outcomes for this domain)
above = count of trailing cycles with artifact_score >= rubric.bar
-- cap the self-declared progress by the rolling artifact reality, and only let
-- it reach "completed" when quality has HELD above bar for N consecutive cycles.
goal.progress = min(goal.progress, roll / rubric.bar)
goal.evidence = { rolling_artifact_mean: roll, consecutive_above_bar: above }
if goal.progress >= 1.0 AND above < N:
goal.progress = 0.99 -- "feature-complete but quality not yet held" — not done
2-D: Memo Adjustments
Read memos.json. The canonical format (v1.1.0, bumped in v1.2.0) is:
{"schema_version":"1.1.0", "score_adjustments":{}, "interventions":[], "history":[], "last_memo":null}
If the file has schema_version: "1.1.0" and no interventions key, treat
interventions as an empty list and leave the file's schema_version at 1.0.0
until the next write (6-C) promotes it to 1.1.0 automatically.
If the file has a memos array but no score_adjustments key (pre-v1.1.0 legacy),
treat score_adjustments as empty {} and history as the memos array.
Two memo kinds:
-
Score adjustments (one-shot, consumed) — score_adjustments: { domain: delta }.
Applied once in Step 3-A. After application, DELETE them (set to {}). They do
not persist across cycles. If a key does not match any domain in
config.domains, log [WARN] Memo adjustment for unknown domain '{key}' -- ignored and cleared. and discard it.
-
Interventions (multi-cycle, decremented) — interventions: [{domain, delta, type, reason, created_at_cycle, expires_after_cycles, applied_count}].
For each entry, apply delta to the named domain's score in Step 3-A (same
place as score_adjustments — their effects sum). After application:
applied_count += 1
expires_after_cycles -= 1
- if
expires_after_cycles <= 0: remove the intervention.
Interventions are written by Step 5-C (auto-starvation, monopoly-breaker,
contrarian) and by external operators. They formalize the "memo-as-active-
intervention" pattern observed in production (Lynceus cycles 61, 107 used
manual +1.0/−10.0 score deltas that spanned multiple cycles).
Compute the combined memo_adjustment[domain] for Step 3-A as:
memo_adjustment[domain] = score_adjustments.get(domain, 0)
+ sum(iv.delta for iv in interventions if iv.domain == domain)
This value flows into the Step 3-A formula as the memo_adjustment term.
2-E: Orient Summary
Read the previous cycle's orient_summary from state.json.decision_log[-1].orient_summary
(if decision_log is non-empty). Use it as prior context: what changed since that assessment?
What predictions held? What was surprising? This creates a cumulative world model that
evolves across cycles rather than being rebuilt from scratch each time.
Write 2-3 sentence world model: what changed, what's urgent, what to focus on.
Truncate orient_summary to 500 characters max. If the previous cycle's summary
exceeds 500 chars when read, truncate it before using as prior context.
Store as orient_summary in the decision_log entry being built.
Print: [Orient] {orient_summary}
2-F: Reflection Recall (Reflexion loop, closes 5-F)
Re-inject recent verbal self-critiques so the loop acts on its own past lessons
instead of relearning them. This is the read side of the Reflexion loop written
in Step 5-F.
-- Read agent/state/evolve/reflections.json (skip silently if missing/empty).
-- Select up to config.memory.reflection_recall_count (default 3) reflections,
-- most recent first, preferring those whose `domain` matches a current
-- candidate domain or the dominant recent pattern.
relevant = recent reflections matching candidate domains (cap N)
if relevant is non-empty:
-- Fold their `lesson` lines into the world model as prior guidance. They
-- inform the orient_summary (2-E) and break ties in Decide (Step 3): when two
-- domains score within ~0.5, a matching lesson nudges the choice.
Print "[Orient] Recalling {len(relevant)} past lesson(s): {lesson_1}; ..."
for each reflection applied this way:
set its status = "applied" -- persisted in Step 6 alongside reflections.json
A reflection whose verdict was miss and whose lesson was applied and then
held (no repeat miss) is the clearest signal the verbal loop is working — surface
it in the Cycle Card LEARN line (Step 7) when no higher-priority delta exists.
2-G: Plateau Detection (the leap trigger — v1.7.0)
The fix for D3: detect when the loop is running but not improving the
artifact, so Decide (3-K) can force a quantum-leap cycle instead of bolting on
yet another feature. Runs after 2-B4 (so it reads outcomes.json with this
cycle's back-annotations applied) and only for domains that declare a rubric.
for the implementation/build domain that declares config.domains[d].quality_rubric:
p = rubric_score.detect_plateau(outcomes.entries, rubric) -- pure, deterministic
-- p.plateau is true when artifact quality is BELOW bar AND either:
-- (a) artifact_score is flat (max−first < plateau_eps) over the last
-- plateau_window artifact-scored cycles, OR
-- (b) the same weakest_dimension has stayed weakest for plateau_window cycles.
-- Anti-circularity (the loop generates the scores it is judged on): a plateau
-- requires below-bar — slow upward drift that never reaches bar still counts as
-- stuck, and "already good" (>= bar) is never a plateau.
if p.plateau:
-- THRASHING GUARD (v1.8.0 fix): if leaps on the TARGET dimension have already
-- failed to clear config.leap.min_dimension_delta
-- `config.leap.max_attempts_per_dimension` times (default 2), do NOT leap
-- again — escalate to a HALT for human help.
-- BUG FIX: the v1.7.x guard read a nonexistent field `e.leap_delta` and
-- matched `weakest_dimension`, so `fails` was ALWAYS 0 and the guard NEVER
-- fired — the loop could thrash forever on an unmeasurable dimension. The
-- real ledger field is `leap_attempts[].delta_score`, and the dimension that
-- was actually leapt is `leap_target` (the weighted-gap winner from 3-K),
-- which can differ from the raw `weakest_dimension`.
fails = count(e in outcomes where e.cycle_mode == "leap"
for a in (e.leap_attempts or [])
if a.dimension == p.leap_target
AND a.delta_score < config.leap.min_dimension_delta)
if fails >= config.leap.max_attempts_per_dimension:
-- STALL → REWRITE escalation (v1.11.0, the anti-maze fix). Before giving up
-- to a HALT, try ONE from-scratch REWRITE: repeated incremental leaps that
-- stall are the signature of the maze (a symptom patched on a wrong
-- architecture — the f1 `sky.visible=false` class). A patch can't escape;
-- a rewrite can. rubric_score.recommend_rewrite() confirms the stall.
rw = rubric_score.recommend_rewrite(outcomes.entries, p.leap_target, rubric,
min_failed = config.leap.max_attempts_per_dimension)
already_rewrote = any(e.cycle_mode == "rewrite" AND e.leap_target == p.leap_target
for e in last config.leap.max_attempts_per_dimension outcomes)
if config.leap.rewrite_on_stall AND rw.rewrite AND not already_rewrote:
-- Reflexion (arXiv:2303.11366): carry a NEGATIVE-EXAMPLE memo so the
-- rewrite explicitly avoids the stalled approach, and re-ground it
-- (dev-cycle Step 3-PRE) in config.references / the research playbook —
-- the rewrite must implement a CITED reference technique, not re-guess.
write memo { type:"stall_detected", dimension:p.leap_target,
stalled_approach: summary of the last {fails} leaps' diffs,
instruction:"Start from scratch on {p.leap_target}. The incremental
approach stalled. Do NOT reuse it. Ground the rewrite in
config.references[{domain}] / the research playbook (Step 3-PRE)." }
set orient.plateau = { active:true, leap_target:p.leap_target, mode:"rewrite",
weakest_dimension:p.weakest_dimension, artifact_score:p.latest,
reason:"incremental stalled → grounded REWRITE" }
Print "[Orient] ♻️ STALL→REWRITE: incremental leaps on '{p.leap_target}' stalled {fails}×. Next cycle REWRITES from a cited reference (not another patch)."
else:
record skill_gap { name: "leap_stuck_{p.leap_target}", type:"quality_gap",
detail:"Leap+rewrite on {p.leap_target} failed without clearing min delta —
likely UNMEASURABLE by the current capture_method (see 5-G)." }
Create HALT: "Leap+rewrite on '{p.leap_target}' stalled — human review needed:
supply a richer capture_method/metrics harness or authored
assets (asset_sources) for this dimension, or reweight the
rubric. The loop cannot self-fix it."
set plateau_leap_blocked = true
else:
set orient.plateau = { active:true, leap_target:p.leap_target,
weakest_dimension:p.weakest_dimension,
artifact_score:p.latest, reason:p.reason }
Print "[Orient] 📉 PLATEAU. Leap target '{p.leap_target}' ({p.reason}). Next cycle should LEAP, not add a feature."
else:
-- DIMENSION LOCK until bar (v1.8.0): the v1.7.x loop cleared the plateau the
-- instant a leap produced any improvement (+min_delta), then coasted through
-- ~plateau_window−1 feature cycles before re-detecting — and re-targeted via a
-- recomputed weighted gap, so a partially-fixed dimension could LOSE its slot
-- before reaching bar. Result: the loop "detected and nudged" instead of
-- "detected and CLOSED" (the F1 probe: visual_fidelity took 0.22→0.41→0.59
-- across non-consecutive leaps and never reached bar). Fix: if the last cycle
-- was a SUCCESSFUL leap whose target is still below bar, keep the plateau
-- active on the SAME target so 3-K leaps it again next cycle — drive it to bar.
last = outcomes.entries[-1] if outcomes.entries else null
bar = config.domains[d].quality_rubric.bar
if (config.leap.lock_until_bar (default true)
AND last is not null AND last.cycle_mode == "leap"
AND last.result_type != "leap_regressed"
AND p.leap_target is not null
AND last.dimension_scores[p.leap_target] < bar - rubric.plateau_eps):
-- tolerance band: within plateau_eps of bar counts as cleared, so critic
-- variance near threshold can't lock the loop forever (max_attempts HALT
-- is the backstop if it genuinely can't clear).
set orient.plateau = { active:true, leap_target:p.leap_target,
weakest_dimension:p.weakest_dimension,
artifact_score:p.latest,
reason:"'{p.leap_target}' improved to {last.dimension_scores[p.leap_target]} but still < bar {bar} — keep leaping" }
Print "[Orient] 🔒 LEAP succeeded but '{p.leap_target}' still below bar. Locking target — leap again, don't coast."
else:
set orient.plateau = { active:false }
orient.plateau is consumed by Step 3-K. With lock_until_bar the loop drives
one dimension to its bar before moving on (constraint exploitation, not
rotation); set config.leap.lock_until_bar=false to restore v1.7.x rotation.
Step 3: Decide
3-K: Leap-Mode Gate (apply BEFORE 3-G — v1.7.0)
The fix for D3: RICE (Reach×Impact×Confidence/Effort) structurally scores
overhauls low (high effort, uncertain confidence), so the loop could only ever
take small safe steps — 22 feature modules, never a re-founding of the broken
core. This gate lets a plateau force a quantum leap. It is a cycle-mode
decision, not a new scorer: it sets a flag and reuses existing machinery
(memo_adjustment + a context var), so it adds no parallel scoring branch.
cycle_mode = "feature" -- default
if orient.plateau.active AND not plateau_leap_blocked AND not dry_run:
-- cost guard: a leap (bigger diff + 2× artifact critique) is pricier. Defer
-- (not HALT) if it would breach the daily cap.
if cost_ledger.total_estimated_usd + config.leap.cost_limit_usd > config.cost.daily_limit_usd:
Print "[Decide] Plateau detected but a leap would breach the daily cost cap — deferring leap to next cycle."
else if leaps_today >= config.leap.max_per_day (default 2):
Print "[Decide] Plateau detected but daily leap cap reached — deferring."
else:
cycle_mode = "leap"
-- v1.7.1: target the largest WEIGHTED gap (weight × (bar − score)), i.e. the
-- dimension whose fix most raises the headline artifact_quality — not just the
-- lowest raw score. (rubric_score.detect_plateau.leap_target. F1 dogfood:
-- visual_fidelity outranked a lower-scoring fun_challenge and matched what a
-- player perceived as "crap".) Falls back to weakest_dimension.
targeted_dimension = orient.plateau.leap_target or orient.plateau.weakest_dimension
-- Pull the weakest-dimension's owning build domain UP via the EXISTING
-- memo_adjustment term (consumed in 3-A) so the normal scorer selects it —
-- no new term in the 3-A formula:
gap = config.domains[build_domain].quality_rubric.bar - orient.plateau.artifact_score
memo_adjustment[build_domain] += config.leap.gap_weight * (gap / bar) -- gap-to-bar bonus
-- protected-path pre-check at trigger time (not just at PR time): if the core
-- this leap must touch is protected, force human review.
if `git diff`/target files would touch config.safety.protected_paths:
force risk_tier = 3 (no auto-merge, regardless of enable_auto_merge)
Print "[Decide] 🚀 LEAP MODE: overhaul '{targeted_dimension}' (artifact {orient.plateau.artifact_score} < bar). RICE bypassed for this cycle."
When cycle_mode == "leap", Step 4-B passes leap_mode=true,
targeted_dimension, config.leap.max_lines (a larger size budget than
max_lines_per_pr), AND — v1.9.0 — the targeted dimension's techniques +
technique_cdns menu + any asset_sources, to the build skill (dev-cycle),
instructing it to make a step-change on the targeted dimension (overhaul /
rebuild / refactor-for-cohesion) rather than pick the top-RICE feature. The
technique menu is the fix for "the loop reached for more BoxGeometry instead of
EffectComposer": the leap instruction is "pick the ONE technique from this menu
(or integrate the supplied asset_sources) most likely to move the score toward
bar_coast and implement it completely; do not add game features." Steps 3-G
(complexity level) and 3-F (confidence) STILL apply. The leap's verification is the
5-G artifact gate (4-C2), not only the unit test.
Mega-leap (v1.9.0, optional). When a dimension can't be moved by a normal leap
(the 2-G thrashing guard would HALT), an operator may author config.leap.mega_leap
- an approved
mega_leap_plan.json to unlock a multi-cycle RE-PLATFORM: a much
larger budget (mega_leap.max_lines) across up to max_cycles, NO per-cycle
revert, only a final-cycle gate (revert ALL cycles if cumulative artifact delta <
min_artifact_delta_at_completion). requires_human_plan_approval keeps the loop
from self-authorising a rewrite — this is how the loop makes a genuinely RADICAL
jump (replace a whole pipeline) instead of only incremental overhauls.
3-G: Progressive Complexity Filter (apply FIRST)
level = config.progressive_complexity.current_level
level_config = config.progressive_complexity.levels[level]
First, exclude non-active domains:
Remove any domain where status == "disabled" or status == "available"
Also remove any domain listed in the active season mode's `disabled_domains`
(Step 1-A removes them from scoring in-memory; re-filter here so a
season-disabled domain can never re-enter via this filter's own list)
Only status == "active" domains count toward the level-based domain limit
Sort remaining (active) domains by weight descending.
Level 0: keep first level_config.domains active domains only.
Level 1: keep first level_config.domains active domains only.
Level 2: all active domains. No implementation.
Level 3: all active domains + implementation (if config.implementation.enabled).
Filter domains BEFORE scoring.
If zero domains remain after filtering (all disabled/available/over limit):
Print "[Decide] No scoreable domains. All are disabled or not yet configured."
Print "[Decide] Configure a domain with /ooda-skill or set status to 'active'."
Log decision_log: { action: "skip", reason: "no_scoreable_domains" }
Jump to Step 6.
3-A0: Implicit Guidance Check (Boyd Shortcut)
Before formal scoring, check if Orient produced a clear, high-confidence directive:
-
Critical alert override. If any domain state file contains alerts with
severity: "critical", skip scoring entirely. Select that domain as the winner.
If multiple domains have critical alerts, pick the one with the highest weight.
Print: [Decide] Implicit guidance: critical alert in {domain}. Bypassing scoring.
Jump directly to Step 3-I.
-
Stable pattern shortcut. If the same domain won scoring for 3+ consecutive
cycles (check decision_log last 3 entries) AND its confidence >= 0.8 AND no
urgent signals exist for other domains: skip full scoring, continue with that
domain. Print: [Decide] Implicit guidance: stable pattern, continuing {domain}.
Jump to Step 3-I.
-
Otherwise, proceed to normal scoring (3-A).
This implements Boyd's key insight: Orient can bypass Decide and feed directly
into Act. An experienced operator does not score every option when the building
is on fire.
3-A: Standard Scoring Formula
For each enabled domain (after filtering):
staleness_term:
if config.scoring.staleness_curve == "linear":
staleness = hours_since_last * domain.weight -- legacy behavior
else: -- "logarithmic" (default)
K = 10.0 -- scaling constant
T = 4.0 -- time constant (hours)
staleness = domain.weight * K * ln(1 + hours_since_last / T)
Reference values (logarithmic, weight=1.0):
1h → 2.23, 4h → 6.93, 12h → 13.86, 24h → 19.46, 168h → 37.62
The curve rises quickly for recently-stale domains but plateaus for very stale
ones, preventing extreme scores that caused domain monopoly in production
(e.g., 168h × 2.0 = 336 under linear).
-- Per-domain cooldown (skip if within min_interval_hours)
if domain.min_interval_hours is set AND hours_since_last < domain.min_interval_hours:
score = 0 -- hard cooldown, domain cannot be selected this cycle
Print "[Decide] {domain} cooldown: {hours_since_last}h < {min_interval_hours}h minimum"
continue to next domain
-- Dry-domain dampener (v1.4.1): a domain that produced NO actionable output the
-- last time it ran has (likely) nothing to do — yet staleness would keep
-- selecting it, burning futile cycles. Dampen its staleness until it next
-- produces output. An active alert always exempts (something IS wrong → go look).
-- WORK domains (ooda_phase strategize/execute, e.g. plan-backlog / dev-cycle):
-- hard dampen — nothing to build means don't build (Iter 2: B goal 80→100%).
-- MONITOR domains (ooda_phase observe, e.g. scan-health): MILD dampen — a quiet
-- monitor must still be POLLED periodically, just not every cycle (Iter 3:
-- A futile 58→50%, goal 67→83%). Cadence emerges from the mild dampen rather
-- than requiring a hand-set min_interval_hours.
-- (tests/sim/RESULTS.md, Iterations 2–3.)
last_run = most recent decision_log entry where selected_domain == domain
if last_run exists AND last_run.had_output == false
AND no active alert for this domain this cycle:
if domain primary skill ooda_phase in ("strategize","execute"):
staleness *= config.scoring.dry_domain_dampen -- default 0.3
else:
staleness *= config.scoring.monitor_dry_dampen -- default 0.6
-- Off-mission dampener (v1.4.1, Iteration 4): when a mission is set, a domain
-- whose mission_alignment is below config.scoring.off_mission_threshold (0.2) is
-- a distraction — it must not steal cycles from the mission on staleness alone.
-- An active alert still exempts (a fire is a fire even off-mission). Sandbox:
-- B mission-hit 42→75% / futile 58→25%, C goal 57→71% (RESULTS.md, Iteration 4).
if config.mission is set
AND (config.domains[domain].mission_alignment or 0.0) < config.scoring.off_mission_threshold
AND no active alert for this domain this cycle:
staleness *= config.scoring.off_mission_dampen -- default 0.2
-- Alert recency dampener (prevents alert-driven domain monopoly)
cooldown_hours = config.signals.alert_cooldown_hours -- default 4
if urgent_signal > 0 AND alert severity is NOT "critical":
recency_factor = max(0, 1.0 - hours_since_last / cooldown_hours)
dampened_alert = urgent_signal * (1.0 - recency_factor)
-- Example: domain ran 0h ago → dampened to 0. Ran 2h ago → 50%. Ran 4h+ → full bonus.
else:
dampened_alert = urgent_signal -- critical alerts bypass dampener (Step 3-A0 still fires)
-- Consecutive alert cap: after N consecutive alert-driven selections, auto-acknowledge
max_alert_cycles = config.signals.max_consecutive_alert_cycles -- default 3
if domain was selected by alert in last max_alert_cycles consecutive cycles:
dampened_alert = 0
Print "[Decide] {domain} alert auto-acknowledged after {max_alert_cycles} consecutive cycles"
-- Entropy-based domain balance penalty (prevents systematic monopoly)
if config.scoring.balance_enabled (default true):
B = config.scoring.balance_weight -- default 5.0
N = count of active domains
domain_share = metrics.counters.domain_executions[domain] / max(metrics.counters.total_skill_executions, 1)
-- (metrics.json nests these under `counters` — see Step 6-C7; reading the
-- flat path silently yields 0/1 and zeroes the balance penalty)
expected_share = 1.0 / N
balance_penalty = max(-B * (domain_share - expected_share), -10.0)
-- Example: 5 domains, domain ran 50% of the time (expected 20%): penalty = -5.0 * 0.3 = -1.5
-- A domain that ran LESS than expected gets a bonus (penalty is positive).
else:
balance_penalty = 0
-- Mission alignment (v1.4.1): the loop drives toward the project's declared
-- mission, captured at /ooda-setup into config.mission with a per-domain
-- config.domains[d].mission_alignment in [0.0, 1.0]. A domain that advances the
-- mission is pulled up; an off-mission distraction (alignment 0) is not. This is
-- what makes an installed OODA-loop *self-drive toward its purpose* rather than
-- merely cycling by staleness. Sandbox A/B/C simulation: adding this term lifted
-- goal completion +13–20pp across all three projects (tests/sim/RESULTS.md).
if config.mission is set:
M = config.scoring.mission_weight -- default 6.0
mission_term = M * (config.domains[domain].mission_alignment or 0.0)
else:
mission_term = 0 -- back-compat: no mission, no term
score = staleness
+ dampened_alert -- from 3-B, with recency dampener
+ mission_term -- v1.4.1 mission alignment
+ (goal_contribution * config.scoring.goal_weight) -- from 3-C
+ (confidence * config.scoring.confidence_weight)
+ memo_adjustment -- from memos.json, consumed in 2-D
+ balance_penalty -- entropy-based domain balance
Floor: if computed score < 0, clamp to 0. Negative scores indicate a domain
that should be avoided this cycle, but displaying them as 0 prevents confusion
in the score table. The decision_log still records the raw (pre-clamp) score
for diagnostics.
Tie-break: prefer domain with fewer total executions (metrics.json.domain_executions).
If still tied, prefer domain with higher weight. If still tied, prefer the domain
that appears first in config.domains (deterministic by insertion order).
3-A2: Implementation Scoring
ONLY if config.implementation.enabled AND progressive_complexity >= 3:
impl_score = (pending_count * config.scoring.implementation_formula.pending_multiplier)
+ (oldest_pending_hours * config.scoring.implementation_formula.age_multiplier)
+ (highest_rice * config.scoring.implementation_formula.rice_multiplier)
+ (open_draft_pr_count > 0 ? config.scoring.implementation_formula.open_pr_penalty : 0)
+ (goal_contribution * config.scoring.goal_weight)
+ (confidence * config.scoring.confidence_weight)
+ memo_adjustment
+ (config.mission set ? config.scoring.mission_weight * (config.implementation.mission_alignment or 1.0) : 0)
if pending_count === 0: impl_score = 0
Add implementation as virtual domain in score table.
3-B: Urgent Signals
5 signal types. Multiple can stack on one domain.
| Signal | Condition | Bonus | Target |
|---|
| health_alert | Domain state has alerts with severity critical/warning | config.signals.health_alert_bonus | Domain with fallback=true that has alerts |
| stale_after_change | PR merged in domain X, but X not run for > config.signals.stale_after_change_hours | config.signals.stale_after_change_bonus | The stale domain |
| queue_pressure | pending_count >= config.signals.queue_pressure_threshold | config.signals.queue_pressure_bonus | implementation |
| queue_age | oldest pending > config.signals.queue_age_hours | config.signals.queue_age_bonus | implementation |
| observe_loop_escape | 3 consecutive non-implementation cycles AND pending > 0 | config.implementation.observe_loop_escape_bonus | implementation |
health_alert only applies to domains with fallback=true.
queue_pressure/queue_age/observe_loop_escape only when config.implementation.enabled.
Season mode signal bonuses (v1.2.0): If the active season mode defines
signal_bonuses: { signal_key: value }, merge those values on top of
config.signals.* for this cycle only (in-memory). Example: a "launch" mode
may set signal_bonuses: { health_alert_bonus: 10.0, queue_pressure_bonus: 1.0 }
to temporarily amplify health alerts and dampen queue pressure. When the
season flips back to default, the bonuses no longer apply — no disk writes
are needed to revert.
3-C: Goal Contribution
default goal_contribution = 0.5
for each domain:
find active goals related to this domain (via goal.related_domains or keyword)
if found: goal_contribution = 0.5 * (1.0 - min(matching_goal.progress))
(optional) if user adds goal_contributions to a domain in config.json, use those values directly
3-D: Score Table Output
Print sorted by score descending:
[Decide] Domain scores:
| # | Domain | Hours | Weight | Urgent | Goal | Conf | Memo | SCORE |
|---|----------------|-------|--------|--------|------|------|-------|-------|
| 1 | domain_a | 24.5 | 2.0 | +5.0 | 0.15 | 0.18 | +0.0 | 54.33 |
| 2 | implementation | P:5 | 1.5 | +3.0 | 0.15 | 0.16 | +0.0 | 9.31 |
Implementation shows pending_count as "P:{N}" in Hours column.
3-E: Min Score Skip
if highest score < 0.5:
Print "[Decide] All scores below 0.5. Skipping cycle."
Log decision_log: { action: "skip", reason: "all_scores_below_minimum" }
Jump to Step 6.
3-E2: Goal-Completion Idle Gate (v1.4.1, Iteration 5)
Loop-engineering canon #1 is that a loop runs until a verifiable goal is met —
so once it IS met, the loop must stop burning cycles on it. Without this the loop
spins futile cycles forever after the mission is accomplished.
if config.goal_completion_idle (default true)
AND goals.json has >=1 active goal AND ALL active goals have progress >= 1.0
AND no domain has a critical/warning alert this cycle
AND no domain has actionable pending work (action_queue empty or all blocked,
and no work-domain produced output last run):
Print "[Decide] 🎯 Mission goals met and nothing actionable — idling this cycle."
Log decision_log: { action: "skip", reason: "goals_met_idle", result: "observe_only" }
-- This is a SKIP, not a futile cycle: the loop succeeded and is now idling
-- (record result_type "observe", quality 0.1 in 6-C9 — correct rest, not waste).
-- A new alert or newly-extracted action immediately ends idling next cycle.
Jump to Step 6.
Sandbox: after a project's goal hit 100%, this idled the would-be-futile cycles
(A: 7 idled instead of spun; C futile 45→35%) — RESULTS.md Iteration 5. When an
operator wants the loop to keep finding NEW work after a goal, they add the next
goal; an empty goal set (no done-condition) disables the gate.
3-F: Confidence Gate
winner = highest scoring domain
if winner confidence < config.safety.confidence_threshold:
Print "[Decide] {winner} confidence below threshold."
# Iterate candidates in descending score order (from Step 3-D sort).
for candidate in scored_domains_descending:
if candidate.confidence >= threshold:
winner = candidate
break
else:
# No domain meets threshold. Try fallback.
fallback = first domain where config.domains[name].fallback == true
if fallback:
winner = fallback
else:
Print "[Decide] All domains below confidence threshold and no fallback domain."
Print "[Decide] Observe-only cycle. Skipping Act, proceeding to Reflect."
Log decision_log: { action: "skip", reason: "all_below_confidence_no_fallback" }
Skip to Step 5.
When confidence-gated: primary skill ONLY, skip chain[].
Print "[Decide] Confidence-gated: primary skill only, no chain."
3-H: Dry-Run Exit
Invocation: /evolve --dry-run or /evolve dry-run
if invoked with --dry-run:
Print score table + "Would execute: {skill}". Chain if any.
Print chain_triggers conditions and whether they would fire.
Print confidence snapshot for all domains.
Print saturation counter: "Observe-only streak: {N} cycles"
Print "[Dry-run] No changes applied. No state files modified."
-- TRUE dry-run: do NOT modify ANY state files.
-- Previous behavior updated metrics.json and CHANGELOG.md which
-- was not truly "dry". Now: zero writes, zero side effects.
-- Delete lock file only.
EXIT.
3-I: Decision Output
[Decide] Selected: {winner} (score: {score})
[Decide] Rationale: {orient_summary}
[Decide] Skill: {config.domains[winner].primary_skill}
[Decide] Chain: {config.domains[winner].chain or "none"}
[Decide] Confidence: {confidence} (threshold: {threshold})
3-J: Score Verification
After selecting the winner, re-derive its score by re-running the exact 3-A
pipeline for that domain — same staleness curve (config.scoring.staleness_curve,
logarithmic by default, NOT a hardcoded linear product), and the same terms:
verify = staleness(per 3-A curve) + signal_bonuses + mission_term + (goal * goal_weight)
+ (confidence * confidence_weight) + memo_adjustment + balance_penalty
Do not re-derive with a simplified formula: a verify formula that differs from
3-A (e.g. linear staleness when the config says logarithmic, or omitting
balance_penalty) fires a false mismatch every cycle and then replaces the
correct score with the wrong one, silently changing the winner.
For implementation domain, use the implementation formula instead (3-A2 components).
If abs(verify - reported_score) > 0.01:
Print: [WARN] Score mismatch for {winner}: computed {verify}, reported {score}. Using recomputed.
Replace the score with the verified value.
Re-sort all domains and re-select winner if rank changed.
Cross-check: verify winner confidence >= config.safety.confidence_threshold (redundant
check against 3-F to catch any gate bypass).
Record "score_verified": true (or false if mismatch was found) in the decision_log entry.
Step 4: Act
4-A: Skill Validation
if config.safety.skill_allowlist is non-empty
AND selected_skill NOT in allowlist:
Print "[Act] SAFETY: {skill} not in allowlist. Skipping."
Log { action: "blocked", reason: "skill_not_in_allowlist" }
Skip to Step 5.
Re-check HALT file. If appeared: delete the lock file, then EXIT immediately.
(A HALT created mid-cycle — e.g. the 2-A2 saturation halt or an operator's
manual `touch` — is caught here; without the lock deletion the next post-HALT
invocation would stay blocked until the stale-lock timeout.)
4-B: Execution Rules
7 rules govern execution:
- confidence >= threshold: execute primary skill, then chain[] sequentially (max 3). Re-check HALT before each chain skill.
- confidence < threshold: primary skill only, skip chain.
- HALT during execution: stop immediately, log partial execution.
- Error during execution: log to skill_gaps.json, increment
state.consecutive_silent_failures (initialize to 0 if missing), continue to
Reflect. A successful execution resets the counter to 0. If the counter
reaches config.safety.max_silent_failures (default 3), create the HALT
file: "{N} consecutive skill executions failed. Unattended operation paused
for human review. Delete this file to resume." — the current cycle still
completes Reflect/Step 6 normally (so the failure is recorded and the lock
is released); the HALT stops the next invocation.
- Chain failure tracking: if chain skill failed 3+ times (skill_gaps.json), mark action as "blocked".
- Chain depth cap: max 3 skills. Truncate with warning if more.
- Skill timeout: if a skill runs longer than
config.safety.lock_timeout_minutes (default 30 min), treat as error. Print [Act] TIMEOUT: /{skill} exceeded {timeout}m. Treating as error. Log to skill_gaps.json and continue to Reflect.
Execute by calling the slash command:
-- Rotation primitive (v1.2.0): if the winning domain has a rotation list,
-- read the cursor, pass the focus_item as a context var, and schedule the
-- cursor increment for after execution.
rotation_focus_item = null
rotation_cursor_path = null
if config.domains[winner].rotation is a non-empty list:
rotation_list = config.domains[winner].rotation
rotation_cursor_path = "agent/state/{winner}/rotation_cursor.json"
if rotation_cursor_path exists and is valid JSON:
cursor = (cursor_json.cursor or 0) % len(rotation_list)
else:
cursor = 0
rotation_focus_item = rotation_list[cursor]
next_cursor = (cursor + 1) % len(rotation_list)
-- Schedule write (only if not dry-run). For dry-run, print what would happen.
rotation_cursor_next = next_cursor
Print "[Act] Rotation: {winner} focus='{rotation_focus_item}' (cursor {cursor} -> {next_cursor})"
-- Active context pass-through (v1.2.0): if active_context_blob is loaded,
-- expose it to the invoked skill via a documented context var. The blob is
-- opaque to evolve; each skill interprets it (e.g., a lawmaker persona for
-- draft-inquiry, a launch config for deploy, etc.).
Print "[Act] /{skill} starting..."
Execute: /{skill}
context_vars:
- focus_item: rotation_focus_item (may be null)
- active_context: active_context_blob (may be null)
Print "[Act] /{skill} completed. (elapsed: {time})"
-- After primary skill execution, persist rotation cursor if it was consumed.
if rotation_cursor_path and NOT dry_run:
Write {"cursor": rotation_cursor_next, "last_updated": now, "focus_item": rotation_focus_item}
to rotation_cursor_path
-- Chain execution: evaluate chain_triggers from the skill's contract
-- Production deployments (209 cycles) showed zero chain records in decision_log.
-- Root cause: chains were read from config.domains[winner].chain (legacy, often empty)
-- instead of from the skill contract's chain_triggers with condition evaluation.
chain_executed = []
if confidence >= threshold:
-- Source 1: skill contract chain_triggers (preferred, condition-based)
contract = read YAML frontmatter from skills/{skill}/SKILL.md
if contract.chain_triggers exists and is non-empty:
for each trigger in contract.chain_triggers (max config.safety.max_chain_depth, default 3):
-- Evaluate condition against the INVOKED SKILL's own output: the
-- top-level fields of the state file its contract lists under
-- output.files (e.g. check-tests → test_coverage.json, run-deploy →
-- deploy.json) — NOT the winner domain's file. If the condition variable
-- is not a top-level field there (e.g. dev-cycle's pr_created), read it
-- from the skill's printed Report variables. A variable found in neither
-- place ⇒ condition is FALSE (log "[Act] Chain condition undecidable:
-- {var} not found — treating as false", never guess).
-- Condition format: "field_name >= value AND other_field == 'string'"
condition_met = evaluate trigger.condition as above
if condition_met:
Check HALT. If exists: stop.
-- SAFETY GATE — chains get the SAME gates as the 4-A primary skill;
-- a chain must never be a side door around them:
if trigger.target not in config.safety.skill_allowlist:
Print "[Act] SAFETY: chain /{trigger.target} not in allowlist. Skipping."
continue
if trigger.target is an implementation/PR-creating skill (e.g. /dev-cycle):
if progressive_complexity.current_level < 3 OR implementation.enabled == false:
Print "[Act] SAFETY: chain /{trigger.target} requires Level 3 + implementation.enabled. Skipping."
continue
if prs_created_this_cycle >= config.safety.max_prs_per_cycle:
Print "[Act] SAFETY: max_prs_per_cycle ({max}) reached. Skipping /{trigger.target}."
continue
Print "[Act] Chain trigger: {trigger.condition} → /{trigger.target} starting..."
Execute: /{trigger.target}
chain_executed.append(trigger.target)
Print "[Act] Chain: /{trigger.target} completed."
else:
Print "[Act] Chain condition not met: {trigger.condition} — skipping /{trigger.target}"
-- Source 2: legacy config.domains[winner].chain (fallback for old configs)
else if config.domains[winner].chain exists and is non-empty:
for each chain_skill in config.domains[winner].chain (max 3):
Check HALT. If exists: stop.
Apply the same SAFETY GATE as Source 1 (allowlist; level/implementation
gate and max_prs_per_cycle for PR-creating skills). Skip on any failure.
Print "[Act] Chain (legacy): /{chain_skill} starting..."
Execute: /{chain_skill}
chain_executed.append(chain_skill)
Print "[Act] Chain: /{chain_skill} completed."
-- Record chain execution in decision_log (MUST be present even if empty)
decision_log_entry.chain_executed = chain_executed
decision_log_entry.chain_count = len(chain_executed)
Print "[Act] Chain summary: {len(chain_executed)} skills executed: {chain_executed or 'none'}"
4-C: PR Post-Processing
After execution, check if a new PR was created (new open PR matching domain
branch_prefix or skill output indicating PR creation).
Track prs_created_this_cycle (starts at 0 each cycle; increment per new PR
detected here). This is what enforces config.safety.max_prs_per_cycle
(default 1): the 4-B chain gate consults it before invoking another PR-creating
skill, and if a skill somehow opens a PR beyond the limit anyway, classify that
PR as Risk Tier 3 (Draft, human review) regardless of other gates and add a
memo {type: "safety_violation", message: "max_prs_per_cycle exceeded"}.
If PR created, determine risk tier (distinct from progressive complexity levels):
Auto-merge is opt-in and OFF by default. It fires ONLY when
config.safety.enable_auto_merge == true. With the default (false), EVERY PR
— including dev-cycle's — is Risk Tier 3 (human merge). That is the "you
stay in command" default. When enabled, evolve re-checks every gate below
ITSELF before merging — fetch the facts with
gh pr view {n} --json isDraft,additions,deletions,changedFiles,files and do
NOT trust dev-cycle's auto_merge_eligible marker. Re-check the HALT file
immediately before any gh pr merge.
Risk Tier 1 — Auto-merge (auto-merges ONLY if ALL are true):
config.safety.enable_auto_merge == true (opt-in; default false)
progressive_complexity.current_level >= 3
- PR is NOT a draft (
isDraft == false)
- no changed file matches
config.safety.protected_paths
- the action did NOT skip a protected path — the PR meta has no
protected_blocked=true (a partial change with protected files removed is NOT
eligible; it may be incomplete/incoherent — #35)
changedFiles <= config.safety.auto_merge_max_files (default 5)
additions + deletions <= config.safety.auto_merge_max_lines (default 100)
- the PR's tests are green — canonical marker: dev-cycle Step 4 reported
test_status == "passed" this cycle ("skipped"/"failed" are NOT eligible)
Action: re-check HALT → take a pre-action checkpoint (4-C2) → gh pr merge {n} --squash → post-merge health check (config.health_endpoints if set, else
re-run config.test_command). If the health check FAILS → 4-C2 auto-rollback
(git revert --no-edit HEAD && git push origin HEAD, create HALT, notify).
Print "[Act] Auto-merged PR #{n} (low-risk, opt-in). Health: {ok|reverted}."
--squash is deliberate: it keeps main linear so the 4-C2 revert is a simple
git revert HEAD (no -m). It requires squash-merge to be enabled on the repo
(GitHub's default). If gh pr merge --squash errors because squash is disabled,
do NOT fall back to --merge (that breaks rollback) — leave the PR ready for a
human merge and log [Act] Auto-merge skipped: enable squash-merge on the repo.
Risk Tier 3 — Human review (the DEFAULT — everything that is not Tier 1; ANY
of: enable_auto_merge is false, protected paths touched OR a protected path was
skipped during the action (protected_blocked=true), complexity level < 3, PR is
a draft, tests not green, or the change EXCEEDS the auto_merge size limits — too
large for the low-risk bar): keep as draft. Print: "PR #{n} requires human review."
There is no separate "auto-merge but oversize" tier — anything past the
low-risk bar is simply Tier 3 (human review). The old Tier 2 was unreachable
via dev-cycle and is removed (#34): a PR is either auto-merged (Tier 1) or left
a Draft you merge (Tier 3).
4-C2: Rollback Protocol
When config.safety.enable_rollback is true (default false), the engine
maintains checkpoints for post-merge recovery.
Pre-action checkpoint (before Step 4-B execution):
if config.safety.enable_rollback OR config.safety.enable_auto_merge:
-- enable_auto_merge FORCES checkpointing: a merge the agent performs by
-- itself must always have a recorded recovery point, even when the operator
-- left enable_rollback off. (4-C Tier 1 additionally re-checkpoints
-- immediately before the merge itself, so the revert target is merge-adjacent.)
checkpoint = {
cycle: cycle_count,
branch: current git branch,
commit_sha: HEAD commit SHA,
timestamp: now,
state_snapshot: {
confidence: copy of confidence.json,
action_queue: copy of action_queue.json pending items
},
-- v1.7.0: for a LEAP cycle, anchor the pre-change artifact scores here so the
-- post-leap 5-G critique is compared against a fixed baseline, never against a
-- drifting outcomes.json entry. Captured by running 5-G at 3-K trigger time.
leap_baseline_scores: { <dimension>: <score>, ... } or null,
rubric_hash: sha256(config.domains[d].quality_rubric) -- integrity (5-G)
}
-- Read or create checkpoints.json
checkpoints_path = agent/state/evolve/checkpoints.json
if checkpoints_path does not exist:
write { "schema_version": "1.0.0", "checkpoints": [] }
checkpoints.append(checkpoint)
-- Retain last 5 only
if len(checkpoints) > 5: remove oldest
Write checkpoints.json
Automatic rollback trigger (after a Risk Tier 1 auto-merge). Reachable only
when config.safety.enable_auto_merge is on (otherwise nothing auto-merges and
this never runs). The pre-action checkpoint above is taken on every action when
enable_rollback is on; when enable_auto_merge is on, evolve forces a
checkpoint before a Tier-1 merge regardless, so this revert always has a target.
if PR was auto-merged AND health check fails:
Print "[Rollback] Health check failed after auto-merge of PR #{n}."
-- Revert the squash-merge commit. Auto-merge uses `gh pr merge --squash`, so
-- HEAD is a normal 1-parent commit and `git revert HEAD` needs no `-m`.
-- CRITICAL: if auto-merge is ever switched back to `--merge` (a 2-parent
-- merge commit), this line MUST become `git revert --no-edit -m 1 HEAD`,
-- otherwise git errors "is a merge but no -m option was given" and the bad
-- merge stays on the branch (verified: Tier-B+ live run, 2026-06).
git revert --no-edit HEAD
git push origin HEAD
-- If the push FAILS (network, protection rule), the bad merge is still live
-- on the remote while local has the revert. Do NOT swallow this: create the
-- HALT regardless (below) and make the failure loud:
if push failed:
Print "[Rollback] ⚠ Revert committed locally but push FAILED: {error}"
Print "[Rollback] Remote still has the bad merge. Push manually: git push origin HEAD"
-- Restore state from checkpoint
restore confidence.json from checkpoint.state_snapshot.confidence
restore action_queue.json pending from checkpoint
-- Create HALT file — ALWAYS, even if the push failed (especially then)
Create HALT: "Auto-rollback: PR #{n} merged but health check failed. Reverted to {checkpoint.commit_sha}.{' PUSH FAILED — remote still has the bad merge.' if push failed}"
Print "[Rollback] Reverted PR #{n}. HALT created. Human review required."
LEAP artifact-regression gate (v1.7.0). A LEAP overhauls the untestable core,
so the unit-test gate is the WRONG arbiter: a restructured module can legitimately
break a smoke test that mocked the old shape, and a leap that passes smoke tests
can still have made the artifact worse. For cycle_mode == "leap" the revert
decision is the artifact, not the unit test:
if cycle_mode == "leap":
-- Re-run 5-G on the post-leap artifact (pre-PR, on the branch — this is the
-- PRIMARY stop, before any merge).
post = rubric_score.aggregate(5-G_recritique.dimension_scores, rubric)
baseline = checkpoints[-1].leap_baseline_scores
delta = post.dimension_scores[targeted_dimension] - baseline[targeted_dimension]
if delta < config.leap.min_dimension_delta (default 0.05):
Print "[Leap] '{targeted_dimension}' did not improve (Δ {delta} < {min_delta}). Reverting leap."
git checkout -- . (or revert the branch commit); do NOT open the PR
record outcome: leap_regressed (quality 0.0), append leap_attempts entry
{ dimension: targeted_dimension, attempt_number: fails+1, delta_score: delta }
-- the 2-G thrashing guard reads these; after max_attempts it HALTs.
else:
Print "[Leap] 🚀 '{targeted_dimension}' {baseline[..]}→{post[..]} (Δ +{delta}). Quantum jump confirmed."
proceed to PR/commit as normal.
-- The unit test still RUNS (it is information, recorded in the PR body) but a
-- unit-test failure alone does NOT auto-revert a leap — only a failed artifact
-- improvement does.
Manual rollback via /ooda-config rollback {cycle} — ooda-config/SKILL.md
Step R is the CANONICAL definition (typed confirmation phrase rollback {cycle},
non-destructive git revert {sha}..HEAD default on linear history, --hard
fallback, HALT on completion). Summary only — do not re-derive behavior from here:
1. Find checkpoint by cycle number in checkpoints.json
2. If not found: "No checkpoint for cycle {cycle}" — exit
3. Typed confirm (exact phrase "rollback {cycle}", anything else cancels)
4. git revert --no-edit {checkpoint.commit_sha}..HEAD (default, non-destructive;
`git reset --hard` only with the operator's explicit --hard flag)
5. Restore state files from checkpoint snapshot
6. Create HALT; print "Rolled back to cycle {cycle}."
4-D: Execution Output
[Act] /check-tests starting...
... (skill output) ...
[Act] /check-tests completed. (elapsed: 1m 42s)
[Act] PR #42 created on branch auto/check-tests/2025-01-15-fix
[Act] Risk Tier 1 (auto-merge eligible)
[Act] PR #42 auto-merged.
Step 5: Reflect
5-A: Skill Gap Analysis
This step MUST execute on every cycle. Production deployments showed 209
cycles with zero skill_gaps detected. The gap detection must be more proactive.
Check for gaps across multiple signal sources:
-- Initialize skill_gaps.json if missing
if skill_gaps.json does not exist or is malformed:
Write: {"schema_version": "1.0.0", "gaps": []}
gaps_detected = []
-- Source 1: execution errors (missing capability, chain failure, timeout)
if Step 4 produced an error:
gaps_detected.append({
name: "execution_error_{skill}",
type: "execution_failure",
detail: error message
})
-- Source 2: domain without recent execution (starvation)
for each active domain:
if domain not executed in last 10 cycles (from decision_log):
gaps_detected.append({
name: "domain_starvation_{domain}",
type: "coverage_gap",
detail: "Domain not executed in {N} cycles"
})
-- Source 3: action queue items aging beyond 2x decay_days without progress
for each pending item older than 2 * config.memory.action_queue_decay_days:
gaps_detected.append({
name: "stale_action_{item.id}",
type: "action_aging",
detail: "Action '{item.title}' pending for {age} days"
})
-- Source 4: chain trigger conditions met but chain not executed (blocked)
if chain_triggers evaluated but not executed (due to confidence gate or disabled impl):
gaps_detected.append({
name: "chain_blocked_{trigger.target}",
type: "chain_gap",
detail: "Chain to {trigger.target} blocked: {reason}"
})
for each detected gap:
if gap_name exists in skill_gaps.json: increment frequency, update last_seen
else: add with frequency=1, first_seen=now
Print "[Reflect] Gaps: {len(gaps_detected)}. Top: {highest_freq_gap}" or "No new gaps."
5-B: Auto Skill Proposal
for each gap in skill_gaps.json where frequency >= 3:
if agent/state/evolve/proposed-skills/{gap_name}.md does not exist:
Generate proposal: background, gap, proposed OODA phase, estimated I/O.
Print "[Reflect] Skill proposal: {gap_name} (freq: {n})"
5-C: Memo Writing
Consecutive domain detection (from 2-A patterns) writes one-shot
score_adjustments (consumed next cycle):
- Same domain 2 consecutive: all other domains +0.5 adjustment.
- Same domain 3 consecutive: all other domains +1.0 adjustment.
PR outcome memos (one-shot score_adjustments):
- PR merged this cycle: that domain gets -0.3 (focus elsewhere).
- PR rejected this cycle: that domain gets +0.5 (retry needed).
Store in memos.json.history (cap at 10). Each entry:
{ timestamp, cycle, domain, type, message }.
Auto-starvation intervention (v1.2.0):
Formalizes the Lynceus +1.0 starvation pattern so it no longer requires
manual memo editing.
for each active domain D in config.domains where D.status == "active":
executions_in_last_10 = count of decision_log entries where
entry.selected_domain == D
AND cycle > (cycle_count - 10) -- strictly: the last
-- 10 completed cycles [cycle_count-9 .. cycle_count];
-- ">=" would make it an 11-cycle window
if executions_in_last_10 == 0:
-- Skip if an active starvation intervention for this domain already exists
if interventions contains iv where iv.domain == D AND iv.type == "starvation":
continue
interventions.append({
domain: D,
delta: +1.0,
type: "starvation",
reason: "No executions in last 10 cycles",
created_at_cycle: cycle_count,
expires_after_cycles: 3,
applied_count: 0
})
Print "[Reflect] Starvation intervention: {D} boosted +1.0 for 3 cycles."
Auto monopoly-breaker intervention (v1.2.0):
Formalizes the Lynceus −10.0 monopoly-breaker pattern and complements the
existing 5-C "consecutive domain" one-shot adjustments above. Where the
one-shots nudge other domains up, the monopoly-breaker pushes the
dominant domain down directly so the next cycle has no pull toward it.
consecutive = same-domain run length from 2-A pattern analysis
active_domain_count = count of config.domains where status in ("active","degraded")
-- Single-domain guard: with only one scoreable domain there is nothing to
-- rotate TO, so penalizing the sole domain -10.0 would just starve the cycle
-- (no winner -> wasted cycle). Skip the monopoly-breaker entirely. (The 2-A
-- futile-loop penalty still guards an *unproductive* single domain, since it
-- only fires when the domain produces no output.)
if consecutive >= 2 AND active_domain_count >= 2:
dominant = decision_log[-1].selected_domain
-- Skip if an active monopoly_breaker for this domain already exists
if interventions contains iv where iv.domain == dominant AND iv.type == "monopoly_breaker":
continue
interventions.append({
domain: dominant,
delta: -10.0,
type: "monopoly_breaker",
reason: "Selected {consecutive} consecutive cycles",
created_at_cycle: cycle_count,
expires_after_cycles: 1,
applied_count: 0
})
Print "[Reflect] Monopoly-breaker intervention: {dominant} penalized -10.0 for 1 cycle."
Contrarian interventions (v1.2.0): When Tier 3 contrarian check (below)
writes a score adjustment, it can optionally emit an intervention with
type: "contrarian" instead of a one-shot score_adjustments entry, if the
operator wants the adjustment to persist beyond one cycle. The default
contrarian path continues to use one-shot adjustments for backward compatibility.
5-C2: Action Extraction