v1.14.1 ·
The event that never fired because we followed the instructions
The problem
executeFinalCompletion's fired-once guard uses retrospective.md as proof the completion event
already fired. The framework's documented completion order writes retrospective.md first.
So obeying the documentation guarantees the event is suppressed, and both actions declared on
on-instance-complete are inert.
The guard's other leg, the retro-pending marker, is not a durable substitute: the
aidlc-retrospective skill removes the marker at step 8. After a normal completion the marker
is gone and retrospective.md is present, so retroDone is the only thing preventing a
re-fire. Deleting retroDone alone would fix the bug and break idempotency. The fix therefore
has to introduce a durable record of "the completion moment executed".
How it could be solved
The first decision was whether to believe the bug report. It said the event had no emitter, and it
made a good case: two automations declared on a lifecycle event, both enabled, neither having ever
run once, and no aidlc complete command anywhere. The cheap move was to add the missing emitter.
That would have been wrong, and the way to find out took one search. The emitter was already there, already shipped, already correct. What suppressed it was its own guard against firing twice, asking "has this already happened?" by checking whether the retrospective file existed — while the framework's written instructions say to produce the retrospective before firing the event. Doing the documented thing was the trigger. Adding a second emitter would have left that intact and given completion two authors instead of one, which is the shape of the next bug rather than the end of this one.
The second decision was what to key the guard on instead. Deleting the retrospective check alone would have fixed the symptom and broken the guarantee, because the only other signal — a marker file — is deleted by the retrospective step itself. After a normal completion neither signal survives, so the event would have re-fired forever. The guard needed something written by completion, which meant completion had to write something: a timestamp on the instance, authored in one place, never refreshed. That also settled a loose end the bug report had noticed in passing — two fields that only humans had ever filled in now have a machine that owns them.
The third decision was how far to reach backwards, and this is where measuring beat guessing. The new key had never been written by anything, so every already-finished instance looked un-fired and would have re-announced itself: eighteen of them. That made the backfill a precondition of the fix rather than a follow-up. But the backfill's other half turned out to be far smaller than the report implied — exactly one roadmap item was stranded, not a sweep. A separate temptation, marking five old instances as owing retrospectives nobody would write, was measured and dropped: it would have put five prompts in front of the maintainer for work finished weeks earlier.
The fourth decision was the one nobody planned. Verifying the single stranded item revealed it could not be seen at all — a backtick opening its title, which the file format treats as reserved, so its header would not parse and the reader returned the same "nothing here" it returns for a file that does not exist. Five items of forty-nine were invisible to every listing and every lookup, silently. The choice was whether to fix the reader or the data. The data was fixed here, because five invisible items are a present harm; the reader — one return value meaning two different things — was written up as its own item, because changing what "nothing here" means touches every caller and deserves its own decision rather than a passing edit inside a bugfix.
Running through all four is the same lesson twice: the code said one thing and the running system said another, and only the running system was right. The guard looked correct in isolation, the migration looked correct in isolation, and both were tested. What found the truth each time was pointing the thing at real data and watching what it did.
How AIDLC solves it
instance-complete-never-fires
The lifecycle event that fires when a piece of work finishes now actually fires. Two pieces of automation had been declared on it for weeks — one that marks an instance as owing a retrospective, one that moves the finished item out of the roadmap's "in progress" pile — and neither had ever run a single time.
The reported cause was that nothing emitted the event. That was wrong, and finding out it was wrong is most of the story. The emitter existed, shipped, and worked. What stopped it was its own safeguard against firing twice, which asked "has this already happened?" by checking whether the retrospective had been written. The framework's own instructions say to write the retrospective first and fire the event second. So the safeguard's answer was always yes, and the event never fired — not because anyone did it wrong, but because they did it right. Two rules contradicted each other and the quieter one won.
The fix moves the question to something the event itself writes: a completion timestamp on the instance. Completion now has one author instead of two, and the timestamp is what proves the event already fired rather than a file some other step happened to leave behind. The retrospective marker also stopped asking for retrospectives that already exist, which is what the old order would otherwise have produced on every single completion.
Because nothing had ever written that timestamp, nineteen already-finished instances would have
looked un-fired to the corrected safeguard and re-announced themselves. A repair pass in
aidlc doctor stamps them first, using each instance's own historical completion time rather than
today's date. That pass is a precondition of the fix, not a tidy-up afterwards.
The second cause, found only by trying it
With the event repaired, one stranded roadmap item still refused to move — while sitting in plain sight in the directory being scanned. Its title began with a backtick, which the file format reserves as a special character, so its header could not be read at all. The code that reads items returns "nothing here" for both absent and unreadable, so the item was invisible to every listing, every lookup, and the very automation meant to move it, with nothing anywhere reporting a problem. Five of forty-nine items were in that state, and had been for up to ten days.
Even a perfect event could never have moved them. The five titles are now quoted, the repair pass names any future unreadable item instead of skipping it silently, and the underlying flaw — one return value meaning two different things — is written up as its own roadmap item rather than patched in passing.
No unit test could have caught either half. Both handlers had passing tests; what nothing tested was that the event arrives. So the proof is this instance's own completion: the retrospective was written first, deliberately, and then the roadmap item moved on its own.
release-gate-runs-too-late
A commit-time CI gate. Every push to main and every pull request now runs
pnpm build → pnpm lint → pnpm test — the same three gates, in the same order, that
scripts/release.sh runs. Before this, release.yml was the only workflow and its only
trigger was a v* tag, so the suite ran exactly once per release, after the tag already
existed. A break landed on main and waited there for whoever tried to ship next.
release.yml also gained the pnpm lint step it never had, so the tag-triggered gate is no
longer weaker than the local one. And the root test script now serialises the workspace
packages, which made the suite four times faster (268s → 66s) and removed two load-induced
30-second timeouts.
The gap is closed
ci.yml had never executed on GitHub when the plan was written. It has now, twice, and the
untested path was tested first:
| Event | Run | Result |
|---|---|---|
pull_request | 33528336464 | pass, 2m40s |
pull_request (after the deployment-record commit) | 33528819635 | pass, 2m2s |
push: main | 33529057385 | pass |
Three runs, both event types. The second pull_request run came in at 2m2s against the
first's 2m40s — the pnpm cache warming, and incidentally evidence that cancel-in-progress
does not interfere with a sequential push.
The pull_request run is the one that mattered — it is AC-1's second trigger and the only
place a detached merge ref gets exercised. It passed on the first attempt, so main never
depended on an unverified workflow.
Confirmed from the run log that every step did real work rather than skipping:
Run pnpm build packages/cli build: Done
packages/website build: ✓ Compiled successfully in 14.9s
packages/website build: ✓ Generating static pages (67/67)
Run pnpm lint packages/cli lint: Done
packages/website lint: Done
✓ CLI docs in sync (reference freshness + Node engine floor + …)
Run pnpm test Test Files 8 passed (8) 15:52:53
Test Files 124 passed (124) 15:53:54
Test Files 21 passed (21) 15:54:12
Those three timestamps are also the AC-5 evidence: the packages complete one after another,
so --workspace-concurrency=1 behaves on a runner exactly as it does locally.
CI wall time 2m36s against 114–134s locally — the difference is cold install and build, and it sits inside the 3–5 minute estimate.
Merged with a merge commit, deliberately
Merge commit fdbbc67, created with git merge --no-ff and the subject form this repo
already uses (Merge fix/<YYYYMMDD>-<name>: <summary>), not a squash. PR #14 closed as
MERGED on the push. The release blog trail
attributes instances to releases by matching BRANCH_RE against merge-commit subjects
(packages/website/scripts/blog/git.mjs:18), so a squash merge would have left no subject
naming this instance and it would have been silently absent from the next release post. That
is the same class of silent omission this instance exists to remove.