Evaluation & self-evolving

Two opt-in blocks in an entity’s frontmatter. evaluation: grades finished executions; loop: turns what the grades say into a pull request against your brain, measures whether the change helped, and reverts it when it did not. Both work for workflow scenarios, pipeline scenarios, heartbeats and chat roles (a role people talk to — its run is a dialog episode).

Nothing here runs unless you declare it. The one rule you cannot skip is a spending ceiling on entities the engine runs for you — see The ceiling you owe.

The page has three levels, and most brains stop at the first.

LevelYou writeRead
1 — Basicenabled, preset, autonomy — on, which loop, who answersLevel 1
2 — Tuningnumbers and policies: trigger, watch, check, budget, metrics and their scales, ratings from people, lock groupsLevel 2
3 — Powercode: <entity>.loop.ts hooks, lineage in your own tables, your own proposer, reading the ledger, supervising from a pipelineLevel 3

The seam map lists every knob, hook and tool on one screen; Four loops, end to end builds the freelancer, a support chat, cold outreach and an ops heartbeat from them.

Your agents also receive the self-evolving skill automatically — an engine system skill mirrored into skills/engine/self-evolving/ by omnislash upgrade (see brain-dev-guide.md § Updating Engine). It is a skill for talking to people: it maps what a person asks — how the loop is doing, why an entity isn’t improving, turn it on, pause or stop it, who merges, try an idea, explain a loop PR — to the loop_* tools (Running the loop from an agent), with the rails an agent keeps around the human gates. The authoring depth sits beside it under skills/engine/self-evolving/references/. This page stays the full contract. A brain that keeps its own copy at skills/self-evolving/ shadows the engine’s skill whole and keeps whatever that copy says — delete it to get the current one.

Words

  • Self-Evolving — the product, and its dashboard page /evolution.
  • loop — the machine: the loop: config, <id>.loop.ts, the loop_* tools, the loop:* capabilities.
  • cycle — one pass of the loop: analysis → proposal rounds → check → merge → watch → verdict; a row of loop_cycles.
  • policy — the resolved loop: terms an entity runs under.
  • grade — how a settled cycle’s experiment came out: Improvement, Regression, Tradeoff, Tie or Inconclusive (The cycle’s grade).
  • target — the entity a cycle improves, named (targetKind, targetId) everywhere the loop names one (rows, events, tools, ?selected= links): targetKind is scenario, heartbeat or role — the entity, never the engine that runs it. A scenario’s runs are workflow runs or pipeline invocations (runKind), and a scenario that gains a .pipeline.ts keeps its cycles, lessons, ideas, holds and eval window. Names one deploy back (workflow / pipeline / chat) are read as the entity they named.
  • judge — system:evaluator (constitution/Agents/system/role-evaluator.md): grades runs; a cycle opens on its grades, and the pre-merge check and the watch grade the change with it.
  • proposer — system:loop:proposer (constitution/Agents/system/loop/role-proposer.md): the stage analyze agent — reads the entity’s runs and writes the proposal. loop.proposer.pipeline: <pipeline id> swaps it for your proposer pipeline (Your own proposer); where both appear, the page says the proposer agent vs a proposer pipeline.
  • implementer — system:loop:implementer (constitution/Agents/system/loop/role-implementer.md): the stage apply agent — makes an accepted proposal’s change in its own checkout; it never runs git, the engine commits. A proposer pipeline replaces it too: the engine writes the pipeline’s files itself.
  • role namespaces — each : after system: in a role id is one subdirectory under constitution/Agents/system/: system:loop:proposer is loop/role-proposer.md, and a brain overrides it by putting its own copy at constitution/Agents/system/loop/role-proposer.md.

The loop in one picture

execution → grade → window() + shouldOpen: open a cycle?
  → proposer (or your proposer pipeline) writes a change ← decide gate: Accept / Skip
  → the engine opens a PR → pre-merge check (replayable workflows only:
    candidate vs incumbent on frozen inputs, `omnislash/validation` status)
  → merge                                               ← merge gate
  → watch: the entity's own next graded runs, compare()
  → validated: kept · regressed: revert PR · unvalidated: kept (or reverted by policy)
  → lesson, read by the next cycle's proposer

Every change ships through a PR; the engine never pushes to your default branch and never merges on its own authority — the most it does is arm GitHub’s own auto-merge. Who answers the two gates is declared (autonomy), never inferred. The stages, the statuses and what each one stores are spelled out in What the loop does, step by step.

Seam map

Every value resolves engine default → constitution/MANIFEST.yaml → the entity’s own block, nearest wins (how a whole block merges: Where the numbers live). Code seams resolve preset → defineProject({ loop }) → <entity>.loop.ts. The engine-maintained twin of this table, with each merge rule spelled out, is contract.md § 9.1.

Level 1 — on, which loop, who answers

SeamEngine defaultSection
evaluation.enabled / loop.enabledtrue inside a block you wrote; the manifest’s is a brain-wide kill switchKill switch
evaluation.sample.maxPerDaynone — owed by every unattended entityThe ceiling you owe
loop.presetrepairPick a preset
loop.autonomy { decide, merge }human / humanPick who answers

Level 2 — numbers and policies

SeamEngine defaultSection
evaluation.method, modelRef, requirementsdefault method, the evaluator role’s refGrading · method: none
evaluation.sample.rate1How much gets graded
evaluation.gradeAfterHours0Late metrics
evaluation.metrics.<id> — kind, direction, scope, role, target, floor, tolerance, mustImprove, critical, aggregate, optionalquality objective, catalog observedMetrics
evaluation.metrics.<id>.scale / .human, kind: reportedjudge-only; a human-rated metric on 1–5Ratings from people
evaluation.human { ask, spotCheck, expiresHours, agreement }ask: neverRating cards · Calibration
evaluation.metricTolerance, scoreWeights0.05 / 10%; tasks 60 : run 40Tolerances · Weighing tasks
evaluation.judge.maxUngradedShare (manifest only)0.34The judge is pinned
evaluation.context { priorEpisodes, maxTokens }, chat.episode.idleHours, chat.onRoleChange0, 40000; 24; keepChat roles
loop.threshold, trigger { windowRuns, minFailures, every }analyse every graded run; 2 of 5; no clockTrigger
loop.check { mode, inputs, trials, deadlineHours, incumbent }derived mode, 5, 1, 24h, frozenWhat a cycle freezes
loop.watch { mode, windowRuns, minFailures, deadlineHours, confidence, onInconclusive }observe, the trigger’s pair, 168h, 0.9, keepThe watch · onInconclusive
loop.budget, cooldownHours, maxAnalysesPerDayunpriced; 6h (24h after a regression); 3What the loop may spend
loop.reviewDeadlineHours168Where the loop waits for a person
loop.windowStalledAfterHours336 (0 = never)A window that waits
loop.autonomy — mergeMethod, maxConsecutiveRegressions, onInconclusive, onUnmeasured, requireCalibrationsquash, 2, human, pause, falseAutonomy · Checklist
loop.groups (manifest only), groupnoneLock groups
loop.editablePaths (entity only), protectedPaths (manifest only), maxChangedLinesthe proposal’s files; the engine floor; 600What the loop may not touch
loop.evidence, lessons5 runs / 40k tokens; 10 lessons / 180 daysStep by step
loop.proposer { modelRef, instructions } (per field)the proposer role’s model; no added linesThe proposer block · Prompts
Ideas — /idea, loop_create_idea / loop_cancel_ideano key: always availableIdeas

Level 3 — your code

