grind

Use when work has to keep going with NO session alive — "run this in a loop overnight", "grind loop", "start the loop and go to bed", "keep iterating until the check passes without me", "a fresh agent

SKILL.md

GitHub

grind — a loop with no session, whose only memory is one append-only journal

What this skill carries — grep references/ for any subject the names below miss: !d=${CLAUDE_SKILL_DIR}; command -v skill-toc >/dev/null 2>&1 && exec skill-toc "$d"; s=$HOME/.claude/skills/plugin-utils/bin/skill-toc; [ -x "$s" ] && exec "$s" "$d"; echo "(skill-toc unavailable: references and scripts are NOT listed here — install the plugin-utils plugin, or start a new session so its bin/ reaches PATH)"

A bash while loop that calls a fresh model process per iteration and keeps nothing between them. Cost inside a chat session is wakes × context, and the context only grows: measured on this project, one orchestrator spent 1.05 billion cache-read tokens in a day across 1,816 turns on a ~575K-token context, and 208 of those wakes were status reports that needed no judgement at all. Here nothing accumulates between iterations because nothing survives them.

The loop is scripts/grind.sh. Nothing session-scoped can be load-bearing in it — no CronCreate, no Stop hook, no Monitor, no session id — so the journal is the whole interface. A human, or a chat session peeking in, tails it and has no other handle on the run.

Three rules, and each one is a defect this loop already hit

1. The supervisor is a shell command, so no model call is spent while it is red. --gate is a cheap precondition — a queue check, a file that appears when a round lands. While it exits non-zero the loop appends a wait, sleeps --sleep, and never invokes the runner. A verdict that takes ten hours otherwise buys ten hours of model calls whose only finding is that the job is still queued. Wake on decisions, not on events. Each wait record carries the gate's last output line as why, so a gate should print the reason it is shut rather than just exiting non-zero. A streak of consecutive waits is announced through the notification channel while the run continues (--wait-alert N, default 6, 0 disables), because a gate stuck closed looks identical to legitimate waiting from outside.

2. The keep decision is --check's exit code, never the agent's opinion. An iteration reports success whether or not it did anything, and no downstream check catches a confidently wrong answer. The agent's only write channel is append, and no record it can write makes the loop declare success.

3. The floor list is read from the journal at the top of every pass, before the prompt is built. A fresh agent handed the same dead pool re-diagnoses it every iteration, for a week. A key filed as a floor is injected into every later prompt as an exclusion, which is the only reason an amnesiac loop converges at all.

<EXTREMELY-IMPORTANT> ## IRON LAW: NO STOP THE LOOP DID NOT COMPUTE

done is written only after --check exited 0, in the loop's own process.

A model asked whether the goal is met answers from the pass it just ran, which is the one view that cannot see the goal. Every other terminal record — stalled, budget, stopped — is likewise a count or an exit code, never a judgement. Pick a check that names the state the WORK reaches: a suite passing, a rate under a number, a count at zero. test -f report.md is true while the objective is unmet, and the loop exits on pass 1. </EXTREMELY-IMPORTANT>

Start it detached

setsid nohup bash ${CLAUDE_SKILL_DIR}/scripts/grind.sh run \
  --journal /abs/run.jsonl \
  --prompt-file /abs/PROMPT.md \
  --check 'bash /abs/measure.sh --lost-below 120000' \
  --gate  'test -f /abs/out/round-ready' \
  --sleep 300 --stall-after 5 --max-iters 200 \
  >/abs/grind.log 2>&1 </dev/null &

Never foreground it: a Bash tool call caps out and takes the run with it, and a session held open to watch is the cost this skill exists to remove. --runner defaults to claude-code and --model is passed through to it; a test or a dry run points --runner at a stub instead. Every ending — done, stalled, budget, stopped — is announced by default through the launching session's watcher (the farm-events stream); agent-msg goes only to a --notify-to other session, or when there is no stream or no live watcher there. A herdr popup too, where herdr is installed. --notify CMD replaces that and gets GRIND_STATE, GRIND_EXIT and GRIND_JOURNAL; --notify none silences it. A failed notification never changes the exit code. A loop killed hard reaches no ending, so it also files itself in the launching session's farm-events stream, whose monitor reports a pid gone with no DONE (--no-events opts out).

If this session will ACT on the result (restart the loop, read the floors, ship the fix), CronCreate an hourly heartbeat in the same turn as the launch — cron 7 * * * *. The prompt names the session's standing objective or journal directory, never one batch, so it never goes stale; each tick runs grind.sh status there. The farm-events monitor is the primary wake; the cron is the backstop that survives a --resume, which monitors and background tasks do not. NEVER delete the heartbeat when a loop ends (done, stalled, budget, stopped): the session's mandate (an overnight or autonomous instruction, or queued next items) outlives any single loop, and a deleted heartbeat leaves the session with no wake. If the prompt must change, create the new one first. Deleting asks the user (permissions.ask), so a delete happens only when the user ends the mandate.

