2026-08-28 · Updated 2026-08-28 · 11 min read

Harden a Ralph loop with tests, task truth, and checkpoints

Four minimal upgrades that leave a Ralph loop recognizably a loop while bounding what each pass may spend, where its memory lives, what stops it, and what it leaves behind: acceptance tests inside the pass, a task board the worker cannot rewrite, stop conditions the loop evaluates itself, and per-pass evidence checkpoints.

By Juno AI INC · ralph-loop · task-truth · yylo

You already run the loop, and most nights it earns its keep. The shape is not what hurts you: a driver that relaunches a worker against whatever work remains is a fine shape, and nothing below replaces it. What hurts you is that each pass can spend without a ceiling, remember through files it rewrites itself, halt only when a human interrupts it, and evaporate leaving nothing a stranger could audit. Those are four separate blast radii, and each one shrinks under a single deliberate change that leaves the loop — driver, worker, restart — exactly where it was.

This page is the how. It presents four upgrades in dependency order, each one change, each adoptable on its own, and each stated with what it bounds and what it deliberately leaves alone. Two sibling pages hold the nearest neighboring jobs, linked below rather than restated: what the technique is and where its vocabulary came from belongs to the Ralph loop reference, and if what you actually have is a loop that already went wrong overnight, the named failure modes and their discriminating checks belong to the diagnostic catalog. This page assumes the loop runs, and makes it survivable. Every command and behavior attributed to YYLO below was read out of the committed CLI source, installed script templates, and README on 2026-08-28; the general principles are harness-agnostic design choices you can lift with the commands or without them.

Keep the shape; shrink four radii

"Minimal" needs a test, or every hardening project becomes a re-architecture. The test used here: an upgrade is minimal when one sentence names what changes, and nothing else about the loop moves. Apply it to the canonical skeleton:

bash
while :; do cat PROMPT.md | claude-code ; done

Four radii hide in that line. Spend is what one pass may consume — iterations, time, money — before something other than your patience says enough. Memory is where the record of remaining work lives, and who is licensed to rewrite it; here it is a plan file the same process edits. Stopping is what decides the run ends, and whether the loop can evaluate that condition without you. Residue is what a pass deposits that outlives it — here, commits plus a terminal scrollback that the next pass overwrites.

Three of the moves below shrink exactly one radius each; the third takes two at once. Every move changes what it names and nothing else about the loop, which is why they can ship one per week. One thing is deliberately kept across all four: the prompt file stays as your steering surface. It is a standing instruction channel the worker rereads on every pass, so it deserves the injection and secrets boundaries the prompt-safety guide sets — hardening the loop does not exempt its biggest input.

Move 1: put the remaining work where the worker cannot rewrite it

A plan file is memory the worker owns. The pass reads it, decides from it, rewrites it, and restarts with no memory except the rewrite — so the loop's past failures become its future instructions. The minimal change is a relocation, not a redesign: the prioritized remainder moves into task records a CLI owns, where state changes only through recorded transitions.

sh
./.juno_task/scripts/kanban.sh create --body-file checkout-task.md --status todo --blocked-by API_FIX
./.juno_task/scripts/kanban.sh ready --sort asc

Two things matter about that pair. Each task body carries its own finish line as commands the worker must run — "make npm test pass" closes; "improve the checkout" never does. And ordering moves out of prose: --blocked-by declares the dependency, ready computes which tasks are actually actionable, and the worker's standing instruction shrinks to "take one ready task, finish it, record the result." YYLO ships that standing instruction as its ralph-loop skill, whose one-line contract reads "Execute exactly one explicitly assigned Kanban task to a validated queued commit" — no selecting neighbors, no broadening scope because a different bug looked interesting mid-pass.

What this bounds is the memory radius: a corrupted or self-serving plan file can no longer steer the loop, because the loop no longer reads prose the worker authored. What it does not bound: nothing here stops anything. A loop reading a board it cannot move will orbit that board forever — that is Move 3's job. The full contract behind these two commands — dependency graphs, enforced readiness, mandatory responses, commit-bound completion, hash-chained history — is the ledger task-truth guide, which owns it end to end.

