# untildone
A Ralph-loop supervisor for driving cheap, flaky models through a pre-written
implementation plan until the work is verifiably done. untildone is the
**parent**: it never edits files or runs project commands itself (aside from
the optional `--check` gate). It repeatedly launches a **child** `opencode`
session, judges each iteration's output, and decides whether to continue, retry
with a fresh session, or stop. It was born from a very specific failure mode:
`deepseek-ai/DeepSeek-V4-Flash-0731` on Deep Infra intermittently emits tool
calls as plain text instead of invoking them, so the harness sees a turn with
no tool execution and goes idle — in the OpenCode desktop app the fix was
retyping "続けて" (continue) by hand, sometimes dozens of times per task.
untildone automates that supervision loop end to end.
## Install
```bash
npm i -g untildone
# or run without installing:
npx untildone run --plan plan.md --check 'npm test'
```
Prerequisites:
- Node.js >= 24 (untildone ships as plain `.ts` run via Node's native type
stripping — no build step, no npm dependencies).
- The `opencode` CLI on `PATH` (`npm i -g opencode-ai` or
`brew install sst/tap/opencode`).
- A Deep Infra API key: set `DEEPINFRA_API_KEY`, or be already logged in to
Deep Infra inside OpenCode (untildone falls back to the `deep_infra` key in
OpenCode's `auth.json` and logs when it does so).
## Quick start
```bash
untildone run --plan docs/plans/2026-08-09-feature.md --check 'npm test' --max-minutes 90
```
Run it with a Bash tool in the background — a full run can take an hour.
`--plan` points at a Markdown plan with `- [ ]` checkboxes (untildone counts
them as a progress signal); `--check` is the strongest cheap deterministic
gate you have (usually the project's test command). Every iteration prints one
line to stdout, for example:
```
#4 nudge verdict=CONTINUE tools=7 progress=worktree,tools noProgress=0
```
Reading it left to right: `#4` is the iteration number; `nudge` is the kind of
prompt sent to the child (`kickoff` on the first iteration, `nudge`
afterwards, tagged `RECYCLED` when the session was discarded and restarted);
`verdict` is the judge's call for that iteration (`DONE`, `CONTINUE`, `STUCK`,
or `JUDGE_DOWN` if the judge API failed and the loop fell back to check-only
completion); `tools` is how many tool-execution events the child's `opencode
run --format json` stream contained (0 means the model talked without
acting — the exact stall this tool exists to catch); `progress` lists which
signals fired that iteration (`commit`, `worktree`, `checkbox`, `tools`,
`first-step` on the very first iteration, or `none`); `noProgress` is the
consecutive no-progress counter, which triggers a session recycle once it
hits `--stall-threshold`. Pass `--json` to get the same data as JSONL events
instead of formatted text — the final line of a `--json` run is a
`{"type":"result",...}` record instead of the plain-text summary line, so
stdout stays valid JSONL throughout.
## How it works
```
INIT → repeat [STEP → PROGRESS → JUDGE]
├─ JUDGE=DONE → check gate → pass: exit 0
│ fail: feed check output tail to child, continue
├─ JUDGE=CONTINUE → next STEP (nudge same session)
├─ JUDGE=STUCK or noProgress ≥ stall-threshold → RECYCLE session
└─ caps hit (--max-iterations / --max-minutes) → exit 1 + summary
```
Each **STEP** runs `opencode run --model <m> --format json --auto [--session
<id>] '<prompt>'` — the first STEP is a kickoff embedding the plan's full
content (a flaky model may not reliably read files on its own) plus execution
rules; later STEPs continue the same session with a nudge (the automated
"続けて"), including a line aimed squarely at the known failure mode: "do not
stop after printing a command — execute it as a tool call."
A STEP counts as **progress** if any of four signals changed since the
previous STEP: a new git commit (`HEAD` moved), a working-tree change (diff or
status hash changed), the checked-checkbox count in the plan file went up, or
at least one tool-execution event appeared in the STEP's JSON stream. All four
unchanged increments a no-progress counter; any one of them resets it.
**Session recycling**: once no-progress hits `--stall-threshold`, or the judge
returns `STUCK`, untildone discards the session and starts a fresh one with
the same kickoff template plus an auto-generated summary (commits since run
start, remaining unchecked plan items, the judge's last reason). This bounds
wasted work after a poisoned-context stall instead of nudging a session that's
already gone in circles.
**Judge fail-open**: the judge is a direct, tool-free call to Deep Infra's
chat completions API, given plan content, git state, and the child's last
output tail — never the child's raw tool-calling context, so weak tool-calling
never corrupts judgment. Malformed judge responses are retried up to 3 times
and then treated as `CONTINUE` (the loop never dies on judge noise). If the
judge API keeps failing outright, untildone degrades to running `--check`
after every STEP as the sole completion signal; with neither a working judge
nor a `--check` command, the loop simply runs to its caps and exits 1.
**Check-gate semantics**: `DONE` from the judge is never sufficient by itself
when `--check` is supplied — the check command must also exit 0. A failing
check feeds its output tail back to the child as the next prompt ("fix this
failure") instead of ending the run.
## Flags
`untildone run`:
| Flag | Default | Notes |
|---|---|---|
| `--plan <file>` | *(required)* | Plan file (Markdown, `- [ ]` checkboxes) |
| `--prompt <text>` | — | Extra one-off instructions appended to the kickoff |
| `--prompt-file <file>` | — | Same, read from a file; mutually exclusive with `--prompt` |
| `--check "<cmd>"` | — | Completion gate; must exit 0 for the run to succeed |
| `--model <id>` | `deep_infra/deepseek-ai/DeepSeek-V4-Flash-0731` | Executor (child) model |
| `--judge-model <id>` | same as `--model`, stripped of the leading `provider/` segment | Judge (parent) model — use a stronger model here while keeping a cheap executor |
| `--max-iterations <n>` | `30` | Iteration cap |
| `--max-minutes <n>` | `120` | Wall-clock cap, counted from process start (see Exit codes) |
| `--iteration-timeout <dur>` | `15m` | Per-STEP subprocess timeout; accepts `900`, `90s`, `15m`, `2h` |
| `--stall-threshold <n>` | `3` | Consecutive no-progress STEPs before a session recycle |
| `--cwd <dir>` | current directory | Working directory for the child and for `--check` |
| `--json` | `false` | Emit JSONL events on stdout instead of formatted text lines |
`untildone resume <run-id> [--cwd <dir>] [--max-iterations N] [--max-minutes N] [--json]`
continues a run after a crash, `Ctrl-C`, or a `SIGTERM`, from the on-disk
state. A run that exited 1 because it hit its `--max-iterations` or
`--max-minutes` cap resumes with that same saved cap by default, which is a
no-op (instant exit 1 again) — pass `--max-iterations`/`--max-minutes` on
`resume` to raise the cap before continuing.
Both `SIGINT` (`Ctrl-C`) and `SIGTERM` are handled the same way: the
in-flight `opencode` child's process group is killed, state is saved, the
run lock is released, and untildone exits `130` (SIGINT) or `143` (SIGTERM)
— the child is never left running after the parent exits.
`untildone init --codex [--codex-home <dir>]` installs the Agent Skill into a
Codex CLI skills directory (default `$CODEX_HOME/skills` or
`~/.codex/skills`).
## Exit codes
| Code | Meaning |
|---|---|
| `0` | Done — the judge returned `DONE` and, if `--check` was given, it exited 0; or the judge was unavailable after retries and `--check` exited 0 (judge fail-open, see below) |
| `1` | A cap was hit (`--max-iterations` or `--max-minutes`) with work still remaining |
| `2` | Environment/config error — missing `opencode` CLI, missing Deep Infra API key, missing plan file, another run already holding `.untildone/lock`, or the executor binary failing to spawn on the first step; stderr says exactly what to fix |
`--max-minutes` counts from process start, not from cumulative active work.
`untildone resume <run-id>` starts a new process, so it restarts the clock
from zero rather than continuing the original deadline — this is intentional:
resume re-sends a full kickoff prompt to re-ground the model after a crash or
interrupt, so it's treated as a new attempt for time-budget purposes too.
## Agent skill
untildone ships an [Agent Skill](https://code.claude.com/docs/en/skills) so
Claude Code and Codex know how to hand it a plan, pick a `--check` command,
launch it, and interpret the results.
- **Claude Code**: install as a plugin —
`/plugin marketplace add yuya-takeyama/untildone` then
`/plugin install untildone@untildone`.
- **Codex**: `untildone init --codex` copies `skills/untildone/SKILL.md` into
your Codex skills directory.
## Files it writes
Each run creates `.untildone/<run-id>/` in the target `--cwd`:
- `state.json` — resumable loop state (session id, iteration/no-progress
counts, caps, `HEAD` at start).
- `run.log` — a plain-text log line per lifecycle event (run start/end,
recycles, errors).
- `iterations.jsonl` — one JSON record per iteration (verdict, progress
signals, tool/check counts) — the file to check after exit 1 for the
judge's last reason.
A single `.untildone/lock` file (containing the owning PID) guards against two
loops running concurrently in the same directory; it's removed automatically
when a run holding a dead PID is detected.
Add `.untildone/` to your project's `.gitignore` — this run history is local
scratch state, not something to commit. untildone also writes its own
`.untildone/.gitignore` (`*`) the first time it creates the directory, so
even an unconfigured target repo won't accidentally track it.
## Caveats
- The `opencode run --format json` event mapping was verified live against a
real `opencode` binary (v1.18.15, 2026-08-09) on
`deep_infra/deepseek-ai/DeepSeek-V4-Flash-0731`, including session
continuation via `--session`, not only against synthetic test fixtures.
- Auto-approval is the `--auto` argv flag passed to every `opencode run`
invocation ("auto-approve permissions that are not explicitly denied"). An
earlier design assumed an `OPENCODE_AUTO_APPROVE` environment variable —
confirmed not to exist in the real binary, so it is not used.
- No `opencode.jsonc` `permission` block is required for `--auto` to work
unattended in the common case. If your OpenCode config has explicit `"ask"`
or `"deny"` entries you want `--auto` to bypass, note that an explicit
`"deny"` always wins over `--auto`; as a troubleshooting fallback you can
allow the tools untildone needs explicitly:
```jsonc
// ~/.config/opencode/opencode.jsonc
{
"permission": {
"bash": "allow",
"edit": "allow",
"write": "allow"
}
}
```
- If a future `opencode` release changes its `--format json` event shape,
`parseEvents` falls back to raw stdout when no line parses as JSON rather
than silently returning empty results — check `.untildone/<run-id>/run.log`
first if progress detection looks wrong after an `opencode` upgrade.