agentleFS
sonichisonichi/sutando/skills/schedule-crons/SKILL.md

schedule-crons

Skill396 starsChanged 6 months agoMITView on GitHub

Re-create all session cron jobs for Sutando. Run this on startup or after a session restart. Usage: /schedule-crons Jobs are defined per host in <workspace>/hosts/<hostname>/crons.json — per-host, synced + backed up via the vault (carried as part of the hosts/*/ per-host subtree (#1717), which is hostname-qualified so it never collapses across hosts; see docs/workspace-hosts-convention.md and docs/workspace-per-host-paths.md). <hostname> is bash scripts/sutando-config.sh host-label — the canonical per-host label ($SUTANDO_HOST_LABEL > scutil LocalHostName > short hostname), matching the sync layer's host slug. (Do…

The file

We split this skill into the rules it gives an agent

8 rules. About 0 are common, 2 are distinctive, and 6 are very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈2

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 6

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Very specific:

    Read <workspace>/hosts/<hostname>/crons.json (resolve <workspace> via bash scripts/sutando-config.sh workspace.

    line 45
  2. Distinctive:

    Check existing cron jobs with CronList

    line 97
  3. Very specific:

    Claude quota-aware main-loop cadence: before registering jobs, run python3 skills/proactive-loop/scripts/claude-quota-cadence.py --json …

    line 98
  4. Very specific:

    Call CronCreate with the cron expression and prompt (using the quota-aware effective cron above for the Claude main-loop entry):

    line 144
  5. Very specific:

    If prompt_skill is set, pass prompt: "/skill-name" (the leading slash makes the scheduled cron fire the skill as a slash command at its …

    line 146
  6. Distinctive:

    Which crons opt in: only *output-producing* crons (pr-shepherd, roadmap-driver, friction-room-sweep, disk-hygiene, ai-frontline-today, …

    line 233
  7. Very specific:

    When the selected core runtime is Codex on macOS, src/agent/codex/cli/start-cli.sh performs this installation/reconciliation automatically.

    line 285
  8. Very specific:

    Do NOT: write results/briefing-* and do NOT mint a synthetic tasks/task-cron-* "for Tasks-tab visibility":

    line 306

≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

Schedule Crons

Re-create all session cron jobs for Sutando. Run this on startup or after a session restart.

Usage: /schedule-crons

How It Works

Jobs are defined per host in <workspace>/hosts/<hostname>/crons.json — per-host, synced + backed up via the vault (carried as part of the hosts/*/ per-host subtree (#1717), which is hostname-qualified so it never collapses across hosts; see docs/workspace-hosts-convention.md and docs/workspace-per-host-paths.md). <hostname> is bash scripts/sutando-config.sh host-label — the canonical per-host label ($SUTANDO_HOST_LABEL > scutil LocalHostName > short hostname), matching the sync layer's host slug. (Do NOT use a bare hostname | sed 's/\..*//': a DHCP lease can drift the hostname (e.g. Comcast → Chis-MBP) and split per-host paths from the stable label; #1745.) A template is in crons.example.json (in this skill dir, version-controlled). Copy it on first setup:

WS="$(bash scripts/sutando-config.sh workspace)"; H="$(bash scripts/sutando-config.sh host-label)"; mkdir -p "$WS/hosts/$H"
cp skills/schedule-crons/crons.example.json "$WS/hosts/$H/crons.json"

(Migrated from the old skills/schedule-crons/crons.json, which lived in the code checkout — misfiled per the workspace contract, and per-host-but-unsynced. The new path is proper per-user state: backed up + visible across hosts, each host keeping its own cron set.)

Each entry has:

  • name — unique identifier (used to avoid duplicates)
  • cron — 5-field cron expression
  • prompt — the prompt to run (direct text)
  • prompt_skill — OR a skill to invoke (e.g. "morning-briefing" → /morning-briefing)
  • loop (optional, value "dynamic") — declares a dynamic (self-pacing) loop using the built-in /loop primitive. An entry with no interval (no cron field) + loop: "dynamic" is run by schedule-crons as /loop without an interval (see step 3) — which is exactly the built-in adaptive mode: the loop self-paces via ScheduleWakeup, deciding each next delay by its own judgment. Optional loop_hint (free text) guides that pacing (e.g. "~10 min when owner active, ~40 min quiet"). Durable because schedule-crons re-launches it every boot; adaptive because that's what /loop-no-interval already is. No min/max/signal schema and no custom gate — the built-in does the pacing. Example: {name:"inbox-score", prompt_skill:"inbox-score", loop:"dynamic", loop_hint:"…"}.
  • execution (optional, value "codex-task") — opt this entry into the durable OS-backed Codex runner instead of session cron registration. Codex entries may also set timezone (IANA name, default America/Los_Angeles), delivery: "proactive", retry_minutes (default 15), max_attempts (default 3), and active_stale_minutes (default 60). Jobs require this explicit opt-in except for the canonical main-loop while the selected runtime is Codex; the runtime-specific exception is described below.
  • launchd (optional bool) — when true, the entry is owned by the OS-level cron-runner (src/cron-runner.py, installed via src/install-cron-runner-launchd.sh), NOT by this session skill. /schedule-crons skips these so the two schedulers never double-fire. Use it for daily-deliverable crons that must fire even when no Claude session is idle (the reliability fix for the 2026-07-02 silent 6am-digest miss).
  • monitor (optional object) — declares a persistent Monitor-based driver, NOT a cron: the entry is armed via the Monitor tool and re-armed on every /schedule-crons, so it survives restarts the way the task watcher (step 1.5) does. Shape: {"command": "<shell to run>", "description": "<one-line>", "match": "<argv substring that detects a running instance>"}. It is NOT CronCreated and NOT counted in the stamp's registered (it is not a session cron). Armed in step 5.4. Use it for a continuous event-emitting driver (e.g. the content-loop) that must re-arm across restarts.
  • artifact (optional string) — the filename STEM of the dated output this job produces, e.g. "fleet-growth" for fleet-growth-2026-08-18.mp4. Read by health-check.py's daily-cron-punctuality probe. Without it the probe infers a stem from the last hyphenated token of the job name, so talk-events-nightly looks for nightly-<date>.*, never observes the real artifact, and reports the job UNCHECKED forever. Declare it whenever the name does not already equal the stem.
  • conditional (optional bool) — set true when the job runs on schedule but produces output only if there is new input (a nightly render with no new beats). The punctuality probe then treats "no artifact today" as evidence of nothing rather than a miss; lateness is still measured from the artifacts that do exist. On macOS, the Codex core launcher automatically reconciles ordinary fixed-interval entries to this owner because Codex has no session CronCreate surface. It preserves main-loop, dynamic loops, and entries already owned by execution: "codex-task", and initializes the runner boundary before changing ownership so activation never replays an old action backlog.
  • owner (optional string) — which session's CronCreate registration this entry belongs to: absent means the core (today's only behavior, unchanged). A worker-pool worker's bare id (the same id pool_roster.py/worker_identity.py use, e.g. "d1fc9b10050b490d894947f96627447f") pins the entry to that worker instead — that worker's own /startup registers it (see skills/startup/SKILL.md's worker section), and step 3 below must skip it here so the two registrations never double-fire the same job. Set this by hand when moving a job that a pinned room's cron output belongs to from the core onto its worker, per src/cron_ownership.py (the one shared filter both registration passes call — see docs/architecture-boundaries.md "Shared adapter policy").

Durable Codex schedules

Install or reconcile the per-minute launchd runner after adding an execution: "codex-task" entry:

python3 skills/schedule-crons/scripts/codex-scheduler.py install
python3 skills/schedule-crons/scripts/codex-scheduler.py health

The runner calculates cron slots in each job's declared timezone, catches up the newest missed slot after sleep, atomically enqueues a deterministic task ID, and uses distinct attempt IDs when an inactive task needs retrying. A queued, claimed, or processed attempt is never duplicated; if it remains active past active_stale_minutes, the run fails with a proactive alert so the schedule cannot stall forever. Durable run state lives at <workspace>/state/schedules/codex-scheduler.json. Exhausted retries produce a proactive-schedule-alert-*.txt result. health exits non-zero for a stale scheduler heartbeat or a latest-run failure.

When core.runtime is codex, the canonical unmarked main-loop entry (prompt_skill: "proactive-loop") is also owned automatically by this runner. Codex has no session CronCreate surface, so each fire emits one silent, low-priority proactive-pass task. The host's crons.json is not rewritten; switching back to Claude restores the normal session-owned loop. The Codex launcher reconciles the launchd runner on every start or attach.

On Activation

  1. Read <workspace>/hosts/<hostname>/crons.json (resolve <workspace> via bash scripts/sutando-config.sh workspace; <hostname> = bash scripts/sutando-config.sh host-label). Transition / self-heal: if that file is missing, seed it once — from the interim <workspace>/crons/<hostname>.json if it still exists (folded-in from the pre-#1717 layout), else the legacy skills/schedule-crons/crons.json (one-time migration), else skills/schedule-crons/crons.example.json — then read it: WS="$(bash scripts/sutando-config.sh workspace)"; H="$(bash scripts/sutando-config.sh host-label)"; CF="$WS/hosts/$H/crons.json"; if [ ! -f "$CF" ]; then mkdir -p "$WS/hosts/$H"; SRC="$(ls "$WS/crons/$H.json" 2>/dev/null || ls skills/schedule-crons/crons.json 2>/dev/null || echo skills/schedule-crons/crons.example.json)"; cp "$SRC" "$CF"; fi

1.5. Start the streaming task watcher NOW, before registering any cron jobs. This step used to run last (as step 5, after every CronCreate round-trip below) — moved here so an incoming task isn't queued unprocessed for the entire registration loop. Measured impact (startup-latency post-mortem, 2026-08-24, RC9 onboarding): with N session crons the old ordering left the watcher unarmed for ~76 seconds on a fresh boot (one CronCreate round-trip per entry, 9 entries measured), during which a brand-new user's first onboarding ping sat unprocessed — the single biggest contributor to "Sutando took a while to respond" reports on first boot.

Start it via the Monitor tool — pass command: 'bash src/watch-tasks-stream.sh', persistent: true, description: 'Streaming task watcher'. The script emits one TASK_FILE: <basename> line per new task file (initial sweep + each subsequent event). Read the named file via the Read tool when notifications arrive. Gate on running watcher TREES, not on the sentinel — if one is already running, skip the Monitor call; the existing one continues. Reuse the shared enumerator rather than restating its rules here (the copies drift, and step 5 used to be one of the copies that did):

python3 -c "
import importlib.util, sys
s = importlib.util.spec_from_file_location('hc', 'src/health-check.py')
m = importlib.util.module_from_spec(s)
try: s.loader.exec_module(m)
except SystemExit: pass
ps = m._ps_snapshot()
sys.exit(2 if ps is None else (0 if m._watcher_trees(ps) else 1))"
case $? in
  0) echo skip;;
  1) echo start;;
  *) echo 'UNKNOWN: ps did not run — do NOT start; a watcher may be live';;
