| name | duty-cycle-tick |
| description | Execute one autonomous duty-cycle fire (START / WATCH / WORK / STOP) for a cycling agent. Invoked by the thin cron prompt on each fire. Use when a "DUTY CYCLE TICK" prompt fires, or to run a cycle fire manually. Holds the durable procedure so the cron prompt stays one-line. |
duty-cycle-tick
Execute one autonomous duty-cycle fire. This skill holds the durable procedure so the cron prompt can stay thin (role + worktree + cron-expr + "run this skill"). The genuinely-transient carry-forward lives in files this skill reads at fire-time — never frozen into the prompt.
When to Use
- A
DUTY CYCLE TICK cron prompt fires (the normal trigger).
- You want to run a cycle fire by hand (
/duty-cycle-tick).
- After compaction, to re-establish the fire procedure without a fat prompt.
What the thin cron prompt provides (the only per-agent constants)
The invoking prompt carries ONLY the irreducible per-agent constants:
- ROLE (e.g. CIO) + role-slug (e.g. cio)
- WORKTREE path (where the session launched; cwd anchors here. Model A = stable per-agent worktree, reused every session, on Amber; Model B = Desktop's ephemeral per-session auto-worktree. Host-dependent since 2026-07-25)
- CRON expression (e.g.
7 2,4-23 * * *) + offset
- Launch model (Model A stable worktree on Amber, Model B ephemeral on Desktop, or a registered variant — e.g. Web main-direct)
Everything else — what's owed, what's active, what's parked — this skill reads from the state files below. If you find yourself wanting to put state in the prompt, that's the smell this skill exists to kill (see Anti-Patterns).
State files (read at fire-time; never frozen in the prompt)
| File | Holds | When |
|---|
dev/2026/MM/DD/{date}-{role}-code-opus-log.md (the session log) | THE log — the single canonical record (PM 2026-06-12: "do the logging in one place"); durable institutional memory, what Docs reads for the omnibus; permanent (dated dir) | created at START; the per-fire entry is written here (Step 5); wrapped at STOP |
dev/active/cycle-log-{role}-{today}.md (tail) | OPTIONAL private scratch — a per-fire scratch list an agent may keep if useful; NOT a logging surface, NOT a parallel record (PM 2026-06-12, supersedes v1.5 dual-surface). dev/active/ is sprint-cleaned — nothing durable lives only here | optional; read at START for continuity if you keep one |
dev/active/{role}-carry-forward.md | the ephemeral session state (active PM threads, PM-attention / escalation items — the residual home since the 6/17 escalations-doc FOLD, parked items, current cron job-id) | read at START / every fire; rewrite at end of every substantive fire |
dev/active/{role}-standing-items.md | durable owed/queued/blocked items (the Task List) | read in the Task Loop |
dev/active/duty-cycle-escalations-{role}.md | DEPRECATED 2026-06-17 (FOLD, PM-ratified) — PM-attention items now ride the carry-forward (above); the cohort-attention rollup GitHub-verifies every item + the freeze-registry handles liveness, so this hand-maintained surface was retired | no longer maintained |
Procedure
A fire is idempotent by design (gbrain-adoption, HOST+CIO co-signed 2026-06-16 — the one concrete action item from that synthesis, executed here 2026-07-06 after sitting unactioned for three weeks): running this procedure twice against the same state must produce the same safe result as running it once. This isn't incidental — it's why the mail loop is drain-to-empty (a re-run finds nothing left, not a duplicate action), why cadence changes are logged explicitly rather than inferred (Section further down), and why Belt-4 spawn-fresh is safe to trigger on a stale-looking session (worst case: two fires converge on the same already-drained state, neither does anything harmful). If you're ever adding a new step to this procedure, ask whether running it twice in a row would double-do something — if yes, that step needs a check-before-act guard, not just a comment.
THE SPINE — the flywheel is the unit of work, not "the fire"
The flywheel runs continuously and cron-independently:
check mail → do unblocked tasks → check mail → do tasks → … → DRAINED → idle
The numbered Steps below are NOT a work-session and NOT a container — they are only how a WAKE re-enters this flywheel (catch up on state, then drain). A wake joins the ongoing loop, drains everything ready, and returns to idle. The cron is a wake-timer for when you're idle, never a work-chunker.
Why this is the spine — and why "save it for the next fire" is structurally impossible, not merely discouraged:
- There is no per-fire bucket to save work into. The flywheel is one loop; "the fire" is just the moment you re-entered it. Deferring unblocked work "to the next fire" doesn't move it into a container — it leaves it undone in the same loop, which the next wake re-enters with the work still there. "Next fire" resolves to "later in the same loop, for no reason" — a disguised stop.
- A wake does not end while unblocked work remains. Commit at each work-unit boundary (git hygiene + interruption protection), but a commit is NOT a stop — keep draining. A wake ends only at (0,0): queue empty, or every remaining item PM-gated.
- The one legitimate pause is quality-banking genuinely-deep work against an explicit, real trigger — a fresh session or a context compaction (an actual capacity limit), named out loud and owned. Never "no rush," "not urgent," or "next fire."
The Steps exist to make a wake correct (don't drop state across the idle gap) — they do not chunk the work.
The boundary — what the spine's "drain it all" does NOT mean (PM ruled both ways 6/15 — get this right): drain work that's ready — but deep / render-sensitive / quality-critical work that genuinely warrants a fresh focused pass is quality-banking, not bite-sizing, and may be deferred to its own wake. The discriminator is WHY you're deferring: to pace the cron tick / because a "fire" conceptually ended = the antipattern; because a complex build deserves fresh focus rather than tail-of-marathon work = legitimate (PM endorsed exactly this for Lead Dev). When unsure, drain it — the antipattern is the common failure, quality-banking the rare exception.
The exception needs an EXPLICIT, REAL trigger (PM 2026-06-16). Quality-banking is legitimate ONLY when you name a concrete trigger out loud — a fresh session or a context compaction (a real capacity limit), not a vague "this deserves focus." "No rush" / "not urgent" / "I'll get to it" with no named trigger is the antipattern in a quality costume. PM: "there is no advantage to saving work… shyness should not be a thing." Two valid states: (1) do it now, or (2) "deferring to a fresh session/compaction because [the explicit reason]" — said explicitly, owned, not implied. And don't tell other agents "no rush" — it plants an imaginary trigger in them too.
Step 1 — Date + cron state (+ Gap-C self-heal)
Run date "+%H:%M %Z (%A %Y-%m-%d)" and CronList. Confirm exactly ONE cron job for your expression:
- Duplicates → CronDelete extras (CronList→CronDelete-old→CronCreate-new is the rotation).
- ZERO crons for your expression → re-arm immediately (
CronCreate your expression) before doing anything else, and note it in the fire entry. This is the Gap-C self-heal: a compaction can silently kill a session-scoped cron (durable:true is a no-op here — PA verified 2026-06-07). Caveat (honest scope): this only fires if the session got a turn at all — a fully-dead cron has no trigger, so this heals on the next turn the session happens to get (a human prompt, or a surviving fire), reducing the dead-window but not curing it. The cure is the external Routines watchdog (roadmap item 1); see procedures/cron-lifecycle.md Gap C.
Step 2 — Sync (worktree model depends on your host)
Read this before Step 2a. This skill was written when Model B (Desktop's ephemeral per-session auto-worktree) was the only model. As of 2026-07-25 the model is host-dependent (PM-ratified): Model A — a stable, reused per-agent worktree at ~/Development/piper-morgan-worktrees/{role} on Amber; Model B on Claude Desktop. See CLAUDE.md §"Worktree model". Where this skill still says "ephemeral worktree," read it as "your worktree" — the sync and push mechanics are identical in both models. The one place the difference is load-bearing is the collision check immediately below, which was a Model-B artifact and is rewritten accordingly.
Step 2a — worktree-collision check, FIRST, before any sync command touches shared state (added 2026-07-19 after a confirmed real-data-loss incident — a PPM-session commit silently reverted already-pushed CIO content, root-caused to a worktree-provisioning defect where one physical directory got assigned to 3 different sessions — CIO, Exec, PPM — simultaneously across several days, undetected until a rebase conflict surfaced it):
basename "$(pwd)" # your worktree directory name
git branch --show-current # your current branch name
On Model B (Desktop) these two values match (branch = claude/<dirname> or worktree-<dirname>) — that pairing is created together and should never drift apart, so a mismatch is the fingerprint of the collision defect (empirically confirmed 2026-07-19: 21 of 22 live worktrees showed the correct 1:1 pairing; the one exception was the directory 3 sessions had been silently sharing). If the branch name doesn't contain your directory's basename, STOP before running Step 2b's sync commands — another session may be using this exact directory, and a routine git merge/git commit risks clobbering its in-flight work.
⚠️ On Model A (Amber) this check is NOT sufficient, and passing it means almost nothing (rewritten 2026-07-25, CIO — Pard concurring on the detection side). Under stable per-agent worktrees the pairing is permanent by construction (cio ↔ claude/cio-cycle matches forever), so the fingerprint passes whether or not a collision exists. The Model-A collision is two live sessions whose cwd is the same worktree — which a branch-name check cannot see at all. Keeping the old check as your only gate on Amber is worse than having none, because it returns a confident pass.
What actually detects it, cheapest first:
- tmux-side — the real gate. SHIPPED AND TESTED 2026-07-25 (Pard-owned,
amber-agent.sh). At worktree standup it refuses to launch if any existing tmux session's cwd is that worktree or a subdirectory of it. Verified by occupying a worktree with a dummy session and confirming a second launch was blocked with nothing spawned. This catches the duplicate-on-one-worktree case a same-session-name check misses, and it is why the branch-name fingerprint above is now genuinely superseded on Model A rather than merely documented as unreliable. You do not run this yourself — it runs at standup, before your session exists.
- In-session sanity (what you can do from here): if you find commits, cron ids, or file content you don't remember creating, the default hypothesis is your own pre-compaction work, not a phantom peer (CLAUDE.md §"Unexplained state after a context gap"). Check your own session log first.
git reflog is the forensic tell, not the gate. Entries of type commit: / rebase (pick): that you don't recognize mean another session really operated in this directory — as distinct from a plain merge origin/main: Fast-forward, which is just a normal sync. Good for confirming after the fact; too slow to gate on.
Either model — if you suspect a collision: don't guess at a fix (don't rename the branch, don't force-checkout, don't touch the other session's state). Flag it in your fire entry and to PM/Exec directly, then proceed cautiously (explicit-path adds only, git status immediately before every stage, push immediately to shrink the window) rather than freezing outright, unless PM says otherwise. This is detection, not a cure — the provisioning layer is where the fix lives, outside this repo.
Step 2a-bis — verify your enforcement hooks actually fire (first fire in a worktree only) (added 2026-07-25 after Finding #4). Project hooks were found silently inactive in a Model-A sibling-path worktree: check-branch.sh did not block a mailboxes/ commit from a feature branch, though the config was present, correctly registered, and the script ran fine when invoked by hand. Config presence proves nothing — an absent hook and a silent hook are indistinguishable from inside a session. On your first fire in a worktree, verify behaviorally.
⚠️ v1.19 (2026-07-26) — YOU MUST RUN BOTH COMMAND SHAPES, AND REPORT THEM SEPARATELY. PA, CXO and PPM ran 14 probes across three fresh Amber seats and found shape is the load-bearing variable:
| shape | BLOCK | BYPASS |
|---|
standalone git commit (staged in a prior call) | 4 | 0 |
compound … && git add … && git commit … (one call) | 3 | 7 |
"Stage a file, then attempt a commit" reads as two steps, so an agent following the old wording literally writes the standalone form — which passes 4/4 while the shape you actually commit with all day bypasses 7 of 10. CXO confirmed two real in-session commits that were never hook-checked, on a seat whose standalone probe had just passed. The old probe systematically certified coverage the agent did not have — the same false-confidence shape as findings #4/#5/#6, reproduced inside the check built to catch them.
git add mailboxes/<role>/inbox/.hookprobe.md
git commit -m "probe"
echo x > mailboxes/<role>/inbox/.hookprobe.md && git add mailboxes/<role>/inbox/.hookprobe.md && git commit -m "probe"
Report A and B separately — a pass on A with a bypass on B is its own state, and the most common one: the hook is alive but does not cover your normal workflow. A single probe cannot express it. Pass still keys on check-branch.sh's distinctive output (BLOCKED: You are on branch …); a classifier refusal is INCONCLUSIVE. Reverse any probe that lands (git reset --hard HEAD~1) and delete the file either way.
★ Free mitigation, available now, no config change — use it whenever you want a commit actually gated: stage in one call, then commit in a separate bare call. That form was caught 4/4. The compound one-liner is convenient and is the one that leaks.
⚠️ Read the OUTPUT, not just the outcome — a bare "the commit didn't happen" is NOT a pass (corrected 2026-07-25 same-day, after Lead Dev ran this probe and got an ambiguous result). Three distinct results:
| Result | Meaning |
|---|
Refused, and the output is check-branch.sh's own text — BLOCKED: You are on branch '<x>' and trying to commit mailbox files + "Files in mailboxes/ are cross-agent infrastructure…" | ✅ PASS — the hook fired |
| Commit succeeds, or is refused with no output at all | ❌ FAIL — hooks not firing |
Refused by the permission classifier (Permission for this action was denied by the Claude Code auto mode classifier) | ⚠️ INCONCLUSIVE — NOT a pass |
Why the third row exists: the permission classifier can intercept the commit before git hooks ever run, so its refusal says nothing about hook liveness. My first version of this check said "a block is the pass" — which is wrong, because a refusal is producible by something other than the thing being tested. That is the same false-confidence shape as the findings this check exists to catch, in my own protocol. Do not work around a classifier denial to force the probe through; report inconclusive and leave the gate closed.
On a fail or inconclusive, enforce mailbox discipline and log maintenance manually and say so in your fire entry; don't assume the hooks have your back. Full protocol: docs/internal/operations/amber-worktree-lifecycle.md Rule 4.
Step 2b — the sync itself:
git fetch origin main -q && git checkout -- mailboxes/*/inbox/MANIFEST.md mailboxes/*/read/MANIFEST.md 2>/dev/null
git merge origin/main --no-edit -q
Discard mailbox MANIFEST regen-noise. (Variant launch models — e.g. Web main-direct — skip the worktree dance per their registry row; see cron-shape-experiments.md.)
Step 3 — Read carry-forward, then dispatch by STATE (shape-independent — HOST finding 2026-06-06)
Read the cycle-log tail + {role}-carry-forward.md so you know where you left off. Then dispatch by a state + window hybrid — state (session-log-today existence) gates START-vs-WORK; hour gates overnight-WATCH-vs-morning-START. (This is the v1.2 refinement: pure-state was almost right, but the continuous shape's ~2am WATCH fire also has no-session-log-today yet, so a bare "no-log→START" rule mis-STARTs it overnight. The overnight-window guard fixes that while keeping HOST's low-freq fix intact.) Check the overnight branch FIRST:
- Overnight window (local hour ~0–4, pre-morning) + nothing urgent → quiet-hold / WATCH — regardless of whether a session-log-today exists yet. No START, no CronDelete, leave armed. For the continuous shape the single ~2am fire is the WATCH (quick
ls mailboxes/{role}/inbox/; commit a one-line WATCH entry; see procedures/watch.md); low-freq shapes' overnight fires are plain quiet-holds. (This branch first — and hour-gated — so the 2am fire doesn't fall into the START rule below.)
- No session log exists for today AND past the overnight window (local hour ≥ ~4) → START: Step 0 FIRST — verify the prior day STOPped properly, and run the missed STOP tasks if not (PM-ratified 2026-06-09, Comms-surfaced):
grep -l "DAY-CLOSED" dev/2026/<prior-day-path>/*{role}*log.md — if the prior day's session log lacks the <!-- DAY-CLOSED: {date} --> marker, that day ended without a STOP (PM takeover, cron reshape, session-death, or engaged-past-STOP-window). Run its missed close NOW before today's START: reconstruct the prior day's wrap from its cycle log + commits — day-arc + the memory-eval 3-bucket + the sign-off checklist + the DAY-CLOSED marker. This is self-healing — it doesn't wait for Docs's merge-keeper sweep to catch it the next morning (that's the reactive net; this is the proactive source-catch). Then proceed: create today's session log (create-session-log skill) + fresh cycle log; mail-loop. Commit a one-line START entry (audit-visibility).
★ START also OWNS YOUR WATCHDOG REGISTRY ROW (added 2026-07-25 after finding #6). Immediately after arming your cron, verify your row in dev/active/duty-cycle-registry.tsv matches the cron you actually armed — and write the row if it doesn't exist. Columns are TAB-separated: role⇥cron_expr⇥threshold_h⇥wake_start⇥wake_end⇥first_fire⇥active_since (threshold_h = a bit more than your largest in-window inter-fire gap; first_fire = your first fire at/after wake_start).
Why this is the agent's job and not the provisioner's: the registry is an opt-in watch list — no row means the freeze-watchdog is structurally incapable of noticing you're dead, and it will report the cohort as clear while you're dark (finding #6: it covered 4 of 10 roles and phrased its subset as a total; five roles had been dark six days). The obvious fix is "the provisioner writes the row at standup" — but the provisioner cannot, because the row's load-bearing field is the cron expression and that isn't known until the agent arms it. Proven immediately: at HOST's 2026-07-25 provisioning Pard correctly declined to guess-edit and deferred the row, so agent #2 came up live and unwatched. You always know your own cadence; nobody else does. Same reason a stale row is its own hazard — if you change cadence mid-day, the row changes with it (see Step 7). (Gating START on "no-session-log-today" — not a fixed "~04" — keeps HOST's fix: a low-freq agent whose first fire is ~06:37 still STARTs correctly. The ≥~4 guard only excludes the overnight-WATCH window, not the whole morning.)
- Session log exists + this is the last scheduled fire of today + PM idle + not yet STOPped today → STOP: day-close; LEAVE CRON ARMED — but re-arming is delete-then-create, not create-only:
CronList first, CronDelete any existing job(s) for this expression (the day's cron is very likely still alive at STOP — STOP is a day-close ritual, not evidence the cron already died), then CronCreate fresh, then CronList once more to confirm exactly one job survives. Skipping the delete step stacks a duplicate on top of the still-live job (self-inflicted instance, 2026-07-10: a fire followed an earlier version of this exact instruction literally — "re-CronCreate ... as the final action" — and produced two jobs on the same expression, diagnosed the next morning as methodology-35, Asymmetric Discipline: the creation-half was specified, the cleanup-half wasn't). The "last scheduled fire of today" rule (v1.6, 2026-06-12, PM-coined): compute the next scheduled fire from your cron expression; if its calendar date is different from today's, this is the last fire of today → STOP. Works elegantly for every shape — continuous 7 2,4-23 STOPs at 23:07 (next is 02:07 tomorrow), windowed 7 3,10,13,16,19,22 STOPs at 22:07 (next is 03:07 tomorrow), windowed 42 6,9,12,15,18,21 STOPs at 21:42 (next is 06:42 tomorrow), windowed 37 6,9,12,15,18,21 STOPs at 21:37 (next is 06:37 tomorrow). Replaces the old "past ~11pm" rule (v1.5 and prior) which never fired for windowed shapes ending before 22:00 → cohort-wide STOP-never-triggers gap, caught by Exec 6/12 + confirmed by PA's identical improvisation ("last evening fire = day-close"). PA's ad-hoc rule was the right rule; PM codified it. The two resolutions COMPOSE (Exec 2026-06-12), don't compete: this proactive last-fire-STOP is the primary mechanism; the v1.4 START Step-0 self-heal is the backstop for when the last fire never fired at all (Gap-C session death / dormancy at the last slot) — that morning's START detects the missing <!-- DAY-CLOSED --> marker and writes the retroactive close. Implement BOTH: proactive-only silently fails if the last fire dies; reactive-only leaves the prior day's session log reading as in-progress until the next START. Heuristic if you can't compute next-fire precisely: "is my current fire-hour the largest hour in my cron expression?" works for most shapes (schedules don't wrap mid-day). Wrap the session log (the single record): the day-arc summary + the memory-eval 3-bucket section (#974) filled + the sign-off checklist (git status clean / @{u}..HEAD empty / main..HEAD empty). (PM 2026-06-12 one-place ruling: wrap the session log; any optional cycle-log scratch needs no formal close.) If the session spanned a day boundary without a STOP (ran continuously / compacted overnight), the retroactive close MUST still wrap the prior day's session log (memory-eval + sign-off), not only its cycle log. Emit the canonical close-out marker: the session-log sign-off section MUST include a literal <!-- DAY-CLOSED: {YYYY-MM-DD} --> line — the grep-able sentinel that START's Step-0 self-heal (and the Lead-owned session-start hook) check for to detect "did a proper STOP happen?" (PM-ratified 2026-06-09; standardizes the close-out detection — prior close-outs varied: "DAY-CLOSE", "## STOP", prose). Distributed cleanup (HOST spec, docs/internal/operations/duty-cycle-stop-cleanup-spec.md, 2026-07-04): before the STOP commit, remove stale ephemeral scratch — dev/active/cycle-log-{role}-YYYY-MM-DD.md files ≥7 days old and dev/active/*.tmp files ≥1 day old, nothing else (never *-carry-forward.md / *-standing-items.md / duty-cycle-registry.tsv / sprint backlogs — see the spec's out-of-scope list; if in doubt, don't delete). Dry-run the list first (find dev/active -name 'cycle-log-*.md' -mtime +7), then delete, then stage each deleted path explicitly by name in the STOP commit (git add <explicit-path> <explicit-path> ... — never git add -A / git add dev/active/, per this repo's explicit-paths-only discipline; HOST's spec illustrates the deletion logic with a directory-level add, but the STOP commit itself must enumerate paths one by one like every other commit in this skill). The commit message names the count + threshold (e.g. stop({role}): cleanup N stale cycle-log files (>7 days)) so the commit log is the audit trail — no separate cleanup artifact. Attention-doc reconciliation — REMOVED 2026-06-17 (FOLD, PM-ratified). The per-role duty-cycle-escalations-{role}.md docs are deprecated. This hand-maintained reconcile step was itself vigilance-dependent and rotted anyway (Exec 6/17: most docs 1–3 weeks stale despite the step — the very pattern the step was meant to prevent). The cohort-attention rollup now GitHub-verifies every item (doesn't trust the docs) and the freeze-registry handles liveness, so the docs are no longer load-bearing. PM-attention / escalation items now ride the carry-forward (the residual home); a genuine PM escalation goes via mail (the signaling layer). Nothing to reconcile at STOP.
- else (session log exists, daytime, work to do) → WORK PARTS: Mail Loop (drain inbox → read/ with disposition; then regenerate your own MANIFESTs —
python scripts/regenerate-mailbox-manifests.py --role {role-slug} — the #1106 derive mechanism (v1.7): the recipient is the sole MANIFEST writer; the regen derives the entry table from filesystem state + frontmatter subject: (first-H1 fallback; (no subject) is warned to stderr, never silent) and preserves any curated content at/below the <!-- curated --> marker verbatim — put your prose annotations there, never in the table; send the regenerated MANIFESTs together with your mail-move via mail-send.sh push-to-ref) → Task Loop (advance owed work; at (0,0) advance smallest-scope unblocked low-pri from standing-items, else quiet hold) → loop to (0,0).
Step 4 — Execute the dispatched part
Hold the discipline: holistic-not-tactical. Quiet hold beats manufactured busywork. Batch identical daytime no-op holds (don't commit a near-duplicate entry each fire) — but WATCH and START always commit a one-line entry.
Step 5 — Log each work UNIT (single-surface — the session log)
Event-based: the log entry rides with each work-unit commit — NOT a per-fire wrap (logging per fire is one of the things that re-implies fire-as-session). Write each work unit to the SESSION log (dev/2026/MM/DD/{date}-{role}-code-opus-log.md): - (HH:MM PT) — what shipped (detail, commit refs, reasoning as warranted). A wake that drains several units MAY group them under a wake header (Fire N (HH:MM) — + sub-bullets) for readability — fine, but the unit is the work, not the fire. The session log is the single canonical record (PM 2026-06-12: "simplify logging, minimize drift — do the logging in one place"). Trivial/quiet-hold fires don't need an entry; any fire that ships a memo / decision / code / methodology edit DOES. "Fire N" labels which wakeup initiated the work — it is NOT a work-unit boundary. A single wake that drains several tasks logs them together under that one fire entry (sub-bullets are fine); the wake does not end after task 1. Don't let the record format pace the work (see the spine above).
Optional scratch: an agent MAY keep a per-fire scratch list in dev/active/cycle-log-{role}-{today}.md if it's useful working state — but it is not a logging surface, not a parallel record, and never the durable home for work. The session log is where logging happens.
Why one place (PM-ratified 2026-06-12, supersedes the v1.5 dual-surface design): the displacement trap (the fire loop wrote only the cycle log → the durable session log silently stubbed → work vanished when dev/active/ was sprint-cleaned) is cured at the source by logging to ONE durable surface, rather than guarding against drift by writing to two. One place can't drift from itself. (Composes with — does not replace — START's session-log creation and STOP's session-log wrap.)
Step 6 — Commit + push (verify it lands)
- Non-mail (logs, docs, design): commit on your ephemeral worktree branch →
git push origin HEAD:main (NEVER git checkout main in this worktree). On non-fast-forward: git fetch origin && git rebase origin/main, then re-push.
- Mailbox writes: via push-to-ref —
scripts/mail-send.sh "mail({role}): subject" <explicit mailbox paths> from your OWN worktree (#1259, swapped live 2026-06-19). It builds the commit on origin/main (commit-tree via a throwaway index) and pushes straight to main — no cd to the main checkout, no stash, no branch-switch; the old main-worktree bridge dance is retired. check-branch.sh stays as the backstop for any interactive mail git commit (commit-tree doesn't trip it).
- Pre-staging discipline (run in this order every time):
git reset HEAD → explicit-path adds → git diff --cached --name-only (read every line, confirm only your files). git reset HEAD clears index residue from rebase/autostash/merge before you stage anything — skipping it is how the wrong files silently get committed (Web 2026-06-15: autostash debris swept into close-out commit instead of the session log).
- EXPLICIT-PATHS-ONLY on every
git add. Never git add -A / git add . / directory adds. NEVER unconditional git stash pop.
- VERIFY the push landed on origin/main — main is busy; fast-forward races happen.
git fetch origin main && git branch -r --contains HEAD | grep origin/main; if rejected, git merge origin/main + re-push.
Step 7 — Update carry-forward + manage cron, then brief status
- Rewrite
{role}-carry-forward.md with current ephemeral state (this is what replaces the frozen prompt block).
- Cron — ONE rule (it's your idle self-wake, nothing more): keep it ARMED by default, including throughout PM conversation. It can't fire while the REPL is busy, so armed-during-work-or-convo is harmless; armed means it re-wakes you the moment you go idle — which is the whole point. (This is why "arm only on reaching idle" is wrong: if the session backgrounds during work or convo, you'd have no armed cron to self-wake — a stall. Armed-by-default closes that.) The only time you DELETE it: while you're actively draining genuinely-substantive multi-step work (so a queued fire can't interrupt mid-build) — then re-arm the instant you return to idle (incl. end of STOP), where "re-arm" always means delete-then-create (
CronList → CronDelete the existing job → CronCreate fresh → CronList to confirm exactly one survives), never a bare CronCreate assumed safe because "the old one probably already died" — at STOP specifically it almost certainly hasn't (see STOP's re-arm step above; this is the same instruction, not a different one). A pending PM question never deletes the cron and never blocks other unblocked work — hold only the thread awaiting the answer; drain everything else. A trivial one-line fire needs no delete. (This collapses the former Rule-1/Rule-2 into one: armed-by-default, deleted only mid-substantive-build, re-armed at idle. PM-ratified shape 2026-06-06; unified 2026-06-23 per the fire-as-timebox structural pass.)
- Whenever you CronDelete+CronCreate to change your OWN cadence (not just a same-expression re-arm): (1) log the explicit old-id → new-id transition with the reason in your session log, in words unambiguous to a future amnesia-recovering read (e.g. "changed my own cron from
X (3×/day) to Y (5×/day), PM-approved bump" — not just "re-armed"); (2) update your row in dev/active/duty-cycle-registry.tsv if the cadence itself changed, not just the job id — the watchdog reads that file, and a stale row there is a second, independent source of confusion on top of (1). Diagnosed 2026-07-06 from two compounding incidents: a cron-id change misread by a later fire as evidence of a second session (see the compaction-recovery guidance in CLAUDE.md), and a registry row that silently went stale for two days after a same-day cadence bump because only the carry-forward was updated, not the registry.
- Give the user a brief status line.
Anti-Patterns to Avoid
| Don't Do This | Why | Do This Instead |
|---|
| Freeze carry-forward state into the cron prompt | You hand-refresh it every re-arm (vigilance); copies drift across agents | Write it to {role}-carry-forward.md; the skill reads it (mechanism — m-36) |
| Put the procedure in the prompt | N agents carry N divergent copies | One versioned skill; the prompt invokes it |
git add -A / directory adds | Sweeps other agents' working-tree state | Explicit paths only, every time |
| Commit a near-identical no-op hold every fire | Log churn | Batch daytime quiet-holds; only WATCH/START always commit |
| Log substantive work in the cycle-log scratch instead of the session log | The durable record stays empty → institutional-memory leak (cycle logs are ephemeral dev/active/, sprint-cleaned; only the dated session log is durable + read by Docs's omnibus) | Log in the session log — the single canonical surface (Step 5, PM 2026-06-12). The cycle log is optional private scratch only |
| STOP by CronDelete-and-leave-deleted | No morning self-wake (Gap A) | STOP leaves the cron ARMED via delete-then-create (CronList → CronDelete existing → CronCreate → verify exactly one) |
| STOP re-arm by bare CronCreate, no prior delete | Stacks a duplicate on the still-live job (methodology-35 instance, 2026-07-10) | Delete-then-create, always — see STOP's re-arm step |
| Assume the push landed | main is busy; fast-forward races | Verify on origin/main; merge + re-push if rejected |
Quality Checklist
After each fire:
Examples
Example 1: Quiet WORK fire (inbox zero, queue clear)
Date 14:07 → WORK PARTS. Sync clean, inbox zero, standing-items has no unblocked low-pri. → Quiet hold (batch if identical to last). No CronDelete (trivial). Brief status. Done.
Example 2: Substantive WORK fire (mail to act on)
Date 10:09 → WORK PARTS. Inbox has a memo needing a reply. → CronDelete FIRST (Rule 1). Draft reply + send via mail-send.sh (push-to-ref, explicit paths), triage source → read/, log the fire, push + verify, rewrite carry-forward, CronCreate same expr back. Brief status.
Example 3: STOP (day-close)
Date 23:37, PM idle → STOP. Final mail-check, append day-close to session + cycle log, commit + push + verify, rewrite carry-forward for tomorrow, re-arm via delete-then-create (CronList → CronDelete the day's job → CronCreate fresh → CronList to confirm exactly one) as the final action. Brief status.
Cross-references
docs/operations/duty-cycle design/procedures/cron-lifecycle.md — Rule 0/1/2 + v0.6.3 in full
docs/operations/duty-cycle design/procedures/watch.md / stop.md / start.md — the day-parts
docs/operations/duty-cycle design/canonical-cron-prompt-template-v0.7.md — the (now thin) cron prompt
docs/operations/duty-cycle design/cron-shape-experiments.md — per-lane cron-shape variants
.claude/skills/create-session-log/SKILL.md — invoked by START on a new day