SeamWhereSection
measure(run), cases<entity>.loop.tsMetrics only your code can produce
window(ctx), shouldOpen, verdict, compare<entity>.loop.ts or defineProject({ loop })The five functions
ctx.runRef (pipeline), context.auth.runRef (your tools)brain codeRecording lineage
ctx.loopLaunched, context.auth.loopLaunched, a tool’s replay: 'dry-run'brain codeReplay and your tools
loop.proposer.pipelinemanifest · entityYour own proposer
loop_historycallTool from a pipeline, any agentReading the history
loop_status, loop_get, loop_trace, loop_summary, loop_list_agents, loop_policy, loop_get_proposaltools (loop:read)Running the loop from an agent
loop_join, loop_settools (loop:configure, asks nobody)Running the loop from an agent
loop_widentool (loop:autonomy, a person approves)Running the loop from an agent
loop_analyzetool (loop:analyze)Running the loop from an agent
loop_holdtool (loop:hold, asks nobody)Supervision
loop_release, loop_rearm, loop_ack_failuretools (loop:supervise, a person approves)Supervision
agent_close_episode, closeChatEpisode(runId)role capability; @omnislash-ai/infra/chat-episodesWhere a dialog ends
constitution/Loop/proposer/<brief>.md, Loop/implementer.md, Loop/chat-privacy.md, constitution/Evaluation/Methods/*.md, the three loop rolesyour copy at the same pathOverriding the prompts

Level 1 — Basic: enabled, preset, autonomy

Three decisions: turn the loop on for an entity, pick which loop it runs, and say who answers its two gates. Everything else has an engine default.

Turning it on

The same two blocks in any entity’s frontmatter — a *.scenario.md (workflow or pipeline), a *.heartbeat.md, or a role’s *.agent.md:

evaluation:
  enabled: true
  sample: { maxPerDay: 20 }     # owed by anything the engine runs on its own — below
loop:
  enabled: true
  preset: repair                # repair | evolve
  autonomy: { decide: human, merge: human }

The loop only reads graded runs, so loop: without evaluation: never opens a cycle. The dashboard’s Self-Evolving page has a Turn on per entity that writes the two enabled: true keys (and asks for the daily ceiling first where one is owed); preset and autonomy then come from the manifest or the engine until you write them. An agent makes the same one write with loop_join: it takes the daily cap as an argument where one is owed, switches a paused entity’s own enabled back on, and refuses an entity whose feature is switched off (Running the loop from an agent).

Where the numbers live

You write the same two blocks in two places, and the nearer one wins:

TierWhereFor
enginebuilt inthe fallback, so an omitted knob is never an error
brainconstitution/MANIFEST.yaml → evaluation: / loop:what these cost across your whole brain
entitythe evaluation: / loop: block in the filewhere one entity differs
# constitution/MANIFEST.yaml — set once, applies to everything that opts in
evaluation:
  sample: { rate: 0.2, maxPerDay: 20 }
loop:
  threshold: 0.8
  trigger: { windowRuns: 5, minFailures: 2 }

An entity then declares only that it wants grading, and overrides a number only where it actually differs. Merging is one level deep — and which way a whole BLOCK merges differs between the two halves, so it is worth knowing before you write one. Under evaluation:, sample and metricTolerance merge field by field: set rate on an entity and you keep the brain’s maxPerDay. Under loop:, every block — trigger, check, watch, autonomy, budget, evidence, lessons — is replaced wholesale by the nearest tier that writes it, and the keys that tier leaves out fill from the engine rather than from the tier above. Each block below repeats which rule it follows; the seam-by-seam table is contract.md § 9.1. omnislash doctor names every key an entity’s partial autonomy or evaluation.human block drops from the manifest (Loop autonomy, Human calibration).

The manifest is hot-synced and editable from the dashboard, so lowering a rate or pulling the kill switch takes effect without a redeploy.

The ceiling you owe

maxPerDay is optional for a scenario a person launches — those arrive at human pace, and whoever presses the button is deciding right then whether the grading is worth paying for.

It is required once nobody decides each execution: a scenario with a schedule:, every heartbeat (a schedule is what a heartbeat is, so there is no attended case to exempt), and every chat role — its episodes are opened by whoever writes to it, and on a public bot that is anyone. There the arrival rate is a property of your cron or of strangers, not of your attention, and rate alone bounds the fraction graded but never the count.

The ceiling may come from the manifest, so one line covers your whole brain.

If it is missing everywhere, the entity keeps running normally and simply is not graded — the engine refuses to spend rather than deleting your entity or billing you quietly. omnislash doctor names every unattended entity in that state under Eval ceilings.

One exception: executions the engine needs a verdict on are graded whatever the ceiling — the reruns it launches to validate a cycle, and the ticks it observes while validating a heartbeat’s. Their number is bounded by the loop, not by your schedule.

Pick a preset — repair or evolve

loop.preset names which loop FUNCTIONS the entity runs under; every number they read stays in the block around it.

repair (default) — fix what broke.

  • Opens when the run that just landed is bad (failed, below threshold, or a missed metric target) and 2 of the last 5 graded runs are.
  • The proposer is briefed on the newest graded runs, bad first.
  • After the merge the next watch.windowRuns graded runs are counted: 2 bad ones regress the change and open its revert; a full window without validates it.

evolve — make what works better.

  • Opens every trigger.windowRuns grades (or every trigger.every hours) with nothing wrong.
  • The proposer is briefed on the window’s best and worst runs by the objective.
  • After the merge the two windows’ metrics are compared, the objective through a watch.confidence interval — so declare an objective the world produces (Improving what already works).

Both run the same stages, gates, PR, revert and lessons. A loop that is neither — its own trigger, its own verdict — replaces the functions one by one (Level 3).

Pick who answers — autonomy

The loop has two gates a policy staffs: decide (accept a proposal) and merge (land its PR).

decidemergeMeaning
humanhumanThe default — you answer the card, you press merge.
autohumanDecision autopilot; you are the last gate at the PR.
humanautoYou approve the proposal; GitHub auto-merge lands it once checks pass.
autoautoFully autonomous — read the checklist first.

auto is never a different loop: the same PR, the same pre-merge check, the same watch and revert, and a circuit breaker that hands both gates back to a person after a regression streak. The full policy — what an inconclusive check or an unmeasured change does, calibration, the breaker — is Choosing who answers the gates.

An agent moves a gate the same way the dashboard does, with one difference: narrowing one (auto → human) is loop_set and asks nobody, while widening one is loop_widen, which a person approves (Running the loop from an agent).

Kill switch

enabled: false is a switch, not a default, at either tier: in constitution/MANIFEST.yaml it holds for every entity at once, and the manifest can never switch an entity on. No hook overrules it — a shouldOpen that could switch itself back on would not be a switch.

You setWhat stopsWhat does not
loop.enabled: falseEvery new cycle: a landing grade, the trigger.every clock, Analyze now, an idea (a new post is refused; queued ideas stay queued). And every cycle in flight that has not merged: the cycle reads the switch before every step it takes until the merge. Switched off, it cancels the waiting card, disarms an armed auto-merge, closes the PR, and settles the cycle skipped (“the loop was switched off”), with one notice. An idea the cycle was opened on goes back to the queue, and no lesson is written.A change that already merged lives out its watch, and a regression still opens and follows its revert. A switch written through loop_set or the dashboard rings every cycle it reaches, so a waiting cycle reads it at once; one that arrives another way (a pushed MANIFEST edit) is read at the cycle’s next look — minutes early on, at most six hours later in a long wait. Until that read, an armed auto-merge can still land the PR. To stop one at once, close the PR yourself. Terms that fail to load are not treated as a switch.
evaluation.enabled: falseAll grading, including the grading an in-flight cycle waits on.The cycles waiting on those grades: a pre-merge check with none is inconclusive at its deadline (autonomy.onInconclusive decides), and a watch with none settles unvalidated — under merge: auto a change nobody measured, which pauses the entity (autonomy.onUnmeasured).
loop_hold on an entity or a group (loop:hold, asks nobody)New cycles there, until loop_release — see Supervision. A cycle already running finishes.Gates and cards: nothing is demoted, nothing is asked.

The manifest is hot-synced and editable from the dashboard — both switches sit on top of Settings → Policy defaults (/evolution?view=settings&tab=policy), which each entity’s Policy view links to (it writes that entity’s own switch only) — so pulling the switch takes effect without a redeploy. An agent pulls either switch with loop_set (enabled or evaluation.enabled, false) and asks nobody; turning the brain-wide loop switch back on hands the whole loop back, so it is loop_widen’s and a person approves it.

Level 2 — Tuning

Numbers and policies, all in YAML: what a grade reads, when a cycle opens, how a change is proven before and after its merge, what the loop may spend, who is asked what, which entities share an outcome, and what the loop may edit. Every key has an engine default; write only the ones you mean.

Grading — evaluation:

---
name: Daily review
schedule: '0 3 * * *'
evaluation:
  enabled: true
  requirements:
    - 'The report names every blocked task'
  sample:
    rate: 0.2
    maxPerDay: 5
---
FieldDefaultMeaning
enabledtrueGrade this scenario’s executions at all.
methoddefaultWhich method file to grade with: constitution/Evaluation/Methods/<method>.md. none grades with no judge — see Grading without a judge.
modelRefrole defaultModel the grader runs on. Left unset, the system:evaluator role’s own modelRef is used. Either way the ref is pinned as the concrete models it resolves to — see The judge is pinned to the cycle. A ref that does not resolve (none declared anywhere, a role or word models.json does not know) fails every evaluation of the entity at once, without retries; omnislash doctor (Eval judges) names such an entity before its first grading.
requirements—Extra run-level criteria, in your words, on top of the plan’s own success criteria.
samplegrade everythingHow much of the stream gets graded — below.
gradeAfterHours0Hours between an execution ENDING and its grading — see When the judge looks.

A grade lands on the execution as a score plus a per-criterion verdict, and on a failed run also a failure category. It is what the loop reads; you do not have to enable loop: to use the grades themselves.

Grading without a judge — method: none

When everything that makes a run good is a number your code already has — views, replies, a delivery rate — a judge adds cost and noise and nothing else. method: none grades with no model at all:

evaluation:
  method: none
  metrics:
    replied:       { kind: computed, direction: max, unit: share, role: objective, aggregate: mean }
    reply_minutes: { kind: computed, direction: min, unit: minutes, role: guard, floor: 120, optional: true }
  • The grade is the entity’s measurements: the engine’s computed catalog (cost_usd, duration_ms, tool_failures) and what your measure() / window() return.
  • passed is read off the declared floors: a run passes when every objective and guard it measured stands on its floor. A target is where you are aiming and does not fail a run — the repair trigger still counts a missed target as a bad run, exactly as it does for a judged entity.
  • There is no score. quality is only reported (absent), the engine does not add it as a guard when another metric is the objective, and a repair threshold — a bar on the judge’s score — reads nothing. Declare a computed objective and, if a run can be bad, a floor or a target on it.
  • Refused at load, naming the fault: a judged metric, quality declared as an objective or guard, and requirements — free text only a judge can check.
  • No ceiling is owed (The ceiling you owe): a grade that calls no model costs nothing to bound.
  • The cycle still pins who graded (The judge is pinned to the cycle): the snapshot says none for the model, the method and the evaluator role, and carries the digest of your *.loop.ts — your measure() IS the instrument, so editing it mid-cycle is a judge change like any other, and so is switching the entity to or from method: none.

How much gets graded — sample

FieldDefaultMeaning
rate1Fraction of executions to grade, 0..1.
maxPerDaynull (unbounded)Most evaluations of this one entity in any rolling 24h.

The two are not substitutes. rate makes the sample representative — the verdict is a hash of the execution id, so the graded runs are spread evenly through the stream, and the same execution always gets the same answer no matter how many times a retry or a replay asks. maxPerDay makes it bounded — a cap alone would grade the first N of the day and then go quiet, which is a sample biased toward whenever the day started.

The ceiling is per entity, never global: a chatty scenario cannot starve the rest of your brain of grading.

Once rate < 1, a pass rate computed over evaluations is biased — you are averaging a sample, not the population. Compute health from the executions themselves; the loop trigger is per-evaluation and is unaffected.

Saying what “better” means — metrics

By default the loop optimizes one number: the grader’s overall score. That is often not what you care about. evaluation.metrics lets you name the quantity you actually want moved, and the loop then optimizes that — and refuses to adopt a change that bought it by breaking something else.

evaluation:
  enabled: true
  metrics:
    cost_usd: { role: objective, target: 0.25 }   # drive this down, aim for $0.25
    relevance:                                     # your own judged metric
      kind: judged
      direction: max
      criterion: 'Every item ties to an event from the last 24 hours'
      role: guard
  sample: { rate: 1, maxPerDay: 10 }

Metrics merge across the same three tiers as everything else, per metric id: a brain-wide cost_usd: { role: guard } applies everywhere, and an entity can promote it to objective without redeclaring what it measures.

Roles

RoleMeaning
objectiveWhat your change is asked to move. The proposer is told the number and the target.
guardMust not get worse beyond its tolerance. Measured on the same challenger runs as the objective.
observeMeasured and reported, never part of a verdict — declaring one changes nothing about how your cycles are judged, including how many challengers they wait for. Useful for building history before you promote a metric.

Declare an objective other than quality and quality becomes a guard automatically. “Drive cost down” otherwise has a trivially winning move — do less of the work — and the metric that catches it is the one nobody thinks to list. You can opt out with an explicit quality: { role: observe }, but it has to be written down.

What the engine already measures

These ids are built in and measured on every graded execution, whether or not you declare them — so a metric you later promote from observe to guard already has history behind it. You may change how they are USED (role, target, tolerance); you may not redefine what they mean.

IdDirectionWhat it is
qualitymaxThe grader’s overall score, 0..1.
passedmaxThe grader’s pass/fail. Fixed as the viability signal — not configurable.
cost_usdminWhat the execution’s agents billed.
duration_msminWall-clock of the execution.
tool_failuresminFailed tool calls (workflow runs and heartbeat ticks).

Declaring your own

FieldBelongs toMeaning
kindthe metricjudged — the eval agent scores it (on its scale, else 0..1). computed — a number derived from execution data. reported — only a person rates it; see Ratings from people.
humanthe metricJudged metrics only. true — a person may rate it too, and their rating stands over the judge’s wherever the loop reads the entity’s own runs. See Ratings from people.
scalethe metricJudged and reported metrics only. { min, max, step?, labels? } — the scale the judge and a person answer on, and its rubric. The vector carries the answer normalized to 0..1.
directionthe metricmax or min.
scopethe metricrun (default) — the number is a property of ONE execution. period — it belongs to the entity over a window, and window() measures it. See below.
unitthe metricDisplay only: usd, ms, count, score.
criterionthe metricJudged and reported metrics: required. What the eval agent — and the person rating — should measure, in your words. It goes into the grading prompt verbatim.
rolethe entityobjective / guard / observe. Required on a metric of your own — see below.
targetthe entityThe number you are aiming for. Missing it opens a cycle even on a run that passed.
tolerancethe entityAbsolute drop allowed before it counts as a regression.
tolerancePctthe entityThe same as a fraction of the baseline. Naming either one takes the whole band off the engine default; declare both and the more forgiving applies.
floorthe entityAn absolute bar the challenger must clear whatever the baseline did: with direction: max the number may not fall below it, with min it may not rise above it. Read on objective and guard, inert on observe.
mustImprovethe entityfalse by default. On the objective, requires the change to MOVE the number rather than merely not break it. Read nowhere else.
criticalthe entityfalse by default. One frozen input past this metric’s tolerance fails the candidate, whatever the other inputs said — a promise made per case. Read on objective and guard, inert on observe.
aggregatethe entitymedian (default) or mean — how this metric’s per-run values become one number wherever the loop compares them: the window’s level, the evolve watch’s bootstrap, and the pre-merge check’s movement over its inputs. Declare mean on a share — a 0/1 per run (👍, replied, escalated): its median is 0 or 1 whatever the rate does, so a move from 70% to 80% is visible only to the mean.
optionalthe entityfalse by default. Computed metrics only. Your measure() (or window(), for a period metric) may leave it out, and an omitted value is no observation of that run or window rather than a failed evaluation — for a number that exists only sometimes (the reply time of a message nobody answered). A judged metric cannot be optional: the judge is asked for every one it is given.

The identity fields belong to whichever tier introduces the metric; a lower tier that tries to redefine them fails the load rather than silently disagreeing.

A metric the engine does not already know must say what it is FOR. kind, direction and role are all required on it, and a declaration missing any of them fails the load naming the one it missed. There used to be a default of observe, which was the quiet failure: an author who declares a metric has by definition decided it matters, and reading their silence as “measure it and ignore it” shipped an entity whose verdict never mentions the number it was written for. Catalog metrics need none of the three — they already have all three, and you are only changing how they are used. omnislash doctor names the entity under Loop modules before the gap costs you a run.

floor and tolerance answer different questions, and an entity often wants both. A tolerance says how far this metric may move from where it was; a floor says where it may not stand at all. Only the floor can hold a promise made outside the loop — an SLO, a pass rate a customer was told about — because a purely relative band lets the entity walk away from it one tolerated step at a time. Ten changes each 4% worse are ten validated changes and a metric 34% below where it started.

A floor on an observe metric does nothing. That role means “measured and reported, never part of a verdict”, and a bar that reverted a change would make it part of one — so the two would contradict each other, and the role wins. If you want the bar enforced, the metric is a guard.

critical is for a promise made per case, not per batch. The pre-merge check compares the candidate with the incumbent input by input and reads each metric as the median of those per-input movements, so a candidate that falls apart on one frozen input and holds on the rest still passes — the median is built to forgive one input. A critical metric forgives nothing: any input where the candidate fell past the tolerance on it fails the check, and the reason names the input. A legal review that may never miss a clause, a report that may never exceed its budget on any case — those are critical. A metric that may wobble on one input as long as it holds overall is not, and marking it critical would fail healthy candidates on the noisiest input in the set.

evaluation:
  metrics:
    quality:
      role: objective
      mustImprove: true    # an inert change is not an improvement here
      floor: 0.7           # …and never below 0.7, wherever the baseline sat

A judged metric you declared and the grader did not answer fails the evaluation, loudly. The value is what your entity is optimized on — a silent zero would have the loop reverting healthy changes to hit a bar nobody measured.

scope says where the number lives — and so who is asked for it. run, the default, means it is a property of ONE execution: your measure() produces it when that run is graded, and the pre-merge check compares it pair by pair, candidate against incumbent on the same frozen input. period means it belongs to the ENTITY over a stretch of time — followers, a week’s revenue, the share of drafts a human approved. No single execution produces such a number, so measure() is never asked for it; the entity’s window() measures it once per window, and the value lands on the window the cycle stores. Like kind and direction it belongs to the metric, not the entity: one id cannot be a single execution’s property here and the account’s total over a month there.

The pre-merge check cannot read a period metric. Both sides of a pair are replays of the same frozen input minutes apart, and a number that belongs to a window has no side there at all — read into the comparison it is absent on both, which says “nobody measured it”, never “the change moved nothing”. So an entity whose only objective is period-scoped gets a guards-only check: it can show that nothing broke, never that the change worked. That objective is compare’s to settle, over the window the watch samples after the merge — and the watch hands compare both sides’ window vector with the period values merged in, so yours can read it. repair’s default cannot. Its comparator asks the trigger’s question over the observed runs and never opens a metric vector, so under the default preset a period metric with a role in a verdict is measured, stored, shown — and decides nothing. Write a compare for that entity, run it under preset: evolve, whose comparator reads both scopes, or leave the metric role: observe, which says outright that it is reported and never part of a verdict. omnislash doctor names both shapes — the guards-only check under Loop check, the objective nothing compares under Loop modules — so either stays a trade you chose rather than one you infer from a PR that says validated without mentioning what you cared about.

period implies computed, and it rules out critical — both are load errors naming the metric. A judge scores one execution against that execution’s own evidence, so a judged period metric asks it to score something with no execution to look at; produce it in window() instead. And critical is the veto the check applies on a single frozen input — a period number never reaches an input to veto, so marking one critical would promise a bar nothing enforces.

An outcome your own tables collect on their own schedule is a period metric — unless a person rates the run itself. A vote rate, the share of outreach that got a reply — the run is long over by the time the world answers, so nothing measure() could return at grading time would be the real number. When the wait is known and bounded it is still a run metric, and gradeAfterHours is the answer: the judge looks later and measure() reads your tables then. When it is not, declare the metric scope: period and let window() read the tally for the window and report one number. When the number is a PERSON’s verdict on one run — your own 1–5 on a draft, your 👍 on a support dialog — it is neither: declare it kind: reported, and the rating lands on that run whenever the person gives it.

evaluation:
  metrics:
    approval_rate:
      scope: period        # measured by window(), once per window
      kind: computed       # required, and `period` allows nothing else
      direction: max
      role: objective      # still required: a metric of your own says what it is FOR

Tolerances

An LLM grader’s own run-to-run noise is bigger than an exact comparison can tell from a real regression, so every metric carries a band it may drift inside:

evaluation:
  metricTolerance:
    judged: 0.05        # absolute, on a 0..1 judged or reported metric (scaled ones included)
    computedPct: 0.1    # relative, on a computed one

Those are the defaults; set them brain-wide in the manifest, or per metric with tolerance / tolerancePct.

The two axes combine as the more forgiving of the pair, so a metric that names either one owns both: cost_usd: { tolerance: 0.01 } means a cent, not a cent-or-10%-whichever-is-kinder. Otherwise the engine default on the axis you did not mention would quietly outvote the number you wrote, and a declaration could only ever widen a band, never tighten one.

Weighing tasks against the run

A workflow scenario is graded twice — once per task, once for the run as a whole — and quality is the two combined. The engine’s split is 60/40 toward the tasks; a scenario whose value is its final artifact can say otherwise:

evaluation:
  scoreWeights:
    tasks: 1
    run: 3

Relative weights, normalized by their total, so 1:3 and 0.25:0.75 say the same thing and quality stays on its 0..1 scale whatever numbers you pick. Declare the pair — half a split is not a split, so naming only tasks is a schema error rather than a silent re-weighing of the other side. Brain-wide in the manifest or per entity, like everything else here. Heartbeats and pipelines have no task grades, so their quality is the run score alone and the split does not apply.

Ratings from people — reported, human, scale

Some numbers only a person can give: your own read of a strategy draft, your 👍/👎 on how a support dialog went. You give them — on a rating card or the dashboard (below); the people your roles talk to have no rating button, and what they feel about a dialog reaches the loop through the judge, who reads the dialog whole (see Chat roles). Two declarations take them:

evaluation:
  metrics:
    helpful:                       # only people rate it — no judge, no code
      kind: reported
      direction: max
      role: objective
      aggregate: mean              # a share: 👍 rate, not the median 0/1
      criterion: 'The dialog solved the client’s problem'
      scale: { min: 0, max: 1, step: 1, labels: { '0': '👎', '1': '👍' } }
    layout_fit:                    # the judge scores it, and a person may rate over it
      kind: judged
      human: true
      direction: max
      role: guard
      target: 0.5                  # on 0..1 — a 3 of 5
      criterion: 'The scenes follow the strategy'
      # no scale → the engine's 1–5 with a rubric

The scale. { min, max, step?, labels? } covers 👍/👎 (0..1), 1–5, 1–10, 0–100. labels is the rubric — a step ("3") or an inclusive band ("2..4") → what it means; .. rather than - so a scale below zero reads unambiguously. A human-rated metric that declares no scale gets the engine’s 1–5 with a rubric (Unusable / Poor / Acceptable / Good / Excellent); a judge-only metric that declares none stays on the judge’s native 0..1. The judge answers a scaled metric on the same scale and against the same rubric a person is shown, and an answer off the scale (or between its steps) fails the grade naming the metric. What lands on the metric vector is the answer normalized to 0..1 — min is 0, max is 1 — so target, floor and tolerance on a scaled metric are written on 0..1 (target: 0.5 is a 3 of 5). scale and human are identity fields, like kind: the tier that introduces the metric owns them, and a lower tier cannot rescale it. The catalog’s quality stays the judge’s alone — neither can be declared on it. A computed metric takes neither: code measures it in its own units.

reported is rated by people and nobody else — no judge is asked for it and no measure() owes it, so a run nobody rated simply has no value for it (never a zero). It needs a criterion (the person rates against it), and it is refused as scope: period, optional (it already is, by nature) or critical (nobody rates a replay, so it never reaches an input to veto). It takes the absolute band metricTolerance.judged, like a judged metric — both live on 0..1. Allowed on an entity graded with method: none.

human: true on a judged metric: the judge still scores every run; where a person rated one, their number stands over the judge’s.

Where a rating comes from. A rating card the engine posts when you ask for one (below), the dashboard’s entity page, Rate runs — the entity’s newest runs with the judge’s number and people’s side by side on each metric’s own scale (a run the sampler passed by with people’s alone), and your own rating to give or change; a chat role lists its episodes instead — and an agent relaying what you said, through eval_rate { runKind, runId, idempotencyKey, ratings: [{ metricId, value, note }] } (capability eval:rate). Every writer goes through one door, and every one of them writes the dashboard’s and the agent’s rating through the same op, eval.rate. A retry with the same idempotencyKey writes nothing and is no new entry in the action log.

Rating a run the sampler passed by grades it. A run sample skipped has no evaluation; a person’s rating of it books the judge on that run — the same booking a sampled run gets, gradeAfterHours counted from the run’s end — and lands on it. Every kind. It is not a sample: it does not ask sample.rate, and a second rating of the same run books nothing more. The rating is checked first — a rating the door then refuses books nothing — and an entity whose grading is off (evaluation.enabled: false) is not graded by a rating either.

The door refuses:

RefusalWhen
not-humanNobody alive gave it — a pipeline, the loop, the engine, or an agent that serves no person (an agent rates only as the person its turn serves: a Telegram user, the dashboard).
goneThe run’s record is gone (the retention sweep took it).
not-gradedThe run has not ended yet, or its entity grades nothing (evaluation.enabled off).
replayThe loop launched the run (a pre-merge replay) — evidence about a candidate, not your entity’s own day.
undeclaredThe entity does not have people rate that metric.
off-scaleThe answer is not one the metric’s scale allows.

An agent rates only for a person, and the person confirms. eval_rate’s capability eval:rate waits on a person’s approval by default (moderate, hitl: true); no role names it, so only a '*' role (the chief) holds it, behind that approval: a rating stands for a person’s judgement and feeds the judge’s calibration, so an agent that records one relays a person’s words — never its own opinion of its own run; the loop does not rate itself. A brain whose role relays ratings from chat as routine names eval:rate in that role (a '*' role already holds it) and relaxes the approval in constitution/engine-capabilities.json. As an op eval.rate answers the door’s refusals as refusals: gone is not_found, not-graded precondition, off-scale invalid, the rest refused.

One rating per person per metric per run. A person who rates again replaces their own rating (their latest gesture stands, a retried gesture writes nothing twice); several people are read by their median.

Where the loop reads people’s ratings — observed windows only. The trigger’s window, the post-merge watch, the proposer’s brief and the dashboard’s trace read the entity’s OWN runs, and there a person’s rating stands over the judge’s. The pre-merge check does not: its incumbents are frozen judge grades and its candidates are replays nobody rates, so a check pair is judge against judge. A judged human: true metric is therefore still measured by the check (the judge answers the replays); a reported objective is not — omnislash doctor warns under Loop check when one is the objective of an entity whose check replays, since every check would come back inconclusive and autonomy.onInconclusive would decide every cycle.

A rating changes a metric, never the judge’s verdict on your requirements: passed stays the judge’s. It reaches repair the way any metric does — through a missed target (a run the judge passed and a person rated under the target is a bad run), or through your own shouldOpen, which sees the rated values on window.

One source per comparison. The watch compares two windows, and a comparison that read one window off people and the other off the judge would measure the difference between a person and a model. So per human-rated judged metric the watch picks ONE source for both windows: people, when each window holds at least evaluation.human.agreement.minPairs rated runs; the judge in both otherwise. A reported metric is always read off people — there is no judge to fall back to.

# constitution/MANIFEST.yaml — or an entity's own `evaluation:` block
evaluation:
  human:
    ask: never          # never | always | until-calibrated — rating cards (below)
    spotCheck: 0.1      # under until-calibrated: the share still carded once calibrated
    expiresHours: 48    # how long a card stays open, and the run waits for it
    agreement:
      minPairs: 5       # engine default: a full default window
      tolerance: 0.25   # judge vs person on one run, on 0..1 — one step of 1–5
      threshold: 0.8    # share of agreeing pairs that makes the judge `ok`
      maxPairs: 20      # agreement is read over the newest pairs

evaluation.human is replaced whole at the tier that writes it, like loop.autonomy: an entity that writes human: owns the block, and keys it does not name fill from the engine default, not from the manifest — omnislash doctor → Human calibration names each key a partial block drops.

A run waiting for a rating is not counted yet. When a person has been asked to rate a run, the run is not a member of any observed window — the trigger’s, the watch’s, the judge-health count — until they rate it or the wait runs out; after that, the judge’s number stands.

Rating cards — evaluation.human.ask

ask: never (the engine default) posts nothing: people rate from the dashboard when they choose to. ask: always asks for every graded run; ask: until-calibrated asks until the judge agrees with people (Calibration), then only spotCheck of runs (engine 0.1, decided by hashing the run id, so a retried run decides the same). An entity with a reported metric is carded every time under until-calibrated: no judge ever takes a person’s place there (omnislash doctor says so). A run the loop launched is never carded.

The card is posted where the run’s grading is booked, by one keyed job per run — never twice, never lost to a restart — to your HITL chat (Telegram) and the dashboard’s inbox. It shows the run’s own summary (a workflow’s result, a heartbeat tick’s output — cut at 1500 characters, saying so — or a chat episode’s shape), a link to the whole run on the dashboard (the run’s permanent /go/run:<id>, the tick’s agent session /go/session:<agentId>, the episode’s transcript), links to the artifacts the run published under its own identity (artifact_publish inside the run — linked only when the deployment has a public artifacts address), and each metric’s criterion and rubric:

  • One metric on at most eight steps (the default 1–5, a 👍/👎) — a button per answer. A reply 4 good pacing to the card rates 4 with a note.
  • Anything else (several metrics, a 1–10, a continuous 0–100) — a form: a number per metric on its scale, and a note. In Telegram, Reply and say it in your words; the engine reads the numbers out of it.

Only a person answers a card (the same refusals as every rating), and the answer is written once, keyed by the card. Unanswered, the card closes after evaluation.human.expiresHours (engine 48) — the same instant the run stops waiting, and the judge’s number stands; nothing is written.

Calibration — when the judge can stand in

For each judged metric with human: true, the engine reads how far the judge agrees with people: over the entity’s newest runs that both scored (up to agreement.maxPairs), a pair agrees when the judge’s and people’s numbers (both on 0..1) are within agreement.tolerance. The metric is

StatusWhen
unknownfewer than agreement.minPairs pairs
lowenough pairs, but fewer than agreement.threshold of them agree
okthe judge agrees with people

Pairs count only under the entity’s current judge — the same model, eval method and evaluator role that graded its newest run. Editing the entity’s .loop.ts keeps its calibration; a new judge model or method starts it over. Because agreement is read over the newest pairs, the spot checks keep it honest: a judge that starts drifting falls back to low, and until-calibrated starts carding again.

Where you see it: the entity’s Policy view on the dashboard, omnislash doctor → Human calibration, each cycle round’s record (the calibration its gates were answered under), and your own window() hook — await ctx.calibration() returns { metrics: [{ metricId, status, pairs, agreement }] } (empty when there is nothing to calibrate).

Earned autonomy — loop.autonomy.requireCalibration. With it on, both auto gates behave as human while any judged human: true metric is not ok — the loop proposes and a person accepts and merges, until the judge has earned the people’s trust; then the declared gates stand, and they fall back the moment calibration does. It reads only judged human: true metrics: on an entity without one it holds nothing (omnislash doctor names it). Autonomy “by track record” is not a knob — switching autonomy is a config edit a person makes.

When a bad run opens an analysis — trigger

A bad run is one the entity should not have had: the evaluation failed, or overallScore fell below your threshold, or a declared metric target was missed. A run with no score counts as failing — no evidence of health is not evidence of health. And a missed target triggers even on a run that passed, which is precisely the case a threshold cannot see: quality is fine and cost is still double what you asked for.

One bad run does not open an analysis. The run that just landed has to be bad and two out of the last five graded ones have to be:

loop:
  trigger:
    windowRuns: 5      # how many recent GRADED evaluations the count runs over
    minFailures: 2     # how many of them must be bad

A single execution is a sample of your entity’s noise, not a measurement of it. An agent that averages 0.8 returns a 0.6 now and then with nothing having changed, and an analysis opened on that run spends a model, one of your answers and a pull request on chasing variance — then ships a “fix” whose validation compares the entity against its own noise all over again. minFailures of windowRuns is the cheapest rule that tells a trend from a bad day.

The run that just landed must itself be bad. The count alone would let an ordinary run open an analysis — two old failures still inside the window, a healthy run on top — and the evaluation that opens a cycle is what the cycle is about: the proposer is briefed on it, and the inputs the cycle freezes are the history that ends on it, so the pre-merge check replays the run that opened the cycle first. Open on a good run and the proposer is asked to fix a success, and the check measures the candidate on a case the incumbent already handled. So the window is only ever a brake: it withholds analyses that “one bad run” would have opened, and opens none that it would not.

Both ends are yours. minFailures: 1 restores “analyse the first failure”; minFailures equal to windowRuns is the “N in a row” rule; minFailures above windowRuns is a schema error rather than a loop that silently never fires. The block merges wholesale per tier, like autonomy — write it and you own both keys, with any you leave out filled from the engine rather than from the tier below. Everything in this section is the repair preset’s reading of the block; under evolve windowRuns is the cadence — how many grades open the next cycle — and a minFailures (or threshold) written on the entity, or beside a brain-wide preset: evolve, is refused at load, because neither describes anything an evolve cycle opens on. A brain-wide one under a brain-wide repair stays your repair entities’ default; an evolve entity does not read it. See Improving what already works.

Ungraded runs are not in the window at all. A run whose judge failed was never scored, so it is neither good nor bad — counting it as bad would open analyses on a sick judge, and merely letting it occupy a slot would be worse: the window is a fixed size, so an ungraded row would push a real graded run out and quietly demand more failures than you asked for. A judge that keeps failing is the cycle’s own problem, and is measured where the cycle measures its judge (maxUngradedShare, below).

With no threshold declared, every graded run is bad — that is what an absent threshold has always meant, “analyse everything”. k-of-n then only withholds the entity’s very first analysis; from the second graded run on, 2 of 5 is always satisfied. If you want the brake, declare a threshold.

You can replace the count outright with your own rule — see Your own trigger rule.

Looking on a schedule — trigger.every

Everything above hangs off a run that happened. That leaves two things no count over graded runs can say: “look at this every Monday”, and anything at all about a week in which the entity did not run. Those are what the cadence producer exists for. Give the entity a number of hours and the engine looks at it on that clock, run or no run:

loop:
  trigger:
    windowRuns: 10
    minFailures: 2
    every: 168        # hours — look at this entity once a week, whatever ran

Leaving every out is the usual answer, and it means what the loop has always done: only a landing grade opens a window. There is no engine default, because the key’s absence is the meaning — a cadence the engine picked would put every entity of every brain on a schedule nobody asked for.

A scheduled look asks the same question, through the same functions. window() samples (with no grade to anchor on — ctx.anchor is null, and the repair preset reads its last windowRuns graded runs through the moment of the look instead), shouldOpen decides, loop.enabled is settled before either, and one live cycle per entity still holds: a look that arrives while an analysis is already running is refused, not queued. The recurrence bounds hold too — a cadence buys the entity no extra analyses, so a look inside its cooldownHours or past its maxAnalysesPerDay is refused like any other. Under repair that means a cadence opens nothing unless the entity’s newest graded run is bad — which is the honest reading of that preset: repair is a rule about failures, so putting it on a clock changes only when a failure is noticed. A cadence that should open on something other than a failure wants its own shouldOpen.

A look that opens nothing costs nothing. No cycle, no proposer, no row — and nothing moves in the entity’s history either, so the next window is sampled exactly as if the look had not happened. The only trace is the reason in the log, the same one a withheld grade leaves.

The same hourly sweep delivers queued ideas. An entity with an idea waiting is asked on every sweep whether or not its cadence has come round, and whether or not it declares every at all — without moving its cadence clock. See Ideas.

The engine looks once an hour, and that is the floor on how fine a cadence it can honour. A smaller every is not refused; it simply gets the sweep’s own cadence, and omnislash doctor says so rather than letting you discover it from a number that quietly rounded up.

For a workflow scenario the trigger — enabled and threshold — is frozen into the plan when the plan is built, because it decides something about that run. Editing it changes runs planned afterwards, not runs already in flight. A pipeline scenario and a heartbeat read it live.

trigger: itself is never frozen, for any entity. It is a measurement over the entity — how many of its last n graded runs went badly — and that count spans runs from many plans. Freezing it into one of them would mean the window is counted under whichever plan happened to produce the newest run, so widening a window would reach only entities that re-plan.

The measurement knobs (check, watch, the deadlines) are never frozen for any entity; each deadline is read once per phase — see Where the loop waits for a person.

Improving what already works — preset: evolve

Everything above is written around a failure: a run went badly, the proposer is shown it, the change is watched for the failure coming back. That is repair, and for “daily-review broke” it is the right loop. The case most brains want as well — make a working entity better — breaks every one of those assumptions at once: nothing is bad, the unit of evidence is the entity over a period rather than one run, and the question after the merge is not “did it fail again” but “did the number move”. preset: evolve is that loop, with the same nine stages, the same gates, the same row.

# a scheduled pipeline posting one short per invocation
evaluation:
  enabled: true
  sample: { maxPerDay: 20 }          # required: a scheduled entity is unattended
  gradeAfterHours: 72                # views land three days after the post
  metrics:
    views_72h:       { kind: computed, direction: max, unit: count, role: objective, mustImprove: true }
    completion_rate: { kind: computed, direction: max, unit: ratio, role: guard, tolerancePct: 0.1 }
    brand_safety:    { kind: judged,   direction: max, unit: score, role: guard, floor: 0.9,
                       criterion: "No unverifiable claims, no slurs, on-brand tone" }
    followers:       { kind: computed, scope: period, direction: max, unit: count, role: observe }
    quality:         { role: observe } # else the judge's taste is a guard on views — see below
loop:
  enabled: true
  preset: evolve
  trigger: { windowRuns: 10 }        # the CADENCE: every ten grades, look
  watch:   { deadlineHours: 336, confidence: 0.9 }
// features/viral-shorts/viral-shorts.loop.ts
import { defineLoop } from '@omnislash-ai/config/loop-module';
import { accountStats, postByInvocation, tiktokStats } from './tiktok.ts';

export default defineLoop({
  async measure(run) {
    if (run.replay) return [];                       // a check replay posts nothing
    const post = await postByInvocation(run.runId);  // brain table: invocation → videoId
    const s = await tiktokStats(post.videoId);
    return [{ id: 'views_72h', value: s.views }, { id: 'completion_rate', value: s.avgWatchRatio }];
  },
  async window(ctx) {                                // adds the period metric to the preset's window
    const w = await ctx.preset.window(ctx);
    if (!w.ready) return w;                          // asked on every grade and every watch tick; pay for the API once the window is ready
    const account = await accountStats(new Date(w.to));
    return { ...w, metrics: [{ id: 'followers', value: account.followers }] };
  },
});

The cadence. trigger.windowRuns is how many grades open the next cycle, and window() under evolve samples every grade that landed since the previous cycle looked — the mark is the last window the entity’s previous cycle read, so a grade still pending at that instant enters the next window rather than the crack between two. ready on the tenth; the trigger rule opens on ready and reads nothing else, because under evolve readiness IS the whole question. trigger.every puts the same look on a clock (Looking on a schedule), and the first cycle of an entity with no mark reads its whole history, capped at the engine’s window bound. A threshold or trigger.minFailures the entity writes (or a manifest writes beside its own preset: evolve) is refused at load: both describe a bad run, and nothing here is. A brain-wide one tuned for repair entities is simply not read.

The brief. The proposer is handed the window — its runs ranked, its vector as the baseline, metrics.json naming the objective it has to move — and runs.json holds the window’s best and worst runs by the objective (half and half, rounded toward the best) rather than the newest: “make this better” is analysed by contrast, what the top runs did that the bottom ones did not. The prompt is constitution/Loop/proposer/evolve.md for every kind, which a brain overrides like any other (Overriding the prompts); it tells the proposer that nothing failed, that its change is judged as a window against this one, and that the loop rewrites the same definition every cycle — replace rather than append.

Period metrics. A number that belongs to the entity over a period — followers, a week’s revenue, the share of drafts a human approved — is declared scope: period and measured by window(), once per window, laid over the window’s vector. The preset’s own sampler measures none, so an entity that declares one composes it as above; a ready window that leaves a declared period id out is a load error naming it, exactly as a measure() that skips a run-scoped id fails the grading, and a window that lays a run-scoped id over the median is refused too. Ready is the window the phase acts on: at the trigger the one an analysis may open on, at the watch the full one the comparator settles on (the preset’s watch-side window is ready once it holds watch.windowRuns runs). The idiom if (!w.ready) return w is therefore the difference between one API call per cycle and one per watch, and one per grade and one per poll.

The watch compares. After the merge the entity runs on its own inputs, and on every tick compare reads the first watch.windowRuns runs graded since the watch opened as a window against the window the cycle opened on — null (not yet) until that window is full, then, in this order:

  1. any objective or guard under its declared floor → regressed;
  2. any guard past its tolerance → regressed;
  3. the objective past its tolerance → regressed;
  4. an objective or guard no observed run measured (or the observed window did not carry) → unvalidated (“inconclusive”, naming it) — a full window is not evidence of a number nobody read;
  5. a mustImprove objective with no gain to show → unvalidated (“shipped, no measurable gain”);
  6. otherwise validated.

Floors (1) and guards (2) are point rules on the median: they are protective, and a guard that looks broken is the case the breaker exists for. Rules (3), (5) and (6) read an interval, not a point. Two unpaired windows of noisy per-run outcomes — ten videos against ten videos, views spanning orders of magnitude — make a bare median delta a coin flip, so for a run-scoped objective the engine bootstraps the median difference between the two windows’ per-run values and reads the central watch.confidence interval (0.9 by default; raise it for a noisier entity). The objective is regressed only when the WHOLE interval is past the tolerance, validated only when the whole interval clears the bar (+allowed for a mustImprove objective, −allowed otherwise), and anything in between settles unvalidated with the reason inconclusive: gain g in [low, high] against ±allowed — shipped, standing, measured, and not shown to have worked. A period metric has one value per window and no distribution, so it keeps the point rule; a run metric measured on fewer than three runs on either side has no interval to read and is inconclusive rather than guessed at; a metric absent on either side is never regressed — absence of evidence must not revert a change — and never validated either: the change settles unvalidated, naming the metric and the side it went unmeasured on (rule 4). Every metric read is on the record (perMetric, the interval beside the gain as spread), which is what the notice, the lesson and the dashboard show, and what tells the autonomy breaker the change was measured rather than blind. The watch stamps no validation_terms under evolve — nothing was counted.

This is a deliberate override of the rule the pre-merge check lives by (two unpaired batches are never the sole accept/revert basis). A pipeline or a heartbeat has no pairs, so the watch IS the only gate; the median and the tolerance band absorb input drift; the interval keeps noise from being called a regression; the breaker still pauses autonomy on a regressed streak; and your own compare may soften anything here — a rank test, a longer window, nothing at all. What the interval cannot remove is the time confound between the two windows: only a variant axis would, and the engine has none.

The judge’s own score. The moment you declare an objective of your own, the catalog’s quality becomes a guard with the engine tolerance (Anti-Goodhart floor), so the judge’s taste vetoes a change that moved views and read five percent “worse” to the judge. Under repair that guard is the point. Under evolve it is usually the one thing you did not mean to declare, and omnislash doctor (Loop evolve) hints the one line that opts it out: quality: { role: observe }. The same check warns an evolve entity whose only comparable metric is quality — that loop optimises the entity against its own grader, with nothing of the world to read; declare an objective the world produces.

Time. A cycle needs watch.windowRuns graded runs after the merge, each landing gradeAfterHours after its execution, inside watch.deadlineHours: the doctor’s Loop watch check adds the sum up against the entity’s schedule, and Eval ceilings bounds the lag against what the brain keeps (When the judge looks). Retention is yours: the engine pins nothing extra for a delayed grade.

What does not change. The nine stages, one live cycle per entity, the judge pinned to the cycle, the frozen set for a replayable workflow (the check still compares run-scoped metrics pair by pair; a period-only objective gets a guards-only check), autonomy, budget, cooldownHours, maxAnalysesPerDay, the breaker, lessons, editablePaths. Same file with preset: repair and no window() behaves exactly as before.

How a cycle is judged

The engine never ships outside a PR and never grades a change it has not measured — but what “measure” means depends on what re-running the entity would cost.

EntityBefore the mergeAfter the merge
workflow scenarioThe pre-merge check replays the candidate from its PR head and the incumbent from the base commit on the inputs the cycle froze, under the pinned judge, and compares them pair by pair — see What the check does.The engine starts nothing: it marks the moment the change went live, samples the scenario’s own next runs on every tick (window) and asks the loop’s compare whether the change held — under repair, a count of the next watch.windowRuns graded runs.
heartbeatNothing — a heartbeat has no input to replay.The engine starts nothing. It marks the moment the change went live and watches your heartbeat’s own next ticks the same way — under repair, a count of the next watch.windowRuns.
pipeline scenarioNothing — a pipeline is brain code registered at boot.The engine starts nothing — same as a heartbeat. It marks the moment the change went live and watches your pipeline’s own next graded invocations the same way.
chat roleNothing — a dialog cannot be replayed without inventing the other side of it.The engine starts nothing. It watches the role’s own next graded episodes — only those that began after the change went live and ran under one version of the role; an episode whose turns span the change counts in neither window.

After the merge every kind is watched the same way: the same deadline, the same one function per tick, the same automatic revert PR on regression. On every tick the watch asks window which of the entity’s runs since the merge are the evidence, stores that window on the cycle (watch_window), and asks compare — the cycle’s window against the observed one — for a verdict or a “not yet”; the verdict it settles on is stored verbatim (watch_verdict). Under repair the comparator counts and never compares: the runs after a merge are on the entity’s own inputs, which are nobody’s pair, and a comparison against its history would measure the inputs’ difficulty as readily as the change — so it asks the trigger’s question instead. The engine never re-runs an entity to score a merged change — a workflow’s measurement happens before the merge, on the frozen inputs, where the candidate and the incumbent can be compared like for like.

The judge is pinned to the cycle

Every grade records who produced it: the model ref it dispatched under, the method file and a hash of that file’s bytes, a hash of the entity’s <entityId>.loop.ts when it has one, and a hash of the evaluator role that resolved: its file (your constitution/Agents/system/role-evaluator.md, else the engine’s) — its body is the judge’s own part of its system prompt, so it is as much the rubric as the method file is — together with the capabilities it resolves to, which is the role file’s grant unioned with role-general.md’s. Granting every role something new in role-general.md therefore changes the judge too, though role-evaluator.md never moved. Only the role file and that grant are pinned: the shared fragments every role’s prompt is composed with (every.md, with-*.md) are not, so an edit to one of them mid-cycle goes undetected. Nothing the judge could recall is pinned either, which is why the engine’s role-evaluator.md declares no memory: block: the judge gets no MEMORY.md, no daily logs and no recall over the prompt it grades, so a verdict cannot drift with your memory. A copy of the role file that adds one is a failure in omnislash doctor (Loop role memory). When a cycle opens, it freezes that set and every evaluation the cycle asks for is graded under it — the candidate and incumbent replays its pre-merge check launches for a workflow scenario, and equally the runs, heartbeat ticks and pipeline invocations it observes after the merge, which are that cycle’s challengers even though your own schedule produced them.

The reason is that a champion and a challenger judged by different judges are not two measurements of one thing. The difference between their numbers is the difference between two graders. So:

  • An evaluation the cycle dispatched refuses if the method file, the eval module or the evaluator role (its file, or the grant it resolves to) has changed since the freeze — it settles failed with a judge: … error naming what moved (judge: method changed since the freeze) rather than producing a number nobody can use. Edit freely; the change reaches the next cycle.
  • It dispatches under the ref the cycle pinned and never re-reads yours: an evaluation.modelRef you break after the freeze fails your entity’s own gradings, not the cycle’s.
  • The numbers measure() returns must come from the loop module the grade records. A <entityId>.loop.ts that changes while a run is being graded (a brain-sync landing mid-grading) fails that measurement, and the retry reads the module and its hash again, together.
  • A judge that answers with no structured output fails the evaluation (judge: no structured output), and so does one whose answer does not match the shape the evaluation asked for (judge: unparseable output). Neither is recorded as a grade of zero — a verdict the judge did not produce is not a verdict against your run. There is no rescue model either: a grade from a judge you never declared is not comparable to the baseline.
  • A judge that cannot be RESOLVED at all when the cycle opens — a method file renamed, a loop module that will not import, a modelRef naming a role your models.json no longer has — ends the proposal before anything ships. The analysis has already run, so the row exists and settles skipped, and you get a notice naming the file. Nothing is applied: a change the loop cannot measure is a change it will not make. Fix the file and the next evaluation opens a fresh proposal.

What the recorded ref is. The snapshot stores the models, not a name. If your modelRef is a role (smart) — or you declared none and the system:evaluator role’s own modelRef applies — the pin expands it to the concrete chain constitution/models.json resolves it to at that moment, so the recorded ref reads openai:gpt-5.2,anthropic:claude-opus-5, not smart. A fallover chain is kept whole: it is what you declared your judge to be. What is not kept is the role’s name, because re-aiming a role mid-cycle would otherwise change who grades without changing the recorded string, and two grades from different models would compare equal. Re-aim freely; the change reaches the next cycle, exactly like an edit to the method file.

Three baskets, not two. Once the pin exists, a challenger can fail in two different ways and the loop has to tell them apart:

BasketWhat it isWhat it means
gradedthe evaluation completedevidence about your change — good or bad
ungradedthe evaluation itself failedevidence about the judge, not about your change
not runno evaluation row at allno evidence either way

A challenger graded by a judge that is not the one this cycle froze counts as ungraded too — its numbers are the other judge’s opinion, and the frozen set says the same of an incumbent grade it cannot compare (it re-runs the incumbent instead). That holds for the pre-merge check’s own replays as well: a replay (or a frozen incumbent grade) re-graded under your current judge while the check waits — an eval_start by hand, say — is read as ungraded, never paired. A snapshot recorded before the evaluator role file was part of the judge states no hash for it and so matches only another such snapshot: a cycle frozen across that upgrade reads its new grades as ungraded and settles unvalidated rather than comparing them. Likewise a snapshot recorded before the grant was part of the evaluator role’s hash never equals one recorded after: across that upgrade the cycle’s next pinned grade refuses with judge: evaluator role changed since the freeze, and the next cycle pins the new hash.

Counting ungraded challengers as failures is how a broken judge reverts healthy work: enough of them and the viability layer below reads a batch nobody could score as a batch that lost. So the loop checks the share first —

# constitution/MANIFEST.yaml — brain-wide only, no per-entity override
evaluation:
  judge:
    maxUngradedShare: 0.34    # default

— and when ungraded ÷ (graded + ungraded) goes above it (challengers that never ran are in neither term: the judge never saw them) the cycle settles unvalidated with the reason judge unhealthy, plus a notice naming the entity. Nothing is reverted, and the lesson the round leaves says exactly that — proposed, and unmeasurable: the batch measured the instrument. Fix the method or the judge model, and the next graded failure opens the loop again.

judge lives only in MANIFEST.yaml, deliberately. Judge health is a property of the grading instrument — one method, one model, one brain — and an entity that could raise its own tolerance for a judge that keeps failing is the entity that most needs the ceiling to hold.

A different judge means incomparable, never re-judged. A grade is comparable to the cycle’s only when all three recorded facts — model ref, method hash and loop-module hash — match the snapshot, the same three a challenger refuses to be graded under. A grade that does not match is set aside whole; nothing is re-scored, because a re-grade would need the run’s artifacts, which do not outlive the run, and a grade the loop re-scored to match itself is a bar the loop moved.

Where that grade sits decides what “set aside” costs. An incumbent grade in the frozen set is simply not used: the pre-merge check re-runs the incumbent on that input, pinned to the commit the cycle froze, and the pair is measured fresh — one rule, no knob. An observed run after the merge graded under another judge is ungraded for the watch: it fills no slot of the window and counts toward the judge-health share above, never for or against the change. A row that records no judge at all is incomparable the same way. A population graded before the snapshot existed heals on its own as new evaluations land under the current judge.

What a cycle freezes

The judge is one half of what a cycle pins at its start. The other half is what is graded: for a workflow scenario the engine may re-execute, the cycle freezes the inputs the candidate and the incumbent are both measured on, so the two sides of the comparison are one measurement — the same inputs, the same judge, the same commit for the incumbent. The set is written once, right after the judge, and never recomputed: a refinement round reuses it, and a run the entity produces while the proposal sits on a card can never slide in.

loop:
  check:
    mode: replay | none   # default: derived from the plan (below)
    inputs: 5             # recent graded runs, distinct by input, frozen at cycle start
    trials: 1             # replays per input per side (1..3)
    deadlineHours: 24     # how long the pre-merge check may wait for its replays
    incumbent: frozen     # frozen | replay — what the candidate is paired against (below)

The block merges wholesale per tier, like autonomy: — write it anywhere and you own all five keys.

What goes into the set:

  • History — the last inputs distinct inputs among the entity’s graded runs through the cycle’s anchor, the evaluation of its window’s newest run (that run’s input leads). The scan runs deeper than the window itself, so a narrow window cannot starve the set; a window that names no runs freezes golden cases only. Under incumbent: frozen (the default) each carries its own grade as the incumbent’s score on that input when that grade was made under the judge the cycle pinned and measured something; a run graded by a different judge, or whose grade holds no metrics, keeps its input and loses its score, and the check re-runs the incumbent on it. Under incumbent: replay no history run keeps its score: the incumbent is re-run on every input, so both sides of every pair run in the same moment. The anchor never keeps its score, under either: it is the run whose grade opened the cycle — picked BECAUSE of its outcome (the failure a repair fix answers) — so its own grade is the one incumbent score biased by construction; pairing a fix against it would credit the fix with a bad run simply not repeating. The incumbent is re-run on the anchor’s input. The runs the loop itself launched — replays, challengers — are never history; the runs it merely observed after a merge are.
  • Golden cases — defineLoop({ cases }), after the history, with no score of their own (the incumbent is always re-run on them). One whose input a history run already covers is dropped.
  • The base commit — origin/<default> as the remote holds it at the freeze, the commit every fresh incumbent replay is pinned to.

Held-out inputs. The proposer’s evidence runs and the check’s frozen inputs come from the same history, so a candidate could pass the check by fitting the exact runs the proposer was shown. The freeze owns that split: it records the runs the proposer was briefed on (frozen.evidenceRunIds — the newest evidence.runs of the same scan) and fills the inputs history slots in this order — the anchor’s input first (a repair fix must be checked on the failure it saw; that is a regression target, not a holdout), then inputs no evidence run ran on (heldOut: true), then inputs the proposer did see only when the scan has nothing else. Golden cases are never held out: the proposer can read your loop module. The check’s verdict reason names the split — held-out k of n — and k = 0 is a hint from doctor_check({ check: "Loop check" }): every input the candidate is checked on is one its proposer was shown, so the check cannot tell a fix from a fit. There is no new knob; the seam is the pair you already have, evidence.runs and check.inputs — raise inputs above the distinct inputs the entity has run on, or lower evidence.runs.

mode has no fixed default — the engine derives it from the plan. With no tier declaring one, a workflow scenario gets replay when its .blueprint.yml needs no capability a human has to grant (a replay asks nobody, so it would fail on one), reaches no capability above moderate, opens no pull request and runs its agents in the brain repo (an agent.repo other than the brain); none otherwise. A heartbeat or a pipeline scenario is always none — neither can be re-executed to measure a change (a pipeline is brain code registered at boot, a heartbeat has no input). Under none the cycle freezes nothing, and the row’s frozen column stays empty: that is the record that this cycle has no pre-merge check.

Declaring replay on a workflow the engine would not have chosen is honoured — you know the plan’s side effects better than a risk label does (a standing grant, say), and a replay performs them twice per frozen input per trial, so be sure. Declaring replay on a heartbeat or a pipeline measures nothing. doctor_check({ check: "Loop check" }) fails the second, and a declared replay on a workflow with no readable plan; warns about the first, naming why; warns about a plan on disk it cannot assess (the engine derives none for it and checks nothing); warns when a replayed entity has fewer inputs to freeze — distinct graded inputs plus golden cases — than inputs (the set comes up thin until it has more to freeze); hints when no held-out input is possible (check.inputs below 2, or the proposer’s evidence.runs covering every input the entity has run on); and names a replayable workflow that declared none. A brain-wide mode: replay in MANIFEST.yaml reaches every entity (the block merges wholesale), and the doctor faults only a heartbeat or a pipeline that declares it in its own frontmatter.

A set the brain’s files will not yield — a cases export that is not { id, input }, a declared replay on a scenario with no readable plan, a remote with no default branch — sets the proposal aside skipped with a notice naming the file, exactly as an unresolvable judge does: a candidate the loop cannot measure is a change it must not apply. A failure on the way that is not the brain’s — the database, the network to the remote — retries instead.

What the check does

For a workflow scenario the cycle froze inputs for, the check runs once the apply PR is open and before anyone merges it:

  1. The head is pinned. The PR’s head commit is recorded on the round (checked_sha), and the omnislash/validation commit status is set to pending on it. Make that status required in your repository’s branch protection and no candidate can merge before the check has spoken, and no commit pushed after it can merge either — the status is on the checked commit alone. Under merge: auto the engine additionally never arms auto-merge until the check has answered, arms it bound to the checked commit (GitHub refuses the arm if the head has moved), and takes the arm back out, demoting the round to the human gate with a notice, if the poll finds the PR pushed to afterwards. Without the required status that last guard is one poll interval wide, which is why the status should be required.
  2. Every pair is replayed. For each frozen input, trials runs of the scenario pinned to the PR head (the candidate). For each input whose frozen entry carries no comparable grade — every golden case, and a history run graded by a different judge — trials runs pinned to baseSha (the incumbent). The replays are the entity’s own runs in every respect but two: they execute the brain at that commit, and they act on nothing — every engine tool that would write, launch or send answers a success marked dry-run, and your own tools and pipelines owe the same (see Replay and your tools). The replays’ ids are written on the round (check_run_ids, baseline_run_ids) before the wait opens, and their grades never open a second proposal in the entity’s name.
  3. The grades are waited for, up to check.deadlineHours. A replay that could not even be created — the candidate’s plan will not load at its head — is read as the change failing that run on the first tick, not waited out.
  4. The judge is checked first, on the candidate side: more than evaluation.judge.maxUngradedShare ungraded → the check is inconclusive. An incumbent with no grade on any input is inconclusive too — there is no bar to compare against.
  5. The comparator answers over the pairs — for each frozen input and trial, the incumbent’s side (its frozen grade, or the fresh replay) beside the candidate’s — by the list of rules below, or by your own verdict().

Three outcomes, and only one touches the PR:

VerdictOn the PROn the rowWhat happens next
validatedstatus successverdict recorded, row stays appliedThe merge gate, as declared: a human merges, or auto-merge is armed on the checked commit.
regressedstatus failure; the PR is closed with the per-metric and per-input story in a commentsettled regressed with the candidate tally; a lesson is writtenNothing shipped, so nothing is reverted — the closed PR is the undo. The regression counts toward the circuit breaker like any other.
unvalidatedstatus error with the reasonverdict recorded (where one ran), row stays appliedThe PR stays open for a person; under merge: auto the round is demoted to the human gate, with a notice — an unmeasured candidate never lands on its own. What the person can do depends on the branch protection: where the status is not required, merge or close it; where it is required, an error blocks the merge exactly as a failure would, and the decision is an administrator merging past the protection, or closing the PR. watch.deadlineHours still bounds the wait either way.

A check the brain’s files will not let run — terms or the loop module that do not load, a comparator that throws — is inconclusive rather than fatal: the status says why, and the proposal waits for a person like any other inconclusive check. The check’s own deadline is separate from watch.deadlineHours, which starts counting toward the merge only once the check has answered.

The verdict, as a list of rules

The comparator asks a fixed sequence of questions over the pairs and stops at the first one that answers. The order is the contract: every rule below the one that fires would have given a different — and wrong — answer on the same batch.

#RuleOutcome
aEnough evidence? Fewer than half the promised pairs judged (ceil(inputs × trials / 2)) — a pair is judged when the incumbent was graded and the candidate answered: graded, or launched and diedunvalidated, insufficient evidence
bWas the judge healthy? ungraded ÷ (graded + ungraded) on the candidate side above maxUngradedShareunvalidated, judge unhealthy — settled by the loop before the comparator is called at all
cDid it work where the incumbent did? The candidate must have passed something, and among the inputs the incumbent passed it must keep a strict majorityelse regressed, naming how many inputs it lost
dDid a critical metric fail ONE input? Any input where the candidate fell past that metric’s toleranceregressed, naming the input
eDid a guard break? Any guard metric past its tolerance on the median of its per-input movementsregressed
fIs every floor still standing? Any compared metric past its declared floor — below it on max, above it on minregressed
gDid the objective hold? Past its tolerance the wrong way, on the median of its per-input movementsregressed
g′…and MOVE, where mustImprove asks it to? A gain inside the noise across inputs — or a metric no pair measured on both sides, since movement that was not measured was not shownunvalidated, inside the noise across inputs / no pair measured it
hNothing above firedvalidated

Four of those are worth their own paragraph.

Rule (a) is why unvalidated is a verdict and not just an accident. A third of a batch is a third of an answer, and reading it as a whole one is how a slow day closes a healthy PR. A side still unfinished is never counted against your change, and neither is one the judge could not grade — both simply leave the pair unjudged, which is exactly what (a) measures. A candidate replay the engine launched that died before the judge saw it — including one whose plan would not load at the candidate’s commit — is different: that is the change failing, not nobody looking, and it counts as a judged pair in (a) and a lost input in (c) alike. An incumbent replay that died is the other way round: it left no bar, and its pair is not judged.

Rule (c) blames the candidate only for what the incumbent held. The frozen history is, by construction, the runs that opened the cycle — the incumbent failed some of them. A candidate that also fails those inputs has not regressed; one that passes them is the fix the cycle was for. Two unpaired batches cannot tell those apart, which is the reason the verdict is over pairs.

Rule (d), the per-input veto, and rule (e), the guard veto, are mechanical and outrank the grader. A guard past tolerance regresses the change whatever score the judge gave it: “cost halved” with quality through the floor is the exact failure guards exist for, and a rule that consulted the objective first would already have validated it. A critical metric is the same veto applied per input, before any median gets to forgive the input it failed on.

Rule (g′) compares your gain against your own noise, not against zero. The band is half the spread of the per-input movements, never tighter than that metric’s declared tolerance: a change that gained 0.3 on one input and lost 0.1 on another has a median gain, and a bare gain > 0 test would adopt one input’s good day as progress. Below three compared pairs there is no spread to read as a distribution, so your own tolerance is the honest band — it is the only statement about this metric’s noise anybody actually made.

Every metric is read as the median of its per-input movements — the candidate’s value on an input minus the incumbent’s on the same input, in the metric’s own direction — over the pairs where both sides measured it. The two medians shown beside it (the incumbent’s and the candidate’s) are for reading; the movement is what decides. A metric no pair measured on both sides is skipped, never counted against you — unless mustImprove asked it to move, in which case the answer is unvalidated: not a closed PR, but not a gain anybody saw either.

Declare nothing and you still get a full spec: quality is the objective, passed is the viability signal, everything else in the catalog is observed, and the rules above run for your brain exactly as they do for one with a page of metric declarations. Each candidate replay is then measured against the incumbent on the same input, with the 0.05 judged tolerance absorbing ordinary grader noise. Set metricTolerance: { judged: 0 } if you want exactness back.

Replacing the whole sequence is one function — see Metrics only your code can produce.

A heartbeat is judged by its own schedule. Re-running one to score it would mean sending three real messages, or making three real calls to somebody’s API, and it would measure a forced run rather than the scheduled tick your change actually has to survive. So the loop waits instead. While it waits, that heartbeat’s ticks are graded regardless of your sample settings — an ungraded tick would leave the window short. That exemption ends the moment the window has the watch.windowRuns grades it was waiting for, so it cannot outrun your ceiling indefinitely. Set watch.deadlineHours to whatever a fair sample of your schedule takes; the clock starts when the change goes live, not when the PR was opened, so a slow review does not eat the window.

The watch — when the evidence never arrives

The watch’s window is the trigger’s unless you say otherwise. After the merge the loop asks the one question the trigger asks — would the entity have triggered again inside its window — so watch.windowRuns and watch.minFailures default to the trigger’s pair (and your own shouldOpen is asked, where you declared one): widen the trigger’s window and the watch widens with it. Declare them under watch: when the two questions deserve different sizes — a longer watch than trigger for an entity whose failures are rare, say — and the block is yours wholesale, like autonomy. There is no rolling baseline and no sample size beyond that: the evidence a comparison needs is sized by check.inputs and check.trials, before the merge, where the comparison actually happens.

Under repair the watch is a count, by the trigger’s own rule. A graded run after the merge is bad exactly when the trigger would have called it bad — it failed, scored below threshold, or missed a declared metric target — and the window is the trigger’s windowRuns. minFailures bad runs regress the change the moment they are counted, whatever the rest of the window brings: the entity would have triggered again, so the change did not hold. A full window with fewer settles validated. Where you declared your own shouldOpen, the comparator asks it instead, at each grade of the window over the runs since the merge — see Your own trigger rule. Nothing after the merge is compared against the entity’s history — those runs are on its own inputs, nobody’s pair. Where you declared your own compare, it is asked instead of all of this — see Your own post-merge comparator.

The window is windowRuns grades, as the trigger’s is: a tick the judge could not grade takes no slot, and the watch waits for the next one. An observed window can close short — your heartbeat fired less often than the deadline assumed, or too many ticks were not graded — with fewer bad runs than regress. The cycle then settles unvalidated: it shipped and nothing was reverted, and the round’s lesson records that and no more — there is nothing further to conclude. The compared variant is the same settle: a compare that answered null on every tick until the deadline — the window never had what it needed, or it read what arrived and never decided — settles unvalidated too, with the comparator’s non-answer as the record (watch_verdict with nothing measured), never regressed and never validated. A missing tick is the absence of evidence, not evidence of harm, and one passing tick out of the five the window wants is a fifth of an answer — calling it validated would be the same overstatement in the opposite direction. If the entity keeps failing, the next graded failure opens the loop again.

A pipeline is your code — which is why the engine never RE-RUNS one. It cannot know whether re-running sends a message, charges a card, or opens a pull request. But not re-running is not the same as not measuring: a pipeline is observed, exactly as a heartbeat is. The engine starts nothing and counts your pipeline’s own next watch.windowRuns graded invocations after the change ships, and while that window is open those invocations bypass your sampling — an ungraded invocation would leave the window short.

A pipeline nobody invokes inside the deadline still settles unvalidated. That is the same honest answer a rarely-firing heartbeat gets, and omnislash doctor warns under Loop watch when a scheduled pipeline’s cadence cannot deliver watch.windowRuns inside watch.deadlineHours. An entity you would rather not watch at all can say so — watch: { mode: none } — and every change it ships settles unvalidated by design, with a notice saying so rather than a window that never fills.

An unproven change — watch.onInconclusive

Every unvalidated above — the gain sat inside the noise, the window closed short, the judge went unhealthy, your compare never answered — leaves the change shipped: absence of evidence is not evidence of harm, and by default (keep) the change stays, on record as unproven.

An autonomous brain in a noisy domain may want the opposite rule — keep only what was shown to work:

loop:
  watch: { onInconclusive: revert }   # keep | revert — default keep

Under revert the loop undoes an unproven change through the same revert PR a regression gets: the same merge gate (merge: auto arms it), the same wait, the same hold on the entity’s next cycle until it lands. The row still settles unvalidated — nothing was measured against the change — and the revert is recorded beside it; the PR, the row and the notice say “unproven” rather than “regressed”, and the dashboard’s cycle view says reverted by policy. The breaker is not involved: an unproven change is not a regression, and whether an auto-merged one nobody could measure pauses autonomy is still autonomy.onUnmeasured’s call.

Not read under watch.mode: none: an entity you told the engine not to watch was never inconclusive, and its changes stay. Nor for a confounded watch — a lock-group sibling’s change, or an edit of the entity’s files no cycle shipped, landed inside it, so the window cannot say which change did what, and nothing is undone on a guess (Lock groups).

Verdict strength — paired, measured, blind

A validated that a pre-merge check and a live watch both measured and one nobody measured are different amounts of evidence, and the loop says which. The strength of a shipped change’s verdict is derived — never stored — by one engine function from the rows below, and read the same way everywhere: the autonomy breaker, the lesson’s helpful credit, loop_history, the dashboard.

StrengthWhen
pairedThe pre-merge check passed the candidate against the old version on the same inputs (check_verdict validated, with metrics), and the watch measured it after the merge.
measuredThe watch alone measured it: its comparator closed on the runs after the merge with a per-metric reading, or — under repair with the preset’s own comparator — with the k-of-n count it read over the window (validation_terms stamped beside a validated / regressed). A compare of your own reads as measured only through the perMetric it returns: a bare validated from it is blind. No check stood beside it, or one that did not pass (an inconclusive check shipped by onInconclusive: ship, a person merging past a regression).
blindNothing measured it: no watch record (watch.mode: none, a judge that broke, a module that would not load), one that read no metric and counted nothing (a window that closed short), or a watch another change confounded (a lock-group sibling’s, or an edit of the entity’s files no cycle shipped). This is exactly what the breaker calls unmeasured — the autonomy.onUnmeasured case.

A change that never shipped — a check that regressed its PR, a proposal set aside — has no strength (null): there is no verdict on a shipped change to weigh. Only paired and measured credit the lessons a validated round cited.

The cycle’s grade — Improvement, Regression, Tradeoff, Tie, Inconclusive

A cycle’s status says where it ended (Kept, Reverted, Unverified); its grade says how the experiment came out. Like the strength, it is derived at read by one engine function — never stored — from the watch’s verdict record and the verdict’s strength, in the loop’s own terms (each metric’s tolerance, the interval the watch read it through); no new number decides it. A candidate the pre-merge check regressed never shipped, and is graded off the check’s record instead: a Regression, or a Tradeoff when its objective cleared its bar.

GradeWhen
ImprovementThe objective moved past its noise band (the whole interval above its tolerance, where the watch read one), nothing broke, and the verdict kept it.
TradeoffThe objective cleared its bar, and a guard, a floor or a per-input promise broke for it — the verdict regressed it.
RegressionThe objective fell past its tolerance, or something broke with no objective gained.
TieMeasured, and the objective’s move stayed inside its tolerance either way (the whole interval, where the watch read one).
InconclusiveBelow the strength floor (blind: nothing measured the change, or another change confounded it), the objective unread on a side, or a move the verdict did not confirm and could not keep inside the band (too few runs, an interval wider than the tolerance).

A verdict with no objective reading — repair’s k-of-n count — is graded by its verdict alone: validated is an Improvement, regressed a Regression. A cycle with no verdict (live, skipped, failed, or never merged for any reason but a regressing check) has no grade. A grade is always shown beside the n it rests on (“Improvement · n = 12”); it qualifies the status, it does not replace it — a Reverted cycle may be a Regression or a Tradeoff. loop_history rows, loop_get_cycle and loop_get’s cycles carry it as grade; loop_summary counts the window’s settles by it. Each round of loop_get_cycle also answers the check’s pairs — { baseline, candidate } run ids, input by input as the check compared them — so a person opens one side by side (/runs/<candidate>?compare=<baseline>).

One cycle per entity at a time

While a cycle is live (accepted, applying, PR open, or validating), a second failure of the same entity does not open a competing pull request against the same files. It is recorded and skipped. A proposal merely waiting on you is not live and holds nothing, so a card nobody answers cannot freeze the entity. The claim is released when the cycle settles — including when its analysis dies mid-flight, or when its PR is left unmerged past watch.deadlineHours, so an abandoned review cannot freeze it either.

What the loop may spend on one entity

The claim above bounds how many cycles run at once. Three more knobs bound the rest of the bill: two on how often the loop may come back — “one at a time” says nothing about an entity that settles a cycle every hour, forever — and one on what a single round may cost.

  • cooldownHours — the entity rests this long after one of its cycles settles. The engine rests 6h after an ordinary settle and 24h after a regressed one: a change the loop had to revert is evidence the proposer is currently wrong about this entity, and the next failure it sees is the one it just failed to fix. Declare the knob and your number applies to both cases — you have priced this entity’s rest, and the engine does not raise it back.

  • maxAnalysesPerDay — a rolling-24h ceiling on proposer agents for this entity, defaulting to 3. It catches what the cooldown cannot: a proposal waiting on your answer has settled nothing, so it starts no rest and holds no claim, and without a ceiling every further graded failure would open another proposer for as long as the card sits unanswered. An analysis that fails needs no ceiling for this — it settles like any other outcome and rests the entity normally. At the engine defaults three a day is the tighter of the two bounds, so the ceiling trims the ordinary path as well.

  • budget.maxUsdPerRun — what one run of the loop may spend, in total. Frequency says nothing about price: an entity whose proposer reads a large evidence bundle and whose implementer edits a wide surface can spend more in one run than a cheap entity spends in a month of them, and until you write this block nothing notices. The engine default is null: unpriced, so an existing brain changes by nothing.

    The ceiling covers the whole run, not one agent and not one round. A run opens a proposer to write the proposal and an implementer to apply it, and opens another pair each time you refine the suggestion or retry a failed apply. The loop enforces the number by subtraction: before each agent it prices what this run has already spent — its own agents, off the same ledger the proposal card reports — and hands that agent only the remainder. A run with nothing left opens no agent at all and is recorded failed, saying what it spent of what.

    What the remainder can STOP is CLI-only. The subtraction happens whatever the ref — an API turn’s cost lands in the same ledger, so it is counted against the run and shortens what the next agent gets. What an API turn does not take is a ceiling: nothing on that path reads one, so the turn itself runs unbounded and can overshoot the remainder it was given. A loop.proposer.modelRef (or a loop/role-proposer.md) that resolves to an API provider is therefore counted but not capped, and a bare role word resolves API-first. If the number is meant to bind, pin the ref to a CLI harness (cli:smart, or a concrete claude:<model>).

    Among the CLI harnesses, enforcement still differs, and you should know which you are buying. The Claude harness takes the ceiling natively and stops its turn the moment it crosses. Codex and OpenCode accept no such option, so the turn runs to its end and is then compared — the cost of that turn alone — and recorded failed with over budget if it is over the share it was handed. On that second lane the ceiling is where an over-spend is noticed, not where it is cut off: the money is already gone when it fires. A fallover chain does not multiply it — a turn stopped on the budget ends the chain instead of buying the same work from the next harness. So the run-level ceiling is a hard stop on how many further agents open, and a promise about the turn in progress only where the harness can keep one.

    Merged wholesale per tier, like autonomy: write budget: anywhere and that tier owns the block.

The two frequency bounds are measured over analyses that actually ran. A refusal costs nothing and starts no rest, so refusing can never lock an entity out.

Refusing is loud. The refused analysis is recorded as a skipped cycle carrying the reason, so it shows up among the entity’s cycles on /evolution instead of the loop quietly going silent on you. And a refinement round is never held by the two frequency bounds — that is you steering a proposal you are already looking at, and those two exist for what the loop spends on its own initiative. A cycle opened on an idea is exempt for the same reason — someone asked for it — and so is a request (“Analyze now”, loop_analyze). The spend ceiling is not in that exemption: a refinement opens an proposer like any other round, it is priced like any other, and it draws on the same run’s remainder — so refining repeatedly is what exhausts a run’s budget soonest.

omnislash doctor names, under Loop ceilings, every unattended entity (a scheduled scenario, any heartbeat) that set maxAnalysesPerDay: null — unbounded is defensible for something you watch, and a standing bill for something you do not.

Where the loop waits for a person

autonomy staffs two gates, but a person can be asked for more than two things. Each wait below exists only under the policy that names it, and each has its own bound — nothing waits forever:

WaitExists whenBounded byUnanswered
A grant request the proposer stops onthe round is a person’s (decide: human, a tripped breaker, a calibration hold)reviewDeadlineHoursthe turn is abandoned; the analysis is failed, with a Retry / Skip card
The decide card — Accept / Skip, a reply refinessamereviewDeadlineHours — the card’s own expirythe card expires; the proposal settles skipped (review-abandoned)
A grant request the implementer’s turn stops onsamereviewDeadlineHoursthe apply is failed, with the error on a Retry / Skip card
The Retry / Skip card a failed analysis or apply offerssamereviewDeadlineHoursnothing further — the row is already failed
The merge of the apply PRmerge: human, or an auto gate demoted for this round (an inconclusive check under onInconclusive: human, a refused or disarmed arm, a moved head, the breaker)watch.deadlineHours, from the check’s answersettles unvalidated; an arm is disarmed first
The merge of a revert PRthe change’s merge gate is a person’swatch.deadlineHours, from the revert PR’s openingthe hold on the entity ends; the change stays live and the outcome says so
Re-arm after the breaker trippedan auto gate was declared and the breaker demoted itnothing — the pause lifts only on a re-arm (dashboard or loop_rearm)both gates stay a person’s
A rating cardevaluation.human.ask is always or until-calibratedevaluation.human.expiresHoursthe judge’s number stands
A move your window() names (awaitingPerson)your sampler says the next window waits on a personnothing — the window waits until it movesthe window stays not ready; windowStalledAfterHours does not apply to it

listCyclesAwaitingPerson() in @omnislash-ai/infra is the one query over the loop’s own gates (decide, retry, grant, merge, revert, rearm) — the dashboard’s N need you list is built on it, beside an unread failure, a config fault and the next cycle’s window (window: a person’s move your sampler named; stall: a window that stopped moving — see A window that waits). An agent reads the same list as loop_status → needsYou[], each item with next: who acts, and through which card, PR, link or tool, and url: the PR or the link where the person acts.

Every wait above is a person’s — and all but one are enforced. An agent cannot answer or close the decide and retry cards or a rating card: hitl_respond and hitl_cancel on them are refused [unauthorized_responder], and the card stays pending for a person. A grant request a loop agent stopped on is an ordinary grant card, and approving any grant card takes a person too — a running boss may approve only what you listed in its approvableCaps (boss-mode.md). telegram_press_button refuses the bot’s own messages, so no agent presses a card through your Telegram account. The merge is the exception. The deployment opens and watches the loop’s PRs under one GitHub identity, so the engine cannot tell an agent’s merge of a loop PR from a person’s. Under merge: human an agent must never merge, close or arm one. That rule lives in the self-evolving skill and in this page, not in code.

reviewDeadlineHours and watch.deadlineHours are separate knobs because they bound different things. watch.deadlineHours is evidence latency — how long the PR may sit unmerged and the challengers may take to land — a property of the entity. reviewDeadlineHours is human latency — a property of whoever answers. One number for both would throw away every proposal sent on a Friday evening. Each is read once per PHASE, not once per wait: reviewDeadlineHours as each analysis round opens (arming all of that round’s human waits), watch.deadlineHours once the change has shipped (arming the merge wait and the challenger window together) — so an edit reaches the next round or proposal, never a wait already counting down.

A short reviewDeadlineHours is a statement about your grants too: an entity set to four hours wants its proposer’s and implementer’s capabilities pre-granted, not asked for. A re-analysis a Retry asks for is spent from the same recurrence budget as any other — a retry tapped inside the entity’s cooldownHours is refused, and the proposal settles skipped with the refusal on the row.

Answering a card. The decide and retry cards are ordinary HITL cards (producer loop, kind approval): they are listed and answered on the dashboard’s HITL surface and on the cycle’s own story, and pushed to the HITL chat when the brain has a Telegram persona. The buttons are Accept / Skip on a proposal and Retry / Skip on a failure; a reply to either card — a Telegram native reply, or the dashboard’s Custom… answer — is a refinement: the loop records it on the round and analyses again with your text. A Skip may say why: the dashboard’s Skip asks for an optional note, which rides the tap (note on the approval answer) and is kept on the round (decision_note) — the next proposer reads it as the reason a person turned this down (What a round teaches). A Telegram button carries no text, so a Skip there is a bare tap. The decide card carries the proposal’s summary and the context a reviewer needs before tapping — what the change will be judged by, each objective with the level its window stands at (🎯 Optimizing: owner_score ↑ at 2.33 (target 7)), the entity’s recent pass count (left out on an entity graded with evaluation.method: none, where a pass only means the run stood on its declared floors), a failure category other entities share, and the spend so far; the whole proposal — every change with its current and suggested text and rationale, new skills, cited lessons — is one Full proposal link away on the dashboard (/go/cycle:<id>?proposal=<round>: the cycle, that round’s proposal open). A retry card links to the cycle (/go/cycle:<id>). A deploy that publishes no dashboard sends the cards without a link. Only a person may answer (hitl_respond answers as an agent, and is refused), and whoever answers is written on the round as decided_by. Each card answers its own round — a refinement is a new round with a new card, and an answered or expired card can never reach the round that replaced it. A card’s deadline is its expiry: the card is opened with expiresAt at the round’s review deadline, so the moment it closes as Expired is the moment the deadline passed — there is no second clock — and the cycle reads that close like any other answer.

You can also pre-authorize one scenario or heartbeat from the dashboard with a loop:auto_accept grant, which accepts on your behalf and burns one grant use. It cannot be declared in frontmatter — it is the delegation of a person’s own oversight — and the loop never looks one up for a chat role.

A round nobody stands on — decide: auto

Under autonomy.decide: auto (with the breaker and calibration not holding the entity back) the four round waits above do not exist, because nobody is there to answer them:

  • The proposer and the implementer run unattended. A grant request either of them makes is refused before any card exists — hitl_request_grant fails, and the tool call that hit missing_grant stays failed. So a capability either loop role needs must be granted ahead of time: relax it with hitl: false in constitution/engine-capabilities.json, or drop it from your copy of the role in constitution/Agents/system/. A capability the role does not hold cannot be asked for either — add it to the role’s capabilities: if the round needs it. omnislash doctor names every hitl: true capability the two roles reach under Loop autonomy.
  • A broken analysis or apply closes the cycle failed at once — no Retry / Skip card, and no lesson (nothing was proposed or measured). A notice says what broke; an idea’s author is still answered. failed never counts toward the circuit breaker; what bounds a brain whose analyses keep breaking is maxAnalysesPerDay.
  • A refinement never happens (nobody replies), and a proposal with nothing to apply still skips, as a person would skip it.

A round a person accepted — decide: human, a tripped breaker, or a standing loop:auto_accept grant — keeps all four waits.

Choosing who answers the gates — autonomy

The loop has two gates a policy staffs: the Accept card (decide) and the PR merge. loop.autonomy declares who answers at each; every other place a person can be asked follows from those two (Where the loop waits for a person). It is a policy, never a different loop — the pipeline (analyze → decide → apply → await-merge → validate → revert) is identical at every level, and so is everything the loop may not touch. The engine default is human at both gates, so declaring nothing changes nothing.

loop:
  autonomy:
    decide: human | auto        # who accepts a proposal (default: human)
    merge: human | auto         # who merges the apply PR (default: human)
    mergeMethod: squash         # squash | rebase | merge; read only when merge: auto
    maxConsecutiveRegressions: 2  # circuit breaker; null disables it
    onInconclusive: human       # human | ship | close — a pre-merge check that could not judge
    onUnmeasured: pause         # pause | continue — an auto-merged change nothing could measure
    requireCalibration: false   # true — both gates a person's until the judge is calibrated against people

The four combinations are tabled in Pick who answers.

The block merges wholesale per tier: write autonomy: anywhere and you own every key — the ones you leave out fill from the engine defaults, never from the tier below. Half an autonomy policy merged with somebody else’s other half is a policy nobody wrote.

What each gate means when it goes auto:

  • decide: auto — proposals are accepted without a card, with a notice instead, and the round’s proposer and implementer run unattended — see A round nobody stands on.
  • merge: auto — once the apply PR is open and the pre-merge check (where the cycle has one) has passed, the engine arms GitHub-native auto-merge on it — never before the check has answered. What happens to a candidate the check could not judge (too few viable pairs, an unhealthy judge, a comparator that could not say) is onInconclusive: human (default) demotes that round to the human merge gate with a notice; ship lets the declared gate stand — the loop posts omnislash/validation: success “by policy” so an armed merge can land, and the cycle history says merged without proof by policy; close closes the PR and settles the cycle unvalidated — nothing unproven ships. The PR stays, at every autonomy level: your repo’s required checks remain a gate, the merge commit keeps the mechanical revert working, and the PR stays the audit record. Direct-push to main does not exist, and neither does a merge the engine performs itself: the only way a change lands on its own is GitHub honouring an arm. So a repository that refuses the arm — its auto-merge setting is off, its base branch requires no status check at all (GitHub will not hold an auto-merge on a PR nothing gates), or it forbids your mergeMethod — demotes that round to the human merge gate with an auto-merge unavailable: <reason> notice: the PR stays open for you to merge, and nothing is ever merged with a method nobody chose. The same happens when a person disables auto-merge on the PR after the arm, or pushes a commit past the one the loop vouched for — the one the check measured, or the one the apply opened the PR on where the cycle has no check: the wait carries on under the human gate, and the notice says so. omnislash doctor names a base branch that would refuse every arm under GitHub merge gate (below). A PR still unmerged when watch.deadlineHours runs out settles unvalidated as ever, and its auto-merge is disarmed first — an abandoned change can never land unmeasured later just because a stuck check eventually greens.
  • A change that shipped on its own and could not be measured demotes both gates. Under merge: auto a change whose watch measured nothing — its verdict strength is blind: the window closed short with nothing counted, the judge was sick, watch.mode: none, a loop module that would not load — means the loop put a change into production with nobody and nothing having looked at it. An unvalidated the watch DID measure (the evolve interval straddling the bar, your compare returning perMetric) is not blind: it stays shipped, on record as unproven, and pauses nothing. That is the failure the circuit breaker exists for — and so is a watch another change confounded (a lock-group sibling’s, or an edit of the entity’s files no cycle shipped), whatever its comparator read — so the entity is paused on the spot, whatever maxConsecutiveRegressions says: the pause is announced at that settle and both gates stay human until someone re-arms it (the entity’s page or loop_rearm), exactly as after a regression streak. A brain that accepts that risk declares onUnmeasured: continue: autonomy carries on, the settle stays on record (the cycle history says so), and the streak ceiling still applies.
  • requireCalibration: true holds both gates until people vouch for the judge — see Calibration. Like the breaker it holds the config and the grant paths alike, and says so on the round (⏸ Autonomy … waits on calibration); unlike it, nothing needs to be re-armed: the hold lifts on its own once every judged human: true metric is ok.
  • Revert inherits merge — no separate knob. merge: auto means revert PRs auto-merge too: autonomy without self-repair is autonomy that rots silently. merge: human keeps reverts human-merged, as ever. A revert PR GitHub will not arm is a person’s to merge, and its round records so (revert_merge_gate).

The circuit breaker is what makes auto safe to leave unattended, and what it produces is a pause. maxConsecutiveRegressions (default 2) consecutive regressed verdicts on an entity — or, as above, a single change that shipped unattended and could not be measured — demote both of its gates back to human. The entity is then paused: it answers to a person at both gates until someone re-arms it — Re-arm on the entity’s dashboard page, or loop_rearm, and nothing else lifts it. Without that, “autonomous” degenerates into “merges regressions in a loop with nobody watching”.

You are told once, at the settle that crosses. The moment a settle takes the streak up to the ceiling — or ships a change nobody could measure — the loop announces the pause (loop:paused on its event stream, and a notice where your notices go). The regressed settles after that one announce nothing further: they are already paused, and an alarm that repeats on every subsequent failure is an alarm its reader learns to ignore. What does repeat is a reminder on the next proposal’s card, so whoever is looking at that card knows this round is theirs to answer because the entity is paused.

Two consequences of the shape. null switches off the streak ceiling only — a change that shipped through an autonomous merge gate and could not be measured pauses the entity whatever the ceiling says, because a loop that would ship the next one the same way is exactly the failure the breaker exists for. And the pause never blocks a revert: it stops the next change from shipping unattended, not the undo of the one that already did.

There is no half-open state. The pause does not expire on a timer, does not probe with one cautious round, and does not lift itself after a quiet spell — a re-arm acknowledging the settle is the only way out, and that is the whole mechanism today. A pause that lifted itself would be able to lift while the thing it was protecting you from is still true.

The streak is derived from the entity’s own cycle history rather than stored — validated resets it, and an unvalidated that did not ship unattended neither counts nor resets it (a PR that sat unmerged past its deadline proves nothing about the change), so there is no autonomy state to keep in sync with anything. Set null to disable the ceiling; omnislash doctor names an unattended entity that does under Loop autonomy, along with any auto/auto entity whose verdict compares nothing beyond quality — autonomy judged by a single scalar is blind, so declare metrics first — any merge: auto entity with both onInconclusive: ship and onUnmeasured: continue (nothing stops on “we don’t know” any more), and, once any entity declares decide: auto, each hitl: true capability the proposer or implementer role reaches (an unattended turn cannot ask for it).

Autonomy is itself part of the protected measurement surface: autonomy lives inside loop:, whose frontmatter and manifest blocks the loop may not touch — the implementer can never grant itself autonomy (see What the loop may not touch).

The standing loop:auto_accept grant is unchanged and layered over a human config: it is the temporary, scoped delegation (admin-issued, entity- scoped, maxCalls-bounded, TTL’d, revocable) where decide: auto is standing policy. The breaker vetoes both.

How the engine notices the PR landed

Whichever gate merges it, the engine has to find out. Two lanes, and they run together:

  • The PR’s own events, where the deploy has a registered GitHub App. The merge wait subscribes to the PR (a resource watch on its github:pr: key, the same one an agent uses — features.md § Events System), so a close GitHub posts to the engine’s receiver rings the cycle within seconds (after a short debounce) and the loop moves on to collecting challengers. An event only makes the wait read the PR at once — the read decides. A person disabling auto-merge on the PR wakes it the same way, which turns an armed wait into a human one.
  • A poll of the PR, always. It starts at two minutes and backs off to six hours, and it is what covers a deploy running on a personal access token rather than an App, a webhook delivery GitHub drops, and the deadline re-check that settles a PR nobody ever merged. Under an armed auto-merge it also reads whether the arm still stands, so a disarm nobody reported is noticed within one interval.

Nothing to configure either way — the difference is only how fast the wait ends.

What your repository must require. The engine publishes two commit statuses on every apply PR: omnislash/diff-gate (success, set on the pushed commit just before the PR opens — the diff was checked against the proposal’s allow-list before the commit) and omnislash/validation (the pre-merge check’s verdict, where the cycle has one). A revert PR carries both as success from the moment it opens — it is the loop’s own undo of a change one of them measured, and a base branch that requires them has to be able to take it, auto-merged or merged by hand. They gate nothing on their own: a base branch that requires neither lets anyone merge a candidate the check found worse, and refuses to hold an auto-merge at all. Add a ruleset (or classic protection) on your base branch requiring both, and omnislash doctor will confirm it under GitHub merge gate, along with whether the credential the loop publishes under may post a status at all. The check asks GitHub rather than reading the token: it posts a status on a commit that cannot exist, under the loop’s own credential, on every lane — GitHub answers 422 when the credential may post and 403 when it may not, and writes nothing either way. statuses: write is part of the App floor, so the App lane needs only an installation that accepted it; an ssh BRAIN_GIT_REMOTE gets no installation token, so there the loop posts, and the check probes, under GH_TOKEN (or GITHUB_TOKEN) — a fine-grained PAT needs Commit statuses: Read and write, a classic one repo. A credential GitHub refuses outranks every other finding of the check, because the loop checks the same thing before an apply round and fails the round on it — before the implementer’s turn, with the credential, the repository, the permission and the fix on the round’s Retry card. A status post GitHub refuses later (the permission revoked mid-cycle) fails its step at once instead of after its retries; a 403 that is a rate limit is retried like any other blip.

Auto-merge must be allowed on the repository. merge: auto arms GitHub-native auto-merge, which a repository with Settings → General → Pull Requests → “Allow auto-merge” off refuses on every PR — every autonomous merge gate then demotes to a person. The same doctor check reads that setting (allow_auto_merge) and warns when it is off while some entity declares merge: auto; GitHub shows the setting only to a credential with push or admin access, and an undisclosed one is a hint rather than a warning.

GitHub offers branch rules — and auto-merge — on a private repository only from GitHub Pro (personal account) or Team (organization) up. On GitHub Free a private brain repository cannot require either status: the loop still runs and publishes both, but every merge: auto gate demotes to a person, and the check says so by name. Make the repository public or move it to a plan with branch rules. With no entity in the loop (loop.enabled off everywhere) nothing opens a PR for a rule to hold, so the check passes without asking GitHub. Run on your laptop, omnislash doctor reads the branch rules with any token it finds (the brain’s .env) and leaves the question of the deployment’s own credential to the doctor inside the deploy — the dashboard’s Doctor page or omnislash doctor in a container.

If your GitHub App was registered before the engine subscribed to pull_request, it is subscribed to nothing. Open the App’s page on GitHub (Settings → Developer settings → GitHub Apps → your App → Permissions & events), tick Pull request under Subscribe to events, and save. Check the webhook URL on the same page while you are there — it must point at /github/webhook on the deploy’s webhooks origin, and it must be Active. Until then every cycle waits on the poll, which costs latency, not correctness.

An App imported rather than registered needs its webhook secret pasted too. The import escape hatch takes it as an optional field, and an App recorded without one leaves the receiver with nothing to verify against — it answers every signed delivery 401. The symptom is the same silent one: the merge wait never leaves the poll lane.

One declaration to reconsider under merge: auto: mustImprove on your objective metric. It is off by default because holding an inert change to a higher bar than “it broke nothing” would fight the human who chose to merge it — a rationale that stops applying when no human did. A fully autonomous entity likely wants

evaluation:
  metrics:
    quality: { role: objective, mustImprove: true }

so a change that moved nothing settles unvalidated — reported, and never quietly counted as a win. It sits on the metric rather than on the loop because “must this number move?” is a question about that number: an entity can demand movement on its objective while its guards are still only asked not to break. The noise band it is measured against is read off the spread of the per-input movements across the check’s pairs, so give it inputs to read: below three compared pairs the band is the metric’s own tolerance.

Full autonomy: checklist

A loop that asks nobody anything, ever. Every line is a place where the default would otherwise stop and wait for a person; omnislash doctor checks the ones it can read (named in brackets).

The two gates

  • loop.autonomy: { decide: auto, merge: auto } — written as one block: autonomy merges wholesale, so the keys you leave out are the engine’s, not the manifest’s (Loop autonomy names a partial block).
  • autonomy.onInconclusive — what a pre-merge check that could not judge does: human (default) waits for a person; ship lets the arm land (“merged without proof by policy”); close closes the PR.
  • autonomy.onUnmeasured — what a change that shipped on its own and nothing measured does: pause (default) hands both gates back until someone re-arms (dashboard or loop_rearm); continue carries on. Doctor warns on onInconclusive: ship together with onUnmeasured: continue — nothing then stops on “we don’t know”.
  • watch.onInconclusive — keep (default) leaves an unproven change shipped; revert keeps only what was shown to work.
  • autonomy.maxConsecutiveRegressions — keep a number (default 2); null removes the streak ceiling (Loop autonomy warns on an unattended entity).
  • autonomy.requireCalibration — true keeps both gates a person’s until the judge agrees with people on every judged human: true metric (no effect without one).
  • Metrics beyond quality, and mustImprove: true on the objective — an inert change then settles unvalidated rather than counting as a win (Loop autonomy names an auto/auto entity judged by quality alone).

Nobody to ask

  • evaluation.human.ask: never (the default) — no rating cards.
  • Grants for the loop’s roles: under decide: auto a grant request from the proposer or implementer fails the turn. Relax each hitl: true capability they reach in constitution/engine-capabilities.json, or drop it from your copy of loop/role-proposer.md / loop/role-implementer.md (Loop autonomy lists them).
  • The entity’s own grants: a pre-merge replay may not ask anybody either, so a workflow whose plan needs a person’s grant gets no replay check (check.mode derives none). If a standing grant covers it (a grants: block, contract.md § 7.1), declare check.mode: replay to get the check back.
  • Supervising from code: loop_hold is loop:hold, which asks nobody. loop_release, loop_rearm and loop_ack_failure are loop:supervise (hitl: true), so a pipeline that holds and releases the loop needs a grants: block on its scenario for the release only — or loop.supervise relaxed in constitution/engine-capabilities.json.
  • loop:autonomy is how an agent widens a gate (loop_widen). One approved grant card mints a grant with no call limit, scoped to the context that asked (contract.md § 7.1): 24 hours for that one agent or scenario, 30 days for a heartbeat (every future tick), 30 days for a hook (every future fire), and 30 days for the whole principal when a bare MCP session asks. Every further widening inside that scope and lifetime goes through unasked — set a call limit and a shorter lifetime on the card (its custom flow: maxCalls, ttlHours) to keep each widening asked. It is not admin-only, so a grants: block can pre-authorize it, and doctor’s Declared grants does not flag that.
  • loop:configure (loop_join, loop_set) asks nobody by default, so any agent that reaches those tools uses it — over MCP, every CLI agent. It cannot widen autonomy, but it can spend: budget, analyses a day, sampling. Set hitl: true on loop.configure in constitution/engine-capabilities.json to put a person on joins and spend changes.
  • A proposer pipeline, if you use one, cannot ask anyone: it answers null or fails.

GitHub (GitHub merge gate)

  • Allow auto-merge on the repository (Settings → General → Pull Requests) — off, every arm is refused and the round falls to a person.
  • A ruleset (or classic protection) on the base branch requiring omnislash/diff-gate and omnislash/validation — GitHub holds no auto-merge on a PR nothing gates.
  • A plan with branch rules for a private repository (Pro / Team).
  • The credential may post commit statuses — statuses: write accepted by the App installation, or Commit statuses: Read and write (fine-grained) / repo (classic) on GH_TOKEN; the doctor probes it.
  • The GitHub App subscribed to Pull request events, webhook active — otherwise the merge wait falls back to polling (latency, not correctness).

Bounds that stand in for a person

  • evaluation.sample.maxPerDay on every unattended entity (Eval ceilings), loop.maxAnalysesPerDay not null (Loop ceilings), and loop.budget.maxUsdPerRun on a CLI-harness implementer if a run’s spend must be capped.
  • watch.deadlineHours long enough for watch.windowRuns graded runs at the entity’s cadence plus gradeAfterHours (Loop watch).

Under full autonomy the only things that still reach a person are notices, the breaker’s pause (until Re-arm), and a failure the dashboard lists under Loop failures until dismissed.

Lock groups — one variable at a time across entities

Some entities share an outcome. A chain of pipelines — idea writes a strategy, layout lays it out, publish posts it — is judged by the views of the videos it makes, and a change to any link moves every link’s number. Two such changes live at once can be told apart by nobody: each entity’s watch would read a window both changes shaped. A lock group is how you say which entities share an outcome, and the loop then keeps them to one live change at a time.

# constitution/MANIFEST.yaml — the groups themselves, declared here only
loop:
  groups:
    audience: { lock: true, repair: bypass }   # engine terms: lock true, repair bypass
  # group: [audience]                          # optional brain-wide membership

# features/freelancer/layout/layout.scenario.md — and idea, production, publish
loop:
  group: [audience]        # [] leaves every group the brain put the entity in
  • loop.groups — MANIFEST only; no groups by default. An entity cannot declare or redefine a group, because a group is a statement about several entities. Ids are lowercase letters, digits, _ and -.
  • loop.group — the groups an entity joins, at any tier (MANIFEST for every entity, the entity’s own block to override it); the nearest tier that wrote the list owns it whole, and [] leaves. A name MANIFEST does not declare joins nothing — omnislash doctor (Loop groups) fails it, and warns about a group fewer than two entities in the loop join.

What lock: true holds. While any member has a change past its Accept and not yet settled (being applied, its PR open, being watched) — or a regressed change whose revert is still awaited — no other member’s cycle opens, and none is accepted:

  • the producer refuses early — the grade, the scheduled tick, “Analyze now” and a queued idea alike — with the reason naming the change that holds the group (“lock group audience is held: a change to pipeline layout is still watching its merged change (…/pull/7)”); ideas stay queued and open once the group is free;
  • an Accept is the arbiter: it takes each group’s database lock, checks for a holder and takes the entity in one transaction, so two members accepted at the same instant cannot both win. A proposal that loses is set aside skipped, the reason naming the holder; under decide: auto it is set aside at once rather than waiting on a card. Nothing was applied, so an idea the cycle was opened on was never tested: it goes back to the queue, at its old place, and opens the entity’s next cycle once the group is free.

A proposal merely waiting on you holds nothing, exactly as for one entity. A hold on a group (loop_hold with { kind: "group", group }) stands on every member the same way a hold on the entity does.

repair: bypass | wait (default bypass) — what a member under preset: repair does while the group is busy. A failing entity is usually worth fixing now, not after a sibling’s week-long watch, so by default its cycle opens and ships anyway. The price is paid by the holder, below. wait holds repair cycles like every other.

lock: false keeps the group as a statement of shared outcome only: nothing waits, and a member whose watch saw another member’s change land closes confounded.

Confounded. When a member’s watch settles, the engine asks whether another member of its groups shipped a change, or had one reverted, after this cycle’s baseline window ended — a sibling that bypassed the lock, one in a group that does not lock, or one that shipped while this proposal waited for its Accept. If so the window “after” carries that change and the window “before” does not, so the comparison says nothing about either: the cycle settles unvalidated, confounded, naming the other change. The comparator’s reading is kept on the record (what it measured is still worth seeing) but never acted on — a validated is not credited, a regressed is not reverted, and watch.onInconclusive: revert does not undo it. The autonomy breaker counts a confounded change that merged through merge: auto as blind, so autonomy.onUnmeasured decides what happens next (pause by default). The holder writes this on its own row; nobody writes a sibling’s.

Edits nobody’s loop made confound too. A watch reads one more thing at every tick, group or no group: what landed on the brain’s branch since the baseline window ended, over the entity’s own files and every file its change landed. A commit there that is not part of this cycle’s own merge — a person’s PR, a lead’s fix, an agent’s edit that brain-sync committed, another entity’s loop change to a shared skill or role — closes the watch confounded exactly as a sibling’s change does, naming the commit (sha, author, subject, files; on the cycle as confounded_by_edit). “Landed” is git reachability, not dates: a PR written last week and merged during the watch counts, and a person’s git pull merge that buries the loop’s own commit does not blame the loop’s change on itself. A commit that changed nothing but the entity’s loop: settings block (most of what loop_set, loop_widen and loop_join write) does not confound — the loop’s settings steer the loop, not the entity; evaluation: and enabled: do, whichever tool wrote them. Only files are seen: a model swap in models.json, an env change or the outside world still move the numbers unseen. Editing an entity by hand while its watch runs therefore costs that cycle its verdict, not its change. If you mean to tune it yourself, wait for the watch to settle (loop_status shows it) — loop_hold stops new cycles, not one already watching.

A clean base. A member’s next window starts no earlier than the last merge or revert-merge of any member of its groups (its own included): the window “before” never carries a sibling’s change the window “after” is compared to. It moves since — what preset: evolve and your own window() read — and ctx.groupChangedAt names the instant itself: repair’s trigger, which counts the last trigger.windowRuns graded runs whatever since says, counts only runs graded after it, so a sibling’s change (or its revert) is never answered by a repair of this entity for failures produced before it.

Shared files. A member’s change may write the files shared between the group’s members — *.agent.md roles, skills/<name>/, constitution/Planner/ — only where the member lists them in its own editablePaths; a new file or a new skill stays the proposal’s to create. And what a member lists in its editablePaths is closed to the change of every entity that shares no group with it: an entity outside the group is not held by its lock, and a member’s watch could not tell its change landed. A fellow member is not walled off — the lock already keeps their changes apart. See What the loop may not touch.

The honest limit. The lock removes simultaneous changes, not overlap by lineage: a video made a week ago still carries the old strategy under a new layout. A brain that needs more filters its window() by the versions a run’s ancestors ran under.

The entity page shows each group with who holds it (the change, what it is doing, until when), who else is in it and what waits, and a banner while a sibling holds a group the entity waits on; a confounded cycle names the other change.

What the loop may not touch

The implementer works under an allow-list, and the allow-list is the accepted proposal: the files it names in target, plus skills/<name>/** for every skill it proposes. Everything else is closed. An agent that edits its own grading criteria improves its score without improving anything, and an agent with a free hand edits what is convenient rather than what was accepted — so the default is “only what the proposal says”, and the boundary is applied before the write, not audited afterwards.

One resolved surface — three path lists and a size bound — rendered in three places:

ListWhat it holdsWho sets it
editableThe proposal’s target files, skills/<newSkill>/**, and the entity’s loop.editablePaths. New files are allowed only under these.The proposal, plus you, per entity — editablePaths in the entity’s frontmatter, globs relative to the brain root. Nothing brain-wide, deliberately: a brain-wide widening would hand every entity’s implementer the same extra files.
deniedThe engine floor (below), every entry of your memory.sources, loop.protectedPaths from constitution/MANIFEST.yaml, the files that define every other entity in the loop — its *.scenario.md / *.heartbeat.md and a pipeline’s <id>.pipeline.ts — every rule another lock-group member lists in its own editablePaths (below), and the *.pipeline.ts of every pipeline an entity names as its loop.proposer.pipeline, the entity’s own included: the loop’s authors never edit themselves, as the proposer never edits constitution/Loop/. Beats editable: an editablePaths rule under a denied path widens nothing.The engine, plus you, brain-wide — protectedPaths only ever adds; no tier can remove a floor entry, and the entity tier does not exist. Another entity’s files are an engine rail: a change proposed for one entity, and measured on it alone, never rewrites another.
unreadableconstitution/Evaluation/** and every *.loop.ts — the judge’s files, closed to reading as well as writing. The proposer is not handed the rubric it is optimized against; the per-criterion feedback on the evaluation is what it works from. A wall on files: the proposer’s db:read reaches the judge’s own recorded turns, prompt included — drop that grant from your loop/role-proposer.md if the rubric must stay out of its reach.The engine.

Files a lock group shares. For an entity in a lock group, an existing file of a kind the group’s members share — a *.agent.md role, anything under skills/, constitution/Planner/ — is not made editable by the proposal naming it: only the entity’s own editablePaths opens it (list the prompts the entity owns there). A new file or a new skill stays the proposal’s to create. An entity in no group writes them as before — except what a member claims: every rule a lock-group member lists in its editablePaths is denied to the change of every entity that shares no group with the claimant, member of another group or of none. A fellow member is not walled off: the group’s lock already keeps the two changes apart, and the shared kinds above still open to it only through its own editablePaths. Otherwise an entity the lock does not hold could ship an edit to layout’s features/freelancer/layout/layout-writer.agent.md while layout’s own change is watched, and layout’s watch would settle it clean.

And how much, not only where. The three lists bound where a change may land; loop.maxChangedLines bounds how much. Every optimizer that rewrites the same artifact each cycle grows it without a bound — GEPA’s prompts grew 75% and got worse until a length cap, and the end state has a name, “context collapse” — and a loop rewrites the same files on a cadence by design. The gate counts what it read off the tree, never what the agent reported: added plus removed lines of a real line diff, a file created or deleted counting its whole length, a binary file counting nothing. Engine default 600, and you may write it brain-wide in constitution/MANIFEST.yaml or per entity in its own frontmatter — the nearer tier wins. Past the bound the apply fails, naming the count, the bound and the three largest files on the Retry card; there is no truncated PR and no partial landing, because a bound that warns is not a bound. The apply prompt states the number, so the agent plans inside it and says in its summary which suggestion it left for a later round. And because the key lives inside the loop: block the gate compares before and after, an implementer cannot raise its own bound.

The engine floor — closed to every cycle, whatever the tiers say:

Off-limitsWhy it is listed separately
constitution/Evaluation/**, constitution/Loop/**The grading methods and these prompts.
constitution/Agents/system/**The engine’s system roles and the fragments every role’s prompt is built from. Among them role-evaluator.md — the judge’s system prompt, pinned with the judge — and loop/role-proposer.md / loop/role-implementer.md, whose grants the next cycle’s proposer and implementer run under. A proposal about one entity has no business in files every agent of the brain runs on.
any *.loop.tsYour measurement code sits beside the entity it measures, so no directory prefix would find it. Adding one counts too — a new module could define its own verdict. The module file only: a helper it imports is yours to list in protectedPaths.
any *.heartbeat.tsA heartbeat’s gate decides which of its ticks run at all — the ticks its grades are read from. A gate that says no to the ticks that would grade badly would “improve” the heartbeat by making it not run. A hook’s gate (*.hook.ts) stays editable: no hook is measured by the loop. Write-denied only — the proposer may read a gate, and the heartbeat’s definition it is handed names it (- gate: <path>).
constitution/models.jsonThe judge’s model and every role’s model, the loop’s own agents’ included — re-aiming the judge is the quietest way to change a grade.
omnislash.config.tsdefineProject, where the brain-wide verdict and shouldOpen rules live.
memory/ and every memory.sources entry you declaredMemory has one writer — the memory tools: a dated deposit, a bounded MEMORY.md, never an LLM rewriting it. A PR edit there would bypass all of that, reach every agent that recalls the line or the source (an instruction planted in MEMORY.md acts on all of them), and a revert of the PR would roll the memory back with it. Closed to writing only — the proposer may still read memory as evidence.
db/, .github/, docker/, Dockerfile, Dockerfile.override, .dockerignoreThe deploy perimeter: migrations, CI, the compose override, the image build. The same list brainSync.additional refuses as a G-path.
.claude/, .codex/, .mcp.json, opencode.json — at any depthThe CLI agents’ own project config: hooks and MCP servers are commands the harness runs on the next spawn — the same class as .github/. Only the loop is denied these; hot-syncing .claude / .codex through brainSync.additional stays legal.
.git/The engine reads the agent’s tree with git status after the turn, and in dev that tree is also the live checkout it lands from; a planted hook or a rewritten .git/config (url.*.insteadOf, credential.helper) would act with the engine’s environment, and git status never lists .git/. The gate compares the checkout’s .git/config and HEAD before and after the turn besides — a move there fails the apply. The agent never runs git, so nothing is lost.
skills/engine/**The engine-skill mirror omnislash upgrade owns. Engine skills are framework content every agent that lists them runs on — the state module points every state agent at one (agent-state).
a brain skill that shadows an engine skill by nameA skill’s identity is its declared name, and a brain skill of an engine skill’s name wins discovery wherever it lives, so no path rule can catch it. The gate names every skill directory skills/<dir>/ the change touches by its head-side SKILL.md (the declared name, the directory name when none is declared) and fails every changed file in a directory an engine skill’s name claims — a new shadow, a rename into one, and an edit to ANY file of an existing override (SKILL.md, references/, scripts/) alike. Deleting an override’s SKILL.md is allowed: the engine copy wins again. Your own overrides are untouched: only the loop is held to this.
the evaluation: / loop: / chat: / budget: / gate: blocks of an entity fileThe implementer edits these files constantly and legitimately — but your objective, its target, its tolerances and its autonomy gates live in that frontmatter, a chat role’s chat: decides what one graded episode is, budget: is the money ceiling the entity’s own agents run under (budgets.md) — a loop that could raise it would spend past the limit it is held to — and a heartbeat’s gate: holds its gate’s timeout: timeoutMs: 1 would fail every tick before its agent exists, so nothing would be graded. It may rewrite the body and the rest of the frontmatter; those blocks are compared before and after. autonomy living inside loop: is what makes “the implementer grants itself autonomy” structurally impossible.
the evaluation: / loop: / chat: / budget: sections of constitution/MANIFEST.yamlSame rule, brain-wide.

Where each layer holds:

  1. The harness, before the write. The implementer role is file:read + file:write and nothing else — no shell, no web, no git — and the engine compiles that grant together with the surface into the CLI harness’s own permissions: an edit outside editable or under denied is refused, a read under unreadable is refused, and so is any read outside the checkout — the same brain sits in other copies on the host. (Claude enforces this natively, per path. Codex only as a writable root: its paths and its read wall hold through the sandbox and the diff gate. The OpenCode harness enforces nothing, and omnislash doctor fails an implementer role on it.)
  2. The sandbox, when isolation.enabled. The implementer runs in a bwrap jail where every denied path is mounted read-only, every unreadable path is masked, and the other copies of the brain on the host (the baseline, the live root, sibling checkouts) are hidden — a wall the agent’s tools cannot see over, whatever the harness lets through. With isolation off, omnislash doctor warns that the write-side protection is the harness and the gate alone (and, for a Codex implementer role, that the judge’s files are then readable from its shell).
  3. The diff gate, before the commit. After the turn the engine reads git status — against a snapshot taken before the agent started, every dirty path with a digest of its content, so pre-existing dirt in a live checkout is never the agent’s while a rewrite of an already-dirty file still is — and every changed file must sit under editable, outside denied and unreadable (the floor re-resolved at the gate and added to what the turn started under, so an apply that waited out an upgrade is still held to every path the floor gained meanwhile), be a regular file (a symlink at a permitted path would land whatever it points at, and is refused instead), and keep its evaluation: / loop: / chat: / budget: blocks byte-identical — and, as ONE change, fit loop.maxChangedLines. That count has no exemptions: a binary file is refused rather than counted, and the two sides are compared as git stored them, because the implementer writes the bytes the gate reads and any “content shaped like X costs nothing” rule would be a switch it holds. In dev, where the checkout is the live one the engine lands from, its .git/config and HEAD must not have moved either. Any violation fails the apply with the offending paths on the Retry card; nothing is committed and no PR is opened. The gate reads the tree once: every later read — the measurement check, the copy into the landing clone — is held to the digest the gate listed the file under, so a write to the tree after the gate (an editor open on a live dev checkout) fails the apply instead of landing bytes the gate never saw.

The implementer never runs git. It edits files in its own checkout and reports { summary, files }; the engine copies exactly the gated files — each one checked against the gate’s digest once copied — into a throwaway clone of its own repository — the brain baseline, or in dev the live checkout — commits them on a branch loop/<entity>-<hex> cut from the exact commit of your default branch the gate read (the remote tip at the gate: if the branch moves before the landing, the PR is still cut from what was gated, so its diff is the judged one; a commit a history rewrite dropped from the branch fails the apply instead), pushes it under the deploy’s own credential, and opens the PR itself. A path that sits under a link your base tracks (features/x -> /etc, then features/x/passwd) is refused rather than written — or deleted — through it. The one git command that runs in the agent’s tree is git status. A proposal names files, never rules: a target with a glob (**) or a .. segment is refused where the proposer’s output is parsed, so one accepted file cannot become a grant for the tree. No git credential and no gh token ever reach the agent: nothing is minted for a confined turn, the jail keeps ~/.ssh masked, and the harness strips whatever credential the engine’s own environment carries before the CLI starts. The grant itself is yours: the loop runs its roles with whatever their RESOLVED grant (the role file unioned with role-general.md) says. omnislash doctor (Loop confinement) warns when either role resolves to '*' or a shell — a shell is a subprocess the surface cannot see, and the proposer works in your LIVE brain, where it would also read constitution/Evaluation/** past the read wall. A brain copy of loop/role-proposer.md / loop/role-implementer.md without its capabilities: block is the usual way that happens by accident.

# constitution/MANIFEST.yaml — brain-wide, ADDS to the floor
loop:
  protectedPaths:
    - integrations/          # everything under it
    - tools/billing.ts       # one file
    - features/daily-brief/db.ts        # helpers a *.loop.ts imports — the floor
    - features/viral-shorts/tiktok.ts   # closes only the module itself
  maxChangedLines: 400       # tighter than the engine's 600, brain-wide

# an entity's frontmatter — widens the allow-list for THIS entity only
loop:
  editablePaths:
    - features/daily-brief/**
  maxChangedLines: 1200      # this entity's files are large; the whole change, added + removed

If a criterion, a target or a tolerance is genuinely wrong, the proposer is told to say so in its summary so you can change it yourself.

Chat roles — a run is an episode

A person’s direct chat with one of your roles (a *.agent.md a bot or a Secretary Mode contact is routed to, or a chat opened on it from the dashboard) is graded by the episode, not by the message: the consecutive turns of that role with that one person, from the first message to the end of the dialog. An escalation, “resolved”, “left angry” are properties of a dialog, so the judge reads the episode whole once it has ended. Group chats and the engine’s own system roles have no episodes.

Every turn is put into its chat’s episode as it is dispatched, whichever surface it came from. An episode ends one of three ways:

Howclose_reason
No new turn for chat.episode.idleHoursidle
The role’s agent calls agent_close_episode, or your code calls closeChatEpisode(runId)closed
The role changed under chat.onRoleChange: reset (or the chat now runs a different role)role-change

The next message after any of them opens a new episode. The grading of the episode that ended starts from its end — gradeAfterHours counts from the close, exactly as it counts from a workflow’s terminal.

Where a dialog ends — chat.episode.idleHours

# constitution/MANIFEST.yaml — every chat role of the brain
chat:
  episode:
    idleHours: 4
# features/support-chat/support.agent.md — this role only
chat:
  episode:
    idleHours: 0.5     # a support desk: half an hour of silence ends the dialog

Engine default 24: a counterpart who answers the next day is still in the same dialog, one who comes back after a full day of silence starts a new one. The pause is also the lag before an idle episode is graded, so shorten it only for a chat whose counterpart answers within minutes. Fractional hours are allowed; each key is nearest-tier-wins (role over manifest over engine).

The pause counts from the role’s REPLY, not from the person’s message, and it never runs while the role is still busy — a long turn, or a turn waiting on you (a grant or a question the role asked the owner): that silence is not the counterpart’s, so the episode stays open until the turn is over. The chat: block is part of how the role is measured — it decides what one episode is — so the loop never edits it, and editing it is not a new version of the role (see A change to the role).

When the dialog is over before the pause — agent_close_episode

Give the role the engine tool when it can tell the dialog is over — the issue is resolved, the customer said goodbye:

# features/support-chat/support.agent.md
capabilities:
  - episode:write     # agent_close_episode — hitl: false, it is called mid-reply

Called with no arguments, it ends the episode of the turn it runs in. An agent may name only itself ({ id } = its own agent id) — an id that reached it through a message or a tool’s output cannot make it end somebody else’s dialog; a person (the dashboard, an MCP session) closes any agent’s open episode by its id. Closing an episode by its run id is your code’s, below.

Your own code closes an episode with the same call the tool makes, by the run id your tool reads from context.auth.runRef. The escalation tool of a support chat records its signal on the episode and ends it:

// tools/escalate.ts — registered through `defineProject({ tools })`
import { createTool, success } from '@omnislash-ai/config/public';
import { closeChatEpisode } from '@omnislash-ai/infra/chat-episodes';
import { z } from 'zod';

export const escalate = createTool({
  name: 'escalate_to_human',
  description: 'Hand the customer to a person and end this dialog.',
  schema: z.object({ reason: z.string() }),
  risk: 'moderate',
  providesCapabilities: ['support:escalate'],
  label: 'Escalate',
  category: 'Support',
  async execute({ reason }, context) {
    const run = context.auth.runRef;           // { runKind: 'chat', runId } in a chat turn
    await notifySupportTeam(reason);           // your hand-off
    if (run?.runKind === 'chat') {
      await db.insert(escalations).values({ episodeId: run.runId, reason });
      await closeChatEpisode(run.runId);       // the dialog is over
    }
    return success('A person will take it from here.');
  },
});

The row in escalations is YOUR record of the episode, joined on its run id the way every late metric is (see Recording lineage): a role’s measure(run) turns it into an escalated metric of the episode, and the share of escalated episodes is that metric with aggregate: mean, and it compares like any other. Do not make it the role’s only objective: an agent optimized to escalate less learns not to escalate — including the customers who needed a person. Keep it a guard beside a judged objective (“the customer’s issue is resolved, or honestly handed to a person”), or an objective beside a judged guard; which is yours to choose.

closeChatEpisode takes effect at once and answers the row it closed (null when the episode had already ended). Called in a turn of that chat, the episode’s grading is queued when the turn ends; called from anywhere else — a pipeline, a hook — it is queued when the episode’s idle pause would have run out.

A change to the role mid-dialog — chat.onRoleChange

The role’s text reaches every turn, so a change to it (by you, or by the loop’s merged change) takes effect on the next message without touching the person’s session. Every turn records the version of the role it ran under — the hash of what the role DOES: its body, its frontmatter without the evaluation: / loop: / chat: / budget: blocks, and the SKILL.md of every skill it names. A rubric edit, a sampling knob or a new idle pause is how the role is measured, not a new version of it: it neither resets a live dialog nor splits an episode. An episode whose turns ran under two versions counts in neither the before nor the after window of a change.

# constitution/MANIFEST.yaml, or a role's own `chat:` block
chat:
  onRoleChange: reset
ValueOn the first message after the role changed
keep (engine default)The dialog goes on in the same session and the same episode — which then holds two versions
resetThe open episode ends role-change, the session starts over (the model no longer sees the dialog so far; the transcript stays in your history), and the message opens a fresh episode under the new version

Resetting a customer’s context mid-conversation is a decision about your customers, which is why the engine does not make it for you.

Putting a chat role in the loop

A role joins the loop the way every entity does — by declaring the two blocks in its own file:

# features/support-chat/support.agent.md
---
name: Support
description: Answers customers of the shop in the chat
skills: [refunds]
capabilities: [episode:write]
evaluation:
  enabled: true
  sample: { rate: 0.5, maxPerDay: 40 }
  requirements:
    - The customer's issue is resolved, or honestly handed to a person
    - The customer is not left angry
  metrics:
    escalated: { kind: computed, direction: min, role: guard, aggregate: mean }
loop:
  enabled: true
---
You answer customers of the shop…
  • A role is a chat entity once its file declares evaluation: or loop: — it is then on the dashboard’s roster, and its role file is walled off from every OTHER entity’s change, as a scenario’s file is. The skills it names stay shared, as a scenario’s do: the role’s loop may change them, and so may the loop of any other entity whose agents load them. A role that declares neither stays an ordinary role — a workflow task’s agent the workflow’s own loop may still edit.
  • <role>.loop.ts next to the role file is its loop module (measure, window, shouldOpen, compare; not cases — a dialog has no input to replay).
  • The engine’s own system roles are not chat entities. Loop blocks in constitution/Agents/system/role-*.md grade and improve nothing, and omnislash doctor (Loop kind support) fails them. To put the bot’s persona in the loop, move it into a feature: copy the system role’s body to features/<feature>/<id>.agent.md, point the surface at it (defineProject({ chief: { role: '<id>' } }), bots[].role, a Secretary Mode contact’s role) and declare the blocks there.
  • sample decides per EPISODE, and the grading of a sampled episode starts from its close (gradeAfterHours counts from there). A chat role owes a sample.maxPerDay like a scheduled entity does (see The ceiling you owe): the people who write to it — strangers, on a public bot — decide how many episodes there are.
  • measure(run) gets the episode: run.runId is the episode’s run id — the one your tools read as context.auth.runRef.runId during its turns — and run.completedAt its close. findChatEpisode(run.runId) from @omnislash-ai/infra/chat-episodes gives its turns, counterpart and how it ended.

What the judge reads — evaluation.context

The judge of an episode reads the whole dialog: every message the person sent, every reply, every tool call the role made in its turns (with a clipped argument and answer), in order — nothing else the agent did outside those turns. The role’s own instructions are not in it: the judge grades the dialog against your requirements and metrics, not the prompt against itself.

# constitution/MANIFEST.yaml, or the role's own `evaluation:` block
evaluation:
  context:
    priorEpisodes: 2     # the last two closed episodes with the same person, as context
    maxTokens: 20000     # the judge's transcript budget
KeyEngine defaultWhat it does
priorEpisodes0Earlier closed episodes of the role with the same person, oldest first, shown under a heading that says they are context and not the run being graded. History costs judge tokens on every grade, so it is opt-in.
maxTokens40000The ceiling on the transcript — by default the proposer’s own evidence ceiling (loop.evidence.maxTokens), so a hundred-turn dialog fits whole beside the judge’s rubric. Over it, earlier episodes go first (oldest first, and the judge is told how many were left out), then the episode itself is cut from the middle — its opening and its ending stay whole — and a marker entry stands where the cut is, saying how much is missing. Never a silent cut.

The transcript is the agent’s own timeline, and compacting the conversation does not take it away: when a long chat’s history is folded into a summary, the turns of its episodes leave the live history (the model sees the summary) but stay readable by the judge, a later episode’s judge and the dashboard.

Each key is nearest-tier-wins. Only a chat episode’s judge reads the block; omnislash doctor (Loop kind support) warns when a scenario or a heartbeat declares it.

Privacy. The dialogs are with real people. One rule says how they are written about, constitution/Loop/chat-privacy.md — describe behaviour, never the person; no quotes, names, contact or account details in a verdict or a proposal: the judge grades under it, and the engine appends it to every chat proposer brief under ## Privacy. The rule binds what they WRITE, not what they read: the proposer’s runs carry each graded episode’s runId, shape (turns, length, how it ended, whether it spans two role versions) and the judge’s verdict, and it reads the dialogs themselves — every message and tool call — from the database with db_query (proposer/chat.md § Reading the dialogs gives it the query). Put your own constitution/Loop/chat-privacy.md in place to change the rule for both; a blank copy leaves both without one. To keep dialogs from the proposer altogether, drop db:read from your copy of loop/role-proposer.md (§ below).

The owner’s rating

A person rates episodes through the same seam as any entity — evaluation.human.ask, engine default never: a fully autonomous brain asks nobody. Turn it on while you set the loop up (always, or until-calibrated against a human: true judged metric): each graded episode’s card carries its shape and a link to the transcript on the dashboard (it needs the dashboard’s public origin) — never the dialog’s words, since the card lands in a chat.

On the dashboard, the role’s Self-Evolving page lists its episodes in place of the entity’s usual “Rate runs” card, newest first: when each began and closed and why (went quiet, closed, role changed, or still open), how many turns it had, whether its turns span two role versions, the judge’s grade and every number recorded on it — the judge’s metrics and your measure()’s, an escalated flag among them — and people’s ratings beside the judge’s. Each episode that has ended has Rate, on the scales of the metrics people rate, and a rating on one the sampler passed by grades it (see Ratings from people). Read the dialog opens the transcript — the role’s agent timeline, cut to the turns of that episode — at the same address the card links to. The share of escalations is the level line of that metric’s trace on the same page — under aggregate: mean each level is the mean of the episodes’ 0s and 1s; the dashboard computes nothing of its own.

An agent reads the same list through eval_list_episodes { roleId, cursor?, limit? } (eval:read; one episode: eval_get_episode { roleId, runId }, its dialog agent_history with episodeId), and records a person’s rating of one through eval_rate — the person’s words, and the person confirms.

How a chat role is improved

The analysis runs on constitution/Loop/proposer/chat.md over the role’s graded episodes; its change targets the role file (body and frontmatter outside the evaluation: / loop: / chat: / budget: blocks) and the SKILL.md of the skills it names. There is no pre-merge check — a dialog cannot be replayed — so the change is judged by the role’s own next episodes (see How a cycle is judged), and only by episodes that began after it went live and ran under one version of the role.

No standing grant decides for a chat role: the loop looks loop:auto_accept up only on a scenario or a heartbeat, deliberately never on a role (a role grant would cover every chat that runs the role). A chat role’s two gates answer to autonomy alone.

Ideas — a human’s hypothesis, next in line

Everything above is the loop finding its own hypotheses: a grade or a cadence opens a window, and the proposer decides what to change. An idea is the second source — a person posting a hypothesis for one entity (“lead with the numbers, not the narrative”, “stop opening with a question”). The entity’s next cycle is opened on it and works it before anything the loop would have proposed itself. Everything after the analysis — the decide gate, the apply, the pre-merge check, the merge, the watch, the verdict, the lesson, the breaker, the revert — never asks where a hypothesis came from, so an idea comes back as a measured experiment: validated, regressed and reverted, or settled inconclusive, like any other change.

Posting. No config key turns ideas on. There are three ways in, and all land on one engine entry point, so they accept and refuse exactly the same things:

  • From the dashboard — the entity’s Ideas tab (/evolution, the entity’s Ideas tab, ?view=ideas). Post an idea writes it as the dashboard actor; the panel lists the bucket — each queued idea with its place (next, #2 in queue) and Withdraw, the ideas cycles took with what that cycle came to (a link to it), withdrawn ones — and says in one sentence what the head of the queue waits on: the cycle that holds the entity with its own deadline (yours when it is a decision, a merge or a grant), a regressed change’s revert PR, a lock group a sibling holds, a hold with its reason, or nothing at all (“picked up within the hour”). It is the loop’s own answer — the same gates, in the same order, the next cycle will meet — so it never says “free” while a hold stands, and never says “resting”: a queued idea steps past the rest and the daily ceiling. The same sentence closes the entity’s timeline (below) and leads the header while an idea is queued. The answer to a post or a withdraw is the engine’s own sentence.

  • From chat — /idea <entity> <text>. <entity> is <kind>:<id> (workflow:weekly-digest, heartbeat:inbox-triage) or just the id, which is looked up among the loop’s entities; when one id names entities of two kinds, the reply asks you for the prefix. /idea alone prints the usage. The reply says in one sentence what happened — the cycle started, or the idea is queued and why — and while the idea is queued it carries a Withdraw button.

  • From an agent — loop_create_idea { targetKind, targetId, text } → { ideaId, opened, reason }. It sits in the Evaluation tool category beside the eval_* tools, so every agent that has those — chat, workflow tasks, pipelines, MCP clients — can call it. This is how an idea about the whole brain reaches entities: an agent that knows your entities posts one idea to each it applies to.

A post is refused, with nothing written, when the entity is not in the loop, when it or its loop is switched off, or when the text is empty or longer than 4000 characters — the agent’s tool call is refused (precondition) with the reason, and the chat reply says it. A post that was accepted but whose cycle did not start at once — the entity has an unsettled cycle, or an earlier idea is ahead of it — is a success with opened: false and the reason. The idea is in the bucket; do not post it again, that queues a second copy.

An agent’s post asks a person first. Both idea tools carry the capability loop:analyze (moderate, hitl: true): an idea spends a proposer and outranks the entity’s own rule for when to improve, so an agent posting one waits on an approval card like any other gated action. /idea is a person asking and asks nobody. A brain whose agents route ideas as routine work lifts the gate — narrowly, with a grants: block on the automation that does the routing, or for every agent at once with "hitl": false on loop.execute in constitution/engine-capabilities.json (see contract § 7).

One idea per cycle, one entity per idea, first in first out. A cycle carries exactly one hypothesis, so its verdict is that idea’s verdict; the others wait their turn. There is no priority field. An idea about the whole brain (“all posts shorter”) is routed to entities by whoever posts it — a cycle has one target.

An idea outranks the loop’s rule, never its gates.

A cycle opened on an idea steps pastand is still bound by
the window’s ready — the window is still sampled and stored, it is the baselineloop.enabled — posting to an entity whose loop is switched off, or which is switched off itself, is refused outright
shouldOpen — under repair it says no to every healthy entity, so “the next cycle” would never comeone cycle at a time — an idea waits until the entity’s previous cycle has settled, including a proposal still waiting on your answer (it holds no claim, but its outcome is what the idea’s proposer should see)
cooldownHours and maxAnalysesPerDaythe breaker, the pinned judge, the frozen set, the protected surface, the edit-size bound, the check, the watch, the decide gate

Your own shouldOpen does not need to know ideas exist: it is simply not asked when one is queued, so a rule written before ideas cannot starve them.

Whoever opens the next cycle, opens it on the idea. The engine reads the entity’s oldest queued idea every time a window is about to open — a landing grade, the cadence, the dashboard’s “Analyze now” or an agent’s loop_analyze (the same capability as the idea tools, loop:analyze), or the post itself. A grade that lands while an idea is queued therefore opens the idea’s cycle; its grade.json still rides in the brief, and if the failure persists the next grade opens the cycle after it. The cycle is keyed on the idea and the instant it was asked for (<kind>:<id>:idea:<ideaId>:<instant>). The post, the hourly sweep and a landing grade racing to the same idea still make one cycle, not three: only one of them can own the entity, the others step aside without spending anything, and only one cycle can ever take the idea. The instant is in the key so that an ask which stepped aside — a rival took the entity first — does not use up the idea: the next ask is a fresh cycle.

Delivery. Posting writes the idea and asks for its cycle at once: if the entity has no unsettled cycle, the cycle starts now. Otherwise the idea waits — and so does everything else on the entity: while an idea is queued behind an unsettled cycle, no grade or cadence opens a cycle in its place. The engine’s hourly sweep asks for every entity with a queued idea — whether or not it declares trigger.every, and without moving its cadence — so an idea starts at most an hour after the previous cycle settles. Ideas therefore run strictly one after another, each proposer seeing how the one before it ended; an unanswered proposal holds the line until you answer it or its review deadline settles it.

What the proposer is told. An idea cycle is briefed with its own prompt, constitution/Loop/proposer/idea.md, whatever the entity’s kind or preset: its job is different — the hypothesis is given, and the proposer makes it concrete and grounded, or declines it and says why. The entity’s loop.proposer.instructions are appended to it as to any brief. The brief is the ordinary one plus idea.md — the text, who posted it and when — which is never trimmed, and is bounded where it is posted (4000 characters) for exactly that reason. The proposer declines — empty promptChanges, the reason in the summary — when the idea would change what the entity is measured by (the protected surface below), needs something the entity does not have, or is contradicted by a validated lesson. The decline reaches you on the decide card, and a refinement can overrule it.

Withdrawing. A queued idea can be withdrawn — with the Withdraw button on the /idea reply, or by an agent with loop_cancel_idea { ideaId } → { withdrawn, reason, targetKind, targetId }, under the same capability as the post (a withdrawal is its undo). The answer is the idea’s state after the call, so withdrawing an idea that is already withdrawn — a second tap, a retried call — answers withdrawn: true again. Once a cycle has taken it, it is that cycle’s hypothesis and stays: the button and the tool answer withdrawn: false with the reason (a cycle already took it; an id that names no idea is refused not_found), and the experiment is stopped at the decide gate instead. Withdrawing and a cycle taking the idea cannot both win: if the withdrawal lands first, the cycle that was on its way is recorded skipped (“the idea was withdrawn before its cycle started”) and spends nothing — no proposer, no cooldown, no slot of the day’s ceiling.

An idea’s status is its cycle’s. queued until a cycle takes it, withdrawn if its author withdrew it first, and from then on whatever its cycle’s status is — live while the cycle runs, is decided, applied and watched, then the verdict it settles on: validated, regressed, unvalidated (inconclusive), or skipped / failed. The lesson its cycle writes is the idea’s result.

Hearing back. When the cycle that took an idea settles, the idea’s author is told: the outcome in plain words, the idea quoted, and why it ended that way — the measured deltas where the change was measured (the same numbers its lesson records), otherwise the proposal’s summary for a skipped cycle (which is where a proposer that declined the idea says why) or the error for a failed one. A /idea post is answered in the chat it was posted from. A post with no chat behind it — an agent’s tool call, even one made by the agent you are chatting with, or an MCP client’s — is answered where the loop’s proposal cards arrive. The answer is sent at exactly the settles that write a lesson, so a cycle settled by something other than its own verdict — the engine ending one that kept failing, or the kill switch, say — gets neither. And it is best-effort: a settle whose work is done never fails on its announcement.

Two things to know. Under evolve, a merged change is watched for up to watch.deadlineHours and the entity is owned for all of it, so an entity runs one measured experiment per watch and queued ideas wait that long. And an idea that opens a cycle on an evolve window that is not yet full gets a thin baseline: the watch then settles unvalidated (“inconclusive”) honestly rather than inventing a verdict.

Level 3 — Power: your code in the loop

Where YAML runs out, the loop takes code at named seams: a <entity>.loop.ts module (measure, window, trigger rule, verdict, comparator, golden cases), the run identity your tools and pipelines record as lineage, the dry-run rule your own code honours under a replay, a pipeline of yours as the author of a change, the ledger as a tool, and supervision from a pipeline. Nothing here edits the engine.

Metrics only your code can produce

Declare a computed metric with an id the engine does not know, and it becomes your job to produce it. Put a loop module beside the entity file — the same sibling convention *.pipeline.ts uses, and hot-synced the same way:

features/daily-brief/
  daily-brief.scenario.md
  daily-brief.loop.ts        ← measured by
// features/daily-brief/daily-brief.loop.ts
import { defineLoop } from '@omnislash-ai/config/loop-module';
import { countDeliveredDigests } from './db.ts';   // a helper: list it in `protectedPaths` (below)

export default defineLoop({
  async measure(run) {
    if (run.replay) return [];                                  // see below
    const delivered = await countDeliveredDigests(run.completedAt);
    return [{ id: 'delivery_rate', value: delivered / EXPECTED }];
  },
});
evaluation:
  metrics:
    delivery_rate:
      kind: computed
      direction: max
      role: objective
      target: 0.95

run describes the EXECUTION being scored, never the grading: { runKind, runId, targetId, completedAt, replay }. Window your queries from completedAt rather than new Date() — a grading is queued, sampled, possibly delayed by gradeAfterHours and retried, so “now” is a different span every time the same run is graded.

run.replay is the one case where you may return fewer values than you declared. It is true when the loop re-ran your entity on a frozen input inside its pre-merge check. That run posted nothing, sold nothing and got no views, so a number that comes from the world has no source to read there, and skipping it is honest rather than broken: the ids you leave out are simply absent from that vector, and the check compares the metrics both sides could produce. On a LIVE grading the same omission still fails the evaluation, by id — there the number was owed — unless you declared the metric optional: true, which says the number is sometimes simply not there.

The same module can name golden cases — inputs every candidate of a workflow scenario is measured on, whatever its recent history happens to hold:

export default defineLoop({
  cases: [
    { id: 'empty-inbox', input: { inbox: [] } },
    { id: 'monday-digest', input: { day: 'monday' } },
  ],
});

They are frozen into each cycle beside the entity’s recent inputs (see What a cycle freezes). Ids are unique per module and name the case in the check’s per-input verdict; a case whose input equals a recent run’s input adds nothing and is dropped at the freeze.

Hot-reload covers the *.loop.ts file itself: the engine re-imports it whenever its bytes change, so a brain-sync tick reaches a long-lived worker without a restart. It does not reach through to a sibling the module imports — the ./db.ts above is resolved once and kept for the life of the process. Keep anything you expect to iterate on inside the loop module, or restart the worker after editing a helper it imports.

The same line runs through what the loop may not touch: the engine floor closes every *.loop.ts to the loop — to reading and writing alike — and not the siblings it imports. ./db.ts is an ordinary brain file, so an implementer whose proposal names it may rewrite your measurement. List every helper a loop module imports in loop.protectedPaths (features/daily-brief/db.ts here, features/viral-shorts/tiktok.ts for the module under Improving what already works); that closes it to writes, while the proposer can still read it. What must stay unseen — a threshold the entity is optimized against — belongs in the *.loop.ts itself.

Declare the metric and forget the module and every evaluation of that entity fails, naming the ids — on a replay too, because nothing about replaying an input makes a missing file appear. That is deliberate: the alternative is an entity silently optimized against a number nobody is measuring. omnislash doctor names the gap under Loop modules before it costs you a run.

That demand covers run-scoped ids only. A period metric is window()’s to produce, and no grading asks measure() for one.

The module can also replace the verdict itself — the rule the pre-merge check settles a candidate’s PR by:

export default defineLoop({
  verdict({ spec, pairs }) {
    return { verdict: /* your call */ 'validated', perMetric: [], perInput: [], reason: null };
  },
});

