ian-operator src/runner/ Five ticks · one alarm clock Folio VI of VII

Proceedings of the loop

An autonomous loop that re-grounds itself every cycle, refuses to repeat what already failed, records what it did whether or not it succeeded, and is watched from outside by a monitor that can see the case where it stops running altogether.

Article VI — Of ticks, cadences and the discipline of not repeating oneself

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.”

The ticks
TickCadenceReadsWrites
observepre-tick, cheapRepository status, file times, calendar and mail deltas; its own memory.Tainted observation rows, deduplicated by hash.
fast (doer)every 30–60 minActive goal model, context packet, current plan step.One action intent or one task patch; one progress row.
slow (reviewer)dailyGoal model, recent memory, three days of progress.Re-prioritised board, drift flags, plan steps.
executorevery 15 min, not enabledIntents at ready or approved across a rolling window.Real effects — only through enumerated tools, only when dry-run is off.
consolidatenightlyThe 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

The ian-operator tick cycle and the processes that observe it A cycle running clockwise: the observe tick perceives cheap deltas and taints them; the fast doer tick re-grounds on the active goal model and the current plan step, passes a drift guard, and routes any risky action through the gate; a gate stop halts the tick and notifies the person; an allowed intent is staged for the executor, which is the only process that acts; the slow reviewer re-prioritises daily and writes forward plan steps; the nightly consolidation writes a state of play, refreshes the goal model as a draft, and prunes ephemeral logs. Outside the cycle, a stall monitor reads state independently so it can detect both a loop making no progress and a loop that has stopped running. 1 · OBSERVE git status, file mtimes, calendar and mail deltas — cheap, no GPU everything untrusted = 1 own memory read: trusted 2 · RE-GROUND active goal model, read fresh context packet, read fresh “the goal comes from goal_model, never from accumulated trajectory” 3 · ADVANCE ONE STEP first pending plan step is the current step; advance THAT no plan → derive, and flag that a re-plan is needed 4 · DRIFT GUARD is this an exact-ish repeat of a tried-and-failed action? if so: do not execute — record blocked 5 · THE GATE record the intent · read the stamp requires_approval or blocked → STOP allowed → outcome ‘staged’ 6 · EXECUTOR separate process, off the MCP face claim → handler → terminal status dry-run unless explicitly armed 7 · RECORD PROGRESS one row every tick, always keyed to the state hash it saw interrupted mid-tick? flush one row DAILY · SLOW REVIEWER re-prioritise the board against the goal model flag drift and what is being fallen behind on write the ordered forward plan the fast tick advances notify only within the interrupt budget NIGHTLY · CONSOLIDATION write the state of play the next morning tick reads first refresh the goal model as a DRAFT — never auto-activated prune ephemeral logs within the retention floors each phase fails independently; a throw in one cannot abort the rest STALL MONITOR outside the loop STUCK SILENT NO_GOAL cooldown + recovery notice reads progress + state independently — it can see a loop that stopped running only on an executable intent chosen action survived the guard the next tick re-grounds on progress, not only on the goal
Figure 7 — the cycle, and the observer outside it Steps 2 to 7 are one fast tick. The dashed panel is the stall monitor, which deliberately does not participate: an in-tick check cannot detect that no tick is running.

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.

Shipped attention defaults
SettingDefaultMeaning
budget_per_day6Unsolicited interruptions per day. Approvals are exempt.
min_gap_min90Minimum minutes between unsolicited interruptions, tested globally rather than per day.
quiet_start / quiet_end22 → 7Profile-local silence window; wraps midnight.
approval_cap20Past this many approval pings, further ones batch into the digest rather than ping.
cost false alarm : missed help4 : 1The cost knobs that determine τ*.
clarify cost / p(resolve)0.5 / 0.6Asking 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.

Autonomy levels in ian-operator, as capability tier against gate verdict A grid with three capability tiers as rows — reversible, external and spend — and three gate verdicts as columns — allowed, requires approval, and blocked. Reversible handlers such as wiki notes, drafts, research notes and jailed workspace builds may fire on an allowed and ready intent. External handlers such as electronic mail and messaging require an approved and linked intent and are refused otherwise by an independent tier floor. The spend tier never executes at all: it hands off to a person. The blocked column is empty at every tier. Below the grid, four global conditions are listed that apply on top of the grid. GATE: ALLOWED · READY GATE: REQUIRES APPROVAL GATE: BLOCKED TIER 1 reversible writes only; no send, no money AUTO-FIRES note · wiki_write → files_write draft · draft_prep → a file, never sent research → search, recall, then a note workspace_task → a jailed build write-if-absent: never overwrite HELD a reversible type can still be gated by the irreversibility or money rule — the tier does not override the verdict NEVER not executable, not approvable, not markable as executed TIER 2 external leaves the machine REFUSED BY THE TIER FLOOR even an allowed + ready external intent is refused, audited, and the claim is released back to its prior status a second, independent check FIRES ONLY IF APPROVED + LINKED email → one validated address only: no commas, semicolons, CR or LF subject stripped of line breaks sender account from a fixed allowlist telegram / message → the notify path NEVER TIER 3 spend money UNREACHABLE rule 1 of the gate makes every money action require approval, so this cell cannot arise STILL NOT EXECUTED — HUMAN HAND-OFF “The operator will NOT auto-purchase.” The handler notifies the person with the title, the amount, the vendor and the intent id, then marks the intent completed with a hand-off flag “so it is never mistaken for an actual machine-executed purchase.” APPLIES TO EVERY CELL ABOVE · dry-run is on unless the environment holds the exact string 0 — the shipped unit pins it on and the timer is not enabled · the handler is reached only for an intent the kernel predicate still judges executable, re-checked at claim time · the claim is compare-and-set; a second process finds zero rows changed and skips · a throw anywhere inside the side-effect zone routes the intent to needs_human — a terminal state that is never retried
Figure 8 — autonomy as tier × verdict The tier floor in the executor is an independent check, not a restatement of the gate: it refuses an external or spend handler unless the claim’s prior status was 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.

terminal · one executor pass, dry-runsrc/runner/executor-tick.mjs
$ 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.