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.
| Level | You write | Read |
|---|---|---|
| 1 — Basic | enabled, preset, autonomy — on, which loop, who answers | Level 1 |
| 2 — Tuning | numbers and policies: trigger, watch, check, budget, metrics and their scales, ratings from people, lock groups | Level 2 |
| 3 — Power | code: <entity>.loop.ts hooks, lineage in your own tables, your own proposer, reading the ledger, supervising from a pipeline | Level 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, theloop_*tools, theloop:*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):targetKindisscenario,heartbeatorrole— 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.tskeeps 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 stageanalyzeagent — 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 stageapplyagent — 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
:aftersystem:in a role id is one subdirectory underconstitution/Agents/system/:system:loop:proposerisloop/role-proposer.md, and a brain overrides it by putting its own copy atconstitution/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
| Seam | Engine default | Section |
|---|---|---|
evaluation.enabled / loop.enabled | true inside a block you wrote; the manifest’s is a brain-wide kill switch | Kill switch |
evaluation.sample.maxPerDay | none — owed by every unattended entity | The ceiling you owe |
loop.preset | repair | Pick a preset |
loop.autonomy { decide, merge } | human / human | Pick who answers |
Level 2 — numbers and policies
| Seam | Engine default | Section |
|---|---|---|
evaluation.method, modelRef, requirements | default method, the evaluator role’s ref | Grading · method: none |
evaluation.sample.rate | 1 | How much gets graded |
evaluation.gradeAfterHours | 0 | Late metrics |
evaluation.metrics.<id> — kind, direction, scope, role, target, floor, tolerance, mustImprove, critical, aggregate, optional | quality objective, catalog observed | Metrics |
evaluation.metrics.<id>.scale / .human, kind: reported | judge-only; a human-rated metric on 1–5 | Ratings from people |
evaluation.human { ask, spotCheck, expiresHours, agreement } | ask: never | Rating cards · Calibration |
evaluation.metricTolerance, scoreWeights | 0.05 / 10%; tasks 60 : run 40 | Tolerances · Weighing tasks |
evaluation.judge.maxUngradedShare (manifest only) | 0.34 | The judge is pinned |
evaluation.context { priorEpisodes, maxTokens }, chat.episode.idleHours, chat.onRoleChange | 0, 40000; 24; keep | Chat roles |
loop.threshold, trigger { windowRuns, minFailures, every } | analyse every graded run; 2 of 5; no clock | Trigger |
loop.check { mode, inputs, trials, deadlineHours, incumbent } | derived mode, 5, 1, 24h, frozen | What a cycle freezes |
loop.watch { mode, windowRuns, minFailures, deadlineHours, confidence, onInconclusive } | observe, the trigger’s pair, 168h, 0.9, keep | The watch · onInconclusive |
loop.budget, cooldownHours, maxAnalysesPerDay | unpriced; 6h (24h after a regression); 3 | What the loop may spend |
loop.reviewDeadlineHours | 168 | Where the loop waits for a person |
loop.windowStalledAfterHours | 336 (0 = never) | A window that waits |
loop.autonomy — mergeMethod, maxConsecutiveRegressions, onInconclusive, onUnmeasured, requireCalibration | squash, 2, human, pause, false | Autonomy · Checklist |
loop.groups (manifest only), group | none | Lock groups |
loop.editablePaths (entity only), protectedPaths (manifest only), maxChangedLines | the proposal’s files; the engine floor; 600 | What the loop may not touch |
loop.evidence, lessons | 5 runs / 40k tokens; 10 lessons / 180 days | Step by step |
loop.proposer { modelRef, instructions } (per field) | the proposer role’s model; no added lines | The proposer block · Prompts |
Ideas — /idea, loop_create_idea / loop_cancel_idea | no key: always available | Ideas |
Level 3 — your code
| Seam | Where | Section |
|---|---|---|
measure(run), cases | <entity>.loop.ts | Metrics 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 code | Recording lineage |
ctx.loopLaunched, context.auth.loopLaunched, a tool’s replay: 'dry-run' | brain code | Replay and your tools |
loop.proposer.pipeline | manifest · entity | Your own proposer |
loop_history | callTool from a pipeline, any agent | Reading the history |
loop_status, loop_get, loop_trace, loop_summary, loop_list_agents, loop_policy, loop_get_proposal | tools (loop:read) | Running the loop from an agent |
loop_join, loop_set | tools (loop:configure, asks nobody) | Running the loop from an agent |
loop_widen | tool (loop:autonomy, a person approves) | Running the loop from an agent |
loop_analyze | tool (loop:analyze) | Running the loop from an agent |
loop_hold | tool (loop:hold, asks nobody) | Supervision |
loop_release, loop_rearm, loop_ack_failure | tools (loop:supervise, a person approves) | Supervision |
agent_close_episode, closeChatEpisode(runId) | role capability; @omnislash-ai/infra/chat-episodes | Where a dialog ends |
constitution/Loop/proposer/<brief>.md, Loop/implementer.md, Loop/chat-privacy.md, constitution/Evaluation/Methods/*.md, the three loop roles | your copy at the same path | Overriding 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:
| Tier | Where | For |
|---|---|---|
| engine | built in | the fallback, so an omitted knob is never an error |
| brain | constitution/MANIFEST.yaml → evaluation: / loop: | what these cost across your whole brain |
| entity | the evaluation: / loop: block in the file | where 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 metrictarget) 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.windowRunsgraded 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.windowRunsgrades (or everytrigger.everyhours) 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.confidenceinterval — 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).
| decide | merge | Meaning |
|---|---|---|
| human | human | The default — you answer the card, you press merge. |
| auto | human | Decision autopilot; you are the last gate at the PR. |
| human | auto | You approve the proposal; GitHub auto-merge lands it once checks pass. |
| auto | auto | Fully 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 set | What stops | What does not |
|---|---|---|
loop.enabled: false | Every 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: false | All 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
---
| Field | Default | Meaning |
|---|---|---|
enabled | true | Grade this scenario’s executions at all. |
method | default | Which method file to grade with: constitution/Evaluation/Methods/<method>.md. none grades with no judge — see Grading without a judge. |
modelRef | role default | Model 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. |
sample | grade everything | How much of the stream gets graded — below. |
gradeAfterHours | 0 | Hours 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 yourmeasure()/window()return. passedis read off the declared floors: a run passes when everyobjectiveandguardit measured stands on itsfloor. Atargetis where you are aiming and does not fail a run — therepairtrigger still counts a missed target as a bad run, exactly as it does for a judged entity.- There is no score.
qualityis only reported (absent), the engine does not add it as a guard when another metric is the objective, and arepairthreshold— a bar on the judge’s score — reads nothing. Declare a computed objective and, if a run can be bad, aflooror atargeton it. - Refused at load, naming the fault: a
judgedmetric,qualitydeclared as anobjectiveorguard, andrequirements— 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
nonefor the model, the method and the evaluator role, and carries the digest of your*.loop.ts— yourmeasure()IS the instrument, so editing it mid-cycle is a judge change like any other, and so is switching the entity to or frommethod: none.
How much gets graded — sample
| Field | Default | Meaning |
|---|---|---|
rate | 1 | Fraction of executions to grade, 0..1. |
maxPerDay | null (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
| Role | Meaning |
|---|---|
objective | What your change is asked to move. The proposer is told the number and the target. |
guard | Must not get worse beyond its tolerance. Measured on the same challenger runs as the objective. |
observe | Measured 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.
| Id | Direction | What it is |
|---|---|---|
quality | max | The grader’s overall score, 0..1. |
passed | max | The grader’s pass/fail. Fixed as the viability signal — not configurable. |
cost_usd | min | What the execution’s agents billed. |
duration_ms | min | Wall-clock of the execution. |
tool_failures | min | Failed tool calls (workflow runs and heartbeat ticks). |
Declaring your own
| Field | Belongs to | Meaning |
|---|---|---|
kind | the metric | judged — 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. |
human | the metric | Judged 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. |
scale | the metric | Judged 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. |
direction | the metric | max or min. |
scope | the metric | run (default) — the number is a property of ONE execution. period — it belongs to the entity over a window, and window() measures it. See below. |
unit | the metric | Display only: usd, ms, count, score. |
criterion | the metric | Judged and reported metrics: required. What the eval agent — and the person rating — should measure, in your words. It goes into the grading prompt verbatim. |
role | the entity | objective / guard / observe. Required on a metric of your own — see below. |
target | the entity | The number you are aiming for. Missing it opens a cycle even on a run that passed. |
tolerance | the entity | Absolute drop allowed before it counts as a regression. |
tolerancePct | the entity | The 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. |
floor | the entity | An 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. |
mustImprove | the entity | false by default. On the objective, requires the change to MOVE the number rather than merely not break it. Read nowhere else. |
critical | the entity | false 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. |
aggregate | the entity | median (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. |
optional | the entity | false 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:
| Refusal | When |
|---|---|
not-human | Nobody 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). |
gone | The run’s record is gone (the retention sweep took it). |
not-graded | The run has not ended yet, or its entity grades nothing (evaluation.enabled off). |
replay | The loop launched the run (a pre-merge replay) — evidence about a candidate, not your entity’s own day. |
undeclared | The entity does not have people rate that metric. |
off-scale | The 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 pacingto 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
| Status | When |
|---|---|
unknown | fewer than agreement.minPairs pairs |
low | enough pairs, but fewer than agreement.threshold of them agree |
ok | the 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:
- any objective or guard under its declared
floor→regressed; - any guard past its tolerance →
regressed; - the objective past its tolerance →
regressed; - 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; - a
mustImproveobjective with no gain to show →unvalidated(“shipped, no measurable gain”); - 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.
| Entity | Before the merge | After the merge |
|---|---|---|
| workflow scenario | The 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. |
| heartbeat | Nothing — 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 scenario | Nothing — 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 role | Nothing — 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
failedwith ajudge: …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.modelRefyou 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.tsthat 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
modelRefnaming a role yourmodels.jsonno longer has — ends the proposal before anything ships. The analysis has already run, so the row exists and settlesskipped, 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:
| Basket | What it is | What it means |
|---|---|---|
| graded | the evaluation completed | evidence about your change — good or bad |
| ungraded | the evaluation itself failed | evidence about the judge, not about your change |
| not run | no evaluation row at all | no 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
inputsdistinct 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. Underincumbent: 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. Underincumbent: replayno 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 arepairfix 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:
- The head is pinned. The PR’s head commit is recorded on the round
(
checked_sha), and theomnislash/validationcommit 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. Undermerge: autothe 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. - Every pair is replayed. For each frozen input,
trialsruns 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 —trialsruns pinned tobaseSha(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. - 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. - The judge is checked first, on the candidate side: more than
evaluation.judge.maxUngradedShareungraded → the check is inconclusive. An incumbent with no grade on any input is inconclusive too — there is no bar to compare against. - 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:
| Verdict | On the PR | On the row | What happens next |
|---|---|---|---|
validated | status success | verdict recorded, row stays applied | The merge gate, as declared: a human merges, or auto-merge is armed on the checked commit. |
regressed | status failure; the PR is closed with the per-metric and per-input story in a comment | settled regressed with the candidate tally; a lesson is written | Nothing shipped, so nothing is reverted — the closed PR is the undo. The regression counts toward the circuit breaker like any other. |
unvalidated | status error with the reason | verdict recorded (where one ran), row stays applied | The 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.
| # | Rule | Outcome |
|---|---|---|
| a | Enough 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 died | unvalidated, insufficient evidence |
| b | Was the judge healthy? ungraded ÷ (graded + ungraded) on the candidate side above maxUngradedShare | unvalidated, judge unhealthy — settled by the loop before the comparator is called at all |
| c | Did it work where the incumbent did? The candidate must have passed something, and among the inputs the incumbent passed it must keep a strict majority | else regressed, naming how many inputs it lost |
| d | Did a critical metric fail ONE input? Any input where the candidate fell past that metric’s tolerance | regressed, naming the input |
| e | Did a guard break? Any guard metric past its tolerance on the median of its per-input movements | regressed |
| f | Is every floor still standing? Any compared metric past its declared floor — below it on max, above it on min | regressed |
| g | Did the objective hold? Past its tolerance the wrong way, on the median of its per-input movements | regressed |
| 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 shown | unvalidated, inside the noise across inputs / no pair measured it |
| h | Nothing above fired | validated |
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.
| Strength | When |
|---|---|
paired | The 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. |
measured | The 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). |
blind | Nothing 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.
| Grade | When |
|---|---|
| Improvement | The objective moved past its noise band (the whole interval above its tolerance, where the watch read one), nothing broke, and the verdict kept it. |
| Tradeoff | The objective cleared its bar, and a guard, a floor or a per-input promise broke for it — the verdict regressed it. |
| Regression | The objective fell past its tolerance, or something broke with no objective gained. |
| Tie | Measured, and the objective’s move stayed inside its tolerance either way (the whole interval, where the watch read one). |
| Inconclusive | Below 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 rests6hafter an ordinary settle and24hafter aregressedone: 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 to3. 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 isnull: 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 aloop/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 concreteclaude:<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
failedwithover budgetif 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: writebudget: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:
| Wait | Exists when | Bounded by | Unanswered |
|---|---|---|---|
| A grant request the proposer stops on | the round is a person’s (decide: human, a tripped breaker, a calibration hold) | reviewDeadlineHours | the turn is abandoned; the analysis is failed, with a Retry / Skip card |
| The decide card — Accept / Skip, a reply refines | same | reviewDeadlineHours — the card’s own expiry | the card expires; the proposal settles skipped (review-abandoned) |
| A grant request the implementer’s turn stops on | same | reviewDeadlineHours | the apply is failed, with the error on a Retry / Skip card |
| The Retry / Skip card a failed analysis or apply offers | same | reviewDeadlineHours | nothing further — the row is already failed |
| The merge of the apply PR | merge: 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 answer | settles unvalidated; an arm is disarmed first |
| The merge of a revert PR | the change’s merge gate is a person’s | watch.deadlineHours, from the revert PR’s opening | the hold on the entity ends; the change stays live and the outcome says so |
| Re-arm after the breaker tripped | an auto gate was declared and the breaker demoted it | nothing — the pause lifts only on a re-arm (dashboard or loop_rearm) | both gates stay a person’s |
| A rating card | evaluation.human.ask is always or until-calibrated | evaluation.human.expiresHours | the judge’s number stands |
A move your window() names (awaitingPerson) | your sampler says the next window waits on a person | nothing — the window waits until it moves | the 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_grantfails, and the tool call that hitmissing_grantstays failed. So a capability either loop role needs must be granted ahead of time: relax it withhitl: falseinconstitution/engine-capabilities.json, or drop it from your copy of the role inconstitution/Agents/system/. A capability the role does not hold cannot be asked for either — add it to the role’scapabilities:if the round needs it.omnislash doctornames everyhitl: truecapability the two roles reach under Loop autonomy. - A broken analysis or apply closes the cycle
failedat 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.failednever counts toward the circuit breaker; what bounds a brain whose analyses keep breaking ismaxAnalysesPerDay. - 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) isonInconclusive:human(default) demotes that round to the human merge gate with a notice;shiplets the declared gate stand — the loop postsomnislash/validation: success“by policy” so an armed merge can land, and the cycle history says merged without proof by policy;closecloses the PR and settles the cycleunvalidated— 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 yourmergeMethod— demotes that round to the human merge gate with anauto-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 doctornames a base branch that would refuse every arm under GitHub merge gate (below). A PR still unmerged whenwatch.deadlineHoursruns out settlesunvalidatedas 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: autoa change whose watch measured nothing — its verdict strength isblind: 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. Anunvalidatedthe watch DID measure (theevolveinterval straddling the bar, yourcomparereturningperMetric) 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, whatevermaxConsecutiveRegressionssays: the pause is announced at that settle and both gates stay human until someone re-arms it (the entity’s page orloop_rearm), exactly as after a regression streak. A brain that accepts that risk declaresonUnmeasured: continue: autonomy carries on, the settle stays on record (the cycle history says so), and the streak ceiling still applies. requireCalibration: trueholds 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 judgedhuman: truemetric isok.- Revert inherits
merge— no separate knob.merge: automeans revert PRs auto-merge too: autonomy without self-repair is autonomy that rots silently.merge: humankeeps 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/webhookon 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:autonomymerges 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;shiplets the arm land (“merged without proof by policy”);closecloses 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 orloop_rearm);continuecarries on. Doctor warns ononInconclusive: shiptogether withonUnmeasured: continue— nothing then stops on “we don’t know”. -
watch.onInconclusive—keep(default) leaves an unproven change shipped;revertkeeps only what was shown to work. -
autonomy.maxConsecutiveRegressions— keep a number (default2);nullremoves the streak ceiling (Loop autonomy warns on an unattended entity). -
autonomy.requireCalibration—truekeeps both gates a person’s until the judge agrees with people on every judgedhuman: truemetric (no effect without one). - Metrics beyond
quality, andmustImprove: trueon the objective — an inert change then settlesunvalidatedrather than counting as a win (Loop autonomy names an auto/auto entity judged byqualityalone).
Nobody to ask
-
evaluation.human.ask: never(the default) — no rating cards. - Grants for the loop’s roles: under
decide: autoa grant request from the proposer or implementer fails the turn. Relax eachhitl: truecapability they reach inconstitution/engine-capabilities.json, or drop it from your copy ofloop/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.modederivesnone). If a standing grant covers it (agrants:block, contract.md § 7.1), declarecheck.mode: replayto get the check back. - Supervising from code:
loop_holdisloop:hold, which asks nobody.loop_release,loop_rearmandloop_ack_failureareloop:supervise(hitl: true), so a pipeline that holds and releases the loop needs agrants:block on its scenario for the release only — orloop.superviserelaxed inconstitution/engine-capabilities.json. -
loop:autonomyis 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 agrants: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. Sethitl: trueonloop.configureinconstitution/engine-capabilities.jsonto put a person on joins and spend changes. - A proposer pipeline, if you use one, cannot ask anyone: it answers
nullor 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-gateandomnislash/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: writeaccepted by the App installation, orCommit statuses: Read and write(fine-grained) /repo(classic) onGH_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.maxPerDayon every unattended entity (Eval ceilings),loop.maxAnalysesPerDaynotnull(Loop ceilings), andloop.budget.maxUsdPerRunon a CLI-harness implementer if a run’s spend must be capped. -
watch.deadlineHourslong enough forwatch.windowRunsgraded runs at the entity’s cadence plusgradeAfterHours(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; underdecide: autoit 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:
| List | What it holds | Who sets it |
|---|---|---|
| editable | The 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. |
| denied | The 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. |
| unreadable | constitution/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-limits | Why 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.ts | Your 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.ts | A 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.json | The 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.ts | defineProject, where the brain-wide verdict and shouldOpen rules live. |
memory/ and every memory.sources entry you declared | Memory 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, .dockerignore | The 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 depth | The 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 name | A 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 file | The 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.yaml | Same rule, brain-wide. |
Where each layer holds:
- The harness, before the write. The implementer role is
file:read+file:writeand 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 outsideeditableor underdeniedis refused, a read underunreadableis 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, andomnislash doctorfails an implementer role on it.) - The sandbox, when
isolation.enabled. The implementer runs in a bwrap jail where everydeniedpath is mounted read-only, everyunreadablepath 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 doctorwarns 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). - 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 undereditable, outsidedeniedandunreadable(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 itsevaluation:/loop:/chat:/budget:blocks byte-identical — and, as ONE change, fitloop.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/configandHEADmust 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:
| How | close_reason |
|---|---|
No new turn for chat.episode.idleHours | idle |
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
| Value | On 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 |
reset | The 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:orloop:— 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.tsnext to the role file is its loop module (measure,window,shouldOpen,compare; notcases— a dialog has no input to replay).- The engine’s own system roles are not chat entities. Loop blocks in
constitution/Agents/system/role-*.mdgrade and improve nothing, andomnislash 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 tofeatures/<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. sampledecides per EPISODE, and the grading of a sampled episode starts from its close (gradeAfterHourscounts from there). A chat role owes asample.maxPerDaylike 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.runIdis the episode’s run id — the one your tools read ascontext.auth.runRef.runIdduring its turns — andrun.completedAtits close.findChatEpisode(run.runId)from@omnislash-ai/infra/chat-episodesgives 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
| Key | Engine default | What it does |
|---|---|---|
priorEpisodes | 0 | Earlier 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. |
maxTokens | 40000 | The 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 thedashboardactor; 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./ideaalone 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 theEvaluationtool category beside theeval_*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 past | and is still bound by |
|---|---|
the window’s ready — the window is still sampled and stored, it is the baseline | loop.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 come | one 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 maxAnalysesPerDay | the 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:
| Function | Asked | repair answers | evolve answers |
|---|---|---|---|
window(ctx) | on every grade that lands, before the trigger rule | the entity’s last trigger.windowRuns graded runs through the grade in hand, always ready | every grade landed since the previous cycle looked, ready once trigger.windowRuns of them have |
shouldOpen({ window, trigger }) | after window, over the runs it named | k-of-n, the newest bad — Your own trigger rule | open: true — the window is ready, so the cadence came round |
measure(run) | at every grading | nothing — brain-only, Metrics only your code can produce | the same |
verdict({ spec, pairs }) | at the pre-merge check | the ordered rule list over the pairs — The verdict, as a list of rules | the 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 observed | the 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 comparator | the 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:
| Field | Meaning |
|---|---|
from | the oldest kept run’s createdAt, or null for “the last n” |
to | the sampling instant |
runIds | the graded executions it names — distinct, oldest first; may be empty |
metrics | the scope: period metrics measured for the window as a whole — the repair preset produces none, your own window() may |
reason | why this window, rendered on the cycle |
progress | optional — 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_starttool — 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:
| Check | Verdict | When |
|---|---|---|
| Eval ceilings | fail | A 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 ceilings | warn | Any 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 watch | warn | loop.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 runs | What it reads |
|---|---|
| A pipeline handler | ctx.runRef — { runKind: 'pipeline', runId, definitionHash } (typed on restate.Context by @omnislash-ai/pipeline-sdk) |
| A tool you wrote, called by a workflow task’s agent | context.auth.runRef — { runKind: 'workflow', runId } |
A tool called through callTool, or by an agent a pipeline ran with runAgent | context.auth.runRef — the pipeline’s own { runKind: 'pipeline', runId } |
| A tool called during a heartbeat tick | context.auth.runRef — { runKind: 'heartbeat', runId } (the tick) |
| A tool called in any turn of a person’s direct chat with one of your roles | context.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.
-
idearecords its run on the strategy. Its task agent calls yourstrategy_savetool; 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:writeinconstitution/project-capabilities.json({ "freelancer": { "write": { "risk": "moderate", "hitl": false, "description": "Save the batch strategy" } } }) and list it undercapabilities:in the roleidea’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. -
Each stage returns its run;
studiowrites the lineage. A stage is a child pipeline, so it is its own run: it handsctx.runRefback, andstudio— 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:
publishlocks the video row, posts, and records the post with itspublish_run_idin onectx.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 declaredoptional: true. -
Each entity’s
measure()attributes by its own column.publishreads the one video it published,layoutthe videos it laid out,ideaevery 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_setand 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'increateTool/defineTool(the engine then answers for it), or readscontext.auth.loopLaunchedand 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 onrestate.Contextby@omnislash-ai/pipeline-sdk) and skips whatever its own code writes, sends or launches outside the engine helpers — your tables, an external API, a rawfetch. The SDK’snotify,emitEvent,kvSet/kvDeletealready act on nothing, andcallTool/runAgentcarry the mark. measure(run)seesrun.replay: trueon 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:
| Field | What it holds |
|---|---|
target | { kind, id } — the entity the change is for. |
cycleId, round | The cycle (what loop_history names it by) and which round of it this is. |
refinement | What a person asked of this round when they refined the one before it, else null. |
refining | The 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. |
privacy | For 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. |
window | The 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). |
grade | The 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. |
runs | The 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. |
metrics | The 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. |
lessons | What 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). |
proposals | The 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). |
relatedFailures | Other 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. |
surface | The 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:
| Column | What it holds |
|---|---|
cycleId, targetKind, targetId | The cycle and the entity it improved. |
status | The 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). |
reason | Why 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. |
round | The 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. |
proposer | Who 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, files | What 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. |
prUrl | Its PR, once the apply delivered one. |
implementerSummary, landedFiles | What 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, mergeGate | When 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. |
revertError | Why 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, watchVerdict | The 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. |
strength | Verdict 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. |
lesson | The newest lesson the cycle taught — { id, outcome, failureCategory, deltas, helpful, harmful } — or null before it settled. |
missingLesson | Why 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, completedAt | When 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:
| Field | What it holds |
|---|---|
outcome | How it ended: validated, regressed, unvalidated, skipped or failed |
hypothesis | What the round claimed would help — its own summary |
files | The files it proposed to touch, plus any skill directory it asked for |
failure_category | The taxonomy of the failure the cycle was opened to answer, where the grader assigned one |
deltas | How 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 / harmful | How 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.json | What it says |
|---|---|
proposer | Who wrote the round’s change: a proposer pipeline’s id, or null for the proposer agent. |
rejection | Why 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. |
strength | How 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 keeps | Column |
|---|---|
| What this round proposed, and the one-line claim under it | suggestions (jsonb), summary |
| The human steer that asked for this round, and the round it refines | refinement, parent_id |
Where the round itself stands — proposed, then accepted / skipped / refined, then applied or failed | status |
| Who answered its card, and when — the chat speaker who tapped it, or the entity itself for every answer no person was asked for | decided_by, decided_at |
| What the person said with the answer — the note a Skip carried (the dashboard’s Skip asks “why”); null otherwise | decision_note |
| Its change, and the revert that undid it without losing the change that shipped | pr_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 carries | implementer_summary, landed_files |
| Why no revert PR exists for a change owed one — the change is still live | revert_error |
How the wait on that revert ended — merged (with GitHub’s instant), closed, or unmerged at the deadline; empty while the loop still waits | revert_outcome, revert_merged_at |
The gate the revert PR stands under — the change’s own, demoted to human when GitHub would not arm it | revert_merge_gate |
| The pre-merge check: the PR head it measured, and the candidate and incumbent replays it launched | checked_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 reason | check_verdict (jsonb) |
| The lessons the proposer said this round was built on | cited_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 keeps | Column |
|---|---|
| The judge it is pinned to, and the inputs its check measures on | judge, 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 off | watch_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 measured | watch_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 under | validation_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 counted | challengers_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 rounds | agent_ids |
| When the proposal became available, and when its PR was opened | proposed_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:
| Gate | What is stored |
|---|---|
decide / retry | a pending HITL card of producer loop whose data is loop.gate.v1 ({ cycleId, round, stage, deadlineAt }, CycleGateDataSchema) |
grant | a pending grant_request from one of a live cycle’s own agents (agent_ids), directly or coalesced onto another card |
merge | an applied row with merge_gate = 'human', due merge_wait_started_at + watch_deadline_hours |
revert | a regressed row whose revert PR has revert_merge_gate = 'human' and is still being waited on |
rearm | an 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.
| Tool | Takes → answers | Capability |
|---|---|---|
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 here | loop: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 more | loop:read (safe) |
loop_summary | the 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 now | loop: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/… modules | loop:read (safe) |
loop_policy | same (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 op | loop:read (safe) |
loop_join | { targetKind, targetId, dailyCap, dryRun? } → { resumed, gradingStarted, autonomy, files } — the dashboard’s Turn on | loop:configure (moderate, asks nobody) |
loop_set | { targetKind, targetId, knob, op, value, dryRun? } (op is set or remove) → { tier, knob, files } — one knob at one tier | loop:configure (moderate, asks nobody) |
loop_widen | the 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 error | loop:analyze (moderate, a person approves by default) |
loop_ack_failure | { cycleId } → { acked, targetKind, targetId } — dismiss a terminal failure | loop: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):
| Tool | Does |
|---|---|
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.
| Key | Engine default | Tiers merge |
|---|---|---|
pipeline | null — the engine’s proposer agent writes the change | The 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. |
modelRef | none — the proposer role’s own ref (constitution/Agents/system/loop/role-proposer.md), the file that re-aims every analysis at once | The 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:
| Was | Now |
|---|---|
loop.modelRef | loop.proposer.modelRef |
loop.proposer: <id> / null | loop.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/:
| Path | Used for |
|---|---|
constitution/Evaluation/Methods/default.md | Grading. Needs a # Run Evaluation section; # Task Evaluation is optional (pipeline runs have no per-task criteria). |
constitution/Loop/proposer/workflow.md | Analysing 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.md | Analysing a failed pipeline invocation. Same placeholders. |
constitution/Loop/proposer/heartbeat.md | Analysing a poorly-graded heartbeat tick. Same placeholders. |
constitution/Loop/proposer/chat.md | Analysing 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.md | Analysing 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.md | Analysing 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.md | Applying 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.md | How 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 flatLoop/<kind>.md,Loop/evolve.md,Loop/idea.md, anything underconstitution/Improvement/— which the engine never reads. Do not move it to the new path: delete it and carry what it changed ininstructions— and a changed## Privacysection of an oldchat.mdinconstitution/Loop/chat-privacy.md. A file underconstitution/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 butproposer/idea.md, the only brief an idea cycle reads — would reach the agent verbatim), and animplementer.mdwithout{{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
-
A grade lands,
window()samples what the cycle would be evidence of, andshouldOpensays 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, soloop.evidencemeans one thing wherever you set it:runs.json—evidence.runsof the entity’s graded executions — underrepairthe newest through the window’s newest run, underevolvethe 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, whereevidence.includeTraceis 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 asampleceiling 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 mostlessons.maxRecalledlessons 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 declaredaggregate: mean), which is the baselinemetrics.jsonaims at — and its runs ranked the wayruns.jsonis 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.jsonwhere the entity declares metrics,refinement.mdwhen a human asked for this round, andfailures.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.maxTokensis 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 underloop.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 underevolve,proposer/idea.mdfor a cycle opened on an idea — withloop.proposer.instructionsappended after it (The proposer block). -
It proposes file changes (or your proposer answers whole files). Who answers is the
decidegate: by default you get a card — Accept / Skip, or reply to it to refine — and nobody answering it insidereviewDeadlineHourssettles the proposalskippedrather than waiting forever; underautonomy.decide: autothe proposal is accepted without a card, with a notice instead (see Where the loop waits for a person). -
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
reviewDeadlineHourstoo — unanswered, the apply is recordedfailed. -
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/validationcommit status — see What the check does. A candidate that regressed has its PR closed: nothing ships. Then themergegate: by default a human merges it; underautonomy.merge: autothe 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. -
Merged → the entity’s own next
watch.windowRunsexecutions are watched and graded. What they are depends on the entity — see How a cycle is judged. -
The watch reads them by the trigger’s own rule — the engine’s count over
watch.minFailures, or yourshouldOpen— 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 samemergegate), orunvalidated— shipped, standing, and the window closed short of the evidence to say either way. Onlyregressedreverts. See When the evidence never arrives. -
After a
regressedsettle the loop follows the revert PR until it merges, is closed, or is still open atwatch.deadlineHours(counted from the revert PR’s opening), and records which on the round (revert_outcome, and GitHub’srevert_merged_atfor a merge). While it waits, no new cycle opens for that entity — from a grade, thetrigger.everytick 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. Undermerge: autothe revert is auto-merged like the change was (the breaker never vetoes an undo), so the hold ends when GitHub lands it; undermerge: humana 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.
| Status | Means |
|---|---|
awaiting_decision | Suggestions ready, waiting for you. |
skipped | You 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 → applied | Being applied → PR open. The pre-merge check runs while the row is applied, before the merge wait starts. |
validating | PR merged, the entity’s own executions being watched (or waited for). |
validated / regressed | Measured 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. |
unvalidated | Terminal 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. |
failed | The 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).
| Phase | What it does, and what it waits on | Bounded by |
|---|---|---|
analyze | Takes 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. | — |
propose | The 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 |
decide | Pins 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. | — |
gate | One 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 / implement | The 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) |
ship | Arms the evidence deadline and launches the pre-merge check’s replays. No wait. | — |
check | The replays, graded under the pinned judge. | loop.check.deadlineHours (24 h) |
merge | The merge gate, then the PR landing — a person’s merge or GitHub’s auto-merge. | watch.deadlineHours from the moment the merge wait started |
sync | Gives brain-sync one interval to deliver the merged content. | one brain-sync interval |
watch | The entity’s own next executions after the change went live, until the verdict. | watch.deadlineHours from the watch’s start |
settle | Pays what the verdict owes: the revert of a regressed change, the lesson, the notices, retiring the cycle’s agents. No wait. | — |
revert | Follows 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
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.onInconclusivesits under the check,watch.onInconclusiveunder the watch,autonomy.onUnmeasuredunder 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 throughconstitution/models.json. Then the lock groups: the brain-widegrouplist (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.editablePathsshows 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.tsloop: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’sevaluation.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.mdunioned 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 whenisolationis 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 whoseloop.proposer.instructionsadd 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.
unvalidatedas 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 ownprogress) 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 neitherevaluation:norloop:is read from a hook, and the loop’s subject kinds arescenario,heartbeatandrole. 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.
| Entity | Kind | Graded on |
|---|---|---|
idea — researches the niche, writes a strategy for a batch of ~10 videos | workflow | a person’s rating at first, then the judge; views of its batch |
layout — strategy → scene layout | pipeline | same; viewers held at 3 s (the hook) |
production — layout → video | pipeline | a judged rubric; share of the video watched |
publish — posts it | pipeline | views in the first hour |
studio — drives layout → production → publish per video, writes lineage | pipeline, not in the loop | — |
stats — pulls platform numbers into video_stats | pipeline, not in the loop | — |
vlog — a video about the freelancer’s own “life” | pipeline | a 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 —
ideasaves its run id on the strategy row (context.auth.runRefin yourstrategy_savetool),studiorecordslayout’s andproduction’sctx.runRefon the video,publishits own with the post, and each entity’smeasure()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 foridea(the topic), viewers held at 3 s forlayout(the hook), share watched forproduction, first-hour views forpublish— asln(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 asobserve, and the engine’scost_usdbecomes aguard. - One variable at a time — the
audiencelock 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 inwindow()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 }andrequireCalibrationdropped; go through the checklist. - The vlog —
vlogreadsloop_historythroughcallTool(Reading the history) and is itself in the loop onkind: reportedhuman ratings. - Stubs —
productionandpublishcan start as stubs;publishbranches onctx.loopLaunchedlike 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 byagent_close_episode; the judge reads it whole: Chat roles. - Escalation — your
escalate_to_humantool records a row keyed by the episode’s run id and callscloseChatEpisode; the role’smeasure()turns it intoescalated0/1, a share underaggregate: 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); eachmeasure(run)returns the share of its own sends that got a reply: Recording lineage. A bounded wait isgradeAfterHours; a tally with no bound would be ascope: periodmetric measured bywindow()instead (Late metrics). - Shared by two entities — the
outreachlock group: one live change across both, and a sibling’s change never lands inside the other’s watch unnoticed (it settlesconfounded). - The weekly business number as a guard — a weekly pipeline outside the
loop calls
loop_holdon the group when meetings drop andloop_releasewhen 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. pitchsends email — a pre-merge replay runs it dry; your own sending tool declaresreplay: 'dry-run', andmeasure()returns[]onrun.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, ormissed_alertsmissed its bar: Trigger. missed_alertscomes from your incidents table in the heartbeat’smeasure(run), joined on the tick’srun.runId(a tool reads it ascontext.auth.runRef): Metrics only your code can produce. If every signal is a number,evaluation.method: nonedrops 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
samplesays: How a cycle is judged. - Retention — a heartbeat’s tick is an agent row: a
gradeAfterHourslonger thanagentRetention.ttlHoursloses every grade (Eval ceilings). - Who answers — keep a person on
mergefor ops, or go through the checklist.