Move 2: run the suite inside the pass, before the pass counts

In the raw loop, a pass is finished when its process exits, and the suite — if anyone runs it — runs later, in a different context, against a tree that has since moved. The minimal change is to move the gate to the pass boundary, so a red suite is part of the pass's own result rather than a discovery for the morning. YYLO's hook system runs shell commands at fixed lifecycle points declared in .juno_task/config.json, and END_ITERATION fires at the close of every iteration:

json
{
  "hooks": {
    "END_ITERATION": {
      "commands": ["npm test"]
    }
  }
}

Run each pass with a single-iteration cap — -i 1, also the committed default — and the iteration boundary is the pass boundary: one pass, one suite run, one recorded verdict. That verdict is truth-in-residue rather than a brake: the runner deliberately logs a nonzero inner exit and continues, because partial completion is normal loop behavior, and repeated red passes produce no board change — which is precisely the signature Move 3's tripwire watches for. The three moves interlock like that: tests make each pass's ending honest, task truth makes progress countable, and the stop conditions make stalling terminal instead of nocturnal.

Hooks also give per-pass tests a place to live before the suite runs. The installed defaults illustrate the pattern: each iteration opens with file-size tripwires that open a kanban task when CLAUDE.md, AGENTS.md, or the plan and task files outgrow their budget — a test whose failure is a new work item rather than a console line nobody reads. What this move bounds is claimed completion: "done" now means a green gate inside the pass, not a process that exited. What it does not bound: a broken commit can still land; the gate catches it at the pass boundary, immediately, instead of at 6 a.m.

Move 3: teach the loop three stops it can evaluate without you

One stop is a judgment call; three stops that check different things are a contract. The runner below carries all three, and each is independently settable.

The first is the numeric ceiling, in two layers. -i 1 bounds iterations inside each invocation, and --max-iterations N bounds the outer loop itself — how many passes the whole run may buy, with JUNO_RUN_UNTIL_MAX_ITERATIONS as the environment form. The two-layer split matters on an unattended night: the inner cap keeps any single pass from spiraling, the outer cap keeps the night from becoming a blank check, and reaching the outer cap is a normal exit with a count in the log, not an error.

The second is the semantic stop: the work queue runs dry. Before the first pass and again after every pass, the runner asks the board for tasks still in backlog, todo, or in_progress, and an empty answer ends the run successfully. Pre-run hooks and commands execute ahead of that check, so an inbound sync — issues fetched, messages triaged — can legitimately feed the loop it is about to gate; the loop ends when there is genuinely nothing left, which is a condition a script can evaluate and a sleep-deprived human cannot.

The third is the staleness tripwire, the one that catches a loop running in place. Between iterations the runner snapshots board state — status counts plus task IDs — and compares; after a configurable run of consecutive no-change iterations (--stale-threshold, default 3, 0 or --no-stale-check to disable) the ON_STALE hook fires and the run exits with status 2. The exit code is distinct from the normal 0, so anything wrapping the loop can tell finished from stuck; the shipped default ON_STALE opens a warning task on the board so the stall itself becomes visible work.

sh
./.juno_task/scripts/run_until_completion.sh -s claude -i 1 --stale-threshold 3 --max-iterations 12 -v

What this bounds is spend and direction together: the numeric ceiling for the first, the tripwire for the second, since a stalled board under a cap still burns every pass the cap allows. The vocabulary for designing these — when a stop belongs to the loop, when to the pass, what a tripwire must watch to be unfakeable — is bounded failure design, which owns the method; this page only installs it in the loop you already have.

Move 4: make every pass deposit a checkpoint

