Finalize a merged issue — archive its OpenSpec change (sync delta specs into openspec/specs/), remove the worktree + branch, and close the issue with a traceable comment. Fifth stage of the CQLite delivery pipeline. Runs after the PR is merged — workers merge autonomously on green (gate PASS + C PASS + roborev clean), so finalize normally follows the worker's own merge. Use right after a merge (or when the owner says "finalize #N").
Install
npx skillscat add pmcfadin/cqlite/flow-finalize Install via the SkillsCat registry.
flow-finalize — archive, clean up, close
You are the CQLite delivery lead. The PR for issue #N is merged. Close the loop.
Steps
FIRST — is this a COMPLETED delivery or a SLICE? (issue #3550) A slice is a merged PR that
shipped part of an issue which deliberately stays OPEN (the shape the lead ruled correct on
#3393). This skill's default path ends by archiving the change, setting the board to Done, and
CLOSING the issue — every one of which is WRONG for a slice, and closing the issue is the
named FORBIDDEN workaround #3550 exists to prevent. Do not run steps 3, 5 and 7 unmodified for a
slice; see "Slice deliveries" below, which says exactly what to do instead. Deciding is
mechanical, not a judgement call: if the merged PR declaredCloses #<N>it is a completed
delivery; if it deliberately did not, it is a slice.
Compare by URL, never by number — an issue NUMBER is repository-scoped, so a PR closingother-repo#<N>would read as closing THIS repo's#<N>and you would close a deliberately-open
issue.delivery-telemetry.pymakes the same comparison the same way (issue #3550).# gh's built-in --jq takes NO --arg, so the issue url is passed through the ENVIRONMENT # and read with jq's env.U. (`gh pr view --jq --arg u "$u" '<expr>'` is a usage error: # --jq consumes "--arg" and the rest become surplus positionals.) export U=$(gh issue view <N> --json url --jq .url) gh pr view <pr> --json closingIssuesReferences \ --jq 'if [.closingIssuesReferences[].url] | index(env.U) then "COMPLETED" else "SLICE" end' # COMPLETED -> run every step as written # SLICE -> follow "Slice deliveries" below
Confirm the merge + capture the merged branch. state MUST be
MERGED; the cleanup in step 6 keys
off the merged PR'sheadRefName(NOT aissue-<N>-*glob — see the #1162 guardrails below):gh pr view <pr> --json state,mergeCommit,headRefName # state MUST be MERGED; record headRefName as <merged-branch> for step 6.If not merged, stop — finalize only runs post-merge.
Update the root checkout's main — but FIRST guard against a commandeered root. A Codex/other
session may have switched the shared root checkout onto its own branch. Do NOTgit switch main/git resetit back (that yanks it from whoever owns it), and NEVER commit to it while it is off-main:repo_root=~/projects/cqlite root_branch=$(git -C "$repo_root" rev-parse --abbrev-ref HEAD) git -C "$repo_root" fetch origin main -q if [ "$root_branch" = "main" ]; then git -C "$repo_root" merge --ff-only origin/main else echo "⚠️ root checkout is on $root_branch, not main — will stamp main-only artifacts via a throwaway worktree (step 4), NOT the root." fi(Archiving + cleanup below run from the worktree; they don't require local main.)
Archive the OpenSpec change (design-driven) — NOT for a SLICE delivery (see the gate above:
the change is unfinished and archiving strands the remaining slices):openspec archive <slug> --yes(use--skip-specs
only for a doc/infra change with no capability delta). This moves the change toopenspec/changes/archive/and syncs its delta spec intoopenspec/specs/<capability>/spec.md.
Commit the archive (and push / open a small PR per the repo's merge norms).Stamp the telemetry ledger. Write one record for this completed issue so the pipeline's
self-improvement loop has data (schema + doctrine:docs/reports/delivery-telemetry.schema.json;
tool:scripts/delivery-telemetry.py). GitHub-derived fields (issue/PR timestamps, priority,
routing) are pulled live; the run counters are what YOU observed during this issue — supply honest
values (a counter you did not observe is an error, never a fabricated0):python3 scripts/delivery-telemetry.py record \ --issue <N> --pr <pr> --slug <slug> --routing design|oracle \ --gate pass|fail|not-run --gate-runs <runs through the first PASS; don't re-run after a pass> \ # `--gate not-run --gate-runs 0` (#3448) is the ONLY honest record when NO full gate of # record ran; the two are coupled both ways (not-run <=> 0) and neither flag is optional. # Add --slice (#3550) when the ISSUE deliberately stays OPEN and this PR shipped a SLICE of it: # it records closed_at: null and bounds cycle_time_s on the PR's mergedAt. The kind is decided by # replaying the issue TIMELINE to the PR's mergedAt (#3559): slice <=> the issue was OPEN at # mergedAt AND this PR closes NOTHING. So a slice STAYS stampable after its issue is closed or # reopened, and --slice is refused when the LAST `closed`/`reopened` event STRICTLY BEFORE # mergedAt is a `closed` -- the LAST one decides, so a close FOLLOWED by a reopen before the # merge leaves the issue open at mergedAt and is ACCEPTED (a deciding close COMPLETED the issue), when a state event falls in the SAME SECOND as # mergedAt (one-second resolution, so the tie is unmeasurable and is refused as that), or when # this PR declares it closes the issue (an ordinary completed delivery whose auto-close lands # after the merge — retry WITHOUT --slice once GitHub records the close). An issue open at # mergedAt stamped WITHOUT --slice is refused too. Where the tool CANNOT disprove your flag it # accepts it and says on stderr that the kind rests on YOUR assertion, not a measurement — read # that note, it is not noise. NEVER close the issue to satisfy the tool, and NEVER hand-append # to the JSONL: both are FORBIDDEN. --claim-collisions <rejected claim pushes> --rebase-events <rebases/conflict resolutions> \ --roborev-findings <roborev findings raised> --rework <re-open / re-review rounds> # This skill NEVER invokes roborev (the closer owns the final pass, via the only sanctioned invocation # `scripts/flow/roborev-review.sh` — #2964). "roborev clean" in this ledger means that wrapper's # terminal `RESULT: PASS`; `NOTHING-TO-REVIEW` or FAIL is not clean and is not finalizable. # Land the ledger via a PR — `main` blocks direct pushes (#2433 branch protection: PR required, # enforce_admins=true). NEVER `git push`/`push origin HEAD:main` (rejected), and NEVER `git checkout` # in the shared root (a closer that switched root to a telemetry branch stranded it off main). # ALWAYS a dedicated telemetry-<N> worktree branched off origin/main: git -C ~/projects/cqlite fetch origin main -q git -C ~/projects/cqlite worktree add /tmp/cqlite-ledger-<N> -b telemetry-<N> origin/main -q cd /tmp/cqlite-ledger-<N> # IMPORTANT: `record` writes to the SCRIPT's repo ledger (the root checkout), NOT $PWD. After running # it, verify the new line landed in THIS worktree's docs/reports/delivery-telemetry.jsonl (move it here # if the tool wrote it to root) and leave the root checkout CLEAN. git add docs/reports/delivery-telemetry.jsonl git commit -m "chore(telemetry): record #<N> delivery (PR #<pr>)" git push -u origin telemetry-<N> gh api repos/pmcfadin/cqlite/pulls -f title="chore(telemetry): record #<N> delivery" \ -f head="telemetry-<N>" -f base="main" \ -f body="Telemetry-only ledger stamp for #<N> (PR #<pr>). Routed via PR — main blocks direct pushes." --jq '.html_url' cd ~/projects/cqlite && git worktree remove /tmp/cqlite-ledger-<N> --force # The telemetry PR merges once its own `required` check goes green. The ledger is a HOT append-only # file: on a rebase conflict, KEEP ALL lines (main's ledger + your new record) — never drop a peer's. # Do NOT block the code merge on the telemetry PR; if its CI is pending, hand its number back and # merge it centrally when green.--routingis required (it is never inferred);--prioritydefaults from the issue'sP?label
(pass it to override).recordrefuses a second stamp for the same issue (pass--allow-duplicateto
override). The live ledger lives onmain, reachable only via a PR (never a direct push).
Confirm withpython3 scripts/delivery-telemetry.py lint.Set the board to Done + release the claim. — NOT Done for a SLICE delivery (see the gate
above: useReady/In Progress; Done hides an unfinished issue from dispatch). The PR-merged / issue-closed server-side automation
should already have moved the Project item toStatus=Done(it fires even when you merge from the
phone/web — noflow-*run needed); if it hasn't, set it yourself:# Run the flow-board detection snippet first (it switches to the project-capable account — the EMU # flip otherwise makes this write fail silently), then set the board Status only. # `--field` is NOT a gh flag (verified gh 2.87.3 offers only --field-id); all four IDs are required: gh project item-edit --id <item-id> --project-id <project-id> \ --field-id <status-field-id> --single-select-option-id <Done-option-id> # when have_project=1Do NOT write any
status:*label here. Done → no board-derived label (board→label mirror #2855:
Backlog/Done carry none), and the mirror only reconciles OPEN issues, so a leftover label on the
now-closed issue is irrelevant to discovery (--state open) and to the detector; a reopen fires
the mirror'sissues: reopenedtrigger and re-derives from board Status.
Releasing the claim = deleting the CLAIM REFrefs/claims/issue-<N>viaclaim.sh release(#2665 — the
ref is THE cross-machine lock); deleting theissue-<N>-<slug>branch is plumbing cleanup of the merged
PR head, not the lock. Both happen in step 6 below. After finalize, nothing for this issue may remainIn Progress/In Review, and neither the claim ref nor anissue-<N>-*branch may remain on origin.Release the locks, THEN remove the worktree + branch via the guarded cleanup (plumbing). Do NOT hand-glob
issue-<N>-*or blindly--force— that destroyed an unrelated active claim on 2026-06-27 (the #1143
incident: PR merged fromissue-1143-read-p99-regression, glob also matched + deleted the separate
activeissue-1143-scan-window-offload). Use the guardrailed script instead — it targets ONLY the
merged PR's branch, refuses on >1 lock for the issue (1:1:1:1 violation), and refuses to remove a
dirty/unpushed worktree.
Release the locks FIRST, while the lane directory still exists — the claim ref, the actual
cross-machine lock (#2665), and the machine-local lane lock. The PR is merged, so the open-PR
guard passes; do NOT use--forcehere (that is the reaper's path inflow-board, not finalize).
The ORDER is load-bearing and was wrong until #3436 roborev round 15: the cleanup below deletes
the worktree, andlane-lock.sh releaseproves you are the holder by walking up from your cwd
INSIDE the lane — so releasing afterwards is refused as not-holder and leaks the record. Round 12
wrote that warning into the comment below and left the steps in the old order, which is why a
later review found it again: an instruction is not a sequence.bash scripts/flow/claim.sh release <N> # deletes refs/claims/issue-<N> → CLAIM: RELEASED # ...and drop the MACHINE-LOCAL lane lock (#3436). THIS IS NOW REQUIRED, not cosmetic: # the lock files live in a lock root OUTSIDE the worktree (`$LANE_ROOT/.lane-locks`), # so removing the worktree does NOT delete the record. An earlier version of this note # said it did, which made a real release read as optional. # KEYED BY ISSUE FIXES THE SUBJECT, NOT THE IDENTITY (#3436, roborev round 12). It needs # no --lane-dir, so it cannot miss a lane locked under a non-default path -- but proving # you are the HOLDER resolves an identity by walking up from your cwd INSIDE the lane, and # the cleanup BELOW removes the worktree. Run from a deleted lane, release is # refused as not-holder, and the `|| true` below turned that into a silent leak: the record # outlives the lane and the next session for this issue meets a lock nobody can clear. # SO THIS BLOCK RUNS FIRST, from inside the lane. If the lane is already gone, # the sanctioned fallbacks are `--pid <durable-holder>` or `--force` (which only DELETES and # needs no identity). Do not suppress the failure, and verify the record is actually gone. # (#3436 FIX 8 -- # measured before the lock root existed: `release 9600` printed # `RELEASED (already free) ... record=absent` rc=0 while the record sat in # `.claude/worktrees/issue-9600-slug/`, i.e. a finalize reported a successful release # having released nothing). # A lock left behind by a killed session is still not a leak: its holder reads DEAD-* # and the next acquire reclaims it automatically. # PASS THE LEASE (#3436, roborev round 24). `release --expect` exists precisely so a DELAYED # or DUPLICATED release cannot delete a NEWER lock: the five-component token is IDENTICAL # across a release-and-reacquire by the same process, so only the lease (`<token>#<nonce>`, # the record INCARNATION) tells the two apart. Adding the option and leaving the documented # caller without it fixes nothing in the flow that actually runs — a second finalize, or a # retried one, still deletes a live lock. Read the lease immediately before releasing; if the # record changed in between, the CAS refuses, which is the point. LEASE="$(bash scripts/flow/lane-lock.sh status <N> 2>/dev/null | grep -oE 'lease=[^ ]*' | head -1 | cut -d= -f2-)" if [ -n "$LEASE" ]; then bash scripts/flow/lane-lock.sh release <N> --expect "$LEASE" else # No lease means no record to protect — release plainly so a genuinely absent lock is still # cleaned up. `--expect ''` is refused by design, so it must not be passed through. bash scripts/flow/lane-lock.sh release <N> fi # NO `|| true` on either branch: a refused release is a LEAK, not noise # VERIFY, do not assume — `status` must report no record for this issue: bash scripts/flow/lane-lock.sh status <N> # confirm gone: `claim.sh status <N>` prints `CLAIM: STATUS none`.Now remove the worktree + branch via the guarded cleanup (plumbing). Do NOT hand-glob
issue-<N>-*or blindly--force.# --confirm-unmerged: a squash-merge leaves the branch tip out of `main`; step 1 # already verified PR state=MERGED, which IS the authority the flag stands for. # `scripts/flow/*.sh` blobs are mode 100644 (no +x) — ALWAYS invoke them via `bash`. bash scripts/flow/finalize-cleanup.sh --issue <N> --merged-branch <merged-branch> --confirm-unmerged # Add --dry-run first to preview. Exit codes: 0 ok · 2 multi-lock · 3 dirty/unpushed · 4 unmerged tip.On a non-zero exit the script changed nothing and surfaced why — resolve the 1:1:1:1 violation or the
dirty worktree by hand; never force past it. Confirm the lock is gone afterward:git ls-remote --heads origin "issue-<N>-*"returns nothing.
(Regression coverage:scripts/flow/tests/finalize-cleanup.test.shencodes the #1143 scenario.)
Then clear this machine's claim heartbeat so it doesn't linger on origin untilflow-board's 4h reap
window (issue #2089):bash scripts/flow/claim-heartbeat.sh clear "$(hostname -s)"Close the issue with a traceable comment referencing the merged PR + commit — only if its
acceptance criteria are fully met, never for a SLICE delivery (see the gate at the top and
"Slice deliveries" above: a slice's issue stays OPEN by design, and closing it here is the
FORBIDDEN workaround of #3550), and never an epic:gh issue close <N> --reason completed --comment "Merged via #<pr> (<commit>). <one-line why>."GitHub API resilience:
gh issue close/gh issue commentride the GraphQL bucket, which
throttles separately from REST (each 5k pts/hr, independent per-bucket windows). If GraphQL is
exhausted, fall back togh apiREST (comment →repos/OWNER/REPO/issues/N/comments,
close →PATCH repos/OWNER/REPO/issues/N -f state=closed). Never stall finalize on one exhausted bucket.
Slice deliveries — what changes (issue #3550)
A slice finalizes the delivery, never the issue. Run steps 1, 2, 4 and 6 as written; the other
three are wrong for a slice, in the direction that destroys the thing the issue is protecting:
| step | completed delivery | slice |
|---|---|---|
| 3 — archive the OpenSpec change | archive it | DO NOT. The change is not finished; archiving it strands the remaining slices with no spec. |
| 4 — stamp telemetry | as written | add --slice. It records closed_at: null and bounds cycle_time_s on the PR's mergedAt. Since #3559 the tool decides this by replaying the issue's timeline to the PR's mergedAt, so it now ACCEPTS --slice for an issue that is closed or reopened NOW but was open when your PR merged — the case that used to be unstampable. It REFUSES when the last closed/reopened event strictly before mergedAt is a closed (that delivery COMPLETED the issue; a later reopen does not change it — but a close FOLLOWED by a reopen before the merge leaves the issue open at mergedAt and IS accepted), when a state event falls in the same second as mergedAt (one-second resolution, so the tie is unmeasurable), and when this PR declares it closes the issue (Closes #N ⇒ completed delivery, whatever the issue's current state). A refusal therefore still means you classified wrong — but re-read which of those it names, and never close the issue to make the stamp succeed. |
5 — board Status=Done |
set Done | DO NOT. Set Status=Ready (more slices to claim) or leave In Progress if you are continuing. Done on an unfinished issue hides it from dispatch. |
| 7 — close the issue | close it | DO NOT CLOSE. Comment instead, naming the slice shipped and what remains: gh issue comment <N> --body "Slice shipped: #<pr> (<commit>) — <what landed>. Remaining: <what does not>. Issue stays OPEN by design (#3393 ruling)." |
Closing a deliberately-open issue because this skill's default path says to is exactly the
substitution #3550 forbids — a tool's or a checklist's shape must never decide whether a problem is
recorded as solved. If you are unsure which kind you have, ASK the lead rather than closing.
- Report the closed issue, the live capability (if a spec was synced), and surface the next board
item. - Reset before the next item (issue #2085). This is the inter-issue compaction point. The ledger stamp
(step 4) is the durable record — carry zero prior-issue history forward. Drop the retained board
renders, gate summaries, roborev findings, PR body, and any Seam-1 spec render for this issue; route any
durable cross-issue lesson toMEMORY.md/process_improvements.md, never the live window. Re-hydrate
the next item from the board alone — the lead must be re-runnable from board + disk state at any
point, so a session that clears N issues stays O(1 issue), not O(N).