pairs is every pair the check promised — one per frozen input per check.trials — and each pair carries both sides: baseline (the incumbent on that input: its frozen grade, or a fresh replay at the base commit) and challenger (the candidate replay at the PR head). A side is one of five things: viable with its metric vector (graded, and it passed), failed (graded, did not pass), ungraded (the grading itself failed), notRun (died before the judge saw it) or unfinished (still running when the batch was read). Five outcomes rather than one “did not produce a vector”, because a comparator that cannot tell them apart cannot tell the change is bad from nobody measured it, and the second answer must never close a PR.

Both sides arrive raw, per input, because the statistic is your call, not the engine’s. The engine’s own comparator reads each metric as the median of the per-input movements; if your domain is a worst case, a p95, or a rate across the inputs, you can apply it to the same pairs and get an honest comparison. What you cannot do is compare two batches on different inputs — the pairs are the contract precisely so that the two sides of every comparison ran the same input under the same judge. (A metric declared aggregate: mean is read by the mean of those movements instead of their median.)

Return validated, regressed, or unvalidated, with perMetric and perInput records for the row and the PR. The last outcome is yours to return too — it is the honest answer when the evidence that arrived cannot tell a real move from this entity’s noise, and it never closes anything. The two negative outcomes owe a reason; validated carries null, and the types say so.