esac

Three states, not two: an unavailable ps is UNKNOWN, not "no watcher". _watcher_trees() catches every ps timeout/error and returns {}, which is byte-identical to a clean empty scan — so the earlier 0 if m._watcher_trees() else 1 form printed start when enumeration merely failed. Starting there is exactly the duplicate this step exists to prevent, and it is the same can't-distinguish defect the paragraph below names for the sentinel, pointing the other way. _ps_snapshot() separates them: None means ps did not run, "" means it ran and found nothing. On UNKNOWN, do nothing — a missing watcher costs delayed tasks, a duplicate one processes every task twice.

Do not gate on the sentinel alone. watch-tasks-stream.sh writes it once at startup, so an absent file means "no watcher" OR "a live watcher whose file was removed" — indistinguishable. Measured 2026-08-07 on a live core: _watcher_trees() returned {'12631': ['12631']} (functioning — it emitted TASK_FILE: for a probe) with the sentinel absent from disk. Gating on the sentinel there would have started a second watcher, and both then emit every task, so every task is processed twice. _watcher_trees() is also what makes the pgrep warning below unnecessary to re-solve: it drops its own pid and matches on argv shape. Don't use pgrep -f watch-tasks-stream: pgrep's -f argument matches the literal string watch-tasks-stream against full argv, which matches the bash wrapper invoking this very pgrep call (the wrapper's argv contains the search string), producing a transient self-match that returns a PID for a subshell that's already gone by the next ps. Same PID-stamp + kill -0 pattern as the catchup sentinel in step 0 — single anti-pattern, single fix. Documented as F5 in workspace/build_log.md 2026-06-03T00:02Z validation pass; replayed on the very next session bootstrap (07:25Z) — Sutando.app's checkWatcher Timer caught the gap and sent a watcher keystroke, but two owner DMs were silently held in tasks/ for ~5 min first. Don't kick off bash src/watch-tasks.sh (retired 2026-05-14).

1.6. Arm the registration watchdog, then handle tasks freely — registration is guarded by a mechanism, not by deferral. Because step 1.5 arms the watcher before the registration loop, a TASK_FILE notification (the startup sweep's, or a live arrival) can land while steps 2-4 are still running. Without a rule here the agent improvises under the general "process notifications when they arrive" instruction, pivots to the task, and may never finish registration — losing the /proactive-loop fallback, the one thing that guarantees the session has a recurring work driver (that silent loss is the incident class this step exists to close). The old cure was a deferral rule ("finish registration first"), which cost a waiting user the whole registration window before any answer. This step replaces discipline with a watchdog: immediately after step 1.5, before touching any task, arm ONE Monitor:

Monitor tool — description "schedule-crons watchdog", timeout_ms 3600000, command:
cd "$(git -C . rev-parse --show-toplevel 2>/dev/null || echo .)" 2>/dev/null || true
T0=$(date +%s); WS="$(bash scripts/sutando-config.sh workspace)"
STAMP="$WS/hosts/$(bash scripts/sutando-config.sh host-label)/schedule-crons-stamp.json"
[ -n "$WS" ] || { echo "CRONS-WATCHDOG-INERT: workspace did not resolve — guard not armed"; exit 1; }
sleep 90
while [ "$(stat -f %m "$STAMP" 2>/dev/null || echo 0)" -lt "$T0" ]; do
  echo "CRONS-UNREGISTERED: schedule-crons has not stamped this boot — return and register now"
  sleep 60
done
echo "CRONS-STAMPED: registration complete"

Then respond to task notifications as they land: send the fail-open progress ack first (python3 skills/task-progress/scripts/notify.py ... — one subprocess, no state mutation, cannot derail anything), and use judgment on handling — a short waiting owner task may simply be served before registration continues; anything substantial gets the ack and is handled when the agent is next free. Either way registration cannot be silently dropped: if it slips, the watchdog emits a nag every 60s until step 5.7's completion stamp lands, and the terminal CRONS-STAMPED line confirms the guard saw the stamp — which is also why that stamp write is not optional bookkeeping; it is what releases the watchdog. The 90s grace means a normal boot never hears from it, and the 1-hour cap bounds noise if a boot dies (an unregistered state dies with its session). Detection and re-prompt share one source of truth: the same stamp health-check.py's session-crons probe already reads. stat -f %m is the darwin form; swap for a python3 -c mtime read on linux. Resolve the workspace INSIDE the command — a Monitor is a fresh shell and the loop's WORKSPACE is a per-command local that is never exported, so an inherited $WORKSPACE is empty there and the stamp path silently becomes /hosts/<label>/..., which never exists: the guard would then nag on every healthy boot (~58 times before the cap) and never print CRONS-STAMPED. That failure looks like the watchdog working hard, which is why the empty-WS refusal above is loud instead: an unarmed guard must announce itself rather than pass as a busy one.

  1. Check existing cron jobs with CronList
    • Claude quota-aware main-loop cadence: before registering jobs, run python3 skills/proactive-loop/scripts/claude-quota-cadence.py --json when the selected core is Claude. For the canonical main-loop entry, use its effective_cron instead of the raw cron field. The helper returns the configured cron below 80% 7-day utilization and */30 * * * * at/above 80% or when quota telemetry is missing, stale, rejected, or not authoritative for this routed core. This is a registration override only; never rewrite crons.json, because that configured value is the restoration target after quota resets. Codex keeps using its own quota telemetry and durable scheduler.
  2. Filter to entries the core owns before iterating. This step always runs as the core, never a worker (workers never call /schedule-crons; see skills/startup/SKILL.md's worker section). Skip any entry whose owner names a worker — it belongs to that worker's own registration, and registering it here too double-fires it (this closed a real incident: a room's event-mining-hourly posted from both the core and its pinned worker until the entry was marked owner-pinned). Absent owner is unaffected — that is still every entry today.
    python3 -c "
    import json, sys
    sys.path.insert(0, 'src')
    from cron_ownership import entries_for_owner, CORE
    cfg = json.load(open('$CF'))
    print(json.dumps(entries_for_owner(cfg, CORE)))"
    Iterate the filtered list below, not the raw file.
    • Skip entries carrying a monitor object — they are Monitors, not crons (no cron, no prompt to register); step 5.4 owns their arming, and a CronCreate attempt on one is invalid.
    • Skip entries with execution: "codex-task"; the OS-backed runner owns them.
    • Skip any entry with "launchd": true — it is owned by the OS-level cron-runner (see "Reliable OS-level crons" below), which emits its task independently. Registering it here too would double-fire (duplicate deliveries — the exact noise class the launchd path was built to avoid).
    • For a CronCreate-registered entry (one visible as a job in CronList): if a job for this entry already exists, RE-REGISTER it rather than skipping — capture every existing job ID, CronCreate the replacement from the current crons.json text, and confirm its new ID in CronList before deleting any captured old ID. Then delete the old IDs and confirm exactly one replacement remains. If creation or confirmation fails, retain the old job and stop loudly; a brief duplicate preserves liveness, while delete-first can strand a session with no driver. This bullet does not apply to the dynamic-loop branch below (loop: "dynamic"), which is never CronCreated and keeps its own freshness-sentinel guard — re-launching one mid-session would re-run the loop body immediately, the exact failure that guard prevents. A session cron is a snapshot of the prompt taken at registration time; editing crons.json afterwards does not reach it. The former rule here was "skip if a job with matching prompt/name already exists", which made that snapshot permanent for the life of the session: a long-lived core kept firing a prompt its own config had already superseded, and every cheap check agreed the config was right. Observed on a long-lived core: it booted 2026-07-30, the pr-flag entry gained --stand "<stand>" on 2026-08-03, and the registered job kept firing the pre-edit text for two days. That flag is what makes the pr-triage skill's pr_flag.py populate is_mine (it is deliberately null without one), so the cron's own instruction — "judge from ci/mergeable/review/approvals/ is_mine" — was reading a field that was structurally always null, with a correct script and a correct config file. Re-registering fixed it: is_mine went from null on all 27 PRs to 20 true / 5 false. Do not "compare the prompt and only re-register on a mismatch": CronList truncates the prompt, so the differing tail is exactly what a comparison cannot see — the --stand drift above sat past the truncation point. Unconditional re-registration is the only reliable form. This does not risk the inline-fire failure described at the end of this step: CronCreate schedules the next fire time and never runs the prompt on registration.
    • Call CronCreate with the cron expression and prompt (using the quota-aware effective cron above for the Claude main-loop entry):
      • If prompt_skill is set, pass prompt: "/skill-name" (the leading slash makes the scheduled cron fire the skill as a slash command at its scheduled time).
      • Otherwise pass prompt: <prompt-string-from-config>.
      • If the entry is a dynamic loop (loop: "dynamic" / no interval), do NOT CronCreate it. Instead invoke the built-in /loop with no interval (the adaptive/self-pacing mode), passing the entry's prompt (/skill-name or prompt) plus any loop_hint as the loop body, and append to that body: "on each re-arm, also stamp state/dynamic-loop-<entry-name>.alive with {ts, next_delay_s}". /loop-no-interval then self-paces via ScheduleWakeup by its own judgment — no min/max/signal needed. Durability comes from schedule-crons re-launching it on every boot. Double-launch guard: a dynamic loop is NOT visible in CronList (ScheduleWakeup schedules a wakeup, not a cron job), and it isn't an OS process either, so neither the cron check nor a PID sentinel can see it. Use the mtime-freshness heartbeat pattern instead (same shape as state/cores/<hostname>.alive): the loop stamps its .alive sentinel on every re-arm (per the body clause above). On boot (first /schedule-crons of a new session), always launch — wakeups are session-scoped and died with the old session, so any leftover sentinel is definitionally stale. On a mid-session re-run, skip the launch if the sentinel's ts is younger than next_delay_s + 120 seconds (loop still armed); launch only if stale or absent. This guard is also what prevents the inline-fire failure mode below for dynamic loops: launching /loop runs the body's first iteration immediately, which is intended at boot but must not repeat on a mid-session /schedule-crons re-invocation.
    • Do NOT invoke the skill or run the prompt body inline during /schedule-crons. Crons fire at their scheduled cron expression, never on registration. (Exception: a dynamic-loop entry's first iteration runs at launch by design — at boot only; the freshness-sentinel guard above is what keeps a mid-session re-run from repeating it.) (Past bug 2026-06-03T16:52Z: a mid-session /schedule-crons re-invocation inline-fired every entry — /morning-briefing plus 5 cron-body prompts — at one instant, dropping 8 spurious prompts atop legit watcher TASK_FILE events. The long-running session drowned and ended at 16:54 without processing queued owner DMs.)
  3. Fallback — ensure /proactive-loop is scheduled. After step 3, check whether any job in crons.json references /proactive-loop (either "prompt_skill": "proactive-loop" or a "prompt" whose body invokes the loop). If none does, call CronCreate directly with the Claude quota helper's effective_cron (*/10 * * * * is its normal missing-config fallback) and prompt: "/proactive-loop" as a bootstrap-safety net. Rationale: post-#954 the CLI boots with -- "/schedule-crons" and exits once registration finishes, so if crons.json is missing/empty/forgot-to-include-the-loop-entry the session goes idle with no recurring work driver. The fallback guarantees the loop runs at least every 10 min when quota telemetry is healthy and below threshold, while preserving the same 30-minute protection near quota exhaustion. Idempotent: if the user has a custom */5 * * * * or */15 * * * * entry, that satisfies the check and the fallback is skipped (no duplicate cron).
  4. Streaming task watcher — already started in step 1.5, before this step's registration loop ran. (Moved 2026-08-24: previously ran here, after every CronCreate round-trip in steps 2-4, leaving the watcher unarmed for the whole registration window — see step 1.5 for the measured impact and the gate logic, which is unchanged, just earlier.) No action needed here on a normal pass; nothing in steps 2-4 above needs the watcher running to complete.

5.4. Arm monitor-type entries (durable Monitors). For each crons.json entry carrying a monitor object, arm its persistent driver via the Monitor tool — command: the entry's monitor.command, persistent: true, description: the entry's monitor.description — UNLESS a process matching monitor.match is already running. Gate exactly like step 1.5's watcher: on a running PROCESS, never on a sentinel. At boot (a fresh session) nothing is running so it always arms; on a mid-session re-run it skips if the driver is live, so it never double-drives. Use a check whose own process tree can NEVER contain the match string (the same self-match trap step 1.5 warns about — do NOT use pgrep -f, and do NOT pass the match string as a literal anywhere on the command line: not as an argv to the checker, and not via a printf | python pipe either — the bash -c wrapper running the pipeline carries the WHOLE command text, match literal included, in its own argv, and it is alive during the scan, so excluding only the checker's pid still false-matches and reports skip while the monitor never arms). The checker must read the match from the crons.json FILE, keyed by entry NAME — the entry name is the only literal on the command line, and no driver's argv contains entry names. AND ITS FAILURES MUST NOT ARM: a two-way && skip || arm maps every checker failure (resolver error, missing/malformed crons.json, ps failure, exception) to arm, duplicating the monitor exactly when the host is least healthy. Three-way exit semantics — 0 = driver RUNNING (skip), 1 = VERIFIED absent (arm), 2 = CHECK FAILED (do NOT arm; post the failure like a loud-stop):

python3 -c "
import subprocess,sys,json,os
try:
    r=subprocess.run(['bash','scripts/sutando-config.sh','workspace'],capture_output=True,text=True,timeout=15)
    h=subprocess.run(['bash','scripts/sutando-config.sh','host-label'],capture_output=True,text=True,timeout=15)
    if r.returncode or h.returncode: sys.exit(2)
    entries=[e for e in json.load(open(os.path.join(r.stdout.strip(),'hosts',h.stdout.strip(),'crons.json'))) if e['name']==sys.argv[1]]
    if not entries: sys.exit(2)
    match=entries[0]['monitor']['match']
    p=subprocess.run(['ps','-axo','pid=,command='],capture_output=True,text=True,timeout=15)
    if p.returncode: sys.exit(2)
    run=[l for l in p.stdout.splitlines() if match in l and 'ps -axo' not in l]
    sys.exit(0 if run else 1)
except Exception:
    sys.exit(2)" '<entry-name>'; rc=$?
case $rc in 0) echo skip;; 1) echo arm;; *) echo "CHECK-FAILED rc=$rc — do NOT arm";; esac

(No pid-exclusion is needed: the match literal exists only inside crons.json and the python process's HEAP, never in any argv in the checker's ancestry — so any ps hit is a genuine driver process. Requirement on monitor.match: pick a substring of the driver's command path that no entry NAME collides with, e.g. personal-content-loop/scripts/content-driver.sh.) arm → call the Monitor tool with the entry's command/description and persistent: true. These entries are Monitors, not crons: do NOT CronCreate them and do NOT count them in step 5.7's registered. This is the durable-Monitor mechanism — a Monitor declared in crons.json and re-armed on every boot, the same shape the task watcher (step 1.5) uses, but data-driven. 5.5. Ensure the core heartbeat is running (sonichi/sutando#2198 prerequisite). src/core_heartbeat.py (the writer of state/cores/<hostname>.alive) is started by src/startup.sh — but the CLI boot path lands here without ever running startup.sh (observed 2026-07-20: desktop-supervised core running for 20+ min with state/cores/ empty, so the dashboard/health-check read the core as dead and the stop-path had no pid/socket target). Check freshness of "$WORKSPACE/state/cores/$(bash scripts/sutando-config.sh host-label).alive" — if the file is missing or its mtime is older than 90 seconds (the documented staleness threshold), start the heartbeat: nohup python3 src/core_heartbeat.py > /tmp/core-heartbeat.log 2>&1 &. Freshness-of-.alive is the running-check by design — do NOT use pgrep -f core_heartbeat (same wrapper-argv self-match anti-pattern as step 1.5's watcher note), and a fresh mtime is exactly the signal every other reader of the file trusts. Idempotent on mid-session re-runs: a live heartbeat keeps the mtime younger than 90s, so the start is skipped.

5.6. Auto session-recap on boot (owner directive 2026-07-13). When more than one session transcript exists (i.e. there is a previous session to recap), run the session-recap skill's boot recap over the previous session. Per the recap contract (skills/session-recap/SKILL.md "Automatic recap on restart"), this is two behaviors with different gates — do NOT gate the whole step on recap_room:

  • Agent catchup — ALWAYS (gate: a previous transcript exists). Generate the structured next-session recap and write it to <workspace>/state/last-session-recap.md (also stamp state/last-recap-session.txt). This is the primary purpose — it seeds the fresh core's context at boot — and does not depend on recap_room. A host with no recap.json still gets this.
  • Human room post — ONLY if recap_room is set (and private). If recap_room is configured in this host's recap.json — <workspace>/hosts/<hostname>/recap.json, per the hosts/<hostname>/ per-host state convention, sibling of crons.json (which itself stays a bare job list) and names a private, owner-only room, additionally post the brief to recap_room (gateway op:message). No recap_room, or a non-private one → skip the post, leave the recap on disk under data/session-recaps/. Idempotence lives in the recap skill's state/last-recap-session.txt stamp — a mid-session /schedule-crons re-run finds the previous session already stamped and skips both the write and the post, so this never double-writes or double-posts (same guard philosophy as the dynamic-loop freshness sentinel in step 3).

5.7. Stamp completion for the health-check divergence guard. After all registrations (and the fallback check in step 4), count the session-owned entries you actually registered this run (CronCreate successes + pre-existing matches from step 3, including the main-loop/fallback; EXCLUDE monitor-type, launchd, and codex-task entries — they are not session crons) and write the stamp — script-visible proof that THIS core boot completed registration:

WS="$(bash scripts/sutando-config.sh workspace)"
H="$(bash scripts/sutando-config.sh host-label)"
mkdir -p "$WS/hosts/$H"
DIGESTS="$(python3 src/cron_entry_digest.py "$WS/hosts/$H/crons.json")"
echo "{\"ts\": $(date +%s), \"registered\": <count>, \"config_total\": <total entries in crons.json>, \"config_digests\": $DIGESTS}" > "$WS/hosts/$H/schedule-crons-stamp.json"

health-check.py's session-crons probe compares this host-owned stamp against the same host's core heartbeat started_at: a stamp older than the boot means session crons died with a previous session and were never re-registered (the silent 2/18 failure observed on a peer instance 2026-07-23). Do not skip the stamp on re-runs — a fresh stamp is what keeps the guard quiet.

config_digests is what makes an edit visible. Everything else in the stamp is a COUNT, and a count cannot see an entry whose prompt changed after it was registered. Stamp the digest map of the crons.json you just registered from; the probe recomputes it and names any session-owned entry whose digest moved. Write it in the SAME command as the counts — a digest map stamped separately can be skipped, and a stamp with fresh counts and a stale digest map is worse than one with no digest map at all. Omitting the field is safe (the probe skips the check); a WRONG map would report drift that is not there.

  1. Confirm what was scheduled — note whether the proactive-loop fallback was triggered (informs operator that crons.json may need a persistent entry).

Adding New Crons

Edit <workspace>/hosts/<hostname>/crons.json (this host's cron set) to add/remove jobs. No need to change this skill file. The proactive-loop fallback (step 4 above) auto-armed if your crons.json is missing the loop entry; add an explicit proactive-loop entry to suppress the fallback message and pick your own cadence.

Defer non-loop crons when owner tasks are queued

Wrap sub-daily non-main-loop cron prompt bodies (e.g. */N, */30, hourly) with scripts/cron-gate.sh so the cron defers when <workspace>/tasks/ has any task-*.txt pending. The next natural tick (≤ a few minutes later for */30, ≤ an hour for hourly) covers a deferred fire. Pattern:

{
  "name": "sync-workspace",
  "cron": "*/30 * * * *",
  "prompt": "Run: bash scripts/cron-gate.sh sync-workspace bash scripts/sync-workspace.sh — <human-readable description>."
}

cron-gate.sh <reason> <command...> either execs the command (queue empty) or prints cron-gate: owner tasks queued — deferring <reason> and exits 0. See crons.example.json for canonical wrapped forms.

Attaching cron output to an AG2 Space room (if connected)

When the agent is connected to AG2 Space, an output-producing cron can post its results into its own dedicated room instead of a shared channel — one room per cron, so the owner can monitor each stream separately. This is opt-in and connectivity-gated: a cron with no room field, or an agent with no gateway token, is unaffected.

Opt in by adding "room": "auto" to a cron entry in crons.json. On /schedule-crons activation (after step 3), run the helper once:

WS="$(bash scripts/sutando-config.sh workspace)"; H="$(bash scripts/sutando-config.sh host-label)"
python3 skills/schedule-crons/ensure-cron-room.py \
  --crons-file "$WS/hosts/$H/crons.json" --owner "@<owner>:ag2.space" --repo .

ensure-cron-room.py is idempotent: for each "room": "auto" entry it creates one room (Sutando · <cron>), invites the owner, posts a self-identifying first message, and rewrites room to the concrete !id:ag2.space. Entries that already hold a !id are skipped — re-running never makes duplicate rooms (the failure mode of ad-hoc creation). If no gateway token resolves, it exits 0 having done nothing. The cron's own prompt then posts output to its room id via the gateway op:message path ([[reference_gateway_op_message_room_post]]).

Which crons opt in: only output-producing crons (pr-shepherd, roadmap-driver, friction-room-sweep, disk-hygiene, ai-frontline-today, morning-briefing). Silent/internal crons (main-loop, sync-memory, briefing-fallback) stay room-less — a room each would be clutter.

Known gateway constraints (2026-07-11), baked into the helper's design:

  • No room-list API (GET /v1/rooms 404; op:list unknown) → the room id recorded in crons.json is the only handle on a created room. Never create without writing the id back (the helper writes after each create so a mid-batch hang can't orphan a room).
  • op:state 502s → a room's display name can't be set or read after creation. Identity rides on the create-time name and the identifying first message, never a post-hoc state write.
  • op:invite is slow/flaky — it can take >15s or time out client-side while the invite still lands server-side. So (a) treat invite as best-effort (the helper tolerates a None result), and (b) do NOT retry-loop it — repeat calls may queue duplicate invites the owner has to dismiss. These are roadmap track-8 (error-legibility) / broker-reliability items.

When to gate (decision rule):

Cron cadenceGate?Why
main-loop (/proactive-loop)NEVER/proactive-loop IS the owner-task handler; gating would deadlock.
Sub-daily (*/N, */30, hourly)YESA skip is recovered by the next natural tick within minutes-hours.
Daily / less-frequent (X Y * * *)NOA skip = function is gone until next day (briefing missed, etc.). M1's no-inline-fire rule already kills the avalanche on registration — gating dailies is over-broad.

Lucy caught this on PR #1437 (2026-06-03): gating daily crons (morning-briefing 06:57, daily-insight 06:50, obsidian-dream 03:37, learned-skills-scan 07:30) means one queued task at briefing time loses the briefing for the entire day. Pinning the gate to sub-daily crons preserves the defense-in-depth where it matters without the missed-day risk.

Reliable OS-level crons ("launchd": true)

Session CronCreate jobs are best-effort: they only fire while the Claude REPL is idle at the fire minute, carry scheduler jitter (recurring fires up to 10% / max 15min late), and die with the session. On 2026-07-02 the 6:02 loop-engineering digest silently never delivered — the owner asked to "make the schedule reliably run".

For a cron that MUST fire regardless of session state, flag it "launchd": true and install the OS-level runner once:

bash src/install-cron-runner-launchd.sh          # install (idempotent)
bash src/install-cron-runner-launchd.sh --status # check
bash src/install-cron-runner-launchd.sh --uninstall

This installs com.sutando.cron-runner (launchd, every 60s → src/cron-runner.py), which reads the same crons.json, decides which "launchd": true entries are DUE since their last recorded fire, and emits a task file into tasks/ for each. The streaming watcher hands it to the session — same OS-level → emit-task → process pipeline as com.sutando.health-check-fallback. Missed fires (machine asleep/off) catch up exactly once on the next tick, never a backlog storm.

Mechanical shell jobs

Launchd-owned entries may set "shell_command" for work that should not wake a model session (for example, a polling or sync script):

{
  "name": "sync-workspace",
  "cron": "*/30 * * * *",
  "launchd": true,
  "shell_command": "bash scripts/sync-workspace.sh"
}

src/cron-runner.py executes the command from the repository root, logs its command, stdout, stderr, and exit code to <workspace>/logs/cron-runner.log, and reports non-zero exits on stderr. A shell job runs even when the core heartbeat is absent and never creates a tasks/ file. If an entry contains more than one execution form, precedence is shell_command > prompt_skill > prompt; use only one form in new configuration.

When the selected core runtime is Codex on macOS, src/agent/codex/cli/start-cli.sh performs this installation/reconciliation automatically. Manual installation remains the opt-in path for Claude-core hosts.

Ownership partition (no double-fire): the launchd runner handles ONLY "launchd": true entries; this session skill (step 3) skips those same entries. Exactly one scheduler owns each cron. Leave main-loop / /proactive-loop session-owned (it drives the session itself — it is not a task and must never be launchd-owned).

Digest cron delivery — write one results/proactive-*.txt, nothing else

notify.py is for progress pings only (≤280 chars). Digest-style cron prompts that produce research summaries (1000–2000 chars) are silently dropped by notify.py's hard limit — the user sees nothing.

Correct delivery pattern for digest crons — the shared proactive primitive:

DELIVERY: Write the complete digest to results/proactive-<name>-$(date +%s).txt
(and nothing else). Do NOT use notify.py for the final result — it rejects
messages over 280 chars (it is a progress-ping tool, not a delivery channel).

results/proactive-*.txt is the one cross-surface delivery contract — every configured bridge (Discord, Telegram, Slack) drains it, and proactive_routing.py routes each file to the channel where the owner was most recently active, exactly once (atomic .sending claim). That's why it's the primitive to use.

Do NOT write results/briefing-* and do NOT mint a synthetic tasks/task-cron-* "for Tasks-tab visibility":

  • briefing-* is not a universal prefix — only Discord (FALLBACK_PREFIXES in poll_dm_fallback) and Telegram (a briefing-as-proactive patch) drain it; Slack never delivers it, so a briefing-* digest is silently archived on a Slack-only install. proactive-* has no such gap.
  • A hand-written tasks/task-cron-* is an orphan: nothing writes a matching results/task-cron-* keyed to that id, so the task never "completes" — the watcher re-processes it (duplicate/noisy execution) and the task-id-keyed consumers (Slack/Telegram/agent-api) deliver nothing. Delivery comes from the proactive-* file alone; you don't need a task file for it.

See crons.example.json for the example-digest entry that shows this pattern. Scripts (like src/morning-briefing.py) already emit results/proactive-*.txt themselves and don't need this — only inline prompt crons that produce long output.

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.