The work lifecycle had a split personality. On a branch, .plan tracked your queue and work next advanced through it. On main, an ad-hoc enrichment pipeline guessed what you might want to do next. Two mechanisms, two codepaths, two sets of failure modes — and a gap between them where work fell through.

The gap shows up as two specific failures. You call work next and skip an issue that isn’t actually finished. Or you call work continue and there’s nothing to continue because the queue drained while you weren’t looking. Both cases end with the same result: work that should have been done isn’t, and nobody told you.

Bidirectional chaining

The fix is a chain of four commands — continue, next, end, find — where each one knows when to cascade forward and when to guard backward.

continue ←→ next ←→ end ←→ find
    →  "nothing to do, cascade forward"
    ←  "not ready, go back"

If you call continue and the active issue is already closed, it chains forward to next. If you call next and the issue is still open, it guards backward: “Issue #42 is still open. Continue working.” If the queue empties, next chains to end. After end finishes its ceremony, it chains to find — which runs the discovery pipeline and populates the queue with new work.

The backward direction is the interesting part. Call find when there’s unfinished work? It pushes you back to next. Call end when the queue has items? Back to next. No matter which command you type, you land at the right place. You can’t accidentally skip work, and you can’t get stuck at a dead end.

Python decides, LLM follows

I wanted the chaining logic out of the skill instructions entirely. When routing decisions live in markdown that Claude interprets, they’re non-deterministic — the LLM might reason its way to the wrong branch, especially under context pressure. A work_chain.py module now evaluates the current state and returns a directive: proceed, chain_to_next, guard_continue, and so on. The skill reads the directive and follows it. No judgment calls.

This is the pattern I want more of: mechanical decisions in Python, creative decisions in the LLM. The state machine shouldn’t depend on whether Claude is having a good context day.

The naming problem

The new state for “queue empty, ceremony done, plan still exists” was originally called ended. The design review caught a collision: the worklog layer already maps idle → ended to mean “branch permanently closed.” A new lifecycle state also called ended with different semantics — “main queue paused between find cycles” — creates ambiguity that worklog consumers can’t resolve.

drained is better. The queue is empty. It’s waiting to be refilled. The plan persists. drained → transitioning → active reuses the existing context-loading path when work find populates new items — no new activation machinery needed.

What this opens up

The .plan on main is the real unlock. Today, starting a session on main means running enrichment scripts and manually picking an issue. With a main .plan, the queue persists across sessions. work find populates it, work next advances through it, work end closes the ceremony and sets state to drained. The same commands, the same queue, whether you’re on a feature branch or main.

The corruption recovery work (#262) builds on top of this. When the state machine reaches an inconsistent state — a mid-ceremony crash, a stale .plan from a previous branch — Python detects the inconsistency and proposes a resolution directive. Same pattern: Python decides, LLM presents. That’s next.


<
Previous Post
A universal scenario executor — from MCP wrapper to working automation
>
Next Post
The Format Nobody Documented