The watch after the merge does not call this function. The entity’s own runs after a merge are on its own inputs — nobody’s pair — so the loop counts bad runs there by the trigger’s rule instead of comparing; see When the evidence never arrives.

For one comparator across the whole brain, put it in your config file instead — defineProject({ loop: { verdict } }). It lives there rather than in the manifest because a function has no YAML form; everything about the verdict that IS a number stays in the manifest where you can turn it on a running brain. Precedence is engine default ← project ← the entity’s own module.

The five functions

Five functions decide a cycle, and each has a preset default. The loop is a skeleton; what it reads and how it judges are functions you may replace, each independently, at three tiers — the engine preset (loop.preset, repair by default) ← defineProject({ loop: { … } }) ← the entity’s own <entityId>.loop.ts:

FunctionAskedrepair answersevolve answers
window(ctx)on every grade that lands, before the trigger rulethe entity’s last trigger.windowRuns graded runs through the grade in hand, always readyevery grade landed since the previous cycle looked, ready once trigger.windowRuns of them have
shouldOpen({ window, trigger })after window, over the runs it namedk-of-n, the newest bad — Your own trigger ruleopen: true — the window is ready, so the cadence came round
measure(run)at every gradingnothing — brain-only, Metrics only your code can producethe same
verdict({ spec, pairs })at the pre-merge checkthe ordered rule list over the pairs — The verdict, as a list of rulesthe same
compare({ targetKind, targetId, spec, before, after, terms })on every tick of the post-merge watch, over the cycle’s window and the one just observedthe trigger’s question over the observed runs: regressed at the grade the entity would have triggered on again, validated on a full watch.windowRuns with none, null (not yet) while the window is short — Your own post-merge comparatorthe two windows’ vectors compared: floors and guards on the median, the objective inside its watch.confidence interval, null until the observed window is full — Improving what already works

