donate-curation

Donate this Claude Code instance to dismech curation on a recurring local window (e.g. "8pm-12am every day"). Runs as an Anthropic cloud routine on the user's own subscription; each fire reconciles ag

SKILL.md

GitHub

donate-curation

Donate this Claude Code instance to dismech curation for a timeframe the user chooses. The skill produces PRs and keeps them unblocked during the window; the production pr-shepherd automation carries the long tail (review re-request, the deterministic auto-merge sweep) after the window closes.

Active concurrency is 1 in this version. The skill works one disease at a time and claims a new one only when no PR of the user's still needs the user's own work. Parked PRs (awaiting review, or approved+green waiting to auto-merge) may accumulate harmlessly; they do not block the next disease. The 8-parallel design that /curate-next supports is a deliberate later scale-up.

The skill never merges, dismisses reviews, or self-approves. Those are the reviewer's and the auto_merge_ready_prs.py sweep's job.

For a plain-language walkthrough aimed at a non-technical user (what to type, what happens each night, how to stop it), see docs/schedule-donation.md. This file is the technical orchestrator spec.

This skill composes the Claude Code cloud-routine API (RemoteTrigger) — the same mechanism as the built-in schedule skill — but wired to the dismech curation loop specifically. It is named donate-curation, not schedule, on purpose: a project skill named schedule collides with the built-in one, and /schedule resolves to the built-in generic routine-creator (which asks "what should the agent do?") rather than to this fixed claim→curate→PR loop. Always invoke it as /donate-curation.

Commands

  • /donate-curation — run once, now: reconcile against GitHub and either finish the one item that needs the user's work or (if nothing does) claim and curate one disease. No routine is registered.
  • /donate-curation <window> [<recurrence>] [for <expiry>] — register a recurring cloud routine for the window (see Schedule syntax), after a one-off proving fire.
  • /donate-curation status — show the active routine (cron / window / expiry) and the one in-flight item's state.
  • /donate-curation stop — disable the recurring routine and print the hard-delete link.

The one rule everything hangs on

Every invocation — manual /donate-curation, or a scheduled cloud fire — begins by reconciling against GitHub, which is the only state (there is no local ledger to go stale). The gate for claiming a new disease is:

No PR of the user's still needs the user's own work, and no un-PR'd claim issue exists.

A PR needs my work (blocks a new claim) when it is CHANGES_REQUESTED, failing a required check, or conflicted. A PR does not need my work — it is parked — when it is awaiting review (green/pending checks) or approved + green waiting only for the auto-merge sweep. An un-PR'd claim issue always needs my work (a prior fire that crashed before pushing — resume it).

This classification is a pure, unit-tested function, not prose to re-interpret each run. Do not hand-classify PRs; call the module.

Step 1 — reconcile (every invocation)

Fetch the user's in-flight work and classify it with dismech.schedule.reconcile:

gh pr list --author @me --state open \
  --json number,title,url,headRefName,reviewDecision,mergeable,statusCheckRollup \
  > /tmp/schedule_prs.json

gh issue list --assignee @me --state open --label claim \
  --json number,title,closedByPullRequestsReferences \
  > /tmp/schedule_claims.json

WHOAMI=$(gh api user --jq .login)   # identity gate: must match config github_login

uv run python - "$WHOAMI" <<'PY'
import json, sys
import yaml
from dismech.schedule.reconcile import reconcile
authenticated = sys.argv[1]
cfg = yaml.safe_load(open(".claude/schedule-config.yaml"))
expected = cfg.get("github_login")   # None -> gate is skipped (caller's call)
prs = json.load(open("/tmp/schedule_prs.json"))
claims = json.load(open("/tmp/schedule_claims.json"))
r = reconcile(prs, claims, authenticated_login=authenticated, expected_login=expected)
print(r.summary)
print("IDENTITY_OK:", r.identity_ok, "SHOULD_CLAIM_NEW:", r.should_claim_new)
if r.item_needing_work:
    print("ITEM:", json.dumps(r.item_needing_work))
for c in r.pr_classifications:
    print(f"  PR #{c.number}: {c.category} (checks={c.checks}, review={c.review_decision}, mergeable={c.mergeable})")
PY

reconcile() returns should_claim_new and, when something needs work, the single item_needing_work to finish this fire. Trust its verdict. If identity_ok is false — the fire authenticated as someone other than the config's github_login — the fire does nothing (no claim, no fix); report the mismatch and stop.

Step 2 — finish-first (if anything needs my work)

If should_claim_new is false, handle the one item_needing_work and then stop — claim nothing new this fire:

  • PR CHANGES_REQUESTED / failing / conflicted → dispatch a fix subagent (Task) that reuses pr-shepherd's logic and scripts, not a bespoke loop. It must obey the CLAUDE.md review rules:
    • Fix everything in one push — every blocking finding, worthwhile optional suggestions, the history/ record for the round, and any conflict resolution. main has dismiss_stale_reviews, so every push costs a full re-review cycle; a chore commit afterward is a wasted cycle.
    • Re-request review via scripts/retry_failed_reviews.py (its scoped App token is the real workaround for the #9321 workflow_dispatch 403 — do not re-solve that here).
    • Never dismiss a review, merge with --admin, or approve its own work.
    • Do not push while a review is in flight; wait for the verdict, then push once. After pushing, confirm checks are not still red before treating the PR as parked (guards against racing our own push).
    • Work in a git worktree; root all file ops in the worktree (the /curate-next parent-checkout-leak incident). Commit/push after each meaningful step so a token/rate limit loses nothing.
  • Un-PR'd claim issue → a prior fire claimed a disease but crashed before pushing a PR. Resume curating it to a pushed PR (use the initiate-new-disorder-creation workflow), in its own worktree.

Then report and stop.

Step 3 — claim one (only if nothing needs my work)

If should_claim_new is true, invoke /claim-disease 1 (its preflight already searches open PRs per #7830) to file the Curate <label> (MONDO:NNNNNNN) issue, then curate that one disease end-to-end to a pushed PR carrying Closes #<issue>, in its own worktree. Use /curate / initiate-new-disorder-creation for the curation itself.

Step 4 — report

Report the one item's final state (curating / PR needs-fix-pushed / awaiting-review / waiting-to-merge / merged) and whether a new disease was claimed or the fire deferred because a PR needed work. Leave awaiting-review and approved-waiting PRs to the review / auto_merge_ready_prs.py automation — report their state only, never merge them.

Registering a recurring window (/donate-curation <window> ...)

Preconditions — do not register a shift until these are satisfied

Registering a recurring cloud shift has hard prerequisites the user sets up once (see the SOP docs/schedule-donation.md). Check them first and, if any is missing, walk the user through it rather than registering a shift that cannot run:

  1. GitHub auth — gh auth status shows the user signed in.
  2. github_login set in .claude/schedule-config.yaml to the user's own login (the identity gate; ships null). Offer to write it after confirming gh api user --jq .login.
  3. routine.environment_id set to a cloud environment (ships null). A shift cannot start without it. Prompt the user for the id (or help them create/connect one at claude.ai/code) and write it back. Never register with a null env, and never commit a specific user's login or env as a default.

Which scheduler

Claude Code exposes two schedulers; only one runs unattended:

  • CronCreate/CronList/CronDelete — in-session cron. Session-only, in-memory, fires only while the REPL is idle, auto-expires after 7 days. It cannot run when Claude is closed → not usable here.
  • RemoteTrigger cloud routine (CCR) — each fire spawns a fully isolated cloud session with its own git checkout, on the user's own subscription; the Mac need not be on. This is what /donate-curation <window> uses.

Expand the config to a UTC cron (do not hand-compute)

Edit .claude/schedule-config.yaml (or write it from the user's phrasing), then let the pure module derive the UTC cron, one-shot instant, prompt window-end, expiry date, and DST-transition dates:

uv run python -m dismech.schedule --config .claude/schedule-config.yaml

Key facts the module encodes, so you don't re-derive them:

  1. Cloud cron is UTC. 8pm America/Los_Angeles = 0 3 * * * PDT / 0 4 * * * PST. The module converts local→UTC for the current DST period and lists the dst_transitions dates; re-register (or let a fire re-register) when the offset flips.
  2. The scheduler cannot enforce a window. A window becomes hourly restart fires across the window (8/9/10/11pm), not four parallel workers — the single-flight guard means a fire is a no-op if the previous one is still running. window_end_local is passed into the prompt so the session stops claiming new work past it.
  3. Minimum interval is one hour (validated by the config parser).
  4. once / "tonight" is a heartbeat, not a single fire. It registers the same hourly crons across the window, bounded by an expiry_date on the window's own date, so it fires 8/9/10/11pm once and then self-disables the next day. self_disabling: true in the expansion flags this. There is no single-instant run_once_at — the diagnostic/proving fire uses RemoteTrigger {action:"run"} instead.

RemoteTrigger {action:"create"} mapping

  • cron_expression — each string in utc_crons (register one event per cron). There is one set of crons for every schedule, once included.
  • job_config.ccr.environment_id — routine.environment_id from the config. It ships null because a cloud env id may be account-scoped; prompt the user for it on first registration (or have them confirm the #11657 value is project-scoped) and write it back to the config. Do not register with a null env.
  • job_config.ccr.session_context.sources — https://github.com/monarch-initiative/dismech.
  • job_config.ccr.session_context.model — the config model.
  • job_config.ccr.session_context.allowed_tools — Bash, Read, Write, Edit, Glob, Grep, Task, WebFetch, WebSearch, Skill, TodoWrite, plus RemoteTrigger if Step 0 confirms it is callable in-session (needed for single-flight and expiry self-disable).
  • events[].data.message.content — the frozen prompt below, with <WINDOW_END_LOCAL> and <EXPIRY_DATE> substituted from the expansion.

Record the returned trigger id back into routine.trigger_id in the config.

Step 0 — the prerequisite spike (run once, before trusting the cadence)

Two facts the cloud design assumes are unconfirmed and must be answered with one diagnostic fire (RemoteTrigger {action:"run"} on a throwaway one-off routine, then get_run_log) before enabling any recurring routine. Record the answers on issue #11657:

  1. Does a cloud-routine session load this repo's .claude/skills? i.e. can the frozen prompt say "use the claim-disease skill" and have it resolve, or must the skill logic be inlined into the prompt?
  2. Is RemoteTrigger callable from inside a routine session? The expiry self-disable and the single-flight list_runs check both assume it is; if not, use the GitHub fallbacks noted in the prompt.
  3. While there, confirm the env carries uv, just, gh auth, ontology DBs, and network to OLS / PubMed / ClinicalTrials, and that one full curation fits a single session's token/time budget. If not, the save-early / reconcile-first resume path must carry a partial disease across fires — verify that works before relying on the nightly cadence.

First delivery therefore includes one RemoteTrigger {action:"run"} proving fire that claims + curates a single disease, reviewed via get_run_log, before the recurring routine is enabled.

The frozen routine prompt

Substitute <WINDOW_END_LOCAL>, <EXPIRY_DATE>, <GITHUB_LOGIN>, and <DST_DATES> from the config and expansion. If Step 0 finds project skills do not load in-session, expand the claim-disease / initiate-new-disorder-creation references into inline instructions.

You are a scheduled dismech curation donation run. Repo: monarch-initiative/dismech.
Work strictly ONE disease at a time. Do not merge, dismiss reviews, or self-approve.
Commit and push after every meaningful step so a token/rate limit loses nothing.

1. SINGLE-FLIGHT: If RemoteTrigger is available, call list_runs on this routine; if another
   run is still active, STOP now (do nothing). If RemoteTrigger is unavailable, rely on the
   reconcile gate below plus the claim label as the lock.
2. IDENTITY: assert `gh api user --jq .login` == <GITHUB_LOGIN>. If it does not match (a bot/
   App token, the wrong account), STOP now — do NOT claim or fix anything. `--author @me`
   would otherwise return an empty list and the one-disease gate would claim every fire.
3. EXPIRY (check before doing any work, so a self-disabling "once"/tonight schedule's
   morning-after heartbeat disables cleanly instead of claiming): if today is past
   <EXPIRY_DATE>, disable this routine (RemoteTrigger update enabled:false if available;
   otherwise do nothing this fire — a disabled cron is then the operator's job) and STOP.
4. DST: if today is one of <DST_DATES>, the registered UTC cron is now an hour off local.
   Re-register by re-running /donate-curation with the same window (RemoteTrigger update with
   the freshly expanded cron) before doing work; if RemoteTrigger is unavailable, note it in
   the report so the operator re-runs /donate-curation. (Empty list -> nothing to do.)
5. RECONCILE from GitHub (the only state) using dismech.schedule.reconcile, passing
   authenticated_login (step 2) and expected_login=<GITHUB_LOGIN>:
   - my open PRs (gh pr list --author @me ...), classified as needs-my-work / awaiting-review /
     waiting-to-merge;
   - my open `claim` issues with no linked PR.
6. FINISH-FIRST: If any PR needs my work, or an un-PR'd claim issue exists, handle that ONE item:
   - PR CHANGES_REQUESTED/failing/conflicted -> fix everything in ONE push (blocking findings,
     worthwhile optional suggestions, the history/ record, conflicts), following CLAUDE.md's
     review rules; re-request review via scripts/retry_failed_reviews.py. Do NOT push while a
     review is in flight. After pushing, confirm checks are not red before considering it parked.
   - Un-PR'd claim issue -> resume curating it to a pushed PR (initiate-new-disorder-creation
     workflow; work in a git worktree; root all file ops in the worktree).
   Then STOP. Claim nothing new this fire.
7. CLAIM ONE only if NOTHING needs my work and no un-PR'd claim issue exists: claim exactly one
   disease (claim-disease logic: two-phase pick, file the `Curate <label> (MONDO:NNNNNNN)` issue),
   then curate it end-to-end to a pushed PR carrying `Closes #<issue>`, in its own worktree.
8. WINDOW END: stop claiming new work after <WINDOW_END_LOCAL>; let in-flight subagent work commit.
9. Report what you did: the one item's final state, and whether a disease was claimed or deferred.

Concurrency control across hourly fires

Cloud fires are isolated sessions with no shared memory; they coordinate only through shared external state — GitHub plus the routine's own list_runs. At concurrency 1, two guards suffice and are already encoded above:

  1. Single-flight per routine (Step 1 of the frozen prompt): a fire is a no-op if a prior run is still active. The hourly cron is a restart heartbeat, not four workers.
  2. The needs-my-work gate (reconcile): a durable GitHub fact that survives restarts, so at most one disease is ever actively in hand and a crashed predecessor's item is resumed, not duplicated.

/donate-curation status

Read routine.trigger_id from the config; if set, RemoteTrigger {action:"list_runs"} (and/or get_run_log on the latest) to show cron / window / expiry and the latest fire's state. Then run Step 1 (reconcile) and print the one in-flight item's classification. If no trigger_id is set, say no recurring routine is registered and show only the reconcile result.

/donate-curation stop

Routines cannot be hard-deleted via the API — only disabled. So:

  1. RemoteTrigger {action:"update", body:{enabled:false}} on routine.trigger_id.
  2. Clear routine.trigger_id in the config (leave environment_id).
  3. Print the hard-delete link: https://claude.ai/code/routines (web UI only).

Stopping never merges, dismisses, or abandons in-flight work: in-flight subagents are allowed to commit/push; pr-shepherd carries the tail.

Schedule syntax

/donate-curation <window> [<recurrence>] [for <expiry>]. The user says when it starts, how long each window runs, and how long the routine lives. Map the phrasing onto .claude/schedule-config.yaml:

  • window → window.start_local / window.end_local (whole hours). 8pm-12am → 20:00 / 00:00.
  • recurrence → every day = every-day, every workday/weekdays = workday, weekends = weekends, once/tonight/for 1 day = once, an explicit local cron = custom + custom_cron_local.
  • expiry → for N days = expiry.mode: days / value: N; for N weeks = weeks/N; until YYYY-MM-DD = date; no phrase = none (never expires). once / tonight / for 1 day set expiry.mode: once (equivalently recurrence: once).

once / tonight does not mean a single 8pm fire — it means run the window's hourly heartbeat tonight (8/9/10/11pm), then self-disable. Use it for a bounded proving run; the expansion reports self_disabling: true and an expiry_date on tonight's date.

Always confirm the derived UTC cron, self_disabling, and DST-transition dates back to the user before registering — the local→UTC conversion is the easiest thing to get wrong.

Rate-limit behaviour

On hitting a limit, start no new claims; let in-flight subagents commit/push and exit cleanly. Because reconcile is the first step of every run, the next fire resumes the in-flight PRs automatically — "finish what you started before starting new ones", realized through GitHub-as-ledger rather than a resume file.

What is pure code vs. orchestration

  • Pure, unit-tested (call it, don't reimplement): dismech.schedule.config (window/recurrence/expiry → UTC cron, one-shot instant, prompt substitutions, DST dates) and dismech.schedule.reconcile (PR classifier + finish-first decision). Tests: tests/test_schedule_config.py, tests/test_schedule_reconcile.py.
  • Reused, not forked: /claim-disease, /curate / initiate-new-disorder-creation, /create-pr, and pr-shepherd (scripts/retry_failed_reviews.py, scripts/auto_merge_ready_prs.py, the shepherd job).
  • This skill: the thin orchestrator that ties them together and the cloud-routine registration.