Then, from anywhere and at any time:

J=/abs/run.jsonl
bash ${CLAUDE_SKILL_DIR}/scripts/grind.sh status --journal "$J"      # state, pid, iterations, floors, stall
bash ${CLAUDE_SKILL_DIR}/scripts/grind.sh tail   --journal "$J" -n 40
bash ${CLAUDE_SKILL_DIR}/scripts/grind.sh floors --journal "$J"
bash ${CLAUDE_SKILL_DIR}/scripts/grind.sh stop   --journal "$J" --why 'grid down for maintenance'

stop is a record, not a signal: the loop honours it at its next boundary, after the pass in flight finishes. Once the loop answers it with stopped, the same run command resumes the journal, floors and all. Exit codes are 0 done, 2 refused, 3 stalled, 4 budget exhausted, 5 stopped. The per-iteration watchdog defaults to FARM_MAX_TURNS=250.

That subcommand is refused from inside an iteration, and the refusal names its reason — the loop marks the environment it invokes the runner in, and an operator's own shell carries no such mark, so an iteration that reads this file and concludes the work looks impossible cannot end a run the operator started. The guard closes the documented path and claims nothing beyond it: an iteration runs bash and can still kill the pid recorded in start, so this prevents a good-faith mistake rather than guaranteeing the loop cannot be stopped from inside.

Against the work hold — the difference is whether anything stays alive

the work hold grind
what stays alive a chat session, held open by a Stop hook and re-woken by a cron heartbeat nothing — a bash loop outside every session
the memory that session's context, which grows with every turn one journal; each iteration is a fresh process that starts empty
what decides to continue the hook, running the check when the session tries to stop the loop, running --check at the top of every pass
what it costs wakes × a context that only grows one model call per decision, none while the gate is red
reach for it when the work needs that session's own tools, connectors or approvals the work is a command plus a prompt, and it will take days

The journal is the only file

There is no pidfile, no state file, no lock and no cache. Every fact is derived by reading the journal — the iteration counter is the largest i, the floor set is every floor record, the pid comes from start. Two files that can disagree about one fact are a bug generator.

kind written by means
start the loop a run began: pid, check, gate, runner, model, prompt file
iter / iter_end the loop iteration N began; the runner exited with this code
wait the loop the gate was red, so the pass cost nothing
progress the agent the goal moved; this is what --stall-after counts back from
floor the agent this key is dead, and every later prompt is handed it until a reopen names its key
reopen the agent or operator with subject: an exhausted subject is worth another pass; with key: that floor is retracted (a later floor on the key closes it again). Always with a non-empty rerunReason saying what changed
done the loop --check exited 0 — the only record that means success
stalled / budget the loop --stall-after passes in a row with no progress (default 3, 0 disables; an attempt or note is not progress); --max-iters spent
stop / stopped the operator / the loop a stop was requested; the loop honoured it

That split is enforced, not merely described. The agent may append only progress, floor, attempt, note and reopen: append refuses every loop-owned kind with exit 2, so nothing an iteration writes can end a run or report a finish the loop did not compute. A floor is refused unless it carries a non-empty key, because one accepted without a key is dropped by the reader and the loop re-diagnoses that family forever.

Any record may also carry a subject — the family the work is about, a CIK or a shard rather than a free-text key — and that is what groups one iteration's attempt with the earlier ones. Every prompt carries a GRIND_SUBJECTS block listing each subject's attempts since its last reset, its lifetime progress count and an excerpt of its newest attempt, so a fresh iteration can see what has already been tried instead of re-diagnosing it under a new key. A subject with --exhaust-after attempts (default 3, 0 disables) and no progress since is marked EXHAUSTED and is excluded from the iteration's choices the way a floor excludes a key. The only way back is a {"kind":"reopen","subject":"...","rerunReason":"what changed"} record, which resets that subject's count and is refused without both a subject and a non-empty rerunReason; a progress record on the subject resets it too. Recording is never refused for exhaustion — an attempt on an exhausted subject still lands, because the journal must not lie about the work that was done.

A floor is lifted the same way, by key: {"kind":"reopen","key":"<floor key>","rerunReason":"..."} drops that floor from floors, status and GRIND_FLOORS, ordered by journal position; a floor filed again afterwards is live again. A note cannot lift a floor — an operator withdrawing one appends this reopen, or the passes keep honouring it.