Every reason a function answers with is read by a person — on the dashboard, in a chat notice, by the agent that relays it. State a moment with formatInstant(date) (Oct 12, 20:17 GMT+2, in the brain’s timezone) and a measured number with formatDecimal(n) (2.33) or formatUsd(n), all from @omnislash-ai/config/public — never toISOString() or a raw float.

Two things a preset decides that are not among the five, because no brain function replaces them: which runs the proposer is briefed on (repair: the newest evidence.runs through the window’s anchor; evolve: the window’s best and worst by the objective) and which prompt briefs it (repair: the prompt of the kind the entity’s executions run as; evolve: evolve.md for every kind).

The window — what a cycle is evidence of

A cycle is not about one run. It is about a window: which of the entity’s runs, over which period, and why — sampled when a grade lands, stored on the cycle, and read by everything after it. The proposer is briefed on it, the baseline is its median, the frozen inputs are scanned through its newest run, and the dashboard renders it on the cycle. The row stores these fields:

FieldMeaning
fromthe oldest kept run’s createdAt, or null for “the last n”
tothe sampling instant
runIdsthe graded executions it names — distinct, oldest first; may be empty
metricsthe scope: period metrics measured for the window as a whole — the repair preset produces none, your own window() may
reasonwhy this window, rendered on the cycle
progressoptional — how far the window has got toward ready, in your sampler’s own unit: { have, need, unit }, e.g. { have: 2, need: 3, unit: 'scored series PRs' }. Both presets fill it with their graded runs ({ have: 4, need: 5, unit: 'graded runs' })

Progress is the sampler’s to say. Nothing else knows what your window counts. A window() that waits on something other than runs — PRs a person has scored, deliveries that settled — answers ready: false, runIds: [] until it has enough, and no reader can tell how far it has got from that. So the dashboard’s watch panel (“2 of 3 scored series PRs since the merge”), the cycle’s phase track and an agent’s loop_status (the phase fact, and live.watch with the window’s progress and reason) read progress — never runIds.length, never watch.windowRuns — and where a window carries no progress the dashboard and live.watch tell its reason. A window() with its own unit should answer both: progress for the count, reason for the sentence behind it (which PRs, scored how). The trigger’s window counts the same way: the “what comes next” line and loop_status’s next.meter read your trigger window’s progress. A count in graded runs — the presets’ unit, kept by a window() that spreads ctx.preset.window(ctx) — is restated by the engine over the runs it kept, so it never runs ahead of runIds. The watch’s challenger grading reads it too: an observed execution is graded under the cycle’s pinned judge while the watch’s window is short — have < need, or, with no progress, fewer than watch.windowRuns runs — and never past the watch’s deadline.

A window that waits — on a person, or on nothing

A window that is not ready waits on something: more graded runs, a customer, traffic, a date — or a person. The engine cannot tell which from progress, so it asks your sampler for exactly one fact and notices one on its own.

Name the person’s move — awaitingPerson. When the window can only fill once a person does something, say so on the not-ready answer:

return {
  from: null, to: ctx.now.toISOString(), runIds: [], metrics: [],
  reason: 'PR #16 is open and unscored — 2 of 3 series PRs scored',
  progress: { have: 2, need: 3, unit: 'scored series PRs' },
  ready: false,
  awaitingPerson: { what: 'score PR #16', url: 'https://github.com/acme/site/pull/16' },
};

