Claude Code’s Getting Started with Loops sorts agent loops by two things: what triggers a cycle, and what stops it. Turn-based (you prompt), goal-based (an evaluator decides), time-based (/loop, /schedule), proactive (events, no human in the room). A good frame. So where does a zero-dependency, one-shot harness like kodr2 sit?
It already has every per-iteration part. What it lacks is the outer driver — and for a do-one-thing tool, that’s correct. The loop is a shell script.
The loop is a shell script
Two seams make it work. kodr run exits 0 only on complete + verification passed, so if kodr run …; then branches on success. And --json prints one line — { completed, verified, noOpCompletion, filesChanged, … } — so a driver decides done / retry / park without scraping output.
What stops the loop thrashing is a ratchet: only a green run (completed, actually changed something, tests pass) gets committed. A red one retries in place with --continue — the broken code stays on disk for the model to fix, the prior transcript replayed — and giving up reverts and marks the task blocked. Three channels carry progress across iterations: the git-committed workspace, --continue (short-term working memory), and MEMORY.md (a human-reviewable retrospective injected into every future system prompt). noOpCompletion in the success check guards a “complete” run that quietly did nothing. That’s the whole recipe — about sixty lines of bash.
The gap: fuzzy stop conditions
A shell test command handles “tests pass.” It’s useless for “the route is documented” or “the docs read clearly,” and it can’t tell real progress from a model spinning. That’s the goal-based loop, and it needs a judge. So kodr2 gets one:
kodr goal "the /health route is documented and has a test" --test "node --test" --max-attempts 4
Each attempt is a full run() — build, --test, heal. Then a read-only model judge inspects the workspace and ends with VERDICT: MET or VERDICT: NOT MET; if not met, its feedback rides into the next attempt as a continuation. Capped at --max-attempts, with early stops on a two-attempt no-change stall or a build error.
The judge borrows the review pass’s hard-won stance: it can’t touch the workspace, and a verdict reached without opening a file is ungrounded. Here the stakes are higher than in review — a fabricated “met” would end the loop early — so ungrounded-met is the one verdict the loop refuses to trust; it keeps iterating. Verdict parsing is deliberately blunt: no explicit VERDICT: line means not met, so a truncated reply is never read as success.
The orchestration is pure. The per-attempt build and the judge are injected callbacks, so the loop logic — attempt capping, stall detection, the ungrounded-met handling — is unit-tested with plain return values, no model anywhere. The judge itself gets a scripted-client test plus a live eval.
The first live run earned its keep
Unit tests green, I pointed it at LM Studio (gemma-4-26b) with a real time budget. It crashed: Cannot read properties of undefined (reading 'getTime'). With --max-run-ms set, the tool loop’s budget check reads startedAt.getTime() — and I’d wired the judge call without a startedAt. The unit tests missed it because they ran with the budget disabled. One-line fix (default the judge’s own clock), one regression test that runs the judge under a budget.
Re-run, same workspace: { met: true, attempts: 1, verified: true, grounded: true }. The build wrote the add function and a node:test for it, the tests passed, and the judge read both files before returning MET. Exit 0.
That’s the dogfooding contract — a live failure is a harness bug, not a model excuse — and the loop found one on its first outing.
A bigger run got stuck
The next goal was bigger — extending a real CRM repo — and it hung. Nine minutes in, I killed it by hand. The transcript was the same tool result over and over: {"error":"path is required"}. gemma had called write_file with a 16 KB body and no path, then re-sent the identical broken call six times. One stuck loop, three separate harness gaps:
- The log had stripped the tools. The saved run held every message but not the tool schemas, so you couldn’t tell from the transcript what the model was even offered. The record now carries the tool definitions — self-describing, no
--debugrequired. - The error was a bare assertion.
path is requiredgives a small model nothing to act on. It now names the tool and the shape it wanted:path is required — write_file needs { "path": "<file>", "content": "..." }. - The prompt said don’t; the model did anyway. “Never repeat a failing call unchanged” was already in the system prompt. So the tool loop now enforces it: consecutive identical (tool, error) failures escalate the message the model sees, then stop the loop with a new
stuckreason. Six wasted minutes collapses to three turns.
None of the three was the model’s fault to fix. Same contract as the first run — a live failure is a harness bug — just three of them this time.
The same failure, in a bigger harness
Curiosity got the better of me: point Claude Code at the same free, local gemma-4-26b and hand it the same CRM repo. Twice.
First, non-interactive — claude --model google/gemma-4-26b-a4b -p "…" against ANTHROPIC_BASE_URL pointed at LM Studio. It ran 37 turns and reported total_cost_usd: 15.50 for a model that costs nothing to run. The number is fiction: Claude Code prices and sizes the backend as if it were talking to Anthropic, regardless of what’s actually on the other end — modelUsage["google/gemma-4-26b-a4b"].contextWindow: 200000 (gemma’s real window, which LM Studio reports and kodr2 already probes for, is 262144), plus a nonzero cache_read_input_tokens against a backend that doesn’t do Anthropic-style prompt caching. The transcript matched the fiction: 18 Bash calls (all read-only, near-duplicate greps for “search-all” in slightly different casings), 14 Read, 2 Plan, one TaskCreate, one TaskUpdate — zero Write, zero Edit. Thirty-seven turns, a confident closing summary, and it never once left planning.
Second, interactive, with a Stop hook I’d built as a rough goal-loop for Claude Code — same shape as kodr goal: block the stop, judge the transcript against a condition, feed the verdict back in. It worked, in the sense that it correctly refused to let the model declare victory: for two and a half hours it kept reporting “still just planning” almost verbatim, three duplicate design subagents got dispatched within 27 seconds of each other, and I had to nudge it along by hand (“are we done?”, “now?”, “how’s it going?”). When it finally reached an actual Edit on storage.mjs, it failed with String to replace not found in file — then submitted the identical failing edit six more times, same anchor string, same error, including twice right after re-reading the very file it was trying to edit. Session over, working tree clean.
That second failure is the exact signature the repeated-call breaker above was built to catch — an identical (tool, error) pair, resubmitted unchanged. kodr2 collapses that to a stuck stop in a few turns, because the fix is a dozen lines in a loop small enough to reason about end to end. Claude Code has no equivalent circuit breaker that I could find, so the loop just spent the clock. Bigger harness, same weak-model failure mode, no breaker.
Stress-testing the loop on a 15-phase build
kodr goal and loop.sh were the primitives. What was still missing: proof the ratchet holds up over a long, many-phase build, not a one-line demo. phased-loop.sh is loop.sh with one branch added - a checklist line prefixed GOAL: runs through kodr goal instead of a bare kodr run, so one TASKS.md can mix hard-gated tasks with judged completeness claims. The test plan, crm-phases.md, is fifteen phases building the same zero-dep CRM two earlier runs stopped at hardening. Four of the fifteen are GOAL: lines: an auth retrofit, cross-entity search, rate limiting, and a second, bigger multi-tenant retrofit.
First run: 5 hours 8 minutes unattended, all fifteen phases processed, 5 succeeded. Reading the log back should have been simple. It wasn’t - the log had frozen two minutes in, looking exactly like a dead process. It wasn’t dead. It kept running correctly for five more hours; the log just stopped saying so.
The ratchet ate its own logs
A park reverts the tree with git reset --hard, then commits just the checklist mark, not the whole workspace, since the workspace is what just failed. But the script’s own log files weren’t gitignored, so a green commit’s git add -A had swept them in too. Every park after the first success reverted them back to that snapshot, discarding whatever they’d logged since. Fix: keep them out of git entirely.
No backoff, and a database that never resets
Two more, same run. Seven of the run’s ~34 attempts died on a flat 10-minute request timeout - a big local model can still be mid-request well before the run’s own wall-clock budget is up. One phase burned all three of its attempts on back-to-back LM Studio HTTP 500s inside 200ms, because nothing paused between retries. And git reset --hard only reverts tracked files - the database is gitignored on purpose, so three separate parked phases left permanent schema drift (extra NOT NULL columns, a whole extra table) baked into the live SQLite file. Fixed: a configurable request timeout, a backoff before retrying a transient error, and an opt-in RESET_PATHS that wipes named gitignored paths alongside the reset.
The fix regressed itself
First relaunch, broken again - two commits where five clean phases should have been. My first fix had excluded the logs with a pathspec; then I also added them to the tracked .gitignore. Together, git add -A -- . ':!path' exited non-zero on git’s “ignored paths” warning - the file was correctly unstaged, but the non-zero exit killed the commit chained after it with &&. Five phases built fine and never got committed. The checklist advanced anyway: marking a line done doesn’t check that its commit worked.
Root cause found by reproducing it in a throwaway repo, not by staring at the original. Fixed properly this time: the scripts register their own log filenames in .git/info/exclude (repo-local, not the tracked .gitignore) and commit with a plain git add -A, plus a fail-loud check that stops the run outright if a commit ever fails.
Two more, caught live
Restarted clean, and two more surfaced within the hour. Same blind spot from the other side: git reset --hard also leaves untracked files alone - a stray test file a failed attempt left behind survived a park and broke the next phase’s npm test outright, since node --test picks up every test file regardless of whether git knows about it. Fixed with git clean -fd right after the reset (it respects .gitignore, so it never touches RESET_PATHS’s own job).
And RESET_PATHS wiping a directory left it missing rather than empty. The CRM’s own storage code never recreated its data directory before opening a database file inside it, so “wipe the database” turned into “break every phase after this one” until someone (me) ran mkdir -p data by hand. Fixed the same day: recreate a wiped directory empty, don’t just delete it.
What actually held up
Five harness bugs across the two runs: the log clobber, the retry path (flat timeout, no backoff), the un-reset database, the untracked debris, and the vanished data directory - plus one of those fixes regressing before it stuck.
Second full run, with all five bugs fixed: 8 of 15 phases succeeded, up from 5 the first time. Four of the parks that had looked like model failure resolved themselves the moment harness noise was removed - the Deal entity, soft delete, import/export, and background jobs all built cleanly once nothing was eating retry budgets or leaving debris behind. One phase went the other way: core entities, the easiest thing in the whole plan, sailed through the first run and hit the tool-call limit then errored twice straight on the second. Not every difference between the two runs is the harness getting better - some of it is just this model on this day.
Six phases still parked in both runs, no exceptions: three of the four GOAL: completeness checks (auth retrofit, cross-entity search, the multi-tenant retrofit) plus filtering, reporting, and webhooks. Every one of them needs correct, verified treatment across most or all of the existing entities at once - a retrofit across five tables, a search that has to cover five tables, filters and reports that touch five tables. The one GOAL: phase that succeeded both times, rate limiting, is judged completeness too, but on a single mostly-additive concern, not a retrofit. That isolates the actual failure mode: it’s not the judge causing the parks. It’s breadth.
Next
The kodr loop-shaped idea from last time is real now - phased-loop.sh, tested the hard way. Still on the list: a cross-model judge (a stronger model grading a faster builder), feeding a stalled goal’s feedback into MEMORY.md so the next session starts knowing why it stalled, and figuring out what to do with the six phases that park reliably regardless of attempts or harness fixes - maybe splitting “touch every entity” into one phase per entity instead of one shot at all of them. But the one primitive a shell wrapper genuinely can’t fake - a fuzzy stop condition, judged by a model instead of an exit code - is in, and it survived a real build.
Links: