Article VI The loop
6.1
Five proceedings, five cadences
There is no long-lived agent process. Each tick is a separate one-shot invocation driven by a timer, and each leaves exactly one row behind so the next one can read what happened. The entry script is emphatic about its own scope: “ONE tick per invocation. Secrets are read by the runner from the environment file — never passed on the command line, never in git.”
| Tick | Cadence | Reads | Writes |
|---|---|---|---|
| observe | pre-tick, cheap | Repository status, file times, calendar and mail deltas; its own memory. | Tainted observation rows, deduplicated by hash. |
| fast (doer) | every 30–60 min | Active goal model, context packet, current plan step. | One action intent or one task patch; one progress row. |
| slow (reviewer) | daily | Goal model, recent memory, three days of progress. | Re-prioritised board, drift flags, plan steps. |
| executor | every 15 min, not enabled | Intents at ready or approved across a rolling window. | Real effects — only through enumerated tools, only when dry-run is off. |
| consolidate | nightly | The day’s progress, memory and audit. | A state-of-play summary; a draft goal-model refresh; retention pruning. |
A sixth process, the stall monitor, is not a tick at all — it is an outside observer, and §6.5 explains why that distinction is load-bearing.
6.2
One cycle
6.3
Re-grounding, and the two drifts it prevents
The loop’s characteristic failure is not doing something dangerous — the gate covers that. It is doing nothing, repeatedly, while appearing busy. Three mechanisms address three distinct versions of that failure.
Drift from the goal
The goal is re-read from the goal-model table at the start of every tick, never carried forward in a conversation. If there is no active goal model the tick refuses to act at all: “Fast tick will not act on trajectory alone.”
Drift within the plan
Without a forward plan, two consecutive ticks both re-derive the opening move. So the reviewer writes an ordered plan, and the doer tick advances the first pending step rather than re-choosing. Reconciliation against the board is careful in a specific way: a task that has become blocked must have its step flipped to blocked, never to done — an earlier version marked such steps done, silently skipping work that never happened.
Repetition of what failed
Before executing, the chosen action is normalised to type|title|recipient, lowercased
and whitespace-collapsed, and compared against the tried-and-failed list. An exact-ish repeat is not
executed; it is recorded as blocked so the escalation machinery engages “instead of the
loop silently re-grinding a dead action.” The comparison is deliberately conservative so a genuinely
new action is never suppressed, and a kernel-sanctioned recovery retry is exempt.
6.4
Every tick leaves a row
The progress row is written on every path — success, refusal, gate stop, exception, and even
interruption. A signal handler installed at the start of a guarded tick best-effort flushes one
failed row if the process receives a termination signal mid-tick, under a hard 2.5-second
cap so the handler can never hang. The comment notes the limit honestly: an uncatchable kill signal
flushes nothing.
A second guard prevents the opposite error. The runner marks the tick recorded before issuing the final write, “so a SIGTERM during the await cannot also write a duplicate ‘failed’ row. One row, never two.”
After a failed or gate-stopped tick, the fast tick emits one short self-critique — one to three sentences plus a failure type drawn from a fixed eight-term taxonomy — keyed to the active goal. The next tick for the same goal is shown that window and told not to repeat those mistakes. It is explicitly ephemeral: pruned to the last three per goal, never promoted, never a standing rule. The model is asked only to critique; it is not allowed to grade the outcome, because the outcome label was already set deterministically.
6.5
The monitor that stands outside
The stall monitor’s header explains why it is a separate program rather than a check inside a tick, and the reasoning generalises well beyond this codebase: an in-tick check can catch a loop that runs without progressing, but it can never catch a loop that has stopped running, “because no tick runs to perform it.”
It escalates on three conditions, each with its own cooldown and each sending a single recovery notice when it clears:
- STUCKThe no-progress streak has reached its threshold. If intents are waiting on the person, the message is reframed as “waiting on your approval” rather than as a fault.
- SILENTThe loop has run before — a progress row exists — but the newest one is older than the silence threshold, so it may be down.
- NO_GOALThe streak is climbing but there is no active goal model, meaning onboarding never completed.
A fourth, independent backstop exists: if the board hash has not changed for a longer run of ticks, the loop is stuck regardless of what the outcome labels say. Its invariants are as tight as the gate’s — it never crash-loops, always exits zero, and its only side effect is a notification.
6.6
Attention as a budgeted resource
An autonomous system that can contact a person will, left alone, contact them too often. The attention layer treats interruption as a cost to be spent rather than a message to be sent, and it is biased toward silence at every step.
The threshold is derived from two explicit cost knobs rather than tuned:
τ* = C_falsealarm / (C_falsealarm + C_missedhelp). With the shipped values — an unwanted
interruption costing four times a missed one — that is 0.8, and it is pooled across channels;
per-channel recalibration is deliberately deferred and, until enough labelled outcomes exist, the
static value stands.
The gate short-circuits to silence on the first failing check, in this order: no candidate; untrusted content, which can never auto-interrupt; not actionable; below threshold; quiet hours; an inferred busy window; and finally the budget.
| Setting | Default | Meaning |
|---|---|---|
| budget_per_day | 6 | Unsolicited interruptions per day. Approvals are exempt. |
| min_gap_min | 90 | Minimum minutes between unsolicited interruptions, tested globally rather than per day. |
| quiet_start / quiet_end | 22 → 7 | Profile-local silence window; wraps midnight. |
| approval_cap | 20 | Past this many approval pings, further ones batch into the digest rather than ping. |
| cost false alarm : missed help | 4 : 1 | The cost knobs that determine τ*. |
| clarify cost / p(resolve) | 0.5 / 0.6 | Asking a clarifying question requires expected value above cost — only a top-priority, blocking, underspecified goal qualifies. |
The budget is spent atomically. A single conditional update both tests the cap and the gap and spends the slot, so two overlapping ticks cannot both pass on a stale snapshot — “removing the send-before-spend window.” A separate arbiter governs the voice channel with its own conservative budget of four proactive messages per day, a rising threshold penalty per message already sent, and a hard suppression window while a live conversation is in progress.
6.7
Autonomy, as a ladder rather than a switch
“How autonomous is it?” has no single answer here. Autonomy is the product of two independent axes: what the gate said about this particular action, and which capability tier the handler that would perform it belongs to. Both must clear before anything happens, and a floor in the executor re-checks the second axis independently of the first.
approved and a
valid decision is linked, whatever the per-profile policy happened to say.6.8
An executor pass, dry
The default posture is instructive: run the whole pipeline, log the call that would have been made,
and make no request. Arming it requires setting one environment variable to the exact string
0.
$ node src/runner/executor-tick.mjs --dry-run [2026-07-07T17:15:03.402Z] REAPED stuck 'sending' intent 37 (email) -> needs_human [2026-07-07T17:15:03.418Z] dispatch intent 44 action_type='note' tier='reversible' dry=true [2026-07-07T17:15:03.421Z] DRY-RUN note->files_write ~/Desktop/wiki/wiki/operator/note-2026-07-07-44-…md: WOULD call ian-brain files_write({"path":"…","content":"…","mode":"create"}) [2026-07-07T17:15:03.430Z] intent 44 -> sent (reversible, dry-run) [2026-07-07T17:15:03.437Z] REFUSED external intent 45 (email): not approved+linked (pre=ready); left pending [2026-07-07T17:15:03.444Z] dispatch intent 41 action_type='spend' tier='spend' dry=true [2026-07-07T17:15:03.446Z] DRY-RUN spend->notify+handoff: WOULD notify Ian to purchase manually: … [2026-07-07T17:15:03.455Z] intent 41 -> completed (spend, dry-run) [2026-07-07T17:15:03.461Z] no executor for action_type='wire_transfer' (intent 46); left for human [2026-07-07T17:15:03.470Z] tick done: considered=4 executed=2 skipped=2 failed=0 handoff=1 dry=true
Log lines follow the exact format strings in the runner;
identifiers and the note filename are illustrative. Invocation flags are those the runner parses:
[--day YYYY-MM-DD] [--dry-run].
Two of those lines carry the article’s most careful reasoning. The reaper routes a claim that has
been in flight past its threshold to needs_human rather than retrying it, because a dead
claimer leaves completion unknown and a re-send could double-fire a side effect that already
happened. A fresh claim, by contrast, is a live concurrent tick and is left alone. The same logic
governs any exception thrown inside the side-effect zone: the outcome is unknown, so the intent is
routed to a terminal state that no path can retry, and a person is asked to reconcile whether the act
actually occurred.