what is one short imperative sentence for the person (at most 200 characters); url is where they do it, when there is one place. The engine’s only decision on it is that the entity needs you: a window item in the dashboard’s N need you list and loop_status’s needsYou (its clause “waiting on you: score PR #16”, its url, its next “a person does it at …”), the entity page’s “Next cycle” line (“2 of 3 scored series PRs · waiting on you: score PR #16”), and next.waits.awaitingPerson on the one-entity loop_status. It never fakes the evidence, never times the window out and never asks the person itself, under any autonomy. Every other wait — a customer, traffic, a date — stays in reason: there is no list of who a window may wait on. A ready window carrying awaitingPerson is a load error, and the field is read at the trigger only (the watch ignores it).

A window that stops moving — loop.windowStalledAfterHours. Most windows will never name anyone, so the engine also watches the count itself: when a not-ready window’s progress.have has not changed for longer than windowStalledAfterHours, the entity gets a stall item — “the next cycle’s window has not moved in 15 days (2 of 3 scored series PRs): <your reason>”. It needs nothing from you beyond progress (both presets state it), and it counts as needs you everywhere window does. A window with awaitingPerson is listed for the person, never also as a stall. The default is 336 hours (two weeks): a weekly entity’s window moves once a week, so two weeks is that cadence missing twice. Set it brain-wide in constitution/MANIFEST.yaml (loop.windowStalledAfterHours) or per entity in its loop: block (loop_set with the knob windowStalledAfterHours); the nearest tier wins, and 0 never reports a stall. A monthly entity wants more; an entity whose window should fill daily wants less.

Neither signal runs your code on the fleet read. Whenever the engine samples the window — a grade landing, a trigger.every tick, “Analyze now”, the entity’s own page — it records what it saw while the window is not ready (omnislash.loop_waiting_windows: the count, awaitingPerson, the reason, and when the count last moved) and marks the wait ended on any other answer — each write landing only over an older decision, so a slow look (a page’s) never undoes a newer one, and a new wait after a cycle starts its own clock. Both items are left out while the loop is off for the entity, the entity is paused, or a cycle owns it; the scheduler’s hourly sweep drops the record of an entity that is paused, out of the loop or gone, so a pause never counts toward a stall; a window() that throws leaves the record as it was, so the stall clock keeps running.

window() — the repair default, spelled out. The last windowRuns graded runs through the grade that landed: from: null, to = now, runIds oldest first with the graded run last, reason: "the last n graded runs", ready: true — under repair the count is the whole question, so a short history is its own answer. It is never bounded by the previous cycle’s mark and never judge-filtered at the trigger: filtering by the judge on disk would silence the trigger for windowRuns grades after every judge edit. For a member of a lock group it counts only runs graded after the group last changed (ctx.groupChangedAt, below; the reason then says so).

What your own window() sees. ctx carries targetKind and targetId; phase — trigger when a grade, a tick or a request asks whether to open a cycle, watch when the post-merge watch asks what to judge a shipped change by; now; anchor — the grade that landed, { id, createdAt }, or null for a call no grade produced (and always at the watch); since — at the trigger the last window the entity’s previous cycle read, or null when it has none, and for a lock-group member never earlier than groupChangedAt; at the watch the instant the watch opened; groupChangedAt — at the trigger, when a member of the entity’s lock groups (itself included) last merged or reverted a change, null outside groups and at the watch — the clean base a sampler that ignores since still starts after; gradedRuns({ since?, through?, limit? }) — the engine’s read of the entity’s own graded history, newest first, already filtered (its own grades only, completed, never the loop’s replays) and capped, answering { comparable, ungraded } where ungraded counts the judge’s failures over the same span; preset.window(ctx) — the preset’s own sampler, so you can build on it rather than restate it; terms — the entity’s resolved evaluation: / loop: blocks and metric spec; and calibration() — how far the judge agrees with people on each judged human: true metric (Calibration), read only if you call it. Answer the stored fields plus ready: false withholds the analysis for this grade and says why (at the watch nothing reads ready but the period obligation below — compare owns “not yet”; the preset’s watch-side window is ready once it is full). A window() that throws withholds it too — the grade itself stands, and the next one asks again. At the watch the same throw is a fault: the change settles unvalidated naming the function, and the watch itself survives it. Under repair the watch-side default is the first watch.windowRuns runs graded since the watch opened, under the judge the cycle pinned.

// features/daily-brief/daily-brief.loop.ts
export default defineLoop({
  async window(ctx) {
    // The preset's choice, then your own condition on top of it.
    const w = await ctx.preset.window(ctx);
    const quiet = ctx.now.getUTCHours() < 6;
    return quiet ? { ...w, ready: false, reason: 'no analyses before 06:00 UTC' } : w;
  },
});

Whatever a window names, the engine re-filters. Before the trigger rule reads the runs and before the brief is assembled, the ids a window names are resolved to their grades under the engine’s own rules — the right entity, completed, the entity’s own run and never a replay the loop launched. An id that fails is dropped and counted (rejected, in the worker’s log line), so a window() cannot smuggle in evidence the loop would not have read itself. The row stores what survived: the cycle’s window is the runs the loop counted, and its newest one is the anchor everything after it reads through. A window that names one run twice is refused outright — a run is one vote and one observation, never two.

Your own trigger rule

When counting is not how your domain decides, replace the rule:

// features/daily-brief/daily-brief.loop.ts
export default defineLoop({
  shouldOpen({ window, trigger }) {
    // Three CONSECUTIVE failures, rather than any two of the last five.
    // `window` is the outcomes of the runs the cycle's window names, newest
    // first — under `repair`, the entity's last `windowRuns` GRADED runs.
    const recent = window.slice(0, 3);
    const bar = trigger.threshold ?? 1;
    const open = recent.length === 3 && recent.every(run => (run.overallScore ?? 0) < bar);
    return { open, reason: `${recent.length} consecutive runs below ${bar}` };
  },
});

Answer with { open, reason }, never a bare boolean. A boolean return is a load error naming the shape, and the reason is why: it is the whole of what anyone is shown for the analysis that did not open. Without it “the rule said no” and “the loop is switched off” look identical from outside.

And answer synchronously — unlike measure, this one is not async. It is asked once per graded run at the trigger and once per grade of the observed window at the watch, so it reads the window it is handed and decides; anything it would have to fetch belongs in the measurement, not in the decision. An async shouldOpen fails with a sentence saying so.

Same three tiers as the comparator: the engine preset ← defineProject({ loop: { shouldOpen } }) ← the entity’s own module, and a function lives in code rather than YAML for the same reason verdict does. trigger carries the terms the preset’s own count would have used, so your rule can borrow the numbers it is replacing the use of.

The one thing it cannot overrule is enabled. That is a kill switch, and a rule that could switch itself back on is not a switch.

The watch asks it too. Once your change has merged, the repair comparator settles by the same rule that opens an analysis — asked the way the trigger asks it, once per landed grade: at each of the entity’s next windowRuns graded runs, your rule sees the runs since the merge, newest first, and answers whether the entity would have triggered (open: true). A yes at any grade regresses the change on the spot and opens the revert; a full window with no yes validates it. Runs from before the merge are never in that window — they measured the old definition. A rule that throws settles the change unvalidated, naming the comparator that asked it, rather than guessing in either direction. Replace compare and the rule is no longer asked after the merge — see Your own post-merge comparator. The window your rule opens a cycle on is what the cycle is about — the proposer is briefed on it, its newest run is the anchor the evidence and the frozen inputs are read through, and the pre-merge check replays that run’s input first. Say yes on a run the entity did well on and you have asked the proposer to fix a success, and handed the check a case the incumbent already passed. The engine’s own count requires window[0] to be bad for exactly that reason.

Your own post-merge comparator

When the count is not how your domain reads a shipped change, replace the comparator:

// features/viral-shorts/viral-shorts.loop.ts
export default defineLoop({
  compare({ before, after }) {
    // Not yet: the watch keeps observing and asks again on the next tick.
    if (after.outcomes.length < 10) return null;
    const views = (w: typeof after) => w.vector['views_72h'] ?? null;
    const [was, now] = [views(before), views(after)];
    if (was === null || now === null) {
      return { verdict: 'unvalidated', perMetric: [], perInput: [], reason: 'no baseline for views_72h' };
    }
    return now < was * 0.8
      ? { verdict: 'regressed', perMetric: [], perInput: [], reason: `views fell ${was} → ${now}` }
      : { verdict: 'validated', perMetric: [], perInput: [], reason: null };
  },
});

What it sees. targetKind and targetId — the entity, for whatever the comparator logs or looks up; spec — the entity’s resolved metrics; before — the window the cycle opened on, and after — the window the watch just sampled (your window() at phase: 'watch', or the preset’s), each as { window, vector, outcomes }: the stored record, the grades of the runs it names after the engine’s filters (the entity’s own completed grades, under the judge the cycle pinned, booked after the watch opened — oldest first), and those grades reduced to one vector (the per-metric median of the runs, with the window’s own period metrics laid over it); terms — the entity’s whole loop, values and resolved functions, so a comparator that only wants to bend the preset can call its rule rather than rebuild it.

Answer null or a verdict record. null is “not yet”: the watch sleeps and asks again on its next tick, until watch.deadlineHours closes the window unvalidated. A VerdictResult — validated, regressed or unvalidated, with perMetric, perInput and a reason on the two negative outcomes — settles the row on the spot, whatever is still to come, and is stored verbatim as the cycle’s watch_verdict. regressed opens the revert; the other two ship and stay shipped. Put in perMetric what you measured: it is what the lesson records, what the notice reads, and what tells the autonomy breaker a unvalidated was measured rather than blind (a record with an empty perMetric reads as “nothing was measured”, exactly as repair’s count does). Same three tiers as the other functions, and the same shape rule: a bare boolean is a load error naming the record you owe.

Two bounds you cannot move. Your window and compare run inside the watch’s journaled tick step, whose inactivity timeout is 60 seconds — a comparator that reads the world reads it inside that. And a throw from either, of anything, settles the change unvalidated with the message in its reason and leaves the watch alive; it never guesses in either direction and never kills the cycle. Judge health is the engine’s own read of the observed span — every execution of the entity since the watch opened, not the runs your window named, so a sampler that picks only the runs the judge managed to grade cannot hide the ones it failed on — checked beside your answer: a verdict is withheld while the judge is over evaluation.judge.maxUngradedShare, and that settles unvalidated on its own terms — see When the evidence never arrives.

Lineage and late metrics

A metric that exists only after the run — views at 72 hours, a reply within a week, a delivery rate that settles overnight — needs two things: the judge has to look late enough, and your measure() has to find the rows THIS run caused. The first is gradeAfterHours (or a scope: period metric your window() measures, when the wait has no bound); the second is lineage your code records while the work happens.

When the judge looks — gradeAfterHours

By default the judge looks the moment the execution ends. That is right whenever everything worth measuring already exists then — the report was written, the message was sent, the plan’s criteria are answerable.

It is wrong whenever the outcome is not the run. A short’s views at 72h, a delivery rate that settles overnight, a reply rate that needs a business day: graded at the terminal, those metrics read zero on every single run, the entity looks equally mediocre every time, and the loop then optimizes against a number nobody ever measured.

evaluation:
  enabled: true
  sample: { maxPerDay: 20 }
  gradeAfterHours: 72        # grade each run three days after it ends
  metrics:
    views_72h: { kind: computed, direction: max, unit: count, role: objective }

Same three tiers as everything else here: the engine’s 0, your constitution/MANIFEST.yaml if the whole brain measures late, the entity’s own block where one differs. An entity that writes gradeAfterHours: 0 under a brain-wide lag grades at the terminal — the nearer tier wins, including when it says zero.

What happens at the terminal does not move: the execution is sampled and booked there, exactly as today, so the grading still holds its sample.maxPerDay slot. The booking also records when the grade is owed, so a delayed job that never arrives (an evicted queue, a drained Redis) is failed with grading job lost instead of holding that slot — and a challenger slot a post-merge watch is waiting on — forever. Only the judge waits. Two gradings deliberately do not:

  • the loop’s own replays inside a pre-merge check — the loop is comparing a candidate against an incumbent now, and nothing about a replay is waiting on a live outcome;
  • the manual eval_start tool — asking for a grade means asking for it now, so it pulls a grading already waiting out its lag forward instead of silently doing nothing.

What a lag costs you. The engine pins nothing extra to keep an execution alive for a late judge — that would make one entity’s lag a bill every other entity pays — so the lag has to fit inside what your brain already keeps. omnislash doctor is where the contradiction shows up before a grade is lost:

CheckVerdictWhen
Eval ceilingsfailA heartbeat whose gradeAfterHours outlives agentRetention.ttlHours in omnislash.config.ts. The tick IS an agent row; once retention sweeps it the judge finds no run, so EVERY grade of that heartbeat fails. Raise the retention past the lag.
Eval ceilingswarnAny entity graded more than 7 days out. The judge still grades — it simply sees a run that used no tools, because the tool calls it quotes as evidence expire with the agent’s output stream. Harmless when your numbers come from your own tables.
Loop watchwarnloop.watch.deadlineHours shorter than the entity’s cadence PLUS the lag. The watch counts GRADED runs, so a deadline that does not span the lag settles every cycle unvalidated.

Recording lineage — which run made what

A late metric is only as good as its join. When the number lands in your tables days after the run — views on a video, replies to an email — your measure(run) has to find the rows THIS run caused, and the engine cannot do that for you: it does not know what your videos are. What it gives you is one identity, the same everywhere, for you to write down while the work happens:

Where your code runsWhat it reads
A pipeline handlerctx.runRef — { runKind: 'pipeline', runId, definitionHash } (typed on restate.Context by @omnislash-ai/pipeline-sdk)
A tool you wrote, called by a workflow task’s agentcontext.auth.runRef — { runKind: 'workflow', runId }
A tool called through callTool, or by an agent a pipeline ran with runAgentcontext.auth.runRef — the pipeline’s own { runKind: 'pipeline', runId }
A tool called during a heartbeat tickcontext.auth.runRef — { runKind: 'heartbeat', runId } (the tick)
A tool called in any turn of a person’s direct chat with one of your rolescontext.auth.runRef — { runKind: 'chat', runId } (the episode the turn belongs to)
A tool called by a sub-agent any of those spawned (agent_task, agent_call, …)the spawner’s runRef — the sub-agent does that run’s work

runId is exactly what measure(run) later receives as run.runId for that execution, so “the rows with my runId” is the whole join. It is present on a graded execution and absent elsewhere (a chat with a system role, a group chat, an ad-hoc agent) — a tool writes lineage only when it is there. It works the same for a CLI agent: the MCP server receives the run with the session.

Two things it is not. It is not the owner of the work: a sub-agent inherits the run it works for, never the run’s checkout or its liveness. And a child pipeline is its own run — ctx.runRef inside the child is the child’s invocation, not the parent’s. Lineage between pipelines is yours to pass: hand the parent’s id to the child as input, or return the child’s ctx.runRef.runId from it.

definitionHash (pipelines) is the sha256 of the scenario’s definition files — its *.scenario.md and its primary <id>.pipeline.ts — as the invocation started, replay-stable. Equal hashes mean the same definition. A change that lands only in a file outside those two (a prompt file the pipeline reads, a skill, a role) does not move it, so to tell versions apart by such a change, compare the run’s time with the change’s merge time instead. It is null when the scenario file was not found.

Example — the freelancer. idea (a workflow) researches the niche and writes a strategy for a batch of about ten videos. studio (a pipeline outside the loop) picks up new strategies and drives layout → production → publish for each video. stats pulls platform numbers into video_stats. Every entity is graded on its own videos, a day or more after the run.

  1. idea records its run on the strategy. Its task agent calls your strategy_save tool; the tool writes the run beside the row:

    // features/freelancer/idea/strategy-save.ts — registered through `defineProject({ tools })`
    import { createTool, success } from '@omnislash-ai/config/tools';
    import { db } from '@omnislash-ai/infra/drizzle';
    import { z } from 'zod';
    import { strategies } from '../../../db/schema.ts';
    
    export const strategySave = createTool({
      name: 'strategy_save',
      description: 'Save the strategy for the next batch of videos.',
      schema: z.object({ niche: z.string(), plan: z.string() }),
      risk: 'moderate',
      providesCapabilities: ['freelancer:write'],  // declared in constitution/project-capabilities.json
      label: 'Save strategy',
      category: 'Freelancer',
      replay: 'dry-run',                        // a loop replay writes nothing
      async execute(args, context) {
        const run = context.auth.runRef;        // { runKind: 'workflow', runId }
        await db.insert(strategies).values({ ...args, ideaRunId: run?.runId ?? null });
        return success('saved');
      },
    });

    The capability is the tool’s door: declare freelancer:write in constitution/project-capabilities.json ({ "freelancer": { "write": { "risk": "moderate", "hitl": false, "description": "Save the batch strategy" } } }) and list it under capabilities: in the role idea’s task runs as. A tool that provides no capability (providesCapabilities: []) fails registration and the app does not boot — no scoped role could ever select it.

  2. Each stage returns its run; studio writes the lineage. A stage is a child pipeline, so it is its own run: it hands ctx.runRef back, and studio — the driver, outside the loop — records it on the video beside the strategy it came from:

    // features/freelancer/layout/layout.pipeline.ts — each stage ends the same way
    run: async (ctx: restate.Context, input: { strategyId: string }) => {
      const scenes = await runAgent(ctx, { agent: { name: 'layout-writer' }, prompt: /* … */ });
      return { scenes, run: ctx.runRef };       // { runKind, runId, definitionHash }
    },
    
    // features/freelancer/studio/studio.pipeline.ts
    import { db, eq } from '@omnislash-ai/infra/drizzle';
    import { videos } from '../../../db/schema.ts';
    import layout from '../layout/layout.pipeline.ts';
    // …
    run: async (ctx: restate.Context, input: { strategyId: string; videoId: string }) => {
      const laidOut = await ctx.serviceClient(layout).run({ strategyId: input.strategyId });
      await ctx.run('lineage-layout', () =>
        db.update(videos)
          .set({
            strategyId: input.strategyId,
            layoutRunId: laidOut.run?.runId,
            layoutHash: laidOut.run?.definitionHash,   // if you filter by versions
          })
          .where(eq(videos.id, input.videoId))
      );
      // … `production` recorded the same way in its columns
    },

    A stage whose effect cannot be taken back writes its own lineage in the same transaction as the effect, rather than leave it to the driver’s next step: publish locks the video row, posts, and records the post with its publish_run_id in one ctx.run — a driver cancelled between the child’s return and its own write would otherwise lose the record of a post that happened (pipelines.md § An operation’s items — pattern A).

    A stage started on its own — by hand, or by a loop-launched execution — is not driven by studio, so nothing records it — exactly right: it made no video, and a metric it cannot have is declared optional: true.

  3. Each entity’s measure() attributes by its own column. publish reads the one video it published, layout the videos it laid out, idea every video of its batch (the walkthrough in Four loops, end to end says which number each reads; the join is the point here):

    // features/freelancer/idea/idea.loop.ts
    import { defineLoop } from '@omnislash-ai/config/loop-module';
    import { db, eq, sum } from '@omnislash-ai/infra/drizzle';
    import { strategies, videoStats, videos } from '../../../db/schema.ts';
    
    export default defineLoop({
      async measure(run) {
        if (run.replay) return [];                 // a replay made no videos
        const [row] = await db
          .select({ views: sum(videoStats.views) })
          .from(videos)
          .innerJoin(strategies, eq(videos.strategyId, strategies.id))
          .innerJoin(videoStats, eq(videoStats.videoId, videos.id))
          .where(eq(strategies.ideaRunId, run.runId));
        return [{ id: 'batch_views', value: Number(row?.views ?? 0) }];
      },
    });

A video made a week ago still carries last week’s strategy with this week’s layout — lineage does not untangle that on its own. Lock groups keep two of these entities from changing at once; if you need finer attribution, filter the window in your own window() by the ancestor columns you recorded (layoutHash, the run’s time against a change’s merge).

Protect the code that writes and reads lineage. The engine floor closes every *.loop.ts — and only that file. The tool that records the run id (strategy_save), the pipeline that copies ctx.runRef onto the video, and any helper a loop module imports (a ./db.ts holding the query) are ordinary brain files: an implementer whose proposal names one may rewrite it, and a changed join is a changed grade. List each of them in loop.protectedPaths (the freelancer’s features/freelancer/**/*.pipeline.ts and features/freelancer/idea/strategy-save.ts, in its manifest), or keep the query inside the *.loop.ts itself, as idea.loop.ts above does.

Replay and your tools

Some executions exist only to be measured: the pre-merge check’s candidate and incumbent replays of a workflow, every sub-agent they spawn, every pipeline they (or a proposer) lead to. The engine marks each one loop-launched and holds it to one rule — read live, act on nothing. You cannot switch this off; it is what makes a replay safe to run at all.

What the engine does:

  • Engine tools that write, launch or send run dry — notification_send, db_execute, scenario_start, email, Telegram, artifact_publish, kv_set and the rest answer a success marked dry-run and perform nothing. The call and its arguments stay in the transcript, so the judge still sees what the run WOULD have done. Authorization still applies: a call the run could not make in production fails the same way here.
  • Reads run live, and so do the run’s own working tree and its sub-agents (which inherit the mark).
  • Nobody is asked anything. A question, grant request, approval or input card from a loop-launched execution is refused before it is written.
  • No memory writes. Memory is an INPUT of what is measured, so a replay reads your lines live and writes none of them, not even the end-of-turn flush.
  • No PR, no git credential. The run page marks such a run replay · dry-run, and its grade never opens a proposal in the entity’s name.

What you owe — the same rule for your own code:

  • A tool that writes, sends or launches declares replay: 'dry-run' in createTool / defineTool (the engine then answers for it), or reads context.auth.loopLaunched and returns its success without the effect. A tool of yours that declares nothing runs live — the engine cannot tell your reads from your writes. A read-only tool needs nothing.
  • A pipeline reads ctx.loopLaunched (typed on restate.Context by @omnislash-ai/pipeline-sdk) and skips whatever its own code writes, sends or launches outside the engine helpers — your tables, an external API, a raw fetch. The SDK’s notify, emitEvent, kvSet / kvDelete already act on nothing, and callTool / runAgent carry the mark.
  • measure(run) sees run.replay: true on a replay’s grading and may return fewer values than it declared — a replay posted nothing and got no views (Metrics only your code can produce).
// features/freelancer/publish/publish.pipeline.ts
run: async (ctx: restate.Context, input: { videoId: string }) => {
  const rendered = await ctx.run('render', () => render(input.videoId)); // live: measured
  if (ctx.loopLaunched) return { rendered, published: false };           // a replay acts on nothing
  await ctx.run('publish', () => publish(rendered));
  return { rendered, published: true };
},

A pipeline or a heartbeat is never replayed on its own — the flag is true for a pipeline only when a loop-launched execution started it (or it is the loop’s proposer). Such an invocation is not one of the pipeline’s own runs: it is never graded and stays out of its windows, calibration and ratings.

What a replay does not freeze is the live state it reads. Under check.incumbent: frozen (default) an input whose history grade is comparable is not replayed on the incumbent side: that grade was earned under the live state of its own time — memory, KV, your database, the outside world — while the candidate replays under today’s. check.incumbent: replay re-runs the incumbent in the same moment, at the price of one more replay per input and trial. Read a close verdict on history-graded inputs under frozen with this in mind.

Your own proposer — loop.proposer.pipeline

The proposer agent is the engine’s author of a change: it reads the evidence, proposes edits, and the implementer applies them. When your domain knows better how a change is written — a stack of templates only your code renders, a generator you already trust, a model call with your own few-shot prompt — name one of your pipelines as the author instead:

# constitution/MANIFEST.yaml — every entity in the loop, unless it says otherwise
loop:
  proposer:
    pipeline: freelancer-proposer
# features/freelancer/publish/publish.scenario.md — this one keeps the proposer agent
loop:
  proposer:
    pipeline: null

Three tiers like every loop value: the engine’s is null (the proposer agent); the brain’s in MANIFEST.yaml; the entity’s in its frontmatter, where null takes the proposer agent back. The rest of the loop.proposer block folds apart from it: an entity naming its own pipeline keeps the brain’s instructions, which reach the pipeline on its brief, while modelRef — the proposer agent’s model — has no effect under a pipeline (omnislash doctor warns). The id is a pipeline the brain serves — the file stem of a *.pipeline.ts an enabled pipeline scenario owns (its primary <scenarioId>.pipeline.ts or an auxiliary <scenarioId>-<suffix>.pipeline.ts, see pipelines.md). An id that names none fails omnislash doctor (Loop proposers), the loop refuses to open a cycle on it (the sentence the dashboard’s Analyze now shows), and a round that reaches one anyway — the file removed mid-cycle — fails with that sentence on its Retry card instead of calling nothing. The round’s author is read with the round’s other terms, so a refinement is written by whoever the entity names by then.

The pipeline is a restate.service with one run handler: it takes the brief and answers a change or null.

// features/freelancer/freelancer-proposer/freelancer-proposer.pipeline.ts — beside its own freelancer-proposer.scenario.md
import { type ProposerAnswer, type ProposerBrief, restate, runAgent } from '@omnislash-ai/pipeline-sdk';

export default restate.service({
  name: 'freelancer-proposer',
  handlers: {
    run: async (ctx: restate.Context, brief: ProposerBrief): Promise<ProposerAnswer> => {
      // A person turned this down before — do not propose it again.
      const turnedDown = brief.lessons.filter(l => l.rejection?.kind === 'human');
      const worst = brief.window.ranked.find(r => r.bad);
      if (!worst) return null; // nothing to fix this round: the cycle is set aside
      const layout = await runAgent(ctx, {
        agent: { name: 'layout-writer' },
        prompt: `Rewrite the layout role around the payoff shot of ${worst.runId}. Avoid: ${turnedDown.map(l => l.hypothesis).join('; ')}`,
      });
      return {
        summary: 'Open every video on the payoff shot.',
        hypothesis: 'Viewers drop in the first three seconds; the payoff first keeps them.',
        files: [{ path: 'features/freelancer/layout/layout-writer.agent.md', content: layout.result ?? '' }],
        citedLessons: turnedDown.map(l => l.id),
      };
    },
  },
});

What it is handed — ProposerBrief. The same evidence the proposer agent reads, as typed JSON instead of files — every document, the value its file serializes — cut by the same bounds:

FieldWhat it holds
target{ kind, id } — the entity the change is for.
cycleId, roundThe cycle (what loop_history names it by) and which round of it this is.
refinementWhat a person asked of this round when they refined the one before it, else null.
refiningThe round that refinement is about — { round, suggestions }, its change exactly as the round stores it (your own { kind: 'files', proposer, summary, hypothesis, files, citedLessons }, or the proposer agent’s { summary, promptChanges, newSkills, citedLessons }) — so a Refine on your card has its subject; null when this round refines nothing. One round, never trimmed.
idea{ id, text, postedBy, postedAt } when the cycle was opened on an idea, else null.
definition{ filename, content } — the entity itself, as its kind renders it for the proposer agent (scenario.md: name, description, skills, the execution and agent settings and the body — not the evaluation: / loop: blocks; heartbeat.md: its file, schedule, description, agent role, its gate’s path when it has one (- gate:, read-only) and the prompt; role.md: a chat role’s file, skills and instructions). Never cut.
instructions{ brain, entity } — the lines loop.proposer.instructions adds, each tier’s apart (the brain’s MANIFEST.yaml, then this entity’s); [] where a tier wrote none. The proposer agent reads the same lines as two sections appended to its brief.
privacyFor a role entity, the resolved constitution/Loop/chat-privacy.md — how the people in its dialogs may be written about, the rule the chat judge grades under and the proposer agent’s brief carries as ## Privacy; whatever your files and summary say about a dialog is bound by it. null for every other kind, and for a blanked brain copy.
windowThe window the cycle was opened on — the stored record, its vector, and its runs ranked bad first (window.json to the proposer agent). At most 100 runs (WINDOW_MAX_RUNS).
gradeThe grade that opened the cycle — { id, taskResults, runResult, summary }: the judge’s verdict per task (a workflow’s; null otherwise), on the run as a whole (criteria, failureCategory, failureDetail) and its summary (grade.json); null for a window no single grade opened (a scheduled tick, a request). Never cut.
runsThe evidence runs in full — the judge’s criteria and summary, the metrics, the run’s own summary and, while it fits, its tool trace — bad first (runs.json). At most loop.evidence.runs, and cut to loop.evidence.maxTokens the proposer agent’s way: traces first, then the oldest runs.
metricsThe objective and guards with the baseline each stands at, and the window’s per-run vectors (metrics.json); null when the entity declared no metrics.
lessonsWhat earlier rounds of this entity taught — who wrote them, the hypothesis, the files, the deltas, a person’s rejection, the verdict strength — at most loop.lessons.maxRecalled, what a person turned down first (What a round teaches).
proposalsThe entity’s rounds across cycles, newest first, at most 10 — { id, round, parentId, summary, status, prUrl, deltas, createdAt }: what was tried, what became of it, how the check moved each metric (proposals.json).
relatedFailuresOther entities’ recent failed grades (14 days, at most 10) in the failure category of the grade that opened the cycle — { targetKind, targetId, summary, createdAt } — so the change aims at the cause, not one symptom (failures.json); [] when the cycle was not opened on a categorized failure.
surfaceThe rails its files will meet: denied (never writable), unreadable, the entity’s editablePaths, sharedPaths (for a lock-group member, the shared kinds an existing file of which only editablePaths opens), maxChangedLines.

Instants are ISO strings. For the history beyond the lessons, call loop_history through callTool.

What it answers. { summary, hypothesis, files, citedLessons? }: summary is the card’s headline, hypothesis is what the lesson records and the next round is shown, files is at least one { path, content } — the file’s WHOLE new content, or null to delete it — each path once, a plain brain-root-relative file (no glob, no ..), and citedLessons names the lessons of the brief the change stands on (their score moves when it settles). null — or returning nothing at all — means nothing to propose: the cycle settles skipped on the proposer’s word (a notice says so) — no round, no card, no lesson, and it is not a person’s rejection. On a refinement round the same holds: the round a person refined stays refined, and no lesson is written for it. Any other shape, a reply that is not JSON at all (a handler declared with its own output serde), or a TerminalError fails the round like a broken analysis: the row failed with the reason on a Retry / Skip card (under decide: auto, closed at once). Any other throw that leaves the handler fails the round the same way, at once; only a restate.RetryableError is replayed, until the loop stops waiting at reviewDeadlineHours and cancels the call. Switching the entity’s loop off while the call runs cancels it too and ends the cycle — no failed round, no card.

How it runs. The loop SENDS the call — once per round, under a key, so a retried send never starts a second run — and does not wait on it: your pipeline’s own run row is the answer. When the run completes or fails, the engine rings the cycle and it reads that row; until then the cycle looks again on its own every few minutes (2 minutes, doubling to 2 hours), so the answer is never lost, only read a little later. A run that never started — the send was killed, or the service is no longer served — is noticed and fails the round instead of being waited out. The run is its own (ctx.runRef) and the loop launched it (ctx.loopLaunched): the engine tools it calls through callTool and the agents it runs act on nothing, and it can ask nobody — a proposer that needs a person cannot have one; it answers null or fails. What it spends is its own: nothing it does is charged to loop.budget, and maxAnalysesPerDay and cooldownHours pace it like the proposer agent.

Only the author changes. The files go through the rails the proposer agent’s change goes through: the same surface (the engine floor, your protectedPaths and memory.sources, every other entity’s definition files, the code of every proposer the loop names — its own *.pipeline.ts included, so no author rewrites itself — a lock group’s shared files — see What the loop may not touch), the same diff gate — measurement blocks unmoved, maxChangedLines — then the decide card (which names the pipeline as the author), the PR the engine opens, the pre-merge check, the merge gate, the watch, the revert and the lesson. The engine writes the files itself; no implementer is opened.

The read ban does not apply. The proposer agent is kept from reading the judge’s files — constitution/Evaluation/** and every *.loop.ts — so it does not optimize against the rubric’s wording. A proposer pipeline is your own code, running with your brain at hand, and nothing stops it reading anything; what binds it is the write side, which is unchanged — those same paths are refused as write targets, like every denied path.

Reading the history — loop_history

Your own code reads the loop’s ledger through one engine tool, so a pipeline that narrates what the loop did (a weekly digest, the freelancer’s vlog about its own “life”), or a window() that filters by which version of an ancestor made a run, never has to query engine tables:

import { callTool } from '@omnislash-ai/pipeline-sdk';

const result = await callTool(ctx, 'loop_history', {
  targetKind: 'scenario',          // optional — every kind when omitted
  targetId: 'layout',              // optional — every entity when omitted
  // feature: 'publishing',        // optional — the entities that feature ships today
  since: '2026-09-01T00:00:00Z',   // optional — cycles that moved at or after it
  limit: 20,                       // optional — 1-100, default 20
  // cursor: previous.nextCursor,  // optional — the next (older) page
});
// result.data = { cycles: [...], count, nextCursor }

Read-only and safe, category Evaluation, capability loop:read (hitl: false — no grant to ask for; a brain that wants it gated sets hitl: true in constitution/engine-capabilities.json). It runs live in an execution the loop launched: a read acts on nothing. since is compared with the cycle’s last movement — opened, advanced a stage, settled — so a change opened two weeks ago and merged yesterday is in “since three days ago”. feature narrows to the entities the feature ships when you call (an entity moved between features takes its cycles with it). The pages follow the cycles’ opening, newest first: pass nextCursor back as cursor for the next page, null on the last.

One row per cycle, newest first — these columns are the contract:

ColumnWhat it holds
cycleId, targetKind, targetIdThe cycle and the entity it improved.
statusThe cycle’s status — validated, regressed, unvalidated, skipped, failed, or a live one (awaiting_decision waits on its card, applied on its merge, validating on its watch).
reasonWhy it ended as it did — the watch’s reason, the pre-merge check’s when the check regressed the candidate (with its held-out split), else the cycle’s own (a set-aside, an error); null for a clean validated.
roundThe cycle’s last round — the one that merged, when one did (at most one round of a cycle ever merges); null when nothing was proposed.
proposerWho wrote that round’s change: your proposer pipeline’s id, or null for the proposer agent — the engine’s default author (and for a cycle with no round). A cycle your proposer answered null on has no round of its own, so it reads proposer: null: tell it by status: skipped and a reason that names the pipeline (“The `freelancer-proposer` proposer had nothing to propose this round”).
hypothesis, filesWhat that round claimed would help, and the files (and skills/<name>/ dirs) it proposed to touch — for a proposer’s round, its hypothesis and every file it wrote or deleted.
prUrlIts PR, once the apply delivered one.
implementerSummary, landedFilesWhat the apply actually delivered: the implementer’s own report of what it applied, what it skipped and why (null for your proposer’s round, whose files land as written), and the paths the PR carries (empty until the apply delivers).
mergedAt, mergeGateWhen the PR merged (ISO; null when it never did) and through which gate — auto or human.
revert{ prUrl, outcome, mergedAt } when a revert PR was opened — outcome merged / closed / unmerged, null while the loop still waits; null when none was.
revertErrorWhy a change owed a revert has NO revert PR — no sandbox, no merge commit on the PR, or the git / GitHub step’s own error. The change is still live and must be reverted by hand. Null when a revert PR was opened or none was owed.
checkVerdict, watchVerdictThe pre-merge check’s and the post-merge watch’s comparator records, verbatim: the verdict, the per-metric moves, the per-input story, the reason. Null where that comparator never ran.
strengthVerdict strength — paired, measured, blind; null when nothing shipped.
rejection{ kind: "human", by, note } when a person skipped the round (with their note), { kind: "timeout" } when nobody answered its card; null otherwise.
lessonThe newest lesson the cycle taught — { id, outcome, failureCategory, deltas, helpful, harmful } — or null before it settled.
missingLessonWhy a settled cycle has no lesson: { kind: "write-failed", error } when the settle wrote one and the archive refused it, else { kind } — nothing-proposed, switched-off (the kill switch closed it), unattended-failure (its apply broke on a round nobody stood on) or not-recorded. Null while the cycle is live and whenever lesson is set.
createdAt, completedAtWhen the cycle opened, and when its last stage transition happened (ISO).

Rows are ordered by when the cycle opened, newest first. They arrive as plain JSON — the table above is the contract, there is no type to import — and every instant is an ISO string, because the answer reaches your pipeline through a journaled callTool.

Lessons and the proposal line

The loop is supposed to get better at an entity, not to keep having the same idea about it. Two archives are what make that possible, and the next proposer of the entity is handed both.

What a round teaches

Every round that reaches a terminal with a proposal behind it leaves one lesson in omnislash.loop_lessons — a row, not a paragraph:

FieldWhat it holds
outcomeHow it ended: validated, regressed, unvalidated, skipped or failed
hypothesisWhat the round claimed would help — its own summary
filesThe files it proposed to touch, plus any skill directory it asked for
failure_categoryThe taxonomy of the failure the cycle was opened to answer, where the grader assigned one
deltasHow the metrics actually moved — the watch’s watch_verdict.perMetric where it measured anything, else the round’s check_verdict — null where no comparator ever measured, because “not measured” is not “measured flat”
helpful / harmfulHow often a later round that cited this lesson went on to validate, or to regress

All five terminals write one, not just the two that shipped something. “Proposed and set aside”, “shipped and unmeasurable” and “the change could not even be made” are facts about this entity that the next analysis should not have to rediscover. A terminal with no round behind it writes none — an analysis that produced nothing, a judge that would not resolve, a cooldown that refused: there is no hypothesis to record. Nothing here is written by a model: the hypothesis came from the proposer and the numbers from the judge.

Two endings with a round write none by design: the kill switch (a call about the loop, not a verdict on the round) and an apply that broke on a round nobody stood on (decide: auto — a broken turn, not a finding). A lesson the settle owed and the archive refused is not dropped silently: the failure is kept on the cycle (loop_cycles.lesson_error), and the dashboard’s Learn chapter says which of these it was instead of “nothing was proposed”.

Three facts ride with every lesson the proposer is handed, read off the round and the cycle behind it rather than stored on the lesson row:

In lessons.jsonWhat it says
proposerWho wrote the round’s change: a proposer pipeline’s id, or null for the proposer agent.
rejectionWhy the round was set aside, when it was: { kind: "human", by, note } — a person answered its card with Skip (with the note they left, or null); { kind: "timeout" } — nobody answered the card before reviewDeadlineHours (or it was cancelled). null for every other ending — a shipped change, a PR closed on GitHub, a failure, and the loop setting a round aside on its own call (a lost claim, a judge or inputs it could not pin), which it records as its own decision.
strengthHow strongly the verdict was measured — paired, measured or blind, see Verdict strength; null for a round whose change never shipped.

A lesson used to read outcome: skipped for all of these alike — a person’s “no” and a card left over a weekend looked the same to the next proposer.

Citations are what make a lesson measurable. The proposer answers with the ids of the lessons it built on, out of the lessons.json it was given. When that round settles validated and something measured the win (strength paired or measured), each cited lesson takes a helpful point; a validated nothing measured (blind — a comparator that answered without reading a metric) credits nothing. regressed charges each cited lesson a harmful one, whatever measured it. The other three terminals measured nothing about the citation and move no score, and an id naming no lesson moves nothing at all. A lesson nobody cites simply stays at zero — which is its own answer.

Recall and retention are one decision, so loop.lessons is replaced wholesale per tier like autonomy. maxRecalled is how many of this entity’s lessons the next analysis reads — 0 reads none, and there is no separate on/off switch. retentionDays is how long a lesson is kept before the scheduler’s periodic sweep deletes it; past that age it describes a version of the entity nobody is improving any more. Both are read live at the moment each applies, so raising maxRecalled widens the next analysis and lowering retentionDays takes effect on the next sweep. The sweep walks the whole table in one dated delete rather than entity by entity, so the number it honours is your brain-wide one (MANIFEST.yaml, or the engine default). Recall reads maxRecalled alone, so a per-entity retentionDays is read by nothing — the dashboard refuses to write one there.

What a person turned down comes first, then newest first — the one ordering the engine applies, and the bound is taken after it: a Skip from last month is recalled ahead of yesterday’s validated round, however small maxRecalled is. A person’s “no” is the one kind of lesson the loop cannot re-derive — a regression shows up in the numbers again if the idea comes back, a Skip does not. A timeout is not a person’s answer and ranks by recency with the rest. Beyond that the engine does not rank by relevance: which of an entity’s past failures still matter is a judgement about your domain.

The proposal line

The second archive is the rounds themselves. proposals.json shows the proposer this entity’s ten most recent rounds — across cycles, so a round from a cycle that settled last month sits beside this cycle’s first — each with its number, the round it refines, its summary, where it ended up, its PR and the per-metric deltas its check recorded. That is the answer to “was this already tried, and what happened”, and it is the reason a second proposer does not re-propose what a human turned down in July. Every proposer prompt the engine ships shows it under Prior Proposals ({{proposals}}); a copy of one of those prompts in your constitution/Loop/ that predates the section must add the placeholder to see it — a prompt without it is never pointed at the file.

The dashboard reads the same two archives: a cycle’s story shows its rounds in the Hypothesize chapter (a Skip’s note quoted under the decision), its lesson in Learn, and the lessons it was handed (“It remembered”) and the later cycles that recalled it beside the story — each named with how it ended and its verdict strength (cycle 3 · r1 · kept · paired), and a round a person turned down with their note under their name, the lesson the next analysis reads first; above the story, how strongly the cycle’s verdict was measured and — for a round set aside — whether a person turned it down. Read them; never write them — engine tables belong to the engine (database.md).

What a settled cycle keeps

The verdict is stored, not recomputed. Your tolerances, your metric roles and your comparator are files that move; re-deriving a past decision from today’s declarations would explain it in terms nobody used to make it. So the settle writes down what it measured — and it writes it in two places, because a cycle and a round of it are two different things.

A round owns its PR and everything that PR carries. One row per round in omnislash.loop_proposals, keyed by the cycle and the round number; a refinement is a child round rather than an overwrite, so what the previous round proposed is still there to read.

What it keepsColumn
What this round proposed, and the one-line claim under itsuggestions (jsonb), summary
The human steer that asked for this round, and the round it refinesrefinement, parent_id
Where the round itself stands — proposed, then accepted / skipped / refined, then applied or failedstatus
Who answered its card, and when — the chat speaker who tapped it, or the entity itself for every answer no person was asked fordecided_by, decided_at
What the person said with the answer — the note a Skip carried (the dashboard’s Skip asks “why”); null otherwisedecision_note
Its change, and the revert that undid it without losing the change that shippedpr_url, revert_pr_url
What the apply delivered: the implementer’s own report (what it applied, what it skipped and why — null for a proposer pipeline’s files), and the paths the PR carriesimplementer_summary, landed_files
Why no revert PR exists for a change owed one — the change is still liverevert_error
How the wait on that revert ended — merged (with GitHub’s instant), closed, or unmerged at the deadline; empty while the loop still waitsrevert_outcome, revert_merged_at
The gate the revert PR stands under — the change’s own, demoted to human when GitHub would not arm itrevert_merge_gate
The pre-merge check: the PR head it measured, and the candidate and incumbent replays it launchedchecked_sha, check_run_ids, baseline_run_ids
The check’s comparator record — per metric, the incumbent median → the candidate median, the median per-input movement, the tolerance it was allowed, how many pairs measured it, whether it tripped; per input, what each side produced, whether the candidate lost it and which critical metrics it failed there — plus the one-clause reasoncheck_verdict (jsonb)
The lessons the proposer said this round was built oncited_lessons

“Was anybody asked?” and “who answered?” are different questions, and the round answers both. Every decision that reaches a round carries an actor, and it is that actor — never a placeholder — that lands in decided_by. An Accept, a Skip or a refining reply on the round’s HITL card records the person who answered (the card’s respondedBy), which is what the column is for: before this, every answer looked alike once the card was gone. Every answer no person was asked for records the cycle itself — an acceptance the entity made under autonomy.decide: auto, one a standing loop:auto_accept grant authorized, a proposal nobody answered before its deadline.

The grant case deliberately does NOT name who issued the grant, and cannot: loop:auto_accept is admin-only, an admin-issued grant is written without an approval card, and hitl_id is the grants table’s one link to a person. So the two autonomous branches are indistinguishable on loop:accepted — both carry auto: true and the cycle’s own actor. Which one it was is in the notice the round sends and in the grant row itself. What the actor buys is the human path: auto says only whether a person was asked, the actor says which person said yes.

A cycle keeps what it fixes or measures once, across every round it takes — omnislash.loop_cycles:

What it keepsColumn
The judge it is pinned to, and the inputs its check measures onjudge, frozen
The post-merge watch’s live state: the window its last tick sampled — rewritten every tick, what the forcing producer and the dashboard read the window’s progress offwatch_window (jsonb)
The post-merge comparator’s record — the verdict it settled on, its per-metric and per-input story, its reason; under repair the count’s sentence with nothing measuredwatch_verdict (jsonb)
The post-merge watch’s settled record: the runs of the window it settled on, and — under repair — the terms it counted them undervalidation_run_ids, validation_terms
The tally the outcome rests on: for the check, viable candidate replays out of the pairs promised; for the watch, good runs out of the window it countedchallengers_viable / challengers_total
The merge gate the PR really stands under — auto or human, after the breaker, an inconclusive check, a refused arm, a moved head or a disarm had their say; written at the arm and again at a demotion, never after the merge, so on a merged row it is which gate let the change through — and when the merge wait started (the merge is due merge_wait_started_at + watch_deadline_hours)merge_gate, merge_wait_started_at
When its PR merged (GitHub’s instant — set under every watch mode, so it is what “this change shipped” means), and when the watch started counting executions as evidence (after brain-sync; empty under watch.mode: none)merged_at, watch_started_at
Every proposer and implementer agent the cycle dispatched, across all of its roundsagent_ids
When the proposal became available, and when its PR was openedproposed_at / applied_at

At most one round of a cycle ever merges, which is why the watch has nothing per-round to say and its record sits on the cycle.

What a person owes the loop is one query, listCyclesAwaitingPerson() in @omnislash-ai/infra — one statement over stored facts, never a guess from row status. Each entry names the cycle, the gate (decide, retry, grant, merge, revert, rearm), the round, since when and until when the loop waits, the HITL row that answers it (for the first three) and the PR:

GateWhat is stored
decide / retrya pending HITL card of producer loop whose data is loop.gate.v1 ({ cycleId, round, stage, deadlineAt }, CycleGateDataSchema)
granta pending grant_request from one of a live cycle’s own agents (agent_ids), directly or coalesced onto another card
mergean applied row with merge_gate = 'human', due merge_wait_started_at + watch_deadline_hours
reverta regressed row whose revert PR has revert_merge_gate = 'human' and is still being waited on
rearman entity whose breaker is tripped under a declared auto gate — passed IN by the caller, which resolves the entity’s autonomy block (brain config the query cannot see); the entry is the settle a re-arm acks

check_verdict is null wherever no check comparator ran — unvalidated, skipped, failed, and a cycle whose check is off. A pre-merge check that ran a comparator writes its record on its round and it stays through the merge and the watch; the same story is on the PR (the commit status and, for a closed PR, the comment). The watch’s own record is the cycle’s watch_verdict, written by the settle it decided and null on every row the watch never settled (a judge that broke, a loop module that would not load, mode: none). The lesson reads the watch’s perMetric first and the check’s after it. The tally is recorded either way: “1 of 5 ticks observed” is the whole explanation of why there is no verdict, and nothing else can reconstruct it, because a tick that never happened leaves no run id behind.

The two timestamps are there because completed_at means “this row is done” and is rewritten by whichever transition finished it last, so every earlier boundary used to be erased by the next one. Time-to-propose is proposed_at - started_at, time-to-apply is applied_at - proposed_at, and time-to-verdict is completed_at - applied_at.

Read these; never write them — engine tables belong to the engine (database.md).

Running the loop from an agent

Everything the dashboard’s loop pages do with one click, an agent does with one tool call, through the same code: the same reads, the same validation, the same write. The self-evolving skill maps what a person asks to these calls. targetKind + targetId name one entity; both null mean the whole fleet (reads) or the brain tier, constitution/MANIFEST.yaml (writes). Naming only one half is refused.

ToolTakes → answersCapability
loop_status{ targetKind, targetId, joinOrder? } or both null → { entities, features, summary, lifecycle } — the fleet (what needs a person first — each needsYou item with its gate, cycle, round, stage, url (the PR or the link where the person acts) and next — counts per state, live and stuck cycles; per entity its feature, description, its own on / off flag entityEnabled, preset, stateWord and short sentence, lockGroups, counts (its own record), graded, pending grades, cadence, the window its next analysis reads, analyzingSince, join (what joining would do — always, in the loop too), activityAt; with joinOrder: true also, for one not in the loop, joinRank, its place in the order a person is offered entities to start with: graded first, then the most hand-tuned, and handEdits, people’s edits to its files in the last month from the brain’s git history — both null without it, and git is not read; features the brain’s features the fleet groups by, with how many of each are in the loop and need a person) or one entity whole (who it waits on with the next step, the live cycle with its phase, since when and when it looks next, the breaker, holds, ideas, lock groups, failures, config error, and its next cycle — { idea, waits, meter, nextAskAt, restingUntil, analysesToday }: the queued idea it opens on ({ id, text, queued }, or null when the entity’s rule decides) and what it waits on, waits.kind one of cycle · revert · lock-group · held · fault · rule (with awaitingPerson — the person’s move { what, url? } a not-ready window names, else null) · cooldown · daily-cap · ask (nothing blocks it: by: 'grade' — the next graded run opens it; by: 'sweep' — the scheduler’s sweep, at its time or null for the next) · stalled (the sweep’s ask is due — a queued idea, or the trigger.every cadence — and the sweep last ran at since, more than a whole sweep ago: the scheduler is not running; an entity a grade asks never stalls); next is null while the loop is off, or a cycle owns the entity and no idea waits — the one read that runs the entity’s own rule); each entity’s page is its permanent dashboard link, /go/<kind>:<id> (null without a public dashboard)loop:read (safe)
loop_get{ targetKind, targetId, cycles? } → { record, page } — one entity whole, as its page reads it: status (state, word, sentence), loop (the switch’s two halves), preset (its value and per preset whether switching to it would be accepted), what a person owes it (attention), demoted, breaker, holds (current and history — who held, released, re-armed, and why, newest first), lockGroups (holder, members), live (its phase, machine, watch and the proposer / implementer agents it opened), stuck, next, analyzeNow, join, firstRun (the way to a first cycle), graded, ratings (judge vs people per human-rated metric), ideas, lessons (each with the cycles that cite it) and its newest cycles (default 25) told whole with their grade — the fold loop_get_cycle answers one of. A prompt that pieced an entity together from loop_status and loop_history may read it hereloop:read (safe)
loop_trace{ targets: [{ targetKind, targetId }] (1–50), metric?, points?, span? } → { traces, honesty } — each entity’s metrics over its graded runs (people’s ratings over the judge’s numbers, as the loop reads them): each run’s value (0..1; the metric’s scale is the scale a person answers it on), whether it was a bad run under the terms that judged it, its brainSha; the level line cut at every change the entity shipped (levels); a marker per cycle with its grade and the metric’s move (delta); one entity: every declared metric (or metric — an id the entity does not declare is not_found) and its pending grades; several: each one’s objective (primary), an entity the loop no longer knows listed in honesty.missing rather than failing the call. span: recent (default) reads the newest 100 graded runs — the loop’s own window; all (one entity) its whole history, up to 2000. points (10–500, default 100) caps what a metric draws — a longer trace is bucket-median decimated, its bad runs kept; honesty.runCap is how many runs the trace read at most and honesty.capped names the entities whose history holds moreloop:read (safe)
loop_summarythe window input (window, default 30d, or from / to / tz) + feature?, entity? → { cycles, winRate, perWeek, uplift, byGrade, owed, window, previousWindow, honesty } — the cycles that settled, the win rate (improvements ÷ graded settles, %) beside the window before, cycles per week by grade, the objective’s uplift of the kept cycles (a distribution with its n), the count per grade and the gates waiting on a person nowloop:read (safe)
loop_list_agents{} → { agents, proposerPipelines, autoDecide } — the loop’s judge, proposer and implementer as the brain’s role files configure them: the file (and whether the brain forked it), what it declares, its model ladder, its grant and what that compiles to per harness, where it runs, the doctor’s verdicts and the consequences in words; the proposer pipelines with the entities each writes for; the entities that decide by themselves. The loop’s prompts are instruction_list’s loop/… modulesloop:read (safe)
loop_policysame (an omitted half is null, so no input reads the brain’s) → { scope, configError, knobs, config, sections, lockGroups, proposerInstructions, calibration, loopCode, functions, page } — every knob as { value, source, tiers, oversight, overriddenBy }, keyed by the ids the writes take; config the resolved terms with the tier behind each; sections the editors’ views, discriminated by scope (knobs, metrics, chat — null on an entity that is not a chat role — and, brain-wide, the lock-group groups); lockGroups the groups in scope with the cycles holding each, a person’s hold and the members; proposerInstructions the lines added to every brief (the brain’s, then an entity’s); calibration how far one entity’s judge agrees with people; loopCode the functions omnislash.config.ts defines and the entities whose own module overrides them; page is the policy’s dashboard link — the entity’s own /go/<kind>:<id> (its loop view holds its policy), the brain tier’s /go/query:loop.policy (the loop settings’ policy)loop:read (safe)
loop_get_proposal{ cycleId, round? } → one round in full: status, summary, the refinement that asked for it, decided ({ actor, at, note } — the record’s Actor, not a label: { kind: 'telegram', username, … } for a person in chat, { kind: 'dashboard' } for an answer from the dashboard, { kind: 'loop', cycleId } for the entity’s own gate), prUrl, and change — the proposer agent’s promptChanges[] (target, current, suggested, rationale) and newSkills[], or a proposer pipeline’s files[] with a diff each against base, the commit the cycle froze as the incumbent (the brain now when that commit is not in the checkout); a judge’s file (ENGINE_UNREADABLE_PATHS) or a path that links out of the brain comes back hidden: true with no diff. round omitted = the newest. The dashboard’s proposal sheet reads the same oploop:read (safe)
loop_join{ targetKind, targetId, dailyCap, dryRun? } → { resumed, gradingStarted, autonomy, files } — the dashboard’s Turn onloop:configure (moderate, asks nobody)
loop_set{ targetKind, targetId, knob, op, value, dryRun? } (op is set or remove) → { tier, knob, files } — one knob at one tierloop:configure (moderate, asks nobody)
loop_widenthe same, knob from a closed list → { tier, knob, files, widened[] }loop:autonomy (dangerous, a person approves)
loop_analyze{ targetKind, targetId } → { opened, ideaId, reason } — the dashboard’s Analyze now; a declined ask is opened: false with the loop’s reason, a pair that names no entity is refused not_found, an entity whose loop settings do not resolve invalid with the parse errorloop:analyze (moderate, a person approves by default)
loop_ack_failure{ cycleId } → { acked, targetKind, targetId } — dismiss a terminal failureloop:supervise (moderate, a person approves by default)

Every write here is an engine op, so the dashboard’s button and the agent’s tool are one definition, and every write that changed something is a row of the action log (Audit › Actions), recorded against the entity it steers — a brain-tier write or a group hold against constitution/MANIFEST.yaml — with who made it. A refusal arrives as one: not_found (the entity, idea or cycle names nothing), precondition (a hold with no reason, nothing to release, an idea the entity cannot take), refused (a write that would break an entity or hand over oversight), conflict (the file kept changing — try again).

Dry run first. loop_join, loop_set and loop_widen take dryRun: true: the same validation the write runs, answered with files — each file the write would touch with its diff — and nothing written. Show the diff (and, for loop_widen, widened[]) to whoever decides, then commit the same call without dryRun.

An agent’s write is the dashboard’s write, minus oversight. It is resolved before it is written: an entity-tier write against that entity’s whole loop, a brain-tier write against every entity it reaches. A write that would break one is refused with that entity’s name and the loader’s reason, and nothing is written. Every loop-settings write — the dashboard’s editors and these tools, from any process — takes one lock, and commits only over the bytes it validated; a file that keeps changing, or a lock held too long, answers “try again” with nothing written. op: remove drops this tier’s own value so the tier below answers. A write of one key of a wholesale block (autonomy, watch, check) at the entity tier copies the block as the entity currently sees it, with that key replaced.

loop_set refuses to hand oversight to the machine. Three families of knob are judged: autonomy (autonomy.decide, .merge, .maxConsecutiveRegressions, .onInconclusive, .onUnmeasured, .requireCalibration, and watch.onInconclusive), the change perimeter (editablePaths, protectedPaths, maxChangedLines), and the brain-wide loop switch. The check runs on resolved values, for every entity the write reaches, before and after — so removing an entity’s decide: human under a brain that says auto widens, and is refused like writing auto. A write that narrows, or changes nothing a person relies on, goes through. The refusal names each widening and points at loop_widen, which makes the same write once a person approves it and answers every widened[] entry { subject, knob, from, to }. loop_set also refuses a write that takes an entity out of a lock group a person holds — the hold would stop applying to it; lifting a hold is loop_release’s.

What the check does not judge: knobs that weaken a guard only indirectly — calibration thresholds, check.mode and watch.mode, spend (budget, maxAnalysesPerDay, evaluation.sample). Those stay loop_set’s.

loop_join resumes a paused entity. When the entity’s own top-level enabled is false, joining writes it back to true (resumed: true), so a paused heartbeat or scenario starts running again. It refuses an entity whose feature is switched off, a brain whose loop switch is off (or whose grading switch is off, for an entity not graded yet), and an entity whose loop settings do not load; an entity that runs on its own with no daily grading cap at any tier is refused until the call names dailyCap.

Who did it. Holds, releases, re-arms and failure dismissals are rows, stamped with the Actor that made them. Every write — a knob or a join, a file edit with no actor column, included — is also an action-log row naming who made it. The dashboard saves an autonomy, perimeter or switch knob through loop_widen’s op: the person at the page is the one who approves.

What no tool does. No tool answers or cancels the loop’s decide or retry card, or a rating card, and none rates a run — hitl_respond and hitl_cancel are refused [unauthorized_responder] on them, and approving a grant card a loop agent stopped on takes a person (a running boss, inside its approvableCaps, aside). One door stays open in code and is closed only by the self-evolving skill: the engine cannot stop an agent with git credentials from merging a loop PR (Where the loop waits for a person).

The exposure you accept by default. loop:configure asks nobody, and every agent that reaches the engine’s tools can use it — over MCP, every CLI agent, whatever its role. It cannot widen oversight or lift a hold, but it can spend, resume a paused entity, and change calibration and check/watch modes. Set hitl: true on loop.configure in constitution/engine-capabilities.json to put a person on those.

Supervision — re-arm, hold, release

Four moves supervise an entity from outside its cycles, on the dashboard’s entity page and as tools, behind two capabilities. The hold is loop:hold (moderate, hitl: false): it only stops the loop, so an agent that sees trouble stops it without waiting on a person. Re-arm, release and dismissing a failure hand oversight back, so they are loop:supervise (hitl: true by default — relax loop.supervise in constitution/engine-capabilities.json, or grant it to the automation that supervises with a grants: block, contract.md § 7.1):

ToolDoes
loop_rearm { targetKind, targetId } → { acked, streak }The dashboard’s Re-arm: acknowledges the newest settle a TRIPPED breaker stands on, so the declared gates apply again. A no-op (acked: false) when the breaker is not tripped. Who re-armed is recorded (regression_acked_by) and shown on the entity page.
loop_hold { target, reason } → { holdId, created, reason }Holds the loop on one entity ({ kind: "entity", targetKind, targetId }) or one lock group ({ kind: "group", group }): no new cycle opens there — not the machine’s, not “Analyze now”, not a queued idea (it waits). A cycle already running finishes (one that was submitted but had not yet claimed the entity is stopped before its proposer runs); gates are not changed and no card is raised. Holding a held target answers the standing hold. A reason is required.
loop_release { target, reason? } → { holdId }Lifts that hold. Refused (precondition) when nothing holds the target.
loop_ack_failure { cycleId } → { acked, targetKind, targetId }Dismisses one terminal failure from Loop failures once a person knows about it, and records who dismissed it (failure_acked_by). Attention bookkeeping only — not a re-arm, and nothing the loop may do changes. acked: false when the cycle is not an unacknowledged terminal failure.

Every hold and release is kept with who (the Actor) and why, and the entity page lists them beside the re-arms under Supervision; Hold / Release sit next to Re-arm. A group hold names a group declared under loop.groups (another id is refused) and stands on every member the group has when the loop reads it.

A business number as a guard. Some numbers no entity owns — a week’s revenue, meetings booked across a whole funnel — so no single watch can attribute them. Keep them out of the entities’ verdicts and let a scheduled pipeline outside the loop hold the loop while the number is down:

// features/outreach/outreach-kpi.pipeline.ts — a weekly pipeline scenario, not in the loop
import { callTool, restate } from '@omnislash-ai/pipeline-sdk';

export default restate.service({
  name: 'outreach-kpi',
  handlers: {
    run: async (ctx: restate.Context) => {
      const rate = await ctx.run('meetings-per-100', () => meetingsPer100SendsLastWeek());
      const target = { kind: 'group', group: 'outreach' };
      if (rate < 2) {
        await callTool(ctx, 'loop_hold', { target, reason: `meetings ${rate}/100 sends last week` });
      } else {
        await callTool(ctx, 'loop_release', { target, reason: 'meetings recovered' }); // fails harmlessly when nothing is held
      }
    },
  },
});

The hold asks nobody (loop:hold); for the release its scenario declares grants: [{ capabilities: [loop:supervise] }]. The hold stops new changes; it does not undo the ones already shipped — a change the watch then reads as worse is reverted by the watch itself. Under preset: evolve the same number can instead be a scope: period guard with a floor that each member’s window() measures, so the watch compares it directly (Improving what already works).

The proposer block — loop.proposer

Who writes an entity’s change, on which model, and what it is told beyond the engine’s brief live in one block. The loop has two roles and a pipeline that may stand in for one, so the proposer’s settings sit in a block of their own, as memory.consolidation: does; the implementer has none — its model is its role file’s, and editablePaths / maxChangedLines bound every change, whoever wrote it.

# constitution/MANIFEST.yaml — every entity in the loop
loop:
  proposer:
    instructions:
      - Our brand voice is plain and concrete; never propose superlatives.

# features/billing/refunds/refunds.scenario.md — this entity adds its own
loop:
  enabled: true
  proposer:
    modelRef: smart
    instructions:
      - Look at refunds first — they are where billing runs go wrong.
KeyEngine defaultTiers merge
pipelinenull — the engine’s proposer agent writes the changeThe nearest tier that writes it wins. Absent = the tier above; null = the proposer agent, so an entity writes null to take the agent back under a brain-wide pipeline. See Your own proposer.
modelRefnone — the proposer role’s own ref (constitution/Agents/system/loop/role-proposer.md), the file that re-aims every analysis at onceThe nearest tier that writes it wins. No effect while a pipeline writes the change — a pipeline picks its own models.
instructions[]Both tiers kept, the brain’s first: an entity adds to the brain’s lines and cannot drop them.

The block folds per field. Every other block inside loop: — trigger, autonomy, check, watch, lessons, evidence, budget — belongs whole to the nearest tier that writes it. proposer: does not: its three keys answer three unrelated questions, so an entity that names its own modelRef keeps the brain-wide pipeline and instructions.

Instructions are added to the brief, never a replacement for it. The engine appends them after whichever brief the cycle reads — the kind’s, the preset’s or an idea’s, the engine’s template or your own copy of it — in two sections, a section with no lines left out:

## Instructions for every entity of this brain

- Our brand voice is plain and concrete; never propose superlatives.

## Instructions for this entity

- Look at refunds first — they are where billing runs go wrong.

(after a chat role’s ## Privacy section, when the entity is a chat role). A proposer pipeline gets the same lines on its brief, as instructions: { brain, entity }. Each line is a bullet; write two or three short ones rather than an essay — and when a line would rewrite how the proposer works rather than what it should look at, that is a brief of your own (Overriding the prompts).

The block is part of loop:, so the loop never edits it: a change that moves it is refused like any measurement block’s (What the loop may not touch).

omnislash doctor checks it: Loop proposers fails a pipeline the brain does not serve; Loop proposer models fails a modelRef constitution/models.json does not resolve (every analysis of the entity would fail before it starts) and warns on a modelRef beside a pipeline, which does nothing.

The keys that used to sit flat in loop: are refused at load, and the message names where each went — an entity carrying one is dropped, a MANIFEST.yaml carrying one fails the brain’s load:

WasNow
loop.modelRefloop.proposer.modelRef
loop.proposer: <id> / nullloop.proposer.pipeline: <id> / null
loop.prompt: <name>removed — lines for the brief go in loop.proposer.instructions; a whole brief of your own is constitution/Loop/proposer/<kind>.md

Overriding the prompts

Every prompt is a file you can override by putting your own copy at the same path in your constitution/:

PathUsed for
constitution/Evaluation/Methods/default.mdGrading. Needs a # Run Evaluation section; # Task Evaluation is optional (pipeline runs have no per-task criteria).
constitution/Loop/proposer/workflow.mdAnalysing a failed workflow run (a scenario’s). The brief’s files arrive as placeholders — {{definition}}, {{runs}}, {{window}}, {{grade}}, {{metrics}}, {{proposals}}, {{lessons}}, {{failures}}, {{refinement}} — and your copy must use these names: {{scenario}} is now {{definition}}, {{evaluation}} is gone (the grade is {{grade}}, and the line naming it is dropped for a window no grade opened).
constitution/Loop/proposer/pipeline.mdAnalysing a failed pipeline invocation. Same placeholders.
constitution/Loop/proposer/heartbeat.mdAnalysing a poorly-graded heartbeat tick. Same placeholders.
constitution/Loop/proposer/chat.mdAnalysing a chat role’s graded episodes, and how the proposer reads each dialog from the database. Same placeholders. The privacy rule is not in it — see chat-privacy.md below.
constitution/Loop/proposer/evolve.mdAnalysing a working entity under preset: evolve, whatever its kind — the window’s best and worst runs by the objective, and a change to move it. Same placeholders.
constitution/Loop/proposer/idea.mdAnalysing a cycle opened on an idea, whatever the entity’s kind or preset — carry the idea out as one grounded change, or decline it with the reason. The same placeholders plus {{idea}}.
constitution/Loop/implementer.mdApplying accepted suggestions — file edits only: the proposal arrives inline as {{suggestions}}, the surface as {{editablePaths}} / {{deniedPaths}} / {{unreadablePaths}} / {{maxChangedLines}} (a copy without {{editablePaths}} — e.g. one still carrying the pre-rename {{allowedPaths}} — is refused as a terminal error at prepare-apply-prompt), and the agent reports { summary, files }; the engine commits, pushes and opens the PR. Owns the rule that a suggestion’s target path is relative to your brain root.
constitution/Loop/chat-privacy.mdHow the people in a chat role’s dialogs are written about — ONE rule for two roles: the chat judge grades under it, and the engine appends it to every chat proposer brief under ## Privacy (your own copy of proposer/chat.md included) and hands it to a proposer pipeline as brief.privacy. A blank copy leaves both without a privacy rule. See Chat roles.

Add a method with eval_write_method — it writes constitution/Evaluation/Methods/<name>.md next to the default (dry run first; a file without # Run Evaluation is refused) — and name it in evaluation.method. eval_list_methods lists every method — the engine’s, the ones you customised (engine-customised) and your own (brain) — with the graded entities that use each now; eval_get_method reads one with the sha a write swaps against. The name none is reserved: it grades with no judge at all — see Grading without a judge.

The loop’s prompts are instruction modules. Every file under the engine’s constitution/Loop/ is the shared instruction loop/<its path without .md> — loop/proposer/workflow, loop/implementer, loop/chat-privacy — read and written like every other one: instruction_list (each loop prompt’s loop says which of the loop’s agents read it, when, and the entities whose analysis reads it now — usedBy: null with unread saying why when MANIFEST.yaml or the loop’s entities do not load; the prompt files still read), instruction_get, instruction_write_file (the first save forks the engine’s template into your constitution/Loop/; a placeholder the loop does not fill, or an implementer task without {{editablePaths}}, is refused invalid — the doctor’s own rule, Loop prompt placeholders) and instruction_reset (back to the engine’s template). A path the engine does not ship under Loop/ is no prompt and no module.

The loop’s three roles are files too — constitution/Agents/system/loop/role-proposer.md (the proposer), loop/role-implementer.md (the implementer) and role-evaluator.md (the judge) — and a copy replaces the engine’s whole, frontmatter included. Each : after system: in a role id is one subdirectory, so the copy of system:loop:proposer goes at constitution/Agents/system/loop/role-proposer.md — a role-proposer.md directly under system/ is a different role (system:proposer), not an override. The proposer’s grant is file:read, db:read (db_query, db_schema) and loop:read (loop_history), all read-only: the evidence files are where a round starts, and when a verdict leaves the cause open it reads the run itself — its conversation and tool calls, a workflow’s tasks, a pipeline’s steps, your own tables, the loop’s ledger. Drop db:read in your copy to confine it to the evidence files (your tables hold something it must not see, or the judge’s recorded prompt must stay out of its reach). Widening is yours to do, knowingly: the proposer runs in your LIVE brain, so a copy (or a role-general.md) that resolves it to '*' or a shell lets it read the judge’s files and write the brain outside any diff — omnislash doctor (Loop confinement) warns. file:write reaches nothing: the proposer’s editable surface is empty. The engine’s copies declare no memory: block: a round is analysed from the entity’s evidence, not from whatever memory recalls, and nothing personal in memory reaches a proposal, a PR or the files it commits. Do not add one in your copy unless you mean it. omnislash doctor (Loop role memory) warns when the proposer or the implementer declares memory — you may want that, knowingly — and fails when the judge does.

The proposer’s brief is chosen in one order: proposer/idea.md for a cycle opened on an idea, whatever the entity; else the preset’s (proposer/evolve.md under preset: evolve, whatever the kind); else the kind the entity’s executions run as (workflow or pipeline for a scenario — read when the cycle opens, so a scenario that gained a .pipeline.ts is analysed as a pipeline — heartbeat, chat for a role). Replacing one re-aims every entity that reads it — there is no per-entity brief. What one entity’s proposer needs telling, or what every proposer of this brain does, goes in loop.proposer.instructions, which the engine appends to whichever brief is read.

Prefer instructions to a copy. A copy of a whole brief is a fork: engine updates to the template — a new evidence file, a new rule — stop reaching it, and an old copy keeps naming placeholders the engine no longer fills. Copy a brief only to replace it whole. omnislash doctor checks the copies you keep:

  • Loop prompt paths fails a file at a retired path — Loop/default.md, Loop/apply.md, a flat Loop/<kind>.md, Loop/evolve.md, Loop/idea.md, anything under constitution/Improvement/ — which the engine never reads. Do not move it to the new path: delete it and carry what it changed in instructions — and a changed ## Privacy section of an old chat.md in constitution/Loop/chat-privacy.md. A file under constitution/Loop/ at no path the engine reads (a misspelled brief) is a warning.
  • Loop prompt placeholders fails a copy carrying a placeholder the engine does not fill in that brief (a stale {{scenario}}, {{plan}}, {{evaluation}} — or {{idea}} anywhere but proposer/idea.md, the only brief an idea cycle reads — would reach the agent verbatim), and an implementer.md without {{editablePaths}}.

A method file’s name: / description: frontmatter is ordinary YAML, on the same rules as every other brain markdown file — so a free-text value containing : needs quoting, and a broken block fails the load loudly instead of being half-read. Method files are not entities, so the frontmatter auto-repair does not reach them; quote the value yourself.

What the loop does, step by step

  1. A grade lands, window() samples what the cycle would be evidence of, and shouldOpen says the window deserves one → a proposer agent is handed one brief about this entity. The brief is assembled the same way for every kind — a heartbeat and a scenario are read through the same documents, so loop.evidence means one thing wherever you set it:

    • runs.json — evidence.runs of the entity’s graded executions — under repair the newest through the window’s newest run, under evolve the window’s best and worst by the objective — the bad ones first. Bad by the trigger’s own definition, not by the pass column, so the failures the analysis is about are at the top. Each run carries the judge’s per-criterion verdict with its reasoning and the evidence it cited, the metric vector with the same, what the execution itself reported producing, its failure category — and, where evidence.includeTrace is on, the tool calls it made. A trace the engine no longer holds is reported empty rather than invented. Read from the grades, so a rarely graded entity still fills the window — an entity under a sample ceiling executes far more often than it is judged, and the brief is about the runs that were judged. The loop’s own replays are left out, the way they are left out of the trigger window: a candidate the pre-merge check ran is not a day the entity had. These are the runs the cycle records as its evidence (frozen.evidenceRunIds), and the pre-merge check prefers inputs the proposer never saw — see What a cycle freezes.
    • proposals.json — the entity’s proposal line: the rounds already proposed against it, in this cycle and in older ones, with what became of each and how its metrics moved.
    • lessons.json — at most lessons.maxRecalled lessons this entity’s settled rounds left behind: what a person turned down first, then newest first (What a round teaches).
    • window.json — what the cycle is evidence of: the window the row stores (the fields above), its one vector — each metric aggregated over its runs (the median, or the mean of a metric declared aggregate: mean), which is the baseline metrics.json aims at — and its runs ranked the way runs.json is ordered, bad first by the trigger’s own rule, each with its grade’s outcome and the ids that place it.
    • grade.json — the grade that opened the cycle, whole (per-criterion verdicts, feedback, the failure category) — only when a single grade did. A window no grade opened carries no grade document, and the line of the prompt naming it is dropped.
    • idea.md — for a cycle opened on an idea: the hypothesis, who posted it and when. Never trimmed.
    • metrics.json where the entity declares metrics, refinement.md when a human asked for this round, and failures.json — other entities recently failing the grade’s category, so a fix aims at the root cause instead of one symptom.

    The whole brief is measured before it is handed over, and evidence.maxTokens is what it is measured against. Over the ceiling the tool traces go first, all of them, and then the oldest runs one at a time; the entity’s definition, its window, its grade, its idea and its metrics are never cut — a proposer that lost those is answering about an entity it cannot see. The proposer dispatches under loop.proposer.modelRef (the proposer role’s own model when no tier names one) and reads the brief for the entity’s kind — the preset’s under evolve, proposer/idea.md for a cycle opened on an idea — with loop.proposer.instructions appended after it (The proposer block).

  2. It proposes file changes (or your proposer answers whole files). Who answers is the decide gate: by default you get a card — Accept / Skip, or reply to it to refine — and nobody answering it inside reviewDeadlineHours settles the proposal skipped rather than waiting forever; under autonomy.decide: auto the proposal is accepted without a card, with a notice instead (see Where the loop waits for a person).

  3. Accepted → an agent applies the changes in its own checkout, the engine checks what changed against the proposal (What the loop may not touch), commits exactly those files and opens a PR. If that agent needs a capability it has not been granted it asks for one (a grant card), and the request is on reviewDeadlineHours too — unanswered, the apply is recorded failed.

  4. PR open → for a workflow scenario the cycle froze inputs for, the pre-merge check replays the candidate from the PR head and the incumbent from the base commit on those inputs, under the pinned judge, and publishes its verdict on the PR as the omnislash/validation commit status — see What the check does. A candidate that regressed has its PR closed: nothing ships. Then the merge gate: by default a human merges it; under autonomy.merge: auto the engine arms GitHub auto-merge once the check has passed (or at once, where there is no check) and your repo’s required checks are the gate. A repository that cannot hold the arm — nothing required on the base branch, a forbidden method — demotes that round to a human, with a notice. Either way the change ships only through the PR.

  5. Merged → the entity’s own next watch.windowRuns executions are watched and graded. What they are depends on the entity — see How a cycle is judged.

  6. The watch reads them by the trigger’s own rule — the engine’s count over watch.minFailures, or your shouldOpen — and answers one of three things: validated (the entity did not trigger again — it keeps), regressed (it did — the engine opens a mechanical revert PR, which inherits the same merge gate), or unvalidated — shipped, standing, and the window closed short of the evidence to say either way. Only regressed reverts. See When the evidence never arrives.

  7. After a regressed settle the loop follows the revert PR until it merges, is closed, or is still open at watch.deadlineHours (counted from the revert PR’s opening), and records which on the round (revert_outcome, and GitHub’s revert_merged_at for a merge). While it waits, no new cycle opens for that entity — from a grade, the trigger.every tick or Analyze now alike (the reason reads waiting on the revert of …): the next analysis would otherwise read, and be measured against, a brain that still carries the regressed change. Under merge: auto the revert is auto-merged like the change was (the breaker never vetoes an undo), so the hold ends when GitHub lands it; under merge: human a person merging the revert ends it. A revert closed unmerged or left open past the deadline ends the hold too — the change stays live, and the outcome says so.

StatusMeans
awaiting_decisionSuggestions ready, waiting for you.
skippedYou declined, nobody answered the card before reviewDeadlineHours, the apply PR was closed unmerged, the judge or the inputs could not be pinned, or another cycle owns the entity. Every cause but your own Skip keeps its reason on the row (error).
accepted → appliedBeing applied → PR open. The pre-merge check runs while the row is applied, before the merge wait starts.
validatingPR merged, the entity’s own executions being watched (or waited for).
validated / regressedMeasured better / worse. A regressed reached before the merge closes the PR — nothing shipped, nothing to revert; one reached after the merge auto-opens a revert.
unvalidatedTerminal with no usable verdict. The entity is not re-runnable, the PR sat unmerged past the deadline, the observed executions never arrived, the judge could not grade the batch — or the comparator read what did arrive and said it was too thin to tell a real move from noise. Nothing is reverted; never mistake it for validated either.
failedThe analysis or the apply broke — including an apply whose grant request went unanswered past reviewDeadlineHours. The card carries the error; under decide: auto there is no card and no lesson, only a notice.

A cycle, phase by phase

The status above is what a cycle decided; its phase is where it is right now. A cycle is its row: each step reads it, does the next thing, writes it back and stops — nothing holds a cycle in memory between steps. When the phase has to wait for something outside (a person, an agent, a PR, the entity’s next runs), the row records the instant to look again, and the cycle sleeps until whatever it waits on rings it (a card answered, a turn finished, a PR event, a replay graded, a knob changed) or that instant comes. loop_status and the cycle’s story on the dashboard show it as, say, In watch since 14:02 · next look in 40m. In loop_status this machine phase is always under machine (live.machine.phase, stuck.machine.phase); a bare live.phase is the story’s phase a person reads (observe · hypothesize · change · prove · learn).

PhaseWhat it does, and what it waits onBounded by
analyzeTakes the entity (or settles the row on a refusal), checks the run’s budget and that the proposer is one the brain serves, and opens the round. No wait.—
proposeThe proposer agent’s turn, or your proposer pipeline — sent once, and it rings back when it ends.30 minutes of the agent’s working time (a turn stopped on a grant card waits up to reviewDeadlineHours); a pipeline: reviewDeadlineHours
decidePins the judge and inputs on the first round, then accepts on its own where autonomy and the breaker allow; otherwise moves to gate. No wait.—
gateOne card per visit — the decide card, or the Retry / Skip card of a failed analysis or apply — and the answer.the card’s own expiry, reviewDeadlineHours from the round
apply / implementThe engine lands a pipeline’s files itself; for the proposer agent’s change, the implementer’s turn edits a checkout, then the engine gates the diff, pushes and opens the PR.30 minutes of the implementer’s working time (a grant card it stops on: reviewDeadlineHours)
shipArms the evidence deadline and launches the pre-merge check’s replays. No wait.—
checkThe replays, graded under the pinned judge.loop.check.deadlineHours (24 h)
mergeThe merge gate, then the PR landing — a person’s merge or GitHub’s auto-merge.watch.deadlineHours from the moment the merge wait started
syncGives brain-sync one interval to deliver the merged content.one brain-sync interval
watchThe entity’s own next executions after the change went live, until the verdict.watch.deadlineHours from the watch’s start
settlePays what the verdict owes: the revert of a regressed change, the lesson, the notices, retiring the cycle’s agents. No wait.—
revertFollows the revert PR until it merges or is closed.watch.deadlineHours from the settle

Every bound above is enforced on the row, so nothing waits forever and a deploy never strands a cycle: a cycle caught mid-step by a deploy simply runs that step again on the new code, and a wait that was counting down keeps counting from the same instant. What makes a step safe to run twice is that the cycle records what it is about to do before it does it — the agent it dispatches, the card it opens, the replays it launches — and finds that same thing on the second run instead of making another.

A cycle that keeps failing — a step that throws on every retry — shows as stuck (loop_status, the fleet, the Loop lifecycle check — see Watching the loop) after about 45 minutes, and the engine ends it after a day of failing in the same phase: unjudged and teaching nothing, so a poisoned cycle never holds its entity forever. There is no cancel operation for a cycle: to end one before its PR merges, switch the entity’s loop off (Kill switch) — the cycle closes its card and PR and is set aside.

Watching the loop

The dashboard renders all of it under Self-Evolving, a top-level nav slot of its own: one route, /evolution, with its state in the URL — ?selected=<kind>:<id> for one entity, &view=policy, &cycle=<id>, &metric=<id>, &knob=<id> (a knob id, metric:<id>.<field> or group:<id>), and ?view=settings (&tab=policy) for the loop’s settings page — reached from the fleet header’s Settings.

The fleet — which of my entities is the loop working on, and which need me? Every improvable entity, enumerated from your resolved config rather than from rows that happen to exist, as a card: a five-arc loop glyph with the live phase lit (in the “you” tone while a person holds a gate), the entity’s state — needs you, stuck, a phase word while a cycle moves, waiting, off — and one sentence saying why, its objective as a mini trace, and objective first → now · N validated · M cycles. A waiting entity also says what its evidence looks like: when its last run was graded, or that none is graded yet. Entities not in the loop fold to one line pointing at Start a loop — the fleet header’s button, which opens the same chooser the empty fleet shows (below), limited to the entities not yet in the loop. An entity whose config would not resolve says so with the reason, never off: a failed resolution is not a decision you made. The N need you pill in the page header is the one list of what the loop is waiting on a person for — a decision or retry card, a grant request, a PR to merge or a revert to merge, a re-arm, an unread failure, a config fault — each with its one move. Home’s Evolution band repeats the fleet’s sentence and, under the entities that need you or are mid-cycle, lists up to four waiting ones with how close each is to its next analysis (3 of 5 new graded runs, or the loop’s own reason) and when the loop looks again.

By feature. A feature with two or more entities in the loop is one group, placed where its first entity would sit in the fleet’s order (so what needs you still leads). Its header carries the feature’s name and description, N in the loop · M need you, its entities’ validated and regressed counts summed, feature off when its FEATURE.md switches it off, and each of its lock groups that is held right now: which change holds it and what that change is doing (being applied, waiting on its PR, being watched, a regressed one’s revert awaited), a person’s hold with its reason, and who waits behind it with ideas queued. A free group says nothing. The header folds the group to a strip of chips — one per entity, its glyph and name, each a link to its page — and the fold is remembered per browser. Under its cards a group lists the entities of the same feature that could join the loop (2 more in freelancer can join · …), and the fleet’s closing line counts only those no group lists. A feature with one entity in the loop is no group: that entity is a card in the common order, beside the other single cards. An entity’s own page lists its feature neighbours in a side column (In ): the others in the loop with their state, then the ones that could join.

Nothing in the loop yet? While no entity is in the loop the fleet explains the five phases and the two gates instead, and lists your scenarios and heartbeats with a Turn on each. Turn on is one atomic write to that entity’s frontmatter: loop.enabled: true; when the entity is not graded, grading turned on with the engine’s and your brain’s defaults (the loop only reads graded runs) — for a scheduled scenario or a heartbeat with no evaluation.sample.maxPerDay on any tier, the confirmation asks for that daily limit first, since nothing else bounds what an unattended entity’s grading costs; and when the entity itself is paused, its own enabled: true, which resumes it. The confirmation says which of these will happen. Only an entity whose feature is switched off cannot be turned on from here — flipping a whole feature is yours to decide. The same chooser stays one click away once something is in the loop: Start a loop in the fleet header. Repair vs evolve and who answers each gate open the policy editor on an entity. An agent’s twin of Turn on is loop_join.

The entity header carries what you change most. A Repair | Evolve switch sits in front of the status sentence it governs and writes preset at the entity tier; an option your config would refuse is disabled with the loader’s reason. Under it, the same “what comes next” line the overview shows — for either preset a meter of its own count (evolve: new graded runs against trigger.windowRuns; repair: bad runs among the last windowRuns against minFailures); with your own window() or shouldOpen, your window’s progress in its own unit (“2 of 3 scored series PRs”) beside your reason — the same count loop_status answers as next.meter (preset: null); and, when something else holds the next cycle back, what (a hold, a busy lock group, the rest after the last cycle, the daily ceiling, the next sweep). With an idea queued the line is the idea and its wait instead — the trigger’s count does not decide an idea’s cycle. Analyze now is disabled, with the loop’s own reason on hover and keyboard focus, whenever it would not open a cycle — no run is graded yet, the entity is resting, or a cycle is already open (then it says so: “decide its proposal first”). The reason comes from the same gates the button runs, so a click never ends in a refusal the page could have told you about. The button never hides, so the action row keeps its shape across states. With the loop off the header offers Turn on the loop instead of both. An agent’s twin of Analyze now is loop_analyze: the same gates, answered opened: false with the reason.

One entity — why is it behaving this way? Its history on one time axis: the objective’s graded runs, and the level line through them — the engine’s median of the metric between two change events. The line steps at every merge (and back at a merged revert), coloured by that cycle’s verdict, and it breaks rather than steps at a judge change — a grade from an edited eval method is not comparable with one from before it, which is the same rule the loop itself applies. Each cycle is written where it moved the line; pick one and it is told below as a story in five phases — Observe (the window and why it opened), Hypothesize (every round, your refine notes with their author, the decision), Change (the PR, the pre-merge check, the merge), Prove (the watch, and the revert), Learn (the lesson, and what it later preceded). The check’s and the watch’s records are drawn as a plot in each metric’s own units — the baseline, the challenger, the gain against the tolerance it was allowed and, under evolve, the interval — or, under repair, as the observed runs with the bad ones marked. The facts beside the story: who opened the cycle (a person’s idea by its author — the Observe chapter quotes the idea itself), its run key, which model each round’s proposer ran on, and what the cycle cost — every proposer and implementer agent across every round. A cycle from before the loop linked its agents reads unpriced rather than $0. A decision is answered right there: the round waiting on you carries Accept, Refine… and Skip — the same HITL card Telegram and the Inbox show, answered through the same gateway. Under the newest cycle’s story (and in its place while the entity has had none) the timeline ends in its next cycle: a ghost track with Observe lit and one sentence — Next: the idea “Lead with numbers…” — after cycle 7 settles; its decision is yours, due Oct 12 14:00., or with no idea queued Next cycle — when 3 of the last 8 graded runs go bad (2 now). It names no forecast, only the gates’ own clocks, and is hidden while a cycle runs with no idea waiting behind it.

Policy — which tier set this? &view=policy renders the resolved loop: config as one sentence per concern — the trigger, the check, the watch, the gates, the budget — with every value a token coloured by the tier that supplied it: engine default, your MANIFEST.yaml, or this entity’s frontmatter (a value the engine derives, like check.mode, is dotted and explains itself). It is the one screen that answers “why does this entity replay 5 inputs when I never wrote a 5” without opening three files. A token opens its editor — the tier chain, a live sentence, Save or Remove override — and the write lands in that tier’s file: the entity’s own frontmatter, or MANIFEST.yaml. Either way only the values that change are touched — a number is replaced where it stands, a key added or dropped, a list item added or removed — so your comments survive, the ones inside the edited block and on its untouched siblings included, and every other line stays byte for byte. enabled is shown as the conjunction it is — the manifest’s kill switch AND the entity’s own flag — because no single tier “wins” it: two controls, never one. An entity’s page writes only its own half; the kill switch is in the settings.

Everything the loop reads as a setting is editable the same way, at the tier you pick: every loop: key (also autonomy.requireCalibration, group and proposer), every evaluation: key the tier accepts — enabled, method, modelRef, requirements, gradeAfterHours, metricTolerance, scoreWeights, sample, judge.maxUngradedShare (manifest only), human with its agreement, context, and each field of each metric (metrics.<id>.role, .target, .tolerance, .tolerancePct, .floor, .mustImprove, .critical, .optional, .aggregate, .criterion) — and, on a chat role, chat.episode.idleHours and chat.onRoleChange (its role tier is the role’s own *.agent.md chat: block). A block that merges wholesale (autonomy, human, scoreWeights, …) is written whole, the siblings you see copied along — so an entity’s first edit to one key of it keeps the rest as they are today, and later manifest changes to that block no longer reach the entity (the editor says so before the save); a block that merges per key (sample, metricTolerance, context, a metric’s fields, chat.episode) gets the one key and nothing else, so the manifest’s other keys keep reaching the entity. Only what the schema forbids is refused — protectedPaths, judge and groups are the manifest’s, editablePaths the entity’s — plus the values nothing would read or the metric spec refuses: lessons.retentionDays on an entity (the lesson sweep runs brain-wide on the manifest’s value), a catalog metric’s criterion (the engine fixes what quality measures), a criterion the manifest already answers for its metric, and critical on a metric only people rate. Those show read-only, with the reason. A value whose absence is its meaning (no threshold, no clock, the role’s own model) is switched off by leaving it out: inside a whole-written block the tier’s block is written without it; a single value an entity cannot leave unset while the manifest sets it — the editor says so and offers the reset or the brain-wide change instead. Editing an evaluation: knob of an entity that is not graded writes enabled: false beside it: the block’s presence is the opt-in, and only the switch turns grading on — which is also when its judge (method file, model) is checked.

Before anything is written, the result is resolved the way the loop will resolve it — for a manifest edit, against the manifest’s own judge and every entity in the loop — through the real resolvers: the metric spec, the method file (none or a file under constitution/Evaluation/Methods/), the judge’s model (it must pin to a concrete chain), the proposer’s model (the same), the proposer pipeline, and the score weights (a pair of zeros leaves quality undefined, so every grade would fail). A save that would break an entity is refused with that entity’s name and the loader’s reason, shown in the editor next to its Save; a fault that was there before the save never blocks it. Loop-settings saves — these editors and an agent’s loop_join / loop_set / loop_widen, from any process — run one at a time across the whole brain, so two of them never drop each other. Each writes over exactly the bytes it validated: a file that changed in between — a brain-sync pull, an edit by hand — is re-read and the edit composed again on top of it. Only a change outside the loop editors that lands in the very instant of the write can still lose one of the two edits. A file that keeps changing is refused with “try again”, nothing written. Everything else is yours: each editor says what the edit does beyond its value — a judge change (method, modelRef: cycles in flight stop measuring, calibration starts over), a silent comparability break (requirements, a metric’s criterion, scoreWeights, context, chat.episode.idleHours: none of them is in the judge snapshot, so grades before and after look comparable and are not), or slower or costlier grading (sample, gradeAfterHours, human.ask: windows fill slower, or more rating cards). A manifest value that every entity it could reach overrides is said to be read by none of them today, with no warning.

Under the sentences, an entity’s Policy view lists its Grading (evaluation:) terms as rows — its own grading switch (the opt-in half; the brain’s half is the kill switch in the settings), the judge (method, modelRef, requirements), which runs and when (sample, gradeAfterHours), the default bands (metricTolerance, scoreWeights), people’s ratings (human and agreement), what a chat judge reads (context), and each metric with its declaration’s fields as chips — and, on a chat role, its Chat episodes (chat:). What a chat judge reads shows on a chat role’s page only. Each row opens the same editor as a token, and the read-only values the sentences quote from evaluation: point down to them. The scenario page has no raw evaluation: form any more, for the same reason it has no loop: one: every write goes through this validation.

Settings — two kill switches, then the defaults. The page opens with the two kill switches — the loop and grading, every entity — above its tabs, because they are not defaults: off outranks every entity’s own switch. Off asks first and says what stops; on removes the manifest’s false. Each entity’s own switch (its opt-in) stays on its page: two controls, never one. Under them two tabs of brain-wide defaults: Agent defaults and Policy defaults.

Settings → Policy defaults — the brain’s own terms. The same knobs at the manifest tier, with no entity: each value from MANIFEST.yaml or the engine, and, per knob, how many entities (and which) write their own value and so do not follow it; MANIFEST’s lock groups (loop.groups.<id>, each { lock, repair }) with their members. A chat: edit here reaches every chat role, in the loop or not. The tab reads top to bottom:

  • Loop (loop:), one row per knob grouped by the cycle step it governs — opening a cycle, writing the change, decide and merge, the pre-merge check, the post-merge watch, the breaker — so the knobs that read alike say where they act: autonomy.onInconclusive sits under the check, watch.onInconclusive under the watch, autonomy.onUnmeasured under the breaker; the review, check and watch deadlines likewise. A knob only one preset reads says so. Under the proposer’s model row, its whole ladder (MANIFEST.yaml → the role file → role-general.md, the entities that override it, the chain it resolves to now); a role name resolves through constitution/models.json. Then the lock groups: the brain-wide group list (Joined by every entity), each MANIFEST group with its terms and members, each a link (Delete group, after asking), and Add a lock group — an id already declared is refused rather than resetting it. editablePaths shows read-only here — it is the entity’s alone.
  • Grading (evaluation:), grouped the same way as on an entity’s page, with the judge’s model ladder under its model row, then the brain-wide metrics field by field.
  • Chat episodes (chat:) — the episode pause and what a role change does.
  • Loop code — which functions omnislash.config.ts loop: defines (every entity runs them over its preset’s), and the entities with their own <id>.loop.ts, each a link to its Policy view, which names the functions it runs. Named, never edited here: code, not settings.

Each row shows the value, who set it (engine default or the brain) and overridden in N entities — each a link to that entity’s Policy view with the same knob’s editor open. A knob with no entity tier (protectedPaths, judge, groups, lessons.retentionDays) is overridden by nobody. Before a save with a consequence the editor says it inline, above Save: the judge changes, grades stop being comparable, or what grading costs and how fast windows fill.

Settings → Agent defaults — who grades, who analyses, who applies. ?view=settings gathers everything the loop reads as a setting; its Agent defaults tab is the loop’s own three agents — what every entity’s rounds run with unless the entity names its own model or prompt — one sub-tab each (Judge, Proposer, Implementer; &agent=proposer|implementer, absent ⇒ the judge): the judge (system:evaluator — it grades runs and chat episodes alike), the proposer (system:loop:proposer) and the implementer (system:loop:implementer); the Proposer tab also lists your proposer pipelines under Your proposers. A card shows what each is configured as and what that means:

  • Model — the ladder it is read from, nearest first (MANIFEST.yaml’s evaluation.modelRef / loop.proposer.modelRef → the role file → role-general.md), the entities that override it, the concrete chain the winner resolves to right now and the harness that implies. The implementer has no manifest or entity tier: its model is its role file’s.
  • Access — the resolved grant (role-general.md unioned with the role file), each capability with where it comes from — the engine template, the brain’s role file, role-general.md, or an implicit '*' (no layer declares any capability, so the fold hands every one — flagged as probably unintended); what it compiles to on a CLI harness (read files and plan — every role’s — file write, shell, web, the engine’s own tools, MCP servers) — on Claude the built-ins follow the grant on every turn; on Codex or OpenCode they follow it only on a confined turn, which the proposer’s and the implementer’s are and the judge’s is not, so a Codex or OpenCode judge has every harness tool on whatever its grant says, and the grant gates only the engine’s tools there; and where the agent runs — the judge and the proposer in the live brain, the implementer in a clone, sandboxed when isolation is on (a dev-gated isolation runs it in a plain clone, and with no brain baseline to clone — a dev box — it runs in the live brain).
  • Prompt flags, skills, MCP servers and the role body, and for the proposer and the implementer their prompts under constitution/Loop/ (each marked engine template or brain copy, with who reads it), and for the proposer the entities whose loop.proposer.instructions add to its brief.
  • The doctor checks that concern it (Eval judges, Loop role memory, Loop confinement) and the consequences of its configuration.

All of it is editable, and any capability in the registry may be granted — the engine does not refuse a grant; it says what the grant does. Edits stage on the card; Review and save asks the server what the write would mean before it happens: a judge change (role bytes, grant or model) makes every cycle in flight refuse its next pinned grade (judge_changed) and restarts calibration; a proposer with a shell reads the rubric and writes the live brain with no sandbox; an implementer with a shell and no isolation can do the same outside its checkout, where the diff gate cannot see it (in the live brain itself where there is no baseline to clone); a role-file model a MANIFEST.yaml tier shadows moves no dispatch; a hitl: true capability under autonomy.decide: auto fails with missing_grant instead of waiting for a person. The write lands in the brain’s copy of the role file under constitution/Agents/system/ (role-evaluator.md, loop/role-proposer.md, loop/role-implementer.md). A brain copy replaces the engine template whole, so the first save copies the entire template there — capabilities, promptFlags, comments and body — and patches that copy; nothing you did not touch changes. That copy is a fork: later engine updates to the template stop reaching this brain, and the page says so before the first save. Prompts are written as-is to their path under constitution/Loop/ — proposer/<brief>.md, implementer.md — (the first write over an engine prompt forks it the same way), and a prompt with a placeholder the engine does not fill is refused before it is written. A proposer pipeline is listed with the entities that name it, read-only — everything inside one is your pipeline’s own code.

Loop checks — the fleet’s footer carries the loop’s readiness, the runtime doctor’s Evolution category (doctor_check({ category: 'Evolution' })): every loop check omnislash doctor prints — the ones that read only your files and config (Eval ceilings, Eval judges, Loop watch / tick / evolve / ceilings / autonomy / groups / proposers / proposer models / confinement, Loop prompt paths / placeholders, Loop modules, Loop role memory, Loop kind support), plus Loop check and Human calibration, which read the database (an entity’s graded inputs; its people’s ratings against the judge’s) and so answer only where the database is reachable — and two that are not omnislash doctor checks: Loop lifecycle and Loop failures. None of them is in Configuration any more: a caller that filtered category: 'Configuration' for the loop’s checks reads Evolution. An agent reads both through loop_status: the fleet’s lifecycle, and an entity’s failureRows. Both read the loop’s own rows rather than your files — nothing else: a cycle keeps its own liveness on its row, so neither degrades when Restate is out of reach. They answer is the loop alive rather than is it configured well, and they are two checks rather than one because they are independent facts: a stuck cycle must not hide a failed revert.

Loop lifecycle reports a cycle that stopped advancing, in one of two ways. Failing — the cycle keeps failing in the same phase: a step throws on every retry (a bug, a service down for good), for longer than any step may take plus half an hour. Unrung — nothing is running it and the instant it was due to look again passed more than half an hour ago: nothing is ringing cycles (the scheduler or pipelines is down). Each line names the entity, the phase and since when; a failing one also names the instant the engine will end it. That is warn, not fail: neither shape is lost — the engine ends a cycle that has kept failing for a day on its own (unjudged, nothing taught: a pre-merge cycle closes its PR and card and is set aside, a merged one settles unvalidated, a revert wait stops), and an unrung one moves the moment rings arrive again. Until then it holds its entity’s claim, so nothing else can be improved for that entity. A deploy is not a reason for either: a cycle caught mid-step by a deploy is simply run again on the new code.

Loop failures reports terminal failures nobody has acknowledged — an analysis or apply that broke, or a shipped regression that was never undone: its revert PR could not be opened, was closed unmerged, was still open at the deadline, or is past the deadline with nobody left waiting on it. A revert still inside its wait is not listed — it is the loop working. A candidate the pre-merge check turned away is regressed too, but it never shipped and owed no revert, so it is not listed here. The failure stays on the row, and in the N need you list, until a person dismisses it — here, or an agent with loop_ack_failure (which asks a person by default); who dismissed it is recorded. Dismissing is not a fix and changes nothing the loop reads — it is not the autonomy Re-arm, which is a separate button acknowledging a separate fact.

The chips, wherever an entity or a run is listed. At run scope — run rows and both run-detail headers — the chip shows that run’s cycle and, once settled, the metric that decided it. At entity scope there are two, and both link to the entity above. A scenario, heartbeat or agent page’s header, the blueprint editor’s bar and the entity rows of the feature lists carry the Evolving chip while the loop is on for that entity, the loop’s sentence about it as its tooltip; while it is off, the page’s header shows a quiet Loop off → instead, so an entity’s page — where loop_status links it — always leads here, to the switch. The /system/scheduler tables and the run list’s scenario groups carry the entity’s fleet row: the loop glyph, still, and the same state word the fleet card prints — never a count — with a Review → cue on the run list’s scenario groups when the entity needs you; an entity that is off still shows it (off, dashed ring). An entity page shows the loop in its header’s chip and, on a scenario or heartbeat page, in its Overview’s Evolution band while the loop is on — the loop’s sentence about it, the live cycle’s step and the gates it owes you, each linking here — and never writes to it: there is no Analyze now, no loop switch and no loop: or evaluation: form on a scenario, heartbeat or agent page. The entity above holds the switch, and the policy is edited only in the Policy view, so every write is resolved the way the loop will resolve it.

Home’s Evolution band is the fleet cut short, beside the live Omnislash mark that opens this page: the fleet’s one-line sentence and up to three of its cards — whatever needs you, is stuck or is mid-cycle, each with its objective’s trace — then the waiting entities’ rows (the rule in words and the window toward the next analysis) beside How it changed itself: the newest cycles on a rail, kept, reverted, regressed, turned down or still moving, each opening in place onto its hypothesis, the evidence it was judged on, the lesson and its PRs; with nothing switched on, a line saying so and a link to start a loop.

The run’s Cycle tab — the one cycle this run’s grade opened, told with the same phase track and the same verdict record as the entity’s story, plus the round’s suggestions, your refinement text and both PR links, and a link to the whole story.

Everything is driven by the loop’s own events, so a cycle changing status updates the open page without a reload.

What it deliberately does not show

  • A cross-entity improvement rate. Two entities’ evaluations are measured against different criteria by different graders; averaging them would produce a league table whose ordering means nothing. Every number is an entity’s own, and there is no way to sort one against another.
  • unvalidated as a failure. It renders neutral, in its own visual register, because “nobody measured it” is a different fact from “it made things worse”.
  • Analytics over tiny numbers. No rate, no count tile, no period delta — the one headline number is the objective’s first level on the trace → now.
  • Drift, forecasts or an ETA. A level moves only at a change event (a merge, a merged revert, a judge change); while a watch is still filling it shows how far its window has got (2 of 3 scored series PRs — the sampler’s own progress) and its deadline, never a projected verdict.
  • Hooks. Not a loop subject, by design. A hook routes an event to an agent; the behavior it triggers lives in a skill, a role or a scenario, and those are already improved through the entities that execute them. Two fires on unlike events are not two measurements of one thing, and rare events would close almost every watch window unvalidated. So neither evaluation: nor loop: is read from a hook, and the loop’s subject kinds are scenario, heartbeat and role. A reaction worth improving should dispatch a scenario (scenario_start), and the scenario is then the subject. No hook appears on the page, and no count on it includes one.
  • A decide control of its own. Accept / Skip (and a reply that refines) are answered on the round’s HITL card — the dashboard’s HITL surface or Telegram — because the card IS the gate. A second place to accept a proposal would be a second human-gate channel.

A proposal that settled before the verdict column existed has no stored verdict, so its panel says so rather than reconstructing one from today’s declarations.

Four loops, end to end

Four loops that look nothing alike, built only from the seams above. Where brain code is needed, the seam is named.

The freelancer — a chain sharing one outcome, rated by a person first

Makes videos in a niche and tries to make each one earn more views.

EntityKindGraded on
idea — researches the niche, writes a strategy for a batch of ~10 videosworkflowa person’s rating at first, then the judge; views of its batch
layout — strategy → scene layoutpipelinesame; viewers held at 3 s (the hook)
production — layout → videopipelinea judged rubric; share of the video watched
publish — posts itpipelineviews in the first hour
studio — drives layout → production → publish per video, writes lineagepipeline, not in the loop—
stats — pulls platform numbers into video_statspipeline, not in the loop—
vlog — a video about the freelancer’s own “life”pipelinea person’s rating; its own views
# constitution/MANIFEST.yaml
evaluation:
  gradeAfterHours: 72                 # views exist three days after the post
  sample: { maxPerDay: 20 }
  human: { ask: until-calibrated }    # cards until the judge agrees with you
loop:
  groups:
    audience: { lock: true }          # one live change across the chain
  protectedPaths:
    - features/freelancer/**/*.pipeline.ts   # pipeline code under live journals: a person edits it
    - features/freelancer/idea/strategy-save.ts   # writes the lineage idea.loop.ts joins on
  autonomy: { decide: auto, merge: auto, requireCalibration: true }

# features/freelancer/layout/layout.scenario.md — idea, production, publish the same way, each
# with its own signal and grade time (production 24 h, publish 72 h, idea 144 h)
evaluation:
  gradeAfterHours: 24                 # a share of viewers settles in a day; views need three
  metrics:
    hold_3s_lift: { kind: computed, direction: max, unit: log-ratio, role: objective,
                    tolerance: 0.05, optional: true }   # layout.loop.ts, against the channel
    views_24h:    { kind: computed, direction: max, unit: count, role: observe, optional: true }
    cost_usd:     { role: guard, tolerancePct: 0.25 }   # a lift bought with more generation regresses
    layout_fit:   { kind: judged, human: true, direction: max, role: guard,
                    criterion: 'The scenes follow the strategy' }   # 1–5 by default
loop:
  enabled: true
  preset: evolve                      # nothing is broken — more views is the aim
  group: [audience]
  trigger: { windowRuns: 10 }         # one batch of videos per cycle
  watch: { deadlineHours: 336 }       # a batch a week (two, if one slips), plus the day's lag
  editablePaths: [features/freelancer/layout/layout-writer.agent.md]
  • Videos per entity — idea saves its run id on the strategy row (context.auth.runRef in your strategy_save tool), studio records layout’s and production’s ctx.runRef on the video, publish its own with the post, and each entity’s measure() reads its own videos: Recording lineage.
  • What the entity controls, against a baseline — raw views make a poor objective: a growing channel lifts every window after a merge over the one before it (the watch would credit the change with the account’s subscribers), one viral video decides a window, and four stages pulling on one number read each other’s changes as their own. So each measure() returns the stage’s own signal — views for idea (the topic), viewers held at 3 s for layout (the hook), share watched for production, first-hour views for publish — as ln(value / median) against the same signal over the channel’s last videos before this one, absent until there is a baseline. The raw totals stay as observe, and the engine’s cost_usd becomes a guard.
  • One variable at a time — the audience lock group, and a clean base after every sibling’s change: Lock groups. The honest limit — a week-old video carries last week’s strategy under this week’s layout — is yours to filter in window() by the ancestor columns you recorded.
  • You first, then the judge — human: true + ask: until-calibrated + requireCalibration: Calibration.
  • Fully autonomous run — the same files with human: { ask: never } and requireCalibration dropped; go through the checklist.
  • The vlog — vlog reads loop_history through callTool (Reading the history) and is itself in the loop on kind: reported human ratings.
  • Stubs — production and publish can start as stubs; publish branches on ctx.loopLaunched like every pipeline that acts (Replay and your tools).

A support chat — the dialog is the run

A role customers write to.

# features/support-chat/support.agent.md
---
name: Support
capabilities: [episode:write, support:escalate]
chat:
  episode: { idleHours: 0.5 }        # half an hour of silence ends the dialog
evaluation:
  sample: { rate: 0.5, maxPerDay: 40 }
  requirements:
    - The customer's issue is resolved, or honestly handed to a person
    - The customer is not left angry
  metrics:
    resolved:  { kind: judged, direction: max, role: objective,
                 criterion: 'The customer’s issue is resolved or honestly handed over' }
    escalated: { kind: computed, direction: min, role: guard, aggregate: mean }
  # human: { ask: until-calibrated }  # rate episodes yourself while you set the loop up
loop:
  enabled: true
---
  • One graded run per dialog — the episode, ended by silence (chat.episode.idleHours) or by agent_close_episode; the judge reads it whole: Chat roles.
  • Escalation — your escalate_to_human tool records a row keyed by the episode’s run id and calls closeChatEpisode; the role’s measure() turns it into escalated 0/1, a share under aggregate: mean, kept as a guard beside a judged objective: When the dialog is over.
  • Unhappy customers are the judge’s to see, in the dialog — a line of your requirements, not a button.
  • Your own rating — evaluation.human.ask, shown commented out above; every episode also has Rate on the dashboard: The owner’s rating.
  • No pre-merge check (a dialog cannot be replayed): a change is judged on the episodes that begin after it goes live.

Cold outreach — a late reply rate, two entities, a weekly business guard

prospect (a pipeline) picks the leads, pitch (a workflow) writes and sends each email. A reply lands 1–7 days after the send; meetings booked per week is what the business cares about.

# constitution/MANIFEST.yaml
loop:
  groups:
    outreach: { lock: true }

# features/outreach/pitch.scenario.md — and prospect.scenario.md alike
schedule: '0 9 * * 1-5'
evaluation:
  gradeAfterHours: 168                 # every reply that will come has come
  sample: { maxPerDay: 50 }
  metrics:
    replied: { kind: computed, direction: max, unit: share, role: objective, aggregate: mean }
loop:
  enabled: true
  group: [outreach]
  watch: { deadlineHours: 504 }        # the watch waits windowRuns runs PLUS the 7-day lag
  • The reply rate, per entity — both write their run id on the send row (sends.prospect_run_id, sends.pitch_run_id); each measure(run) returns the share of its own sends that got a reply: Recording lineage. A bounded wait is gradeAfterHours; a tally with no bound would be a scope: period metric measured by window() instead (Late metrics).
  • Shared by two entities — the outreach lock group: one live change across both, and a sibling’s change never lands inside the other’s watch unnoticed (it settles confounded).
  • The weekly business number as a guard — a weekly pipeline outside the loop calls loop_hold on the group when meetings drop and loop_release when they recover: Supervision.
  • A share reads by the mean — aggregate: mean, or a move from 7% to 9% is invisible to the median of 0s and 1s.
  • pitch sends email — a pre-merge replay runs it dry; your own sending tool declares replay: 'dry-run', and measure() returns [] on run.replay (Replay and your tools).

An ops heartbeat under repair

A heartbeat that triages alerts every hour; the loop fixes it when it starts missing things.

# features/operations/alert-triage.heartbeat.md
schedule: 'every 60 minutes'
evaluation:
  sample: { maxPerDay: 24 }            # owed: a heartbeat is unattended
  requirements:
    - Every alert raised in the last hour is either acknowledged or escalated
  metrics:
    missed_alerts: { kind: computed, direction: min, unit: count, role: guard, target: 0 }
loop:
  enabled: true                        # preset: repair is the default
  threshold: 0.7
  trigger: { windowRuns: 6, minFailures: 2 }
  watch: { deadlineHours: 24 }         # six hourly ticks fit inside a day
  maxAnalysesPerDay: 2
  • Bad run — the judge failed it, scored it under 0.7, or missed_alerts missed its bar: Trigger.
  • missed_alerts comes from your incidents table in the heartbeat’s measure(run), joined on the tick’s run.runId (a tool reads it as context.auth.runRef): Metrics only your code can produce. If every signal is a number, evaluation.method: none drops the judge (Grading without a judge).
  • No pre-merge check — a heartbeat has no input to replay; the watch counts its next six graded ticks, and grades them all whatever sample says: How a cycle is judged.
  • Retention — a heartbeat’s tick is an agent row: a gradeAfterHours longer than agentRetention.ttlHours loses every grade (Eval ceilings).
  • Who answers — keep a person on merge for ops, or go through the checklist.

Screens from Sixty Seconds Studio, a fictional demo studio, running on the real dashboard.