Presence-gated scheduler for personal machine chores — jobs that should run "the first time I'm active in the morning", not at a fixed wall-clock time on a machine that might be asleep.
A short-lived dispatcher (routines tick) is invoked by a launchd user agent
(macOS) or systemd user timer (Linux) at login and on a coarse interval. Each
tick it reads a TOML manifest, decides which jobs are due (period + optional
time-of-day window + optional guard command), runs them, and exits. The
scheduler is deliberately dumb: jobs are opaque executables — shell one-liners,
headless AI-agent invocations, or launchers that detach long-running work — and
it never branches on what a job is; all variability lives inside the job,
behind one small contract.
Homebrew (macOS or Linux):
brew install ramsrib/tap/routinesOr from source, if you've cloned this repo to hack on it (requires Go):
make install # builds to ~/.local/bin/routines (override with PREFIX=...)Then point a trigger at it (see Trigger setup).
mkdir -p ~/.config/routines
curl -fsSL https://raw.githubusercontent.com/ramsrib/routines/main/jobs.toml.example \
-o ~/.config/routines/jobs.toml # or cp jobs.toml.example from a clone; edit to taste
routines list # what's registered
routines tick --dry-run # what would run right now, and why/why not
routines run <name> # run one job immediately (ignores schedule)
routines status # last run / status / exit code / next due per jobDefault location ~/.config/routines/jobs.toml (override with --manifest or
$ROUTINES_MANIFEST). Symlinking it from a dotfiles repo is the intended
setup; relative entrypoint paths resolve against the symlink-resolved
manifest's directory, so job scripts can live next to it in the repo.
[jobs.refresh-auth]
owner = "infra" # optional: project the job belongs to; shown in list/status, scheduler ignores
schedule = "daily" # daily | every:<Go duration> | weekly:<weekday>
window = "05:00-12:00" # optional: only fire inside this local-time window
cwd = "~" # optional working dir (default: manifest dir)
entrypoint = "jobs/refresh-auth" # executable; relative to the manifest dir
guard = "" # optional `sh -c` command; non-zero exit → skip, retry next tick
timeout = "5m" # default 10m
isolation = "host" # host | podman | remote:<alias> — hint for the job, scheduler ignores
requires = [] # documented needs, e.g. ["gui", "keychain"]; scheduler ignores
enabled = true- Non-interactive; exit code = status; stdout/stderr captured to
~/.config/routines/logs/<name>/<timestamp>.log(last 10 kept). - Environment:
ROUTINE_NAME,ROUTINE_TRIGGER(launchd/manual/...),ROUTINE_SCRATCH(fresh per-run dir),ROUTINE_LOG_DIR. - Long work must detach itself (tmux, task queue, remote box) and return — the dispatcher stays short-lived.
- Interactive handoff is one-way: emit a reminder or park a resumable session, never block waiting for a human.
macOS — a launchd user agent with RunAtLoad + StartInterval (900s
works well; launchd coalesces a catch-up run after wake):
<key>ProgramArguments</key>
<array>
<string>/Users/you/.local/bin/routines</string>
<string>tick</string>
<string>--trigger</string>
<string>launchd</string>
</array>
<key>RunAtLoad</key><true/>
<key>StartInterval</key><integer>900</integer>Linux — a systemd user timer (OnStartupSec=1min,
OnUnitActiveSec=15min, Persistent=true) invoking
routines tick --trigger systemd.
tick always exits 0 — a skipped or failed job is logged, never surfaced to
launchd/systemd as a crash. State (last runs, lock) lives in
~/.local/state/routines/.