The residue radius closes last because it needs the others: a checkpoint from an unbounded, ungated pass is just a longer receipt for work nobody checked. A checkpoint here is the smallest artifact set that reconstructs one pass without scrollback — four things, all of them records the machinery writes rather than claims the worker authors:

  • The response — the worker's own account, recorded on the task. Every transition must carry one: invoking mark with neither --response nor --response-file is a usage error that mutates nothing.
  • The commit — the change itself. Completion carries it explicitly, binding the account to a diff Git can show.
  • The run log — what happened, replayable later without terminal history.
  • The session id — the handle that resumes that same conversation where it stopped.
sh
./.juno_task/scripts/kanban.sh mark in_progress --id T1 --response "Starting: fix the checkout test"
./.juno_task/scripts/kanban.sh mark done --id T1 --response-file response.md --commit "$(git rev-parse HEAD)"

Around the passes, the same records accumulate on their own: every run appends to session_history.json — subagent and model, cost, turn counts, session ids — and yylo session list with yylo session info <id> reopens them afterwards; yylo view-log replays a run log as structured output. Read as a pair, response and commit are an audit unit: when the account says a gate went green but the committed diff contains edits the task never asked for, the pass fails review no matter what exit code accompanied it. What this bounds is the residue radius — the morning question stops being "what happened here?" and becomes "read four artifacts." What it does not bound: these are per-pass records, not resumable multi-step state; when steps must exchange results and survive crashes as a unit, that is workflow territory, owned by the auditable-workflows guide.

The assembled loop is the one you started with

Stack the four moves and compare the result to the skeleton: a driver relaunches a worker against remaining work, the work lives in records the worker cannot rewrite, each pass clears a gate before it counts, and the run ends on conditions the driver itself evaluates. Nothing about that description left the original shape — and it is worth saying plainly that YYLO's runner is that skeleton with the four upgrades installed. The committed script opens by describing itself as a while-loop pattern that always runs pre-run hooks, then checks the board before running the agent — the same while :, relocated. The two forms you will actually type:

sh
ypl '/skill:ralph-loop' -i 1
./.juno_task/scripts/run_until_completion.sh -s claude -i 1 --stale-threshold 3 -v

The first is one bounded pass through the skill contract — worker, task, gate, record, stop. The second is that pass under a driver with the three stops live; yylo --til-completion is the same loop reached through one flag. If you have never run a bounded pass at all, the starter walkthrough owns the first evening step by step. Full operational reference for the runner, including its hooks and flags, lives in the run-until-completion documentation. What the assembled loop deliberately is not: a choreography. When work becomes ordered steps that hand results to each other, you are no longer hardening a loop — you are building a workflow, and that is a different page's job.

Prove the stops before the first unattended night

A stop condition you have never watched fire is a hypothesis. Rehearse it on a disposable board where the worst outcome is a throwaway repository, and watch each of the three stops do its job once:

  • The empty-board exit. One tiny task with a green gate. The run ends successfully the moment the board reports nothing open — read the closing log lines; "All tasks completed" with the iteration count is the sound of a semantic stop working.
  • The numeric ceiling. Three real tasks, --max-iterations 2. Two passes, a normal exit, work remaining — the loop obeyed a number rather than a feeling, and the log tail says so.
  • The tripwire. One task the worker cannot close, or a stuck board with --stale-threshold 1. The ON_STALE hook fires, the warning task appears on the board, and the exit status is 2 — check it with echo $? and confirm your wrapper can distinguish it from 0.

Ten minutes, three exits observed, and the difference from the raw loop becomes measurable: you know what stops it, because you watched it stop. From there the honest sequence is the one this page's CTA names — install YYLO with npm, point it at one small task, and let the first bounded, evidence-producing run teach you the rest. Beyond this page, three boundaries are already owned elsewhere: whether the bounded workflow would serve that work better than the loop does belongs to the run-anatomy comparison, changing the worker under the same contract to the multi-harness loop guide, and fanning one board out to concurrent lanes to safe parallel execution. The loop you hardened this morning will still be a loop when you arrive at any of them — just one that stops, remembers, tests, and explains itself.