- Python 100%
Orchestration (flip_cycle.py): - Rewrite around direct library delegation to close_cycle/create_cycle, removing the brittle inspect/_pick_fn design that silently fell back to built-in logic (close_cycle had no pickable function; create_cycle was invoked with wrong arg types). - Safe ordering: create/adopt the destination cycle BEFORE any destructive call; roll Todo/In Progress/In Review; restore stranded issues to the closing cycle on partial add failure; archive only after a clean roll. Archive failure leaves issues safe in the destination and the next run adopts the destination and retries (idempotent). - Deterministic selection for the weekly Monday cron: no-op while a cycle is still active; due = newest unarchived cycle ended within one cycle length; refuse (exit 1, explicit report) when only stale history exists instead of archiving ancient cycles. --cycle-id remains the operator override. - Surface Signal delivery failure in the report (Signal: FAILED + stderr) without masking the Plane result in the exit code (documented policy). Cycle math: unify on 14-day inclusive windows (end = start + 13) across create_cycle.py and flip_cycle.py; next cycle starts the day after the previous ends, so Monday-start chains never gap or overlap. Fixes the start+14 vs start+13 drift. close_cycle.py: roll_issues now returns RollOutcome(rolled, failures, stranded); add restore_issues(); skip end-date/archive when the roll had errors so nothing strands. create_cycle.py: add ensure_cycle() (adopt-or-create, idempotent reruns), cycle_end(), find_cycle_by_start(); main refactored to run(argv) -> int. signal_notifier.py: send with --message-from-stdin (body on stdin was previously ignored), recipient as positional arg, optional SIGNAL_ACCOUNT; fallback log moved from unwritable /var/log to ~/.hermes/logs/hermes-flip.log with parent creation; HERMES_FLIP_FALLBACK_LOG override. _plane_api.py: retry HTTP 429 (honoring Retry-After) like 5xx so a Monday-morning rate limit does not fail the cron flip. Docs: README rewritten to match reality (real CLI options --next-start-date/ --force/--dry-run, In Review rollover, hermes cron create shape, no signal-cli pipe in wrapper, accurate timezone claim); DISCOVERY create-body example corrected to the start+13 boundary. requirements: drop unused python-dotenv. Tests: 45 stdlib-unittest tests over a recording FakePlane (no network): selection/no-stale-close, weekly 14-day boundary, In Review rollover, safe ordering + failure/restore/idempotent-retry behavior, Signal command construction + fallback path, CLI option truth, 429 retry. RED verified before fixes (3 failures + 28 errors), GREEN after. |
||
|---|---|---|
| scripts/plane/cycles | ||
| tests | ||
| .gitignore | ||
| DISCOVERY.md | ||
| README.md | ||
| requirements.txt | ||
Hermes Automation
Automation scripts for Plane cycle management in the homelab workspace.
Purpose
Biweekly sprint automation for Plane. A single cron entry point
(flip_cycle.py) closes the due cycle — rolling Todo, In Progress, and
In Review issues into the next 14-day cycle — archives the old cycle, and
sends Aaron one combined Signal report. Cycles are 14 days long
(end = start + 13); the cron runs every Monday 07:00
America/Guatemala and self-gates: it only flips on the Monday after a
cycle ends, and cleanly no-ops on the in-between Monday.
Prerequisites
- Python 3.10+ (system
/usr/bin/python3is enough; onlyrequestsis needed) - sops with an age key configured
plane_api_keypresent inagent-config/secrets/mcp.yaml- Access to the Plane instance at
https://plane.aaron.reynoza.org - Optional:
signal-cliat/usr/local/bin/signal-clifor Signal delivery (falls back to~/.hermes/logs/hermes-flip.logwhen unavailable)
Installation
pip install -r requirements.txt
Usage
All scripts live under scripts/plane/cycles/ and share the helper module
_plane_api.py for authentication and HTTP requests. Run them directly with
python3 from the repo root. Dates are accepted as YYYY-MM-DD and sent to
the API as ISO 8601 with a Z suffix. All new cycles use the
America/Guatemala timezone.
Rollover (close due cycle + open next) — the cron entry point
python3 scripts/plane/cycles/flip_cycle.py \
--project-id 205a933f-aab9-41a8-a439-c487059aa36d
Options:
--project-id UUID— required. Plane project UUID (see DISCOVERY.md).--cycle-id UUID— close a specific cycle (operator override; bypasses the deterministic selection below).--next-start-date YYYY-MM-DD— start date for the next cycle (default: the day after the closing cycle ends).--force— skip the overlapping-cycle assertion when creating the next cycle.--dry-run— resolve cycles and print what would happen; change nothing.
Exit codes: 0 = flip succeeded or clean no-op (sprint still active),
1 = failure or refusal (stale history, API error), 2 = invalid CLI
arguments. The exit code reflects the Plane result only: Signal
delivery failure is reported in the report block (Signal: FAILED) and on
stderr but never turns a successful flip into a nonzero exit.
Deterministic selection (what the cron closes):
- An unarchived cycle that has not ended yet → no flip due, clean no-op.
- Otherwise the due cycle is the unarchived cycle with the latest
end_dateat or before now. - If that cycle ended more than 14 days ago, the history is stale and
the script refuses (exit 1) rather than archiving ancient cycles. Close
such cycles manually with
--cycle-id.
Safe ordering: the destination cycle is created (or adopted, if a matching cycle already exists) before anything destructive. Issues that cannot be added to the destination are restored to the closing cycle, and the closing cycle is only archived after a clean roll. If archiving fails, the next run adopts the existing destination and retries the archive.
Soft-close a cycle manually
python3 scripts/plane/cycles/close_cycle.py \
--project-id 205a933f-aab9-41a8-a439-c487059aa36d \
--cycle-id <old-cycle-uuid> \
--next-cycle-id <new-cycle-uuid>
Moves Todo, In Progress, and In Review issues into the next cycle,
then sets end_date to today and archives the closing cycle. Leaves
Backlog, Done, and Cancelled issues untouched. --dry-run lists what
would roll without changing anything. If the roll completes with errors,
the cycle is left unarchived and stranded issues are restored, so nothing
is lost.
Create a cycle manually
python3 scripts/plane/cycles/create_cycle.py \
--project-id 205a933f-aab9-41a8-a439-c487059aa36d \
--start-date 2026-07-20 \
--name "Sprint 2026-07-20"
Options: --force skips the overlapping-cycle assertion. There is no
--end-date: the window is always start + 13 days (14 days inclusive).
If an unarchived cycle with the same start date already exists, it is
reused instead of duplicated.
Cron Wiring
One scheduled job, registered with the Hermes CLI (hermes cron create,
not the in-chat model tool syntax):
- Cycle rollover — every Monday 07:00 America/Guatemala
(
0 7 * * 1). Runs as a no-agent job:flip_cycle.pyis self-contained and sends its own Signal report viasignal_notifier.py, so the wrapper must NOT pipe to signal-cli (that would duplicate delivery).
The wrapper script lives at ~/.hermes/scripts/hermes-plane-flip.sh:
#!/usr/bin/env bash
set -euo pipefail
cd /home/aaron/repos/hermes-automation
exec /usr/bin/python3 scripts/plane/cycles/flip_cycle.py \
--project-id 205a933f-aab9-41a8-a439-c487059aa36d "$@"
Registered with:
hermes cron create "0 7 * * 1" \
--name hermes-plane-flip \
--deliver local \
--no-agent \
--script ~/.hermes/scripts/hermes-plane-flip.sh \
--workdir /home/aaron/repos/hermes-automation
No --repeat count: the job recurs indefinitely. Hermes' timezone is set
top-level (hermes config set timezone America/Guatemala), so 0 7 * * 1
fires Monday 07:00 -06:00. The job's stdout (the single report block) is
delivered verbatim via deliver=local; the Signal copy is sent by
flip_cycle.py itself. Never run the production job manually — that would
mutate Plane. Validate with ~/.hermes/scripts/hermes-plane-flip.sh --dry-run, which passes through to flip_cycle.py --dry-run.
Deferred jobs (not scheduled): cycle-flip LLM supervisor, weekly backlog surfacing.
Tests
Dependency-free (stdlib unittest), safe to run anywhere — they use a
recording fake, never the network:
python3 -m unittest discover -s tests -v
Coverage includes: deterministic selection and the no-stale-close guard,
the weekly 14-day window boundary (start + 13) shared by
create_cycle.py and flip_cycle.py, In Review rollover, safe
create-before-close ordering with restore-on-failure, Signal command
construction (--message-from-stdin) and fallback-log behavior, and CLI
option truth (the options above are exactly what the parsers accept).
Discovery
See DISCOVERY.md for the full API discovery findings: workspace and project IDs, state IDs, verified endpoints, cycle object shapes, and auth details.
Signal
flip_cycle.py sends its own combined report via
scripts/plane/cycles/signal_notifier.py (signal-cli with
--message-from-stdin, fallback ~/.hermes/logs/hermes-flip.log). The
other scripts only print summaries to stdout. Example report:
=== Cycle Flip Report ===
Project : HOMELAB (205a933f-...)
Action : flipped
Closed : Sprint 2026-07-20 [a1b2c3d4] archived=yes
Created : Sprint 2026-08-03 [e5f6a7b8] 2026-08-03 -> 2026-08-16 (new, America/Guatemala)
Rolled : 4 issue(s) (In Progress/Todo/In Review)
Signal : sent
Status : OK
Notes : none
=========================