Every prompt carries GRIND_JOURNAL, GRIND_SH, GRIND_ITER, GRIND_FLOORS, GRIND_SUBJECTS and GRIND_NOTES, so an iteration needs nothing from outside itself:

bash ${CLAUDE_SKILL_DIR}/scripts/grind.sh append --journal "$GRIND_JOURNAL" \
  '{"kind":"progress","key":"2004-noseries","note":"heading index resolved 41K rows"}'
bash ${CLAUDE_SKILL_DIR}/scripts/grind.sh append --journal "$GRIND_JOURNAL" \
  '{"kind":"floor","key":"1934-paper-scans","why":"no machine-readable text in the source"}'

Iterations run with FARM_OUT_CHILD=1, so an iteration writes and commits directly; farm out only work the prompt itself asks to delegate.

An operator steers a running loop with the same channel: append {"kind":"note","key":"...", "note":"..."} and the newest ten notes are printed as GRIND_NOTES in every later prompt, newest last, outranking the prompt's own ranking. No iteration opens the journal file, so a note that is not injected is a note nobody reads.

A record must be one JSON object on one line and must fit in a single atomic write, or it is refused — never split, never truncated — because a split record leaves a log nobody can replay. Readers skip lines that do not parse, which is what makes a journal cut short by a crash resumable rather than fatal. Anything larger than a key and a note belongs in a file the record names.

Reading it by hand is the same one pass:

grep -c '"kind":"iter"' "$J"                                  # iterations attempted
grep '"kind":"floor"' "$J" | jq -r '"\(.key)\t\(.why)"'       # the set every prompt is handed
grep '"kind":"iter_end"' "$J" | jq -r 'select(.exit != 0) | .i'

Red flags — STOP

About to Why wrong Do instead
Let an iteration's own report decide the run is finished the loop would be grading itself, and one confident wrong answer ends a week of work with the goal unmet only --check exiting 0 writes done; the agent writes records, never verdicts
Point --check at a milestone — a file existing, a report written, an agent having reported back it is true while the objective is unmet, so the loop stops on pass 1 name the state the work reaches: a suite passing, a count under a number
Write a --check that passes once the inputs are fetched or built the loop records done with the deliverable missing — 209 exhibit PDFs fetched and none read, 1,404 order PDFs fetched and 0 outcomes coded (board-structuring, twice) fail the check until the final deliverable exists and is non-empty: coded rows > 0, or every fetched file extracted; print the count so done shows it
Run without --gate when the verdict takes hours every pass spends a model call to learn the job is still queued — that is the whole bill a shell precondition; while it is red the loop records wait and sleeps for free
Hand an iteration a key an earlier one closed it re-diagnoses the same dead family every pass, and the loop never converges read GRIND_FLOORS before choosing work; file a floor the first time a key dies
Append a whole report as one record over the single-write bound it is refused, and splitting it would leave a log nobody can replay a key and a one-line note; write the report to a file and name its path
Add a pidfile, a progress file or a notes file beside the journal two files that can disagree about one fact, and the tiebreak rule is the bug append a record; derive the pid, the counter and the floors from the journal
Arm a hold in a session while a grind loop works the same objective the hold blocks that session's stop while the grind's gate waits for no round in flight: each waits for the other and neither moves one driver per objective — grind owns a long loop, and no session holds alongside it
Foreground the loop from a chat session the Bash tool call caps out and kills the run mid-flight, and the live session is the cost grind removes setsid nohup … &, then grind.sh status when you want to know
kill -9 the loop to end it the journal then ends on iter, and nothing can tell a kill from a crash grind.sh stop, honoured at the next boundary
About to write a pass prompt that says "do one chunk, commit, exit" each pass re-reads the investigation doc and the journal, so an hour of analysis became 10+ passes (docket-outcomes reached pass 59, ~20M weighted tokens per 14 passes) tell the pass to finish the deliverable when it can, committing at checkpoints rather than exiting at them
About to ask a pass for regression tests, "contracts" or edge-case hardening beyond what --check reads passes polished instead of finishing, and operator "estimate now" notes took 2-3 passes to land (s220-deterrence) the pass prompt names the deliverable and the check, nothing else
Read iter_end with exit 0 as progress it says the process ran, not that anything moved progress records, which are what --stall-after counts
Launch a loop you will act on without a heartbeat a --resume restores no monitor and no background task, so a resumed session never hears it end CronCreate an hourly tick at launch, naming the standing objective; keep it past the ending
About to delete the heartbeat cron because a loop/batch ended the mandate outlives the batch; a loop that lost its only wake this way sat idle for 3 h until the user found it leave it; the user ends the mandate, and the delete asks them
Reach for the work hold to keep this going the hold keeps a SESSION alive, which is the thing this loop exists to avoid the hold while a session must live; grind when none should