until — a hold that runs a check, and a heartbeat that keeps asking

SkillAI & models

Use when a session's stopping condition is being written or repaired, \"set the stopping condition\", \"hold it until the tests pass\", \"what should the objective be\", \"give it something to work toward before I go to bed\", \"write the brief for the spawned agent\", \"it stopped overnight\", \"it idled while I was asleep\", \"it asked me a question instead of continuing\", \"why did it stop\", \"is the hold actually armed\", \"make it keep working\", \"run this unattended\", \"leave it running overnight\". Use proactively BEFORE handing work to any session that will outlive the user's attention, a spawned agent, a background job, a craft dispatch left running, or this session at night. NEGATIVE ROUTING: composing a craft run's own stopping condition is work-dispatch.sh and needs no help; spawning the session is agent-spawn; delegating a task to a subagent is farm-out. This skill owns the CHECK COMMAND that settles it, the standing authority that travels with it, and the proof that both mechanisms are live.

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the until — a hold that runs a check, and a heartbeat that keeps asking skill

What this skill tells your AI

The instructions your AI receives, as published by edwinhu/workflows in skills/until/SKILL.md and read by ahel’s review.

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)"

Two mechanisms, neither with a transport, so neither can fail to be delivered.

HOLDuntil-arm.sh writes a session-scoped state file; hooks/until.ts runs the check on every Stop and blocks while it exits non-zero. It decides whether to continue.

HEARTBEATCronCreate, a model tool called directly. It guarantees something asks once the session has gone quiet, which no Stop hook reaches: nothing fires when no turn is running.

An unattended run needs both.

Arm the hold

A=${CLAUDE_SKILL_DIR}/scripts/until-arm.sh
bash $A '<the CHECK command>' --rounds 8 --minutes 720
bash $A --status     # armed? prints the check and the ceilings
bash $A --disarm     # release early

Pass the check as one single-quoted argument, with no apostrophes in it — an apostrophe ends the quote, and double quotes would hand every backticked fragment to the shell to run first. Write "the exemption in vendor-lint.sh", not "vendor-lint.sh's exemption".

The hold is self-clearing: the hook removes the state file the moment the check exits 0, and again when either ceiling is reached — that release says UNMET, and saying so is the session's job.

The CHECK COMMAND

The check is the whole objective. There is no separate end state to write down: a sentence and a command disagree eventually, and only one of them runs.

  • It must be RED when armed. until-arm.sh refuses a check that already exits 0 — a hold on a passing check holds nothing and teaches the session that the block is noise.
  • It must be able to run. Exit above 1 is could-not-run, not a verdict, and is refused too; arming on a broken command would hold the session forever on a typo.
  • One clause per claim. bun test x.test.ts && bash measure.sh --rate-below 0.01 — each half fails on its own terms, and the failure names which.
  • It must run in that session's own cwd. A check whose paths live in another repo can never be met where it runs, only released unmet.
  • No milestone. test -f report.md is true while the objective is unmet. Name what the WORK reaches: a suite passing, a rate under a number, a count at zero.

The escapes are flags, not sentences

--rounds N stops a losing run; --minutes M stops a stuck one. The hook counts and clocks both, so neither is prose the session can re-adjudicate away. Defaults are 8 and 720; the clock has to outlast rounds × a round.

The CRON PROMPT — the text that re-enters the session

CronCreate takes a prompt, and that prompt is the only thing present when a tick fires into a quiet session. The authority, the continuation rule and the terminal blockers live in it.

Run `<CHECK>` and report its exit code — judge from the command, not from the conversation. If it
fails, take the next action now rather than proposing it. If it passes, spend the remaining budget:
hunt for work the check does not cover — an ungated checker, a suite nothing runs, a vendored copy,
a count that has drifted — fix the largest one and say in one line why you picked it. Standing
authority: <commit / push / pick the next scope / re-dispatch> without asking. The only terminal
blockers are <a missing credential, a dead network, an irreversible or outward-facing action>;
everything else is the next task, difficulty included. When the budget is spent, end this heartbeat
with CronDelete.

Enumerate the terminal blockers. The list is what keeps a hard problem from being filed as a blocker. "When done or blocked, notify" produced a 5-hour idle: the session hit a fixture it could not cut cleanly and, having no list, called the difficulty a blocker.

Lint it before creating the cron:

bun ${CLAUDE_SKILL_DIR}/scripts/cron-prompt-lint.ts "<the prompt>"
bun ${CLAUDE_SKILL_DIR}/scripts/cron-prompt-lint.ts --file BRIEF.md

Exit 0 clean, 1 findings, 2 usage. Eleven rules, all decidable from the string. Fix every critical and major. It settles what an exit code can settle and renders no opinion on whether the objective is right.

