v1.12.1: the lifecycle skills stop believing the wrong oracle

Released 2026-08-19. The patch that landed the day after the issue lifecycle got its outlet, and found that most of what the lifecycle skills were reading as truth was a proxy for it.

What we were trying to fix

A patch release is a story about what the minor before it got wrong, and this one is unusually tidy on that score. Merging #176 is literally what produced the first bug in this list.

gh pr merge --squash --delete-branch does two unrelated things: it merges on the server, and it tidies the local checkout afterwards. merge-pr read the single exit code (#178), so a local failure was reported as a merge rejection. Measured while merging #176: the command exited non-zero on fatal: 'main' is already used by worktree at ... while GitHub said the pull request was already merged, with a timestamp and the remote branch gone. That is not an edge case in this repository, it is the normal path, because implement-issue puts the PR branch in a linked worktree and the primary checkout sits on main, which is exactly the branch gh tries to switch to after merging. For the fleet the consequence is worse than a bad message: the worker reports failure, and the slot is re-driven against a pull request that no longer exists.

The CI gate had a mirror-image problem (#91). It refused pull requests that this repository’s own CI design produces. cancel-in-progress, added a release earlier so that a retitle cannot leave two runs racing to publish the same check, leaves a superseded cancelled check-run attached to the head commit forever, beside the real success. Measured on one PR, the head carried three runs of the same job: cancelled at 09:34:38, success at 09:35:07, success at 09:48:09, while GitHub reported the PR mergeable throughout. The unguarded half of the same bug is the dangerous one: a stale success sitting beside a newer failure reads as fine. The gate had only ever been wrong in the safe direction, which is why nobody had noticed.

Then tick-plan.sh, which could still hang after a successful write (#135). And the report renderer, which raised a bare KeyError on a screenshot format it did not support (#142): a dict subscript rather than a lookup with a fallback, so the error named a Python dictionary key instead of the file the author had given it.

What we decided

In every case, ask the authority rather than a proxy for it.

merge-pr now decides the merge on GitHub’s own state (#181) and judges CI by the latest run per job rather than by every run on the commit (#190). That second recipe has one detail worth stealing: the golden suite extracts the verdict program verbatim from the marked block in the reference an agent actually pastes from, and runs that. The program proven green is the program in the documentation, with no second copy to drift.

tick-plan.sh bounds every gh call, and the bound releases the caller (#179). The second half of that sentence is the part that took thought. Killing a call does not un-send it, so a timeout is not a failure and must not be reported as one: the script reads the issue back and lets the read-back decide, which is why the skill’s instructions now say to re-run a bounded tick unchanged rather than restore the original body over it.

The rest went the same way. Phase 6 collects coverage where the local flow’s report actually reads it (#103), the report names the unsupported format rather than raising, and the followups suite takes its scratch directory from the shared test library like every other suite (#160). One performance change rode along: the audit reads every .cs file once instead of four or five times (#169), worth 96 percent of one measured run.

What got cut

More than the size of the release suggests, and mostly by writing the rejection down.

The CI fix pointedly did not blanket-ignore cancelled runs. A human cancel, or a timeout cancel, is a genuine non-verdict and must still block the merge, so a job whose latest run is cancelled keeps the pull request red. Ignoring the conclusion outright would have been one line and would have traded a false refusal for a false approval.

report.json did not get a declared schema, though the screenshot bug is the third field in a row whose failure mode acquired a name only after somebody tripped over it. A schema would close the whole class, and it is a contract change touching the template and every report already written, so it was deferred with the reasoning attached rather than smuggled in. Widening the image format map instead was considered and rejected for a sharper reason: some formats no browser renders inline, so “no error” would mean a silently broken image in the deliverable, which survives to the reader and is therefore worse than an exit.

The audit’s remaining duplicate work was measured and left alone. Folding its two directory walks into one is defensible as tidiness, but per-phase timers put the second walk at 1.6 to 4.8 percent of the run on every repository that is actually slow, and rewriting the traversal a second time is exactly where an earlier regression had got in.

What bit us

The best story in the release is that the filed root cause was not the real one.

#135 was filed against an unbounded read-back, which was real and was fixed. It is not what produced the reported hang. The culprit was a ${var//[[:space:]]/} substitution over the whole issue body: bash 3.2’s pattern substitution is quadratic in the subject, so a 4 KB body cost 5 seconds, an 8 KB body 33 seconds, and the real 15.8 KB body 247 seconds. Pure CPU, after the PATCH had already landed, with no deadline over it because it is not a network call at all. It therefore presents exactly as “hangs after a successful write, needs a kill”, and it grows with every checkbox a plan gains. End to end against the same body: 236.87 seconds of CPU became 0.13.

The bound itself had a second defect of the same family. The deadline killed only the process it had launched, leaving a surviving descendant holding the inherited output streams, so a caller reading the script through a pipe, which is how every agent harness runs it, blocked for the full duration of the call the deadline claimed to bound. Measured with a two-second deadline against a sixty-second call: four seconds when output goes to a file, sixty when it goes to a pipe, same output, same exit 0. Every existing test redirected to a file, which is precisely why the suite was green.

And the screenshot crash was reachable in its worst shape only because an earlier fix had improved things. Before that fix, a bad screenshot path died on a missing file. Afterwards the caller was told the screenshot was fine, path resolved and file present, and the run died anyway three frames deeper.

If there is a lesson particular to this release rather than inherited from the one before it, it is that the issue title is a hypothesis. Two of the seven fixes here corrected something other than what their issue had named, and both times the real cause was found only because somebody measured the thing before changing it.