routines · design

design this page mirrors docs/PLAN.md; the runner's contract is bin/routine.

executable markdown on rails

A routine is a single markdown document — frontmatter for schedule, prose for judgment, fenced sh blocks for mechanics. bin/routine runs the blocks deterministically; when one fails, the document itself becomes the prompt: claude receives the error in full context, repairs state, and the runner retries the block once. Every scheduler — launchd locally, a CI cron, a human at a shell — is a dumb tick that ends at the same routine run.

try   { blocks, in order }          # zero-LLM happy path — cheap, fast, CI-safe
catch { claude("handle $error") }   # the doc's prose is the handler's context
The whole control flow. Everything below is detail on these two lines.

The document — $ROUTINE_HOME/<name>.md

Documents and run state live in a routines home$ROUTINE_HOME, default ~/.routines, with state under its .state/; this repo is only the tool, so personal routines never enter it and the runner can move without breaking launchd. One file per routine. Frontmatter is flat key: value, parsed with sed — no yq, no yaml library. Only sh / bash fences execute, sequentially, each under bash -e; other fences are material for the prose.

---
schedule: 30 7 * * 1-5     # 5-field cron, evaluated in UTC; omit ⇒ on-demand only
timeout: 600               # seconds for the whole routine
requires: GITHUB_TOKEN     # env vars that must be non-empty
on_error: claude           # claude (default) | fail
---

# Morning brief

Prose is not decoration — it is the context the error-handler inherits:
intent, traps, judgment calls. Write it for the agent that arrives when
a block below has just failed.

```sh
gh api notifications --paginate > "$ROUTINE_STATE_DIR/inbox.json"
```

```sh
claude -p "summarize inbox.json …"   # a block may itself call claude — an agent step on rails
```
The schedule, the judgment and the mechanics in one artifact — script and prompt stop drifting apart.

try / catch — the failure contract

The happy path never calls a model. The document is the handler's context precisely because it was written before anything broke.

① try
  • blocks run in order; each block's output is appended to the run log under a block delimiter
  • each block's exit code is the truth — nothing interprets it
② catch — on_error: claude
  • the runner invokes claude --model opus --permission-mode=auto --no-session-persistence -p with the full document, the failing block, and its captured output, cwd = the routines home
  • instruction: running unattended; diagnose and repair state; do not continue the routine yourself
③ retry
  • the runner re-executes the failing block once — pass ⇒ continue to the next block; fail ⇒ the routine exits with that block's code
  • one repair attempt per block, no loops
④ degrade
  • no claude binary or auth (bare CI) ⇒ behaves as on_error: fail, with the reason logged

Due-ness — derived, never matched

A routine is due when next-fire-after(last started run, schedule) ≤ now; a missing last-run.json means due. Cron evaluates in UTC — one clock for launchd, CI, and the shell.

one evaluator buyswhy
CI correctness under delayed or skipped tickscompare against the last run, not "does now match the expr"
launchd catch-up after sleepfree — the same comparison, no wake hook
routine run --due as the universal entryevery scheduler is a dumb tick

next-fire-after is a minute-scan forward from the last run (horizon 366 days) — dumb, correct, sub-second.

Schedulers are dumb ticks

Neither scheduler knows a routine's schedule. Both wake up, ask the one evaluator what is due, and exit.

launchd — com.routines.due
  • routine install renders one agent; routine uninstall boots it out
  • StartInterval 60 + RunAtLoad true, running the runner's absolute path with run --due — no cd, the runner enters the routines home itself
  • RunAtLoad doubles as login and wake catch-up
CI — one static workflow
  • routine sync-ci emits .github/workflows/routines.yml into the repo it is invoked in
  • cron every 15 min + workflow_dispatch; ROUTINE_HOME = that repo's routines/ dir, restore routines/.state from actions/cache, fetch bin/routine if absent, run --due, save cache
  • cache eviction just makes everything due once — the idempotency contract absorbs it
routine run  frontmatter → requires gate (exit 78, loud list of missing vars)
             → lock (mkdir .state/<name>/lock, pid inside; live pid ⇒ 75, dead ⇒ reap)
             → watchdog (bash-native; SIGTERM, SIGKILL after grace; exit 124)
             → cd $ROUTINE_HOME (blocks and the catch run there; a missing home ⇒ 64)
             → blocks under the try/catch contract
             → last-run.json {started, started_epoch, finished, exit, duration, degraded}, written on every path (trap) and appended to runs.jsonl
             → the failing block's exit passes through
The shared wrapper — every verb that runs a routine runs it through this.

The plate

One execution path, end to end: the tick, the gate, the lock, the blocks, and the single catch that repairs and retries.

One execution path through the runner — scheduler tick, requires gate, lock, blocks under try/catch, the claude repair and the single retry, and the last-run record.
one execution path — docs/PLAN.png, regenerated by docs/plate.py

Verbs

Six verbs — the whole surface. Every one of them ends at the same runner.

verbduty
run <name>run one document through the wrapper — gate, lock, timeout, try/catch
run --duethe universal entry — run every due routine; silent when none are
statusfrontmatter × last-run join: schedule, due?, last exit, duration
installrender and load the single launchd tick agent
uninstallboot the agent out and remove its plist
sync-ci(re)write ./.github/workflows/routines.yml in the invoking repo

Exit codes and contracts

codemeaning
64unknown routine, or usage
75lock held by a live run
78requires missing
124timeout — the watchdog fired
elsethe failing block's own code, passed through