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.claude/skills/donate-curation/SKILL.md
GitHubdonate-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-inscheduleskill — but wired to the dismech curation loop specifically. It is nameddonate-curation, notschedule, on purpose: a project skill namedschedulecollides with the built-in one, and/scheduleresolves 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 reusespr-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.mainhasdismiss_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 #9321workflow_dispatch403 — 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-nextparent-checkout-leak incident). Commit/push after each meaningful step so a token/rate limit loses nothing.
- Fix everything in one push — every blocking finding, worthwhile optional
suggestions, the
- 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-creationworkflow), 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:
- GitHub auth —
gh auth statusshows the user signed in. github_loginset in.claude/schedule-config.yamlto the user's own login (the identity gate; shipsnull). Offer to write it after confirminggh api user --jq .login.routine.environment_idset to a cloud environment (shipsnull). 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.RemoteTriggercloud 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:
- 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 thedst_transitionsdates; re-register (or let a fire re-register) when the offset flips. - 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_localis passed into the prompt so the session stops claiming new work past it. - Minimum interval is one hour (validated by the config parser).
once/ "tonight" is a heartbeat, not a single fire. It registers the same hourly crons across the window, bounded by anexpiry_dateon the window's own date, so it fires 8/9/10/11pm once and then self-disables the next day.self_disabling: truein the expansion flags this. There is no single-instantrun_once_at— the diagnostic/proving fire usesRemoteTrigger {action:"run"}instead.
RemoteTrigger {action:"create"} mapping
cron_expression— each string inutc_crons(register one event per cron). There is one set of crons for every schedule,onceincluded.job_config.ccr.environment_id—routine.environment_idfrom the config. It shipsnullbecause 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 configmodel.job_config.ccr.session_context.allowed_tools—Bash, Read, Write, Edit, Glob, Grep, Task, WebFetch, WebSearch, Skill, TodoWrite, plusRemoteTriggerif 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:
- Does a cloud-routine session load this repo's
.claude/skills? i.e. can the frozen prompt say "use theclaim-diseaseskill" and have it resolve, or must the skill logic be inlined into the prompt? - Is
RemoteTriggercallable from inside a routine session? The expiry self-disable and the single-flightlist_runscheck both assume it is; if not, use the GitHub fallbacks noted in the prompt. - While there, confirm the env carries
uv,just,ghauth, 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:
- 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.
- 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:
RemoteTrigger {action:"update", body:{enabled:false}}onroutine.trigger_id.- Clear
routine.trigger_idin the config (leaveenvironment_id). - 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 daysetexpiry.mode: once(equivalentlyrecurrence: 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) anddismech.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, andpr-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.