Prove both are live

bash ${CLAUDE_SKILL_DIR}/scripts/until-arm.sh --status settles the hold. CronList — the tool, not a command — is the only thing that proves the heartbeat exists. Call it. A cron you remember creating is not a job; measured 2026-09-16, a creation reported success while CronList showed none.

Two teardowns

the holdself-clears when the check exits 0 or a ceiling is hit; --disarm ends it early
the cronCronDelete, a model tool with no CLI. Nothing else can end it — a session cron lives in memory, and no hook event fires when the work completes — so the last sentence of the cron prompt is the only thing present at the moment it should stop

An objective names the state the WORK reaches, never the event on the way there.

"craft has returned a verdict", "the recon report exists", "BRIEF.md has been carried out", "the agent has reported back" — each is true while the objective is still unmet. The moment it closes the session stops, and at 02:00 the work stops for the night. craft has returned a verdict closed on overallPass=false with 0 of 5 tasks done and 20 blocking findings; craft's own loop is FAIL → fix → re-run, and calling FAIL "done" stops the loop that was going to fix it.

Every decision left open becomes a question asked into an empty room.

If the session may commit, push, pick round-2 scope, choose between two branches it named itself, or spend the rest of its round budget — the cron prompt says so, in one sentence, up front.

The continuation rule

When a sub-run returns and the check still fails, take the next action. Do not propose it.

Every measured stall happened at a moment of legitimate completion — a verdict landed, a brief finished, a recon report arrived. That is the moment with the most information the session will ever have, and the next action is the one it just finished naming. If two branches are genuinely open, pick one, say why in a clause, do it. A menu offered at 02:00 is a five-hour pause with extra steps.

Red flags — STOP

About toWhy wrongDo instead
Self-send anything — a prompt, a command, a stopping condition — from a session that will not go idlea typed transport needs an idle pane and a session working back-to-back never offers one; the send exits 0 and nothing landsuntil-arm.sh writes a file, CronCreate is a tool call: both land inside the turn
Report a heartbeat as armed because you created one29 of 81 heartbeats raised on 2026-09-16 never armed, and nothing told the sessionCronList settles it — no job, no heartbeat
Arm a check that already exits 0it holds nothing and teaches the session the hold is noiseuntil-arm.sh refuses it; pick the check that is red now
Arm a check whose paths live in a different repoit can never be met where it runs, only released unmetthe check must run in that session's own cwd
Put an apostrophe in the checkit ends the single quote, and double quotes run every backticked fragment firstwrite the word without it
Arm a hold on YOUR OWN session to try the hook outit then blocks your own stop until the check passesread tests/until.test.ts, or arm a check you can satisfy on demand
Treat a stopping condition you wrote down as binding on yourselfprose is re-adjudicated away; only the hook blocks a stop. Measured 2026-09-02: a session wrote "I'm treating that as binding regardless" and idled three hours laterarm it, and confirm with --status
Restate --rounds or --minutes as prose in the prompttwo ceilings that can disagree, and the prose one is the bugthe flags; the hook counts and clocks them
Write "has returned a verdict" / "the report exists" as the checkmilestone: true while the objective is unmeta suite passing, a rate under a number, a count at zero
Leave a session running overnight on the hold alonenothing in a Stop hook runs once the session is quietCronCreate a heartbeat; CronDelete when the work is done
End a turn with a question mark under an armed holdat 02:00 that is a five-hour pauseanswer it in one line and act
Write "when done or blocked, notify and stop"every difficulty becomes terminalenumerate the terminal blockers; the rest is the next task
Hold a green commit "because the user is asleep"the authority clause should have pre-authorized it; the commit is reversible, the silence is notcommit, and say so in the report
Put "and the user has approved" in the objectivea session cannot close it by working — measured 18hreview after the hold releases, as a step it performs
Write "or stop after N turns"nothing in the harness counts turns--rounds, which the hook counts
Report "N of M rounds used" and stop at Nthe budget was the authorization, not a ceiling on ambitionspend it, or say why the remainder is unusable

References

  • references/templates.md — the check-command and cron-prompt templates, the unattended-brief template, and three real stalls rewritten side by side.
  • scripts/until-arm.sh, ../../hooks/until.ts — the arm and the hold. The hook's header records the three properties that make a Stop hook safe rather than a trap.

Signals

GitHub stars
21
Forks
4
Last commit
Sep 2026

ahel review

  • K6low
    bundled executables the agent is told to run

Automated review, not a security audit. Ruleset v1+k2.

Advanced
Catalog kind
skill
Gateway key
until
Source
github.com/edwinhu/workflows