Biweekly Plane cycle automation for the homelab workspace. Scripts for cycle creation, soft-close rollover, and Signal reporting.
Find a file
Aaron Reynoza f5e1dfbcc9 fix(cycles): safe weekly flip — deterministic selection, create-before-close, 14-day boundary, Signal fixes
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.
2026-07-15 07:48:30 +00:00
scripts/plane/cycles fix(cycles): safe weekly flip — deterministic selection, create-before-close, 14-day boundary, Signal fixes 2026-07-15 07:48:30 +00:00
tests fix(cycles): safe weekly flip — deterministic selection, create-before-close, 14-day boundary, Signal fixes 2026-07-15 07:48:30 +00:00
.gitignore feat: biweekly Plane cycle automation scripts 2026-07-15 06:41:35 +00:00
DISCOVERY.md fix(cycles): safe weekly flip — deterministic selection, create-before-close, 14-day boundary, Signal fixes 2026-07-15 07:48:30 +00:00
README.md fix(cycles): safe weekly flip — deterministic selection, create-before-close, 14-day boundary, Signal fixes 2026-07-15 07:48:30 +00:00
requirements.txt fix(cycles): safe weekly flip — deterministic selection, create-before-close, 14-day boundary, Signal fixes 2026-07-15 07:48:30 +00:00

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/python3 is enough; only requests is needed)
  • sops with an age key configured
  • plane_api_key present in agent-config/secrets/mcp.yaml
  • Access to the Plane instance at https://plane.aaron.reynoza.org
  • Optional: signal-cli at /usr/local/bin/signal-cli for Signal delivery (falls back to ~/.hermes/logs/hermes-flip.log when 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_date at 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.py is self-contained and sends its own Signal report